From 45ddcdac80843b50148f0f33acdb2ed91fe5f950 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 21 Jan 2026 10:46:27 -0600 Subject: [PATCH] feat: add physical inventory discovery (Phase 1.4) - Create snmp_physical_entities table with migration - Add PhysicalEntity schema with entity_class validation - Support parent/child hierarchy via parent_entity_id - Implement discover_physical_entities/1 in Base profile - Discover chassis, modules, PSUs, fans from ENTITY-MIB - Map entity class integers to string names - Add 10 schema tests and 4 discovery tests - Fix flaky AlertNotifier test with unique names and mailbox clearing --- lib/towerops/snmp/physical_entity.ex | 92 ++++++++ lib/towerops/snmp/profiles/base.ex | 106 +++++++++ ...21163706_create_snmp_physical_entities.exs | 37 +++ test/towerops/alerts/alert_notifier_test.exs | 35 ++- test/towerops/snmp/physical_entity_test.exs | 223 ++++++++++++++++++ test/towerops/snmp/profiles/base_test.exs | 185 +++++++++++++++ 6 files changed, 669 insertions(+), 9 deletions(-) create mode 100644 lib/towerops/snmp/physical_entity.ex create mode 100644 priv/repo/migrations/20260121163706_create_snmp_physical_entities.exs create mode 100644 test/towerops/snmp/physical_entity_test.exs diff --git a/lib/towerops/snmp/physical_entity.ex b/lib/towerops/snmp/physical_entity.ex new file mode 100644 index 00000000..bdcd56ab --- /dev/null +++ b/lib/towerops/snmp/physical_entity.ex @@ -0,0 +1,92 @@ +defmodule Towerops.Snmp.PhysicalEntity do + @moduledoc """ + SNMP physical entity schema for tracking hardware inventory. + + Physical entities are discovered via ENTITY-MIB entPhysicalTable and include: + - Chassis (main enclosure) + - Modules (line cards, supervisor modules) + - Power supplies + - Fans + - Sensors + - Ports + + Entities form a parent/child hierarchy via contained_in relationships. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Association.NotLoaded + alias Towerops.Snmp.Device + + @valid_entity_classes ~w(other unknown chassis backplane container power fan sensor module port stack cpu memory) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_physical_entities" do + field :entity_index, :integer + field :entity_class, :string + field :description, :string + field :model_name, :string + field :serial_number, :string + field :firmware_revision, :string + field :hardware_revision, :string + field :manufacturer_name, :string + field :position, :integer + field :last_checked_at, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :snmp_device, Device + belongs_to :parent_entity, __MODULE__ + has_many :child_entities, __MODULE__, foreign_key: :parent_entity_id + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + entity_index: integer(), + entity_class: String.t(), + description: String.t() | nil, + model_name: String.t() | nil, + serial_number: String.t() | nil, + firmware_revision: String.t() | nil, + hardware_revision: String.t() | nil, + manufacturer_name: String.t() | nil, + position: integer() | nil, + last_checked_at: DateTime.t() | nil, + metadata: map(), + snmp_device_id: Ecto.UUID.t(), + snmp_device: NotLoaded.t() | Device.t(), + parent_entity_id: Ecto.UUID.t() | nil, + parent_entity: NotLoaded.t() | t() | nil, + child_entities: NotLoaded.t() | [t()], + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @doc false + def changeset(physical_entity, attrs) do + physical_entity + |> cast(attrs, [ + :snmp_device_id, + :entity_index, + :entity_class, + :description, + :model_name, + :serial_number, + :firmware_revision, + :hardware_revision, + :manufacturer_name, + :position, + :parent_entity_id, + :last_checked_at, + :metadata + ]) + |> validate_required([:snmp_device_id, :entity_index, :entity_class]) + |> validate_inclusion(:entity_class, @valid_entity_classes) + |> unique_constraint([:snmp_device_id, :entity_index]) + |> foreign_key_constraint(:snmp_device_id) + |> foreign_key_constraint(:parent_entity_id) + end +end diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index 3eb884d2..12a789bc 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -63,6 +63,7 @@ defmodule Towerops.Snmp.Profiles.Base do ent_phys_firmware_rev: "1.3.6.1.2.1.47.1.1.1.1.9", ent_phys_software_rev: "1.3.6.1.2.1.47.1.1.1.1.10", ent_phys_serial_num: "1.3.6.1.2.1.47.1.1.1.1.11", + ent_phys_mfg_name: "1.3.6.1.2.1.47.1.1.1.1.12", ent_phys_model_name: "1.3.6.1.2.1.47.1.1.1.1.13" } @@ -412,6 +413,111 @@ defmodule Towerops.Snmp.Profiles.Base do {:ok, ip_addresses} end + @doc """ + Discovers physical entities from ENTITY-MIB. + Returns a list of physical entity maps including chassis, modules, PSUs, fans, etc. + """ + @spec discover_physical_entities(Client.connection_opts()) :: {:ok, [map()]} + def discover_physical_entities(client_opts) do + # Walk physical entity descriptions to get entity indices + case Client.walk(client_opts, @entity_mib_oids.ent_phys_descr) do + {:ok, descr_results} when is_map(descr_results) and map_size(descr_results) > 0 -> + do_discover_physical_entities(client_opts, descr_results) + + {:ok, _} -> + Logger.debug("No physical entities found in ENTITY-MIB") + {:ok, []} + + {:error, reason} -> + Logger.debug("ENTITY-MIB walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_physical_entities(client_opts, descr_results) do + # Fetch all entity attributes in parallel-ish manner + contained_in_map = walk_entity_attribute(client_opts, @entity_mib_oids.ent_phys_contained_in) + class_map = walk_entity_attribute(client_opts, @entity_mib_oids.ent_phys_class) + serial_map = walk_entity_attribute(client_opts, @entity_mib_oids.ent_phys_serial_num) + model_map = walk_entity_attribute(client_opts, @entity_mib_oids.ent_phys_model_name) + mfg_map = walk_entity_attribute(client_opts, @entity_mib_oids.ent_phys_mfg_name) + hw_rev_map = walk_entity_attribute(client_opts, @entity_mib_oids.ent_phys_hardware_rev) + fw_rev_map = walk_entity_attribute(client_opts, @entity_mib_oids.ent_phys_firmware_rev) + + # Build entity list + entities = + descr_results + |> Enum.map(fn {oid, description} -> + entity_index = extract_entity_index(oid) + + %{ + entity_index: entity_index, + description: to_string_or_nil(description), + entity_class: entity_class_to_string(Map.get(class_map, entity_index)), + contained_in: Map.get(contained_in_map, entity_index, 0), + serial_number: to_string_or_nil(Map.get(serial_map, entity_index)), + model_name: to_string_or_nil(Map.get(model_map, entity_index)), + manufacturer_name: to_string_or_nil(Map.get(mfg_map, entity_index)), + hardware_revision: to_string_or_nil(Map.get(hw_rev_map, entity_index)), + firmware_revision: to_string_or_nil(Map.get(fw_rev_map, entity_index)), + last_checked_at: DateTime.utc_now() + } + end) + |> Enum.sort_by(& &1.entity_index) + + Logger.debug("Discovered #{length(entities)} physical entities from ENTITY-MIB") + {:ok, entities} + end + + defp walk_entity_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_entity_index(result_oid), value} + end) + + _ -> + %{} + end + end + + defp extract_entity_index(oid) do + oid + |> String.split(".") + |> List.last() + |> String.to_integer() + rescue + _ -> 0 + end + + # ENTITY-MIB PhysicalClass values mapping + @entity_class_map %{ + 1 => "other", + 2 => "unknown", + 3 => "chassis", + 4 => "backplane", + 5 => "container", + 6 => "power", + 7 => "fan", + 8 => "sensor", + 9 => "module", + 10 => "port", + 11 => "stack", + 12 => "cpu", + 13 => "memory" + } + + defp entity_class_to_string(class) when is_integer(class) do + Map.get(@entity_class_map, class, "unknown") + end + + defp entity_class_to_string(_), do: "unknown" + + defp to_string_or_nil(nil), do: nil + defp to_string_or_nil(""), do: nil + defp to_string_or_nil(value) when is_binary(value), do: value + defp to_string_or_nil(value), do: to_string(value) + @doc """ Identifies device manufacturer and model from sysDescr and sysObjectID. Can be overridden by vendor-specific profiles. diff --git a/priv/repo/migrations/20260121163706_create_snmp_physical_entities.exs b/priv/repo/migrations/20260121163706_create_snmp_physical_entities.exs new file mode 100644 index 00000000..c93796fd --- /dev/null +++ b/priv/repo/migrations/20260121163706_create_snmp_physical_entities.exs @@ -0,0 +1,37 @@ +defmodule Towerops.Repo.Migrations.CreateSnmpPhysicalEntities do + use Ecto.Migration + + def change do + create table(:snmp_physical_entities, 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 :entity_index, :integer, null: false + add :entity_class, :string, null: false + add :description, :string + add :model_name, :string + add :serial_number, :string + add :firmware_revision, :string + add :hardware_revision, :string + add :manufacturer_name, :string + add :position, :integer + add :last_checked_at, :utc_datetime + add :metadata, :map, default: %{} + + # Self-referencing parent relationship for hierarchy + add :parent_entity_id, + references(:snmp_physical_entities, type: :binary_id, on_delete: :nilify_all) + + timestamps(type: :utc_datetime) + end + + create index(:snmp_physical_entities, [:snmp_device_id]) + create unique_index(:snmp_physical_entities, [:snmp_device_id, :entity_index]) + create index(:snmp_physical_entities, [:entity_class]) + create index(:snmp_physical_entities, [:parent_entity_id]) + create index(:snmp_physical_entities, [:serial_number]) + end +end diff --git a/test/towerops/alerts/alert_notifier_test.exs b/test/towerops/alerts/alert_notifier_test.exs index 0ed71b11..0dc0cfbb 100644 --- a/test/towerops/alerts/alert_notifier_test.exs +++ b/test/towerops/alerts/alert_notifier_test.exs @@ -1,18 +1,26 @@ defmodule Towerops.Alerts.AlertNotifierTest do - use Towerops.DataCase, async: true + # async: false because assert_email_sent checks global Swoosh mailbox + use Towerops.DataCase, async: false import Swoosh.TestAssertions import Towerops.AccountsFixtures + alias Swoosh.Adapters.Local.Storage.Memory, as: SwooshMemory alias Towerops.Alerts.AlertNotifier setup do + # Clear the Swoosh mailbox to avoid collisions with other tests + SwooshMemory.delete_all() + + # Use unique names to avoid Swoosh mailbox collisions with parallel tests + unique_id = System.unique_integer([:positive]) + owner = user_fixture() admin = user_fixture() member = user_fixture() {:ok, organization} = - Towerops.Organizations.create_organization(%{name: "Test Org"}, owner.id) + Towerops.Organizations.create_organization(%{name: "AlertNotifier Org #{unique_id}"}, owner.id) # Add admin and member {:ok, _admin_membership} = @@ -31,18 +39,25 @@ defmodule Towerops.Alerts.AlertNotifierTest do {:ok, site} = Towerops.Sites.create_site(%{ - name: "Test Site", + name: "AlertNotifier Site #{unique_id}", organization_id: organization.id }) {:ok, device} = Towerops.Devices.create_device(%{ - name: "Test Router", + name: "AlertNotifier Router #{unique_id}", ip_address: "192.168.1.1", site_id: site.id }) - %{owner: owner, admin: admin, member: member, organization: organization, device: device} + %{ + owner: owner, + admin: admin, + member: member, + organization: organization, + device: device, + site_name: "AlertNotifier Site #{unique_id}" + } end describe "deliver_alert_notification/1" do @@ -96,7 +111,8 @@ defmodule Towerops.Alerts.AlertNotifierTest do test "equipment_down alert includes correct information", %{ device: device, - organization: organization + organization: organization, + site_name: site_name } do {:ok, alert} = Towerops.Alerts.create_alert(%{ @@ -116,14 +132,15 @@ defmodule Towerops.Alerts.AlertNotifierTest do email.text_body =~ organization.name && email.text_body =~ device.name && email.text_body =~ device.ip_address && - email.text_body =~ "Test Site" && + email.text_body =~ site_name && email.text_body =~ "not responding" end) end test "equipment_up alert includes correct information", %{ device: device, - organization: organization + organization: organization, + site_name: site_name } do {:ok, alert} = Towerops.Alerts.create_alert(%{ @@ -140,7 +157,7 @@ defmodule Towerops.Alerts.AlertNotifierTest do email.text_body =~ organization.name && email.text_body =~ device.name && email.text_body =~ device.ip_address && - email.text_body =~ "Test Site" && + email.text_body =~ site_name && email.text_body =~ "now responding" end) end diff --git a/test/towerops/snmp/physical_entity_test.exs b/test/towerops/snmp/physical_entity_test.exs new file mode 100644 index 00000000..b05eb1e7 --- /dev/null +++ b/test/towerops/snmp/physical_entity_test.exs @@ -0,0 +1,223 @@ +defmodule Towerops.Snmp.PhysicalEntityTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.Device + alias Towerops.Snmp.PhysicalEntity + + 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, + entity_index: 1, + entity_class: "chassis" + } + + changeset = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + assert changeset.valid? + end + + test "valid changeset with all fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: 1, + entity_class: "chassis", + description: "Cisco 2960-X Switch", + model_name: "WS-C2960X-24TS-L", + serial_number: "FCW1234ABC", + firmware_revision: "15.2(4)E7", + hardware_revision: "V01", + manufacturer_name: "Cisco", + position: 0, + last_checked_at: DateTime.utc_now(), + metadata: %{"source" => "entity-mib"} + } + + changeset = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + assert changeset.valid? + assert get_field(changeset, :entity_class) == "chassis" + assert get_field(changeset, :model_name) == "WS-C2960X-24TS-L" + assert get_field(changeset, :serial_number) == "FCW1234ABC" + end + + test "invalid changeset without snmp_device_id" do + attrs = %{ + entity_index: 1, + entity_class: "chassis" + } + + changeset = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).snmp_device_id + end + + test "invalid changeset without entity_index", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + entity_class: "chassis" + } + + changeset = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).entity_index + end + + test "invalid changeset without entity_class", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: 1 + } + + changeset = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).entity_class + end + + test "invalid entity_class value", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: 1, + entity_class: "invalid" + } + + changeset = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).entity_class + end + + test "valid entity_class values", %{snmp_device: snmp_device} do + for {entity_class, idx} <- + Enum.with_index(~w(other unknown chassis backplane container power fan sensor module port stack cpu memory)) do + attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: idx + 1, + entity_class: entity_class + } + + changeset = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + assert changeset.valid?, "entity_class '#{entity_class}' should be valid" + end + end + end + + describe "parent relationship" do + test "entity can have a parent entity", %{snmp_device: snmp_device} do + # Create parent chassis entity + chassis_attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: 1, + entity_class: "chassis", + description: "Main Chassis" + } + + chassis_changeset = PhysicalEntity.changeset(%PhysicalEntity{}, chassis_attrs) + assert {:ok, chassis} = Repo.insert(chassis_changeset) + + # Create child module entity + module_attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: 2, + entity_class: "module", + description: "Line Card", + parent_entity_id: chassis.id + } + + module_changeset = PhysicalEntity.changeset(%PhysicalEntity{}, module_attrs) + assert {:ok, module} = Repo.insert(module_changeset) + + # Verify relationship + loaded_module = Repo.preload(module, :parent_entity) + assert loaded_module.parent_entity_id == chassis.id + assert loaded_module.parent_entity.description == "Main Chassis" + end + + test "entity can have child entities", %{snmp_device: snmp_device} do + # Create parent chassis entity + chassis_attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: 1, + entity_class: "chassis" + } + + chassis_changeset = PhysicalEntity.changeset(%PhysicalEntity{}, chassis_attrs) + assert {:ok, chassis} = Repo.insert(chassis_changeset) + + # Create multiple child PSU entities + for idx <- 1..2 do + psu_attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: 100 + idx, + entity_class: "power", + description: "PSU #{idx}", + parent_entity_id: chassis.id + } + + psu_changeset = PhysicalEntity.changeset(%PhysicalEntity{}, psu_attrs) + assert {:ok, _psu} = Repo.insert(psu_changeset) + end + + # Verify relationship + loaded_chassis = Repo.preload(chassis, :child_entities) + assert length(loaded_chassis.child_entities) == 2 + end + end + + describe "unique constraint" do + test "prevents duplicate entity_index on same device", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + entity_index: 1, + entity_class: "chassis" + } + + # Insert first entity + changeset = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + assert {:ok, _entity} = Repo.insert(changeset) + + # Attempt to insert duplicate + changeset2 = PhysicalEntity.changeset(%PhysicalEntity{}, attrs) + + assert {:error, changeset} = Repo.insert(changeset2) + errors = errors_on(changeset) + + assert "has already been taken" in Map.get(errors, :entity_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 95760fb9..caae175a 100644 --- a/test/towerops/snmp/profiles/base_test.exs +++ b/test/towerops/snmp/profiles/base_test.exs @@ -771,4 +771,189 @@ defmodule Towerops.Snmp.Profiles.BaseTest do assert ip.prefix_length == nil end end + + describe "discover_physical_entities/1" do + test "discovers physical entities from ENTITY-MIB" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # entPhysicalDescr + "1.3.6.1.2.1.47.1.1.1.1.2" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1", value: {:octet_string, "Cisco 2960-X Chassis"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.2", value: {:octet_string, "Power Supply 1"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.3", value: {:octet_string, "Fan Tray 1"}} + ]} + + # entPhysicalContainedIn + "1.3.6.1.2.1.47.1.1.1.1.4" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.4.1", value: {:integer, 0}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.4.2", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.4.3", value: {:integer, 1}} + ]} + + # entPhysicalClass + "1.3.6.1.2.1.47.1.1.1.1.5" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1", value: {:integer, 3}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.2", value: {:integer, 6}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.3", value: {:integer, 7}} + ]} + + # entPhysicalSerialNum + "1.3.6.1.2.1.47.1.1.1.1.11" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.11.1", value: {:octet_string, "FCW1234ABC"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.11.2", value: {:octet_string, "PSU-001"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.11.3", value: {:octet_string, ""}} + ]} + + # entPhysicalModelName + "1.3.6.1.2.1.47.1.1.1.1.13" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.13.1", value: {:octet_string, "WS-C2960X-24TS-L"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.13.2", value: {:octet_string, "PWR-C1-350WAC"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.13.3", value: {:octet_string, "FAN-MOD-4HS"}} + ]} + + # entPhysicalMfgName + "1.3.6.1.2.1.47.1.1.1.1.12" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.12.1", value: {:octet_string, "Cisco"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.12.2", value: {:octet_string, "Cisco"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.12.3", value: {:octet_string, "Cisco"}} + ]} + + # entPhysicalHardwareRev + "1.3.6.1.2.1.47.1.1.1.1.8" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.8.1", value: {:octet_string, "V01"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.8.2", value: {:octet_string, "A0"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.8.3", value: {:octet_string, ""}} + ]} + + # entPhysicalFirmwareRev + "1.3.6.1.2.1.47.1.1.1.1.9" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.9.1", value: {:octet_string, "15.2(4)E7"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.9.2", value: {:octet_string, ""}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.9.3", value: {:octet_string, ""}} + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, entities} = Base.discover_physical_entities(@client_opts) + + assert length(entities) == 3 + + chassis = Enum.find(entities, &(&1.entity_index == 1)) + assert chassis.description == "Cisco 2960-X Chassis" + assert chassis.entity_class == "chassis" + assert chassis.serial_number == "FCW1234ABC" + assert chassis.model_name == "WS-C2960X-24TS-L" + assert chassis.manufacturer_name == "Cisco" + assert chassis.hardware_revision == "V01" + assert chassis.firmware_revision == "15.2(4)E7" + assert chassis.contained_in == 0 + + psu = Enum.find(entities, &(&1.entity_index == 2)) + assert psu.description == "Power Supply 1" + assert psu.entity_class == "power" + assert psu.contained_in == 1 + + fan = Enum.find(entities, &(&1.entity_index == 3)) + assert fan.description == "Fan Tray 1" + assert fan.entity_class == "fan" + assert fan.contained_in == 1 + end + + test "returns empty list when ENTITY-MIB not supported" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, entities} = Base.discover_physical_entities(@client_opts) + assert entities == [] + end + + test "handles walk errors gracefully" do + stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end) + + assert {:ok, entities} = Base.discover_physical_entities(@client_opts) + assert entities == [] + end + + test "maps entity class integers to strings" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + "1.3.6.1.2.1.47.1.1.1.1.2" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1", value: {:octet_string, "Other"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.2", value: {:octet_string, "Unknown"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.3", value: {:octet_string, "Chassis"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.4", value: {:octet_string, "Backplane"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.5", value: {:octet_string, "Container"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.6", value: {:octet_string, "PSU"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.7", value: {:octet_string, "Fan"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.8", value: {:octet_string, "Sensor"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.9", value: {:octet_string, "Module"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.10", value: {:octet_string, "Port"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.11", value: {:octet_string, "Stack"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.12", value: {:octet_string, "CPU"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.13", value: {:octet_string, "Memory"}} + ]} + + "1.3.6.1.2.1.47.1.1.1.1.4" -> + {:ok, + Enum.map(1..13, fn i -> + %{oid: "1.3.6.1.2.1.47.1.1.1.1.4.#{i}", value: {:integer, 0}} + end)} + + "1.3.6.1.2.1.47.1.1.1.1.5" -> + {:ok, + Enum.map(1..13, fn i -> + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.#{i}", value: {:integer, i}} + end)} + + _ -> + {:ok, []} + end + end) + + assert {:ok, entities} = Base.discover_physical_entities(@client_opts) + + assert length(entities) == 13 + + expected_classes = [ + "other", + "unknown", + "chassis", + "backplane", + "container", + "power", + "fan", + "sensor", + "module", + "port", + "stack", + "cpu", + "memory" + ] + + for {expected_class, idx} <- Enum.with_index(expected_classes, 1) do + entity = Enum.find(entities, &(&1.entity_index == idx)) + assert entity.entity_class == expected_class, "Entity #{idx} should have class #{expected_class}" + end + end + end end