From 29abb688d2f493bfc91b6ebc797734dd31932b23 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 12:49:11 -0600 Subject: [PATCH] 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. --- lib/towerops/devices/device.ex | 15 +- lib/towerops/topology/device_link.ex | 68 ++++++++ lib/towerops/topology/device_link_evidence.ex | 37 +++++ .../topology/device_link_evidence_test.exs | 90 +++++++++++ test/towerops/topology/device_link_test.exs | 151 ++++++++++++++++++ 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 lib/towerops/topology/device_link.ex create mode 100644 lib/towerops/topology/device_link_evidence.ex create mode 100644 test/towerops/topology/device_link_evidence_test.exs create mode 100644 test/towerops/topology/device_link_test.exs diff --git a/lib/towerops/devices/device.ex b/lib/towerops/devices/device.ex index 64daad97..a27dcad0 100644 --- a/lib/towerops/devices/device.ex +++ b/lib/towerops/devices/device.ex @@ -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() diff --git a/lib/towerops/topology/device_link.ex b/lib/towerops/topology/device_link.ex new file mode 100644 index 00000000..8f3deb96 --- /dev/null +++ b/lib/towerops/topology/device_link.ex @@ -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 diff --git a/lib/towerops/topology/device_link_evidence.ex b/lib/towerops/topology/device_link_evidence.ex new file mode 100644 index 00000000..a05f0633 --- /dev/null +++ b/lib/towerops/topology/device_link_evidence.ex @@ -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 diff --git a/test/towerops/topology/device_link_evidence_test.exs b/test/towerops/topology/device_link_evidence_test.exs new file mode 100644 index 00000000..d001f3af --- /dev/null +++ b/test/towerops/topology/device_link_evidence_test.exs @@ -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 diff --git a/test/towerops/topology/device_link_test.exs b/test/towerops/topology/device_link_test.exs new file mode 100644 index 00000000..6c3b09fc --- /dev/null +++ b/test/towerops/topology/device_link_test.exs @@ -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