diff --git a/lib/towerops/snmp/memory_pool.ex b/lib/towerops/snmp/memory_pool.ex new file mode 100644 index 00000000..ddac0c4e --- /dev/null +++ b/lib/towerops/snmp/memory_pool.ex @@ -0,0 +1,69 @@ +defmodule Towerops.Snmp.MemoryPool do + @moduledoc """ + SNMP memory pool schema for tracking RAM, swap, and other memory types. + + Memory pools are discovered via: + - HOST-RESOURCES-MIB hrStorageTable (RAM, virtual memory) + - UCD-SNMP-MIB memory group (Linux memory stats) + + Tracks total and used memory for capacity monitoring. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Association.NotLoaded + alias Towerops.Snmp.Device + + @valid_pool_types ~w(ram swap cache buffer other) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_memory_pools" do + field :pool_index, :string + field :pool_name, :string + field :pool_type, :string + field :total_bytes, :integer + field :used_bytes, :integer + field :last_checked_at, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :snmp_device, Device + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + pool_index: String.t(), + pool_name: String.t() | nil, + pool_type: String.t(), + total_bytes: integer() | nil, + used_bytes: integer() | nil, + last_checked_at: DateTime.t() | nil, + metadata: map(), + snmp_device_id: Ecto.UUID.t(), + snmp_device: NotLoaded.t() | Device.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @doc false + def changeset(memory_pool, attrs) do + memory_pool + |> cast(attrs, [ + :snmp_device_id, + :pool_index, + :pool_name, + :pool_type, + :total_bytes, + :used_bytes, + :last_checked_at, + :metadata + ]) + |> validate_required([:snmp_device_id, :pool_index, :pool_type]) + |> validate_inclusion(:pool_type, @valid_pool_types) + |> unique_constraint([:snmp_device_id, :pool_index]) + |> foreign_key_constraint(:snmp_device_id) + end +end diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index 12a789bc..50d24ded 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -84,6 +84,33 @@ defmodule Towerops.Snmp.Profiles.Base do ip_ad_ent_net_mask: "1.3.6.1.2.1.4.20.1.3" } + @host_resources_oids %{ + # HOST-RESOURCES-MIB - hrStorageTable for memory and storage + hr_storage_type: "1.3.6.1.2.1.25.2.3.1.2", + hr_storage_descr: "1.3.6.1.2.1.25.2.3.1.3", + hr_storage_allocation_units: "1.3.6.1.2.1.25.2.3.1.4", + hr_storage_size: "1.3.6.1.2.1.25.2.3.1.5", + hr_storage_used: "1.3.6.1.2.1.25.2.3.1.6" + } + + # HOST-RESOURCES-MIB storage type OIDs mapping + # Memory types (for discover_memory_pools) + @memory_type_oids %{ + [1, 3, 6, 1, 2, 1, 25, 2, 1, 2] => "ram", + [1, 3, 6, 1, 2, 1, 25, 2, 1, 3] => "swap" + } + + # Disk/storage types (for discover_storage) - excludes "other" (1) which is too generic + @storage_type_oids %{ + [1, 3, 6, 1, 2, 1, 25, 2, 1, 4] => "fixed_disk", + [1, 3, 6, 1, 2, 1, 25, 2, 1, 5] => "removable_disk", + [1, 3, 6, 1, 2, 1, 25, 2, 1, 6] => "floppy_disk", + [1, 3, 6, 1, 2, 1, 25, 2, 1, 7] => "compact_disc", + [1, 3, 6, 1, 2, 1, 25, 2, 1, 8] => "ram_disk", + [1, 3, 6, 1, 2, 1, 25, 2, 1, 9] => "flash_memory", + [1, 3, 6, 1, 2, 1, 25, 2, 1, 10] => "network_disk" + } + # ENTITY-MIB physical class values to track for state sensors # 6 = powerSupply, 7 = fan @state_sensor_classes [6, 7] @@ -413,6 +440,154 @@ defmodule Towerops.Snmp.Profiles.Base do {:ok, ip_addresses} end + @doc """ + Discovers memory pools from HOST-RESOURCES-MIB hrStorageTable. + Returns a list of memory pool maps (RAM, swap/virtual memory). + """ + @spec discover_memory_pools(Client.connection_opts()) :: {:ok, [map()]} + def discover_memory_pools(client_opts) do + # Walk storage type to identify memory entries + case Client.walk(client_opts, @host_resources_oids.hr_storage_type) do + {:ok, type_results} when is_map(type_results) and map_size(type_results) > 0 -> + do_discover_memory_pools(client_opts, type_results) + + {:ok, _} -> + Logger.debug("No storage entries found in HOST-RESOURCES-MIB") + {:ok, []} + + {:error, reason} -> + Logger.debug("HOST-RESOURCES-MIB walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_memory_pools(client_opts, type_results) do + # Fetch all storage attributes + descr_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_descr) + alloc_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_allocation_units) + size_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_size) + used_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_used) + + # Build memory pool list (only RAM and virtual memory) + memory_pools = + type_results + |> Enum.map(fn {oid, type_oid} -> + index = extract_hr_storage_index(oid) + pool_type = hr_storage_type_to_memory_type(type_oid) + + if pool_type do + allocation_units = Map.get(alloc_map, index, 1) + size_units = Map.get(size_map, index, 0) + used_units = Map.get(used_map, index, 0) + + %{ + pool_index: to_string(index), + pool_name: Map.get(descr_map, index), + pool_type: pool_type, + total_bytes: size_units * allocation_units, + used_bytes: used_units * allocation_units, + last_checked_at: DateTime.utc_now() + } + end + end) + |> Enum.reject(&is_nil/1) + + Logger.debug("Discovered #{length(memory_pools)} memory pools from HOST-RESOURCES-MIB") + {:ok, memory_pools} + end + + @doc """ + Discovers storage from HOST-RESOURCES-MIB hrStorageTable. + Returns a list of storage maps (fixed disks, removable disks, network disks, etc.). + """ + @spec discover_storage(Client.connection_opts()) :: {:ok, [map()]} + def discover_storage(client_opts) do + # Walk storage type to identify storage entries + case Client.walk(client_opts, @host_resources_oids.hr_storage_type) do + {:ok, type_results} when is_map(type_results) and map_size(type_results) > 0 -> + do_discover_storage(client_opts, type_results) + + {:ok, _} -> + Logger.debug("No storage entries found in HOST-RESOURCES-MIB") + {:ok, []} + + {:error, reason} -> + Logger.debug("HOST-RESOURCES-MIB walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_storage(client_opts, type_results) do + # Fetch all storage attributes + descr_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_descr) + alloc_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_allocation_units) + size_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_size) + used_map = walk_hr_storage_attribute(client_opts, @host_resources_oids.hr_storage_used) + + # Build storage list (only disk types, not RAM/virtual memory) + storage = + type_results + |> Enum.map(fn {oid, type_oid} -> + index = extract_hr_storage_index(oid) + storage_type = hr_storage_type_to_disk_type(type_oid) + + if storage_type do + allocation_units = Map.get(alloc_map, index, 1) + size_units = Map.get(size_map, index, 0) + used_units = Map.get(used_map, index, 0) + + %{ + storage_index: index, + description: Map.get(descr_map, index), + storage_type: storage_type, + allocation_units: allocation_units, + total_bytes: size_units * allocation_units, + used_bytes: used_units * allocation_units, + last_checked_at: DateTime.utc_now() + } + end + end) + |> Enum.reject(&is_nil/1) + + Logger.debug("Discovered #{length(storage)} storage entries from HOST-RESOURCES-MIB") + {:ok, storage} + end + + defp walk_hr_storage_attribute(client_opts, oid) do + case Client.walk(client_opts, oid) do + {:ok, results} when is_map(results) -> + Map.new(results, fn {result_oid, value} -> + {extract_hr_storage_index(result_oid), value} + end) + + _ -> + %{} + end + end + + defp extract_hr_storage_index(oid) do + oid + |> String.split(".") + |> List.last() + |> String.to_integer() + rescue + _ -> 0 + end + + # Map hrStorageType OID to memory pool type (RAM, swap) + defp hr_storage_type_to_memory_type(type_oid) when is_list(type_oid) do + Map.get(@memory_type_oids, type_oid) + end + + defp hr_storage_type_to_memory_type(_), do: nil + + # Map hrStorageType OID to storage type (disk types only) + defp hr_storage_type_to_disk_type(type_oid) when is_list(type_oid) do + Map.get(@storage_type_oids, type_oid) + end + + defp hr_storage_type_to_disk_type(_), do: nil + @doc """ Discovers physical entities from ENTITY-MIB. Returns a list of physical entity maps including chassis, modules, PSUs, fans, etc. diff --git a/lib/towerops/snmp/storage.ex b/lib/towerops/snmp/storage.ex new file mode 100644 index 00000000..19d49ec7 --- /dev/null +++ b/lib/towerops/snmp/storage.ex @@ -0,0 +1,77 @@ +defmodule Towerops.Snmp.Storage do + @moduledoc """ + SNMP storage schema for tracking disk and filesystem usage. + + Storage is discovered via HOST-RESOURCES-MIB hrStorageTable and includes: + - Fixed disks (HDD, SSD) + - Removable disks (USB, external) + - Network storage (NFS, iSCSI) + - RAM disks and virtual memory + + Tracks total and used space for capacity monitoring. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Association.NotLoaded + alias Towerops.Snmp.Device + + @valid_storage_types ~w(other ram virtual_memory fixed_disk removable_disk floppy_disk compact_disc ram_disk flash_memory network_disk) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_storage" do + field :storage_index, :integer + field :storage_type, :string + field :description, :string + field :device_name, :string + field :total_bytes, :integer + field :used_bytes, :integer + field :allocation_units, :integer + field :last_checked_at, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :snmp_device, Device + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + storage_index: integer(), + storage_type: String.t(), + description: String.t() | nil, + device_name: String.t() | nil, + total_bytes: integer() | nil, + used_bytes: integer() | nil, + allocation_units: integer() | nil, + last_checked_at: DateTime.t() | nil, + metadata: map(), + snmp_device_id: Ecto.UUID.t(), + snmp_device: NotLoaded.t() | Device.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @doc false + def changeset(storage, attrs) do + storage + |> cast(attrs, [ + :snmp_device_id, + :storage_index, + :storage_type, + :description, + :device_name, + :total_bytes, + :used_bytes, + :allocation_units, + :last_checked_at, + :metadata + ]) + |> validate_required([:snmp_device_id, :storage_index, :storage_type]) + |> validate_inclusion(:storage_type, @valid_storage_types) + |> unique_constraint([:snmp_device_id, :storage_index]) + |> foreign_key_constraint(:snmp_device_id) + end +end diff --git a/priv/repo/migrations/20260121164728_create_snmp_memory_pools.exs b/priv/repo/migrations/20260121164728_create_snmp_memory_pools.exs new file mode 100644 index 00000000..d1082632 --- /dev/null +++ b/priv/repo/migrations/20260121164728_create_snmp_memory_pools.exs @@ -0,0 +1,27 @@ +defmodule Towerops.Repo.Migrations.CreateSnmpMemoryPools do + use Ecto.Migration + + def change do + create table(:snmp_memory_pools, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :snmp_device_id, + references(:snmp_devices, type: :binary_id, on_delete: :delete_all), + null: false + + add :pool_index, :string, null: false + add :pool_name, :string + add :pool_type, :string, null: false + add :total_bytes, :bigint + add :used_bytes, :bigint + add :last_checked_at, :utc_datetime + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:snmp_memory_pools, [:snmp_device_id]) + create unique_index(:snmp_memory_pools, [:snmp_device_id, :pool_index]) + create index(:snmp_memory_pools, [:pool_type]) + end +end diff --git a/priv/repo/migrations/20260121164729_create_snmp_storage.exs b/priv/repo/migrations/20260121164729_create_snmp_storage.exs new file mode 100644 index 00000000..036711bd --- /dev/null +++ b/priv/repo/migrations/20260121164729_create_snmp_storage.exs @@ -0,0 +1,29 @@ +defmodule Towerops.Repo.Migrations.CreateSnmpStorage do + use Ecto.Migration + + def change do + create table(:snmp_storage, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :snmp_device_id, + references(:snmp_devices, type: :binary_id, on_delete: :delete_all), + null: false + + add :storage_index, :integer, null: false + add :storage_type, :string, null: false + add :description, :string + add :device_name, :string + add :total_bytes, :bigint + add :used_bytes, :bigint + add :allocation_units, :integer + add :last_checked_at, :utc_datetime + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:snmp_storage, [:snmp_device_id]) + create unique_index(:snmp_storage, [:snmp_device_id, :storage_index]) + create index(:snmp_storage, [:storage_type]) + end +end diff --git a/test/towerops/snmp/memory_pool_test.exs b/test/towerops/snmp/memory_pool_test.exs new file mode 100644 index 00000000..d165e78f --- /dev/null +++ b/test/towerops/snmp/memory_pool_test.exs @@ -0,0 +1,156 @@ +defmodule Towerops.Snmp.MemoryPoolTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.Device + alias Towerops.Snmp.MemoryPool + + setup do + user = user_fixture() + + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, device_schema} = + Towerops.Devices.create_device(%{ + name: "Test Router", + ip_address: "192.168.1.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id + }) + + snmp_device = + %Device{} + |> Device.changeset(%{ + device_id: device_schema.id, + sys_name: "test-router", + sys_descr: "Test Router" + }) + |> Repo.insert!() + + %{device: device_schema, snmp_device: snmp_device} + end + + describe "changeset/2" do + test "valid changeset with all required fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + pool_index: "1", + pool_type: "ram" + } + + changeset = MemoryPool.changeset(%MemoryPool{}, attrs) + assert changeset.valid? + end + + test "valid changeset with all fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + pool_index: "1", + pool_name: "Physical Memory", + pool_type: "ram", + total_bytes: 8_589_934_592, + used_bytes: 4_294_967_296, + last_checked_at: DateTime.utc_now(), + metadata: %{"source" => "host-resources"} + } + + changeset = MemoryPool.changeset(%MemoryPool{}, attrs) + assert changeset.valid? + assert get_field(changeset, :pool_name) == "Physical Memory" + assert get_field(changeset, :total_bytes) == 8_589_934_592 + assert get_field(changeset, :used_bytes) == 4_294_967_296 + end + + test "invalid changeset without snmp_device_id" do + attrs = %{ + pool_index: "1", + pool_type: "ram" + } + + changeset = MemoryPool.changeset(%MemoryPool{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).snmp_device_id + end + + test "invalid changeset without pool_index", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + pool_type: "ram" + } + + changeset = MemoryPool.changeset(%MemoryPool{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).pool_index + end + + test "invalid changeset without pool_type", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + pool_index: "1" + } + + changeset = MemoryPool.changeset(%MemoryPool{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).pool_type + end + + test "invalid pool_type value", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + pool_index: "1", + pool_type: "invalid" + } + + changeset = MemoryPool.changeset(%MemoryPool{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).pool_type + end + + test "valid pool_type values", %{snmp_device: snmp_device} do + for pool_type <- ~w(ram swap cache buffer other) do + attrs = %{ + snmp_device_id: snmp_device.id, + pool_index: pool_type, + pool_type: pool_type + } + + changeset = MemoryPool.changeset(%MemoryPool{}, attrs) + assert changeset.valid?, "pool_type '#{pool_type}' should be valid" + end + end + end + + describe "unique constraint" do + test "prevents duplicate pool_index on same device", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + pool_index: "1", + pool_type: "ram" + } + + # Insert first pool + changeset = MemoryPool.changeset(%MemoryPool{}, attrs) + assert {:ok, _pool} = Repo.insert(changeset) + + # Attempt to insert duplicate + changeset2 = MemoryPool.changeset(%MemoryPool{}, attrs) + + assert {:error, changeset} = Repo.insert(changeset2) + errors = errors_on(changeset) + + assert "has already been taken" in Map.get(errors, :pool_index, []) or + "has already been taken" in Map.get(errors, :snmp_device_id, []) + end + end +end diff --git a/test/towerops/snmp/profiles/base_test.exs b/test/towerops/snmp/profiles/base_test.exs index caae175a..f44166cc 100644 --- a/test/towerops/snmp/profiles/base_test.exs +++ b/test/towerops/snmp/profiles/base_test.exs @@ -772,6 +772,264 @@ defmodule Towerops.Snmp.Profiles.BaseTest do end end + describe "discover_memory_pools/1" do + test "discovers memory pools from HOST-RESOURCES-MIB" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # hrStorageType - type references + "1.3.6.1.2.1.25.2.3.1.2" -> + {:ok, + [ + # RAM: 1.3.6.1.2.1.25.2.1.2 + %{oid: "1.3.6.1.2.1.25.2.3.1.2.1", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 2]}}, + # Virtual Memory: 1.3.6.1.2.1.25.2.1.3 + %{oid: "1.3.6.1.2.1.25.2.3.1.2.3", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 3]}}, + # Fixed Disk: 1.3.6.1.2.1.25.2.1.4 (filtered out for memory) + %{oid: "1.3.6.1.2.1.25.2.3.1.2.31", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 4]}} + ]} + + # hrStorageDescr - descriptions + "1.3.6.1.2.1.25.2.3.1.3" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: {:octet_string, "Physical memory"}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.3.3", value: {:octet_string, "Virtual memory"}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.3.31", value: {:octet_string, "/dev/sda1"}} + ]} + + # hrStorageAllocationUnits + "1.3.6.1.2.1.25.2.3.1.4" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.2.3.1.4.1", value: {:integer, 1024}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.4.3", value: {:integer, 4096}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.4.31", value: {:integer, 4096}} + ]} + + # hrStorageSize - total units + "1.3.6.1.2.1.25.2.3.1.5" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: {:integer, 8_388_608}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.5.3", value: {:integer, 16_777_216}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.5.31", value: {:integer, 244_140_625}} + ]} + + # hrStorageUsed - used units + "1.3.6.1.2.1.25.2.3.1.6" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: {:integer, 4_194_304}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.6.3", value: {:integer, 8_388_608}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.6.31", value: {:integer, 122_070_312}} + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, memory_pools} = Base.discover_memory_pools(@client_opts) + + # Should only return RAM and Virtual Memory, not Fixed Disk + assert length(memory_pools) == 2 + + ram = Enum.find(memory_pools, &(&1.pool_index == "1")) + assert ram.pool_name == "Physical memory" + assert ram.pool_type == "ram" + assert ram.total_bytes == 8_388_608 * 1024 + assert ram.used_bytes == 4_194_304 * 1024 + + virtual = Enum.find(memory_pools, &(&1.pool_index == "3")) + assert virtual.pool_name == "Virtual memory" + assert virtual.pool_type == "swap" + assert virtual.total_bytes == 16_777_216 * 4096 + assert virtual.used_bytes == 8_388_608 * 4096 + end + + test "returns empty list when HOST-RESOURCES-MIB not supported" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, memory_pools} = Base.discover_memory_pools(@client_opts) + assert memory_pools == [] + end + + test "handles walk errors gracefully" do + stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end) + + assert {:ok, memory_pools} = Base.discover_memory_pools(@client_opts) + assert memory_pools == [] + end + end + + describe "discover_storage/1" do + test "discovers storage from HOST-RESOURCES-MIB" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # hrStorageType - type references + "1.3.6.1.2.1.25.2.3.1.2" -> + {:ok, + [ + # RAM (filtered out for storage) + %{oid: "1.3.6.1.2.1.25.2.3.1.2.1", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 2]}}, + # Fixed Disk + %{oid: "1.3.6.1.2.1.25.2.3.1.2.31", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 4]}}, + # Removable Disk + %{oid: "1.3.6.1.2.1.25.2.3.1.2.32", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 5]}}, + # Network Disk + %{oid: "1.3.6.1.2.1.25.2.3.1.2.33", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 10]}} + ]} + + # hrStorageDescr + "1.3.6.1.2.1.25.2.3.1.3" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: {:octet_string, "Physical memory"}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.3.31", value: {:octet_string, "/"}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.3.32", value: {:octet_string, "/mnt/usb"}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.3.33", value: {:octet_string, "/mnt/nfs"}} + ]} + + # hrStorageAllocationUnits + "1.3.6.1.2.1.25.2.3.1.4" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.2.3.1.4.1", value: {:integer, 1024}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.4.31", value: {:integer, 4096}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.4.32", value: {:integer, 4096}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.4.33", value: {:integer, 4096}} + ]} + + # hrStorageSize + "1.3.6.1.2.1.25.2.3.1.5" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: {:integer, 8_388_608}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.5.31", value: {:integer, 244_140_625}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.5.32", value: {:integer, 7_812_500}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.5.33", value: {:integer, 976_562_500}} + ]} + + # hrStorageUsed + "1.3.6.1.2.1.25.2.3.1.6" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: {:integer, 4_194_304}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.6.31", value: {:integer, 122_070_312}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.6.32", value: {:integer, 3_906_250}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.6.33", value: {:integer, 488_281_250}} + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, storage} = Base.discover_storage(@client_opts) + + # Should only return Fixed Disk, Removable Disk, Network Disk (not RAM) + assert length(storage) == 3 + + root = Enum.find(storage, &(&1.storage_index == 31)) + assert root.description == "/" + assert root.storage_type == "fixed_disk" + assert root.allocation_units == 4096 + assert root.total_bytes == 244_140_625 * 4096 + assert root.used_bytes == 122_070_312 * 4096 + + usb = Enum.find(storage, &(&1.storage_index == 32)) + assert usb.description == "/mnt/usb" + assert usb.storage_type == "removable_disk" + + nfs = Enum.find(storage, &(&1.storage_index == 33)) + assert nfs.description == "/mnt/nfs" + assert nfs.storage_type == "network_disk" + end + + test "returns empty list when HOST-RESOURCES-MIB not supported" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, storage} = Base.discover_storage(@client_opts) + assert storage == [] + end + + test "handles walk errors gracefully" do + stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end) + + assert {:ok, storage} = Base.discover_storage(@client_opts) + assert storage == [] + end + + test "maps all storage type OIDs correctly" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + "1.3.6.1.2.1.25.2.3.1.2" -> + {:ok, + [ + # hrStorageType values 1-10 (other, ram, virtual_memory, fixed_disk, etc.) + %{oid: "1.3.6.1.2.1.25.2.3.1.2.1", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 1]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.2", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 2]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.3", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 3]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.4", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 4]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.5", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 5]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.6", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 6]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.7", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 7]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.8", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 8]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.9", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 9]}}, + %{oid: "1.3.6.1.2.1.25.2.3.1.2.10", value: {:object_identifier, [1, 3, 6, 1, 2, 1, 25, 2, 1, 10]}} + ]} + + "1.3.6.1.2.1.25.2.3.1.3" -> + {:ok, + Enum.map(1..10, fn i -> + %{oid: "1.3.6.1.2.1.25.2.3.1.3.#{i}", value: {:octet_string, "Storage #{i}"}} + end)} + + "1.3.6.1.2.1.25.2.3.1.4" -> + {:ok, + Enum.map(1..10, fn i -> + %{oid: "1.3.6.1.2.1.25.2.3.1.4.#{i}", value: {:integer, 4096}} + end)} + + "1.3.6.1.2.1.25.2.3.1.5" -> + {:ok, + Enum.map(1..10, fn i -> + %{oid: "1.3.6.1.2.1.25.2.3.1.5.#{i}", value: {:integer, 1000}} + end)} + + "1.3.6.1.2.1.25.2.3.1.6" -> + {:ok, + Enum.map(1..10, fn i -> + %{oid: "1.3.6.1.2.1.25.2.3.1.6.#{i}", value: {:integer, 500}} + end)} + + _ -> + {:ok, []} + end + end) + + assert {:ok, storage} = Base.discover_storage(@client_opts) + + # Only disk-type storage (indices 4-10, not RAM/virtual memory) + assert length(storage) == 7 + + expected_types = [ + {4, "fixed_disk"}, + {5, "removable_disk"}, + {6, "floppy_disk"}, + {7, "compact_disc"}, + {8, "ram_disk"}, + {9, "flash_memory"}, + {10, "network_disk"} + ] + + for {idx, expected_type} <- expected_types do + entry = Enum.find(storage, &(&1.storage_index == idx)) + assert entry.storage_type == expected_type, "Index #{idx} should have type #{expected_type}" + end + end + end + describe "discover_physical_entities/1" do test "discovers physical entities from ENTITY-MIB" do stub(SnmpMock, :walk, fn _, oid, _ -> diff --git a/test/towerops/snmp/storage_test.exs b/test/towerops/snmp/storage_test.exs new file mode 100644 index 00000000..d553b592 --- /dev/null +++ b/test/towerops/snmp/storage_test.exs @@ -0,0 +1,161 @@ +defmodule Towerops.Snmp.StorageTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.Device + alias Towerops.Snmp.Storage + + setup do + user = user_fixture() + + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, device_schema} = + Towerops.Devices.create_device(%{ + name: "Test Router", + ip_address: "192.168.1.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id + }) + + snmp_device = + %Device{} + |> Device.changeset(%{ + device_id: device_schema.id, + sys_name: "test-router", + sys_descr: "Test Router" + }) + |> Repo.insert!() + + %{device: device_schema, snmp_device: snmp_device} + end + + describe "changeset/2" do + test "valid changeset with all required fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + storage_index: 1, + storage_type: "fixed_disk" + } + + changeset = Storage.changeset(%Storage{}, attrs) + assert changeset.valid? + end + + test "valid changeset with all fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + storage_index: 1, + storage_type: "fixed_disk", + description: "/dev/sda1", + device_name: "/", + total_bytes: 500_000_000_000, + used_bytes: 250_000_000_000, + allocation_units: 4096, + last_checked_at: DateTime.utc_now(), + metadata: %{"source" => "host-resources"} + } + + changeset = Storage.changeset(%Storage{}, attrs) + assert changeset.valid? + assert get_field(changeset, :description) == "/dev/sda1" + assert get_field(changeset, :total_bytes) == 500_000_000_000 + assert get_field(changeset, :used_bytes) == 250_000_000_000 + end + + test "invalid changeset without snmp_device_id" do + attrs = %{ + storage_index: 1, + storage_type: "disk" + } + + changeset = Storage.changeset(%Storage{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).snmp_device_id + end + + test "invalid changeset without storage_index", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + storage_type: "disk" + } + + changeset = Storage.changeset(%Storage{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).storage_index + end + + test "invalid changeset without storage_type", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + storage_index: 1 + } + + changeset = Storage.changeset(%Storage{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).storage_type + end + + test "invalid storage_type value", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + storage_index: 1, + storage_type: "invalid" + } + + changeset = Storage.changeset(%Storage{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).storage_type + end + + test "valid storage_type values", %{snmp_device: snmp_device} do + for {storage_type, idx} <- + Enum.with_index( + ~w(other ram virtual_memory fixed_disk removable_disk floppy_disk compact_disc ram_disk flash_memory network_disk) + ) do + attrs = %{ + snmp_device_id: snmp_device.id, + storage_index: idx + 1, + storage_type: storage_type + } + + changeset = Storage.changeset(%Storage{}, attrs) + assert changeset.valid?, "storage_type '#{storage_type}' should be valid" + end + end + end + + describe "unique constraint" do + test "prevents duplicate storage_index on same device", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + storage_index: 1, + storage_type: "fixed_disk" + } + + # Insert first storage + changeset = Storage.changeset(%Storage{}, attrs) + assert {:ok, _storage} = Repo.insert(changeset) + + # Attempt to insert duplicate + changeset2 = Storage.changeset(%Storage{}, attrs) + + assert {:error, changeset} = Repo.insert(changeset2) + errors = errors_on(changeset) + + assert "has already been taken" in Map.get(errors, :storage_index, []) or + "has already been taken" in Map.get(errors, :snmp_device_id, []) + end + end +end