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