diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index 80beac99..845d478c 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -12,6 +12,8 @@ defmodule Towerops.Topology do alias Towerops.Snmp.Interface alias Towerops.Snmp.MacAddress alias Towerops.Snmp.Neighbor + alias Towerops.Topology.DeviceLink + alias Towerops.Topology.DeviceLinkEvidence @doc """ Builds lookup maps for matching evidence to managed devices in an organization. @@ -219,4 +221,57 @@ defmodule Towerops.Topology do end) |> Enum.reject(fn ev -> ev.matched_device_id == device_id end) end + + @doc "Merge two independent confidence scores: 1 - (1-a)(1-b)" + def merge_confidence(a, b), do: 1.0 - (1.0 - a) * (1.0 - b) + + @doc """ + Create or update a device link. Keyed on source_device_id + source_interface_id + + discovered_remote_mac. If existing, merges confidence and appends evidence. + Always updates last_confirmed_at. + """ + def upsert_link(attrs) do + evidence_attrs = Map.get(attrs, :evidence, []) + link_attrs = Map.delete(attrs, :evidence) + + existing = + Repo.one( + from(l in DeviceLink, + where: + l.source_device_id == ^link_attrs.source_device_id and + l.source_interface_id == ^link_attrs[:source_interface_id] and + l.discovered_remote_mac == ^link_attrs[:discovered_remote_mac] + ) + ) + + result = + case existing do + nil -> + %DeviceLink{} + |> DeviceLink.changeset(link_attrs) + |> Repo.insert() + + link -> + merged_confidence = merge_confidence(link.confidence, link_attrs.confidence) + + link + |> DeviceLink.changeset(%{ + confidence: merged_confidence, + last_confirmed_at: link_attrs.last_confirmed_at, + target_device_id: link_attrs[:target_device_id] || link.target_device_id, + metadata: Map.merge(link.metadata || %{}, link_attrs[:metadata] || %{}) + }) + |> Repo.update() + end + + with {:ok, link} <- result do + Enum.each(evidence_attrs, fn ev -> + %DeviceLinkEvidence{} + |> DeviceLinkEvidence.changeset(Map.put(ev, :device_link_id, link.id)) + |> Repo.insert!() + end) + + {:ok, Repo.reload!(link)} + end + end end diff --git a/test/towerops/topology_test.exs b/test/towerops/topology_test.exs index c89bce3f..4e5dca5a 100644 --- a/test/towerops/topology_test.exs +++ b/test/towerops/topology_test.exs @@ -283,6 +283,137 @@ defmodule Towerops.TopologyTest do end end + describe "merge_confidence/2" do + test "combines two independent confidence scores" do + assert_in_delta Topology.merge_confidence(0.95, 0.7), 0.985, 0.001 + end + + test "combining with 0 returns the other score" do + assert Topology.merge_confidence(0.8, 0.0) == 0.8 + end + + test "combining two high scores approaches 1.0" do + assert_in_delta Topology.merge_confidence(0.95, 0.95), 0.9975, 0.001 + end + end + + describe "upsert_link/1" do + test "creates a new link from evidence", %{router: router, router_iface: iface} do + now = DateTime.utc_now() + + {:ok, link} = + Topology.upsert_link(%{ + source_device_id: router.id, + target_device_id: nil, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.95, + discovered_remote_mac: "dd:ee:ff:00:11:22", + discovered_remote_ip: "10.0.0.5", + discovered_remote_name: "remote-switch", + last_confirmed_at: now, + evidence: [ + %{ + evidence_type: "lldp_neighbor", + evidence_data: %{"remote_chassis_id" => "dd:ee:ff:00:11:22"}, + observed_at: now + } + ] + }) + + assert link.source_device_id == router.id + assert link.confidence == 0.95 + assert link.link_type == "lldp" + + link_with_evidence = Repo.preload(link, :evidence) + assert length(link_with_evidence.evidence) == 1 + end + + test "updates existing link and merges confidence", %{router: router, router_iface: iface} do + now = DateTime.utc_now() + + # Create initial LLDP link + {:ok, _link1} = + Topology.upsert_link(%{ + source_device_id: router.id, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.95, + discovered_remote_mac: "dd:ee:ff:00:11:22", + last_confirmed_at: now, + evidence: [ + %{ + evidence_type: "lldp_neighbor", + evidence_data: %{}, + observed_at: now + } + ] + }) + + # Upsert again with MAC evidence — should merge confidence + {:ok, updated} = + Topology.upsert_link(%{ + source_device_id: router.id, + source_interface_id: iface.id, + link_type: "mac_match", + confidence: 0.7, + discovered_remote_mac: "dd:ee:ff:00:11:22", + last_confirmed_at: now, + evidence: [ + %{ + evidence_type: "mac_on_interface", + evidence_data: %{}, + observed_at: now + } + ] + }) + + # Confidence merged: 1 - (1-0.95)(1-0.7) = 0.985 + assert_in_delta updated.confidence, 0.985, 0.001 + # Evidence accumulated + updated_with_evidence = Repo.preload(updated, :evidence) + assert length(updated_with_evidence.evidence) == 2 + end + + test "populates target_device_id when matched", %{ + router: router, + switch: switch, + router_iface: iface + } do + now = DateTime.utc_now() + + {:ok, link} = + Topology.upsert_link(%{ + source_device_id: router.id, + target_device_id: switch.id, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.95, + discovered_remote_mac: "bb:cc:dd:00:11:22", + last_confirmed_at: now, + evidence: [] + }) + + assert link.target_device_id == switch.id + end + + test "creates link without evidence list", %{router: router, router_iface: iface} do + now = DateTime.utc_now() + + {:ok, link} = + Topology.upsert_link(%{ + source_device_id: router.id, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.95, + discovered_remote_mac: "dd:ee:ff:00:11:22", + last_confirmed_at: now + }) + + assert link.id + end + end + describe "collect_arp_evidence/2" do test "returns evidence for ARP entries matching known devices", %{ organization: org,