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:
Graham McIntire 2026-02-12 12:49:11 -06:00
parent cd6f2153ce
commit 29abb688d2
No known key found for this signature in database
5 changed files with 360 additions and 1 deletions

View file

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

View 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

View 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

View 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

View 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