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
This commit is contained in:
Graham McIntire 2026-01-21 10:46:27 -06:00
parent 1cf6f327ea
commit 45ddcdac80
No known key found for this signature in database
6 changed files with 669 additions and 9 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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