towerops/lib/towerops/snmp/physical_entity.ex
Graham McIntire 45ddcdac80
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
2026-01-21 10:46:27 -06:00

92 lines
2.8 KiB
Elixir

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