feat: add DeviceLink and DeviceLinkEvidence schemas
Ecto schemas for persistent topology model. DeviceLink tracks connections between devices with confidence scores. DeviceLinkEvidence records why each link is believed to exist. Device schema gets device_role and device_role_source fields.
This commit is contained in:
parent
cd6f2153ce
commit
29abb688d2
5 changed files with 360 additions and 1 deletions
|
|
@ -18,6 +18,7 @@ defmodule Towerops.Devices.Device do
|
|||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Sites.Site
|
||||
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||
alias Towerops.Topology.DeviceLink
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
|
@ -61,11 +62,17 @@ defmodule Towerops.Devices.Device do
|
|||
field :mikrotik_enabled, :boolean
|
||||
field :mikrotik_credential_source, :string, default: "site"
|
||||
|
||||
# Topology role (auto-inferred or manually set)
|
||||
field :device_role, :string
|
||||
field :device_role_source, :string, default: "inferred"
|
||||
|
||||
belongs_to :site, Site
|
||||
belongs_to :organization, Organization
|
||||
|
||||
has_one :snmp_device, SnmpDevice
|
||||
has_many :agent_assignments, AgentAssignment
|
||||
has_many :outbound_links, DeviceLink, foreign_key: :source_device_id
|
||||
has_many :inbound_links, DeviceLink, foreign_key: :target_device_id
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
|
@ -104,12 +111,16 @@ defmodule Towerops.Devices.Device do
|
|||
mikrotik_use_ssl: boolean() | nil,
|
||||
mikrotik_enabled: boolean() | nil,
|
||||
mikrotik_credential_source: String.t() | nil,
|
||||
device_role: String.t() | nil,
|
||||
device_role_source: String.t(),
|
||||
site_id: Ecto.UUID.t() | nil,
|
||||
site: NotLoaded.t() | Site.t() | nil,
|
||||
organization_id: Ecto.UUID.t(),
|
||||
organization: NotLoaded.t() | Organization.t() | nil,
|
||||
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
|
||||
agent_assignments: NotLoaded.t() | [AgentAssignment.t()],
|
||||
outbound_links: NotLoaded.t() | [DeviceLink.t()],
|
||||
inbound_links: NotLoaded.t() | [DeviceLink.t()],
|
||||
inserted_at: DateTime.t(),
|
||||
updated_at: DateTime.t()
|
||||
}
|
||||
|
|
@ -151,7 +162,9 @@ defmodule Towerops.Devices.Device do
|
|||
:mikrotik_ssh_port,
|
||||
:mikrotik_use_ssl,
|
||||
:mikrotik_enabled,
|
||||
:mikrotik_credential_source
|
||||
:mikrotik_credential_source,
|
||||
:device_role,
|
||||
:device_role_source
|
||||
])
|
||||
|> validate_required([:ip_address, :organization_id])
|
||||
|> validate_name()
|
||||
|
|
|
|||
68
lib/towerops/topology/device_link.ex
Normal file
68
lib/towerops/topology/device_link.ex
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
defmodule Towerops.Topology.DeviceLink do
|
||||
@moduledoc """
|
||||
Schema for links between network devices discovered via topology protocols.
|
||||
|
||||
Links represent physical or logical connections between devices, identified
|
||||
through LLDP, CDP, MAC table analysis, ARP inference, wireless associations,
|
||||
or manual configuration.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Topology.DeviceLinkEvidence
|
||||
|
||||
@valid_link_types ~w(lldp cdp mac_match wireless_association arp_inference manual)
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "device_links" do
|
||||
field :link_type, :string
|
||||
field :confidence, :float, default: 0.5
|
||||
field :metadata, :map, default: %{}
|
||||
field :discovered_remote_name, :string
|
||||
field :discovered_remote_ip, :string
|
||||
field :discovered_remote_mac, :string
|
||||
field :last_confirmed_at, :utc_datetime
|
||||
|
||||
belongs_to :source_device, Device
|
||||
belongs_to :target_device, Device
|
||||
belongs_to :source_interface, Interface
|
||||
belongs_to :target_interface, Interface
|
||||
|
||||
has_many :evidence, DeviceLinkEvidence
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(link, attrs) do
|
||||
link
|
||||
|> cast(attrs, [
|
||||
:source_device_id,
|
||||
:target_device_id,
|
||||
:source_interface_id,
|
||||
:target_interface_id,
|
||||
:link_type,
|
||||
:confidence,
|
||||
:metadata,
|
||||
:discovered_remote_name,
|
||||
:discovered_remote_ip,
|
||||
:discovered_remote_mac,
|
||||
:last_confirmed_at
|
||||
])
|
||||
|> validate_required([:source_device_id, :link_type, :last_confirmed_at])
|
||||
|> validate_inclusion(:link_type, @valid_link_types)
|
||||
|> validate_number(:confidence,
|
||||
greater_than_or_equal_to: 0.0,
|
||||
less_than_or_equal_to: 1.0
|
||||
)
|
||||
|> foreign_key_constraint(:source_device_id)
|
||||
|> foreign_key_constraint(:target_device_id)
|
||||
|> unique_constraint([:source_device_id, :source_interface_id, :discovered_remote_mac],
|
||||
name: :device_links_src_iface_remote_mac
|
||||
)
|
||||
end
|
||||
end
|
||||
37
lib/towerops/topology/device_link_evidence.ex
Normal file
37
lib/towerops/topology/device_link_evidence.ex
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule Towerops.Topology.DeviceLinkEvidence do
|
||||
@moduledoc """
|
||||
Schema for evidence supporting a device link.
|
||||
|
||||
Each piece of evidence records a specific observation (LLDP neighbor,
|
||||
CDP neighbor, MAC on interface, ARP entry, etc.) that contributed to
|
||||
the existence or confidence of a device link.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Topology.DeviceLink
|
||||
|
||||
@valid_evidence_types ~w(lldp_neighbor cdp_neighbor mac_on_interface arp_entry wireless_registration subnet_match)
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "device_link_evidence" do
|
||||
field :evidence_type, :string
|
||||
field :evidence_data, :map, default: %{}
|
||||
field :observed_at, :utc_datetime
|
||||
|
||||
belongs_to :device_link, DeviceLink
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(evidence, attrs) do
|
||||
evidence
|
||||
|> cast(attrs, [:device_link_id, :evidence_type, :evidence_data, :observed_at])
|
||||
|> validate_required([:device_link_id, :evidence_type, :observed_at])
|
||||
|> validate_inclusion(:evidence_type, @valid_evidence_types)
|
||||
|> foreign_key_constraint(:device_link_id)
|
||||
end
|
||||
end
|
||||
90
test/towerops/topology/device_link_evidence_test.exs
Normal file
90
test/towerops/topology/device_link_evidence_test.exs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
defmodule Towerops.Topology.DeviceLinkEvidenceTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.Topology.DeviceLinkEvidence
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset with required fields" do
|
||||
attrs = %{
|
||||
device_link_id: Ecto.UUID.generate(),
|
||||
evidence_type: "lldp_neighbor",
|
||||
observed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without device_link_id" do
|
||||
attrs = %{
|
||||
evidence_type: "lldp_neighbor",
|
||||
observed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).device_link_id
|
||||
end
|
||||
|
||||
test "invalid without evidence_type" do
|
||||
attrs = %{
|
||||
device_link_id: Ecto.UUID.generate(),
|
||||
observed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).evidence_type
|
||||
end
|
||||
|
||||
test "invalid without observed_at" do
|
||||
attrs = %{
|
||||
device_link_id: Ecto.UUID.generate(),
|
||||
evidence_type: "lldp_neighbor"
|
||||
}
|
||||
|
||||
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).observed_at
|
||||
end
|
||||
|
||||
test "validates evidence_type inclusion rejects invalid type" do
|
||||
attrs = %{
|
||||
device_link_id: Ecto.UUID.generate(),
|
||||
evidence_type: "invalid",
|
||||
observed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "is invalid" in errors_on(changeset).evidence_type
|
||||
end
|
||||
|
||||
test "validates all valid evidence_types" do
|
||||
valid_types = ~w(lldp_neighbor cdp_neighbor mac_on_interface arp_entry wireless_registration subnet_match)
|
||||
|
||||
for evidence_type <- valid_types do
|
||||
attrs = %{
|
||||
device_link_id: Ecto.UUID.generate(),
|
||||
evidence_type: evidence_type,
|
||||
observed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
|
||||
assert changeset.valid?, "expected #{evidence_type} to be a valid evidence_type"
|
||||
end
|
||||
end
|
||||
|
||||
test "valid changeset with optional evidence_data" do
|
||||
attrs = %{
|
||||
device_link_id: Ecto.UUID.generate(),
|
||||
evidence_type: "arp_entry",
|
||||
evidence_data: %{"ip" => "10.0.0.1", "mac" => "AA:BB:CC:DD:EE:FF"},
|
||||
observed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
151
test/towerops/topology/device_link_test.exs
Normal file
151
test/towerops/topology/device_link_test.exs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
defmodule Towerops.Topology.DeviceLinkTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.Topology.DeviceLink
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid changeset with required fields" do
|
||||
source_device_id = Ecto.UUID.generate()
|
||||
|
||||
attrs = %{
|
||||
source_device_id: source_device_id,
|
||||
link_type: "lldp",
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without source_device_id" do
|
||||
attrs = %{
|
||||
link_type: "lldp",
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).source_device_id
|
||||
end
|
||||
|
||||
test "invalid without link_type" do
|
||||
attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).link_type
|
||||
end
|
||||
|
||||
test "invalid without last_confirmed_at" do
|
||||
attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
link_type: "lldp"
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).last_confirmed_at
|
||||
end
|
||||
|
||||
test "validates link_type inclusion rejects invalid type" do
|
||||
attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
link_type: "invalid",
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert "is invalid" in errors_on(changeset).link_type
|
||||
end
|
||||
|
||||
test "validates all valid link_types" do
|
||||
valid_types = ~w(lldp cdp mac_match wireless_association arp_inference manual)
|
||||
|
||||
for link_type <- valid_types do
|
||||
attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
link_type: link_type,
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
assert changeset.valid?, "expected #{link_type} to be a valid link_type"
|
||||
end
|
||||
end
|
||||
|
||||
test "validates confidence rejects value above 1.0" do
|
||||
attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
link_type: "lldp",
|
||||
confidence: 1.5,
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset).confidence != []
|
||||
end
|
||||
|
||||
test "validates confidence rejects negative value" do
|
||||
attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
link_type: "lldp",
|
||||
confidence: -0.1,
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset).confidence != []
|
||||
end
|
||||
|
||||
test "accepts confidence at boundaries 0.0 and 1.0" do
|
||||
base_attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
link_type: "lldp",
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset_zero = DeviceLink.changeset(%DeviceLink{}, Map.put(base_attrs, :confidence, 0.0))
|
||||
assert changeset_zero.valid?
|
||||
|
||||
changeset_one = DeviceLink.changeset(%DeviceLink{}, Map.put(base_attrs, :confidence, 1.0))
|
||||
assert changeset_one.valid?
|
||||
end
|
||||
|
||||
test "defaults confidence to 0.5" do
|
||||
attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
link_type: "lldp",
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
assert changeset.valid?
|
||||
assert Ecto.Changeset.get_field(changeset, :confidence) == 0.5
|
||||
end
|
||||
|
||||
test "valid changeset with all optional fields" do
|
||||
attrs = %{
|
||||
source_device_id: Ecto.UUID.generate(),
|
||||
target_device_id: Ecto.UUID.generate(),
|
||||
source_interface_id: Ecto.UUID.generate(),
|
||||
target_interface_id: Ecto.UUID.generate(),
|
||||
link_type: "cdp",
|
||||
confidence: 0.95,
|
||||
metadata: %{"vlan" => 100},
|
||||
discovered_remote_name: "switch-01.local",
|
||||
discovered_remote_ip: "10.0.0.1",
|
||||
discovered_remote_mac: "AA:BB:CC:DD:EE:FF",
|
||||
last_confirmed_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue