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:
parent
1cf6f327ea
commit
45ddcdac80
6 changed files with 669 additions and 9 deletions
92
lib/towerops/snmp/physical_entity.ex
Normal file
92
lib/towerops/snmp/physical_entity.ex
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
223
test/towerops/snmp/physical_entity_test.exs
Normal file
223
test/towerops/snmp/physical_entity_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue