feat: add Topology context with device matching
Build lookup maps to resolve evidence (MAC, IP, hostname) to managed device IDs. Foundation for evidence collectors.
This commit is contained in:
parent
29abb688d2
commit
c74243d321
2 changed files with 220 additions and 0 deletions
83
lib/towerops/topology.ex
Normal file
83
lib/towerops/topology.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule Towerops.Topology do
|
||||
@moduledoc """
|
||||
Topology inference engine. Builds and maintains a persistent model of
|
||||
network device relationships by analyzing SNMP polling data.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Snmp.Interface
|
||||
|
||||
@doc """
|
||||
Builds lookup maps for matching evidence to managed devices in an organization.
|
||||
|
||||
Returns a map with `:by_ip`, `:by_name`, and `:by_mac` keys, each mapping
|
||||
an identifier string to a device ID.
|
||||
"""
|
||||
def build_device_lookup(organization_id) do
|
||||
devices =
|
||||
Repo.all(
|
||||
from(d in Device,
|
||||
where: d.organization_id == ^organization_id,
|
||||
left_join: sd in assoc(d, :snmp_device),
|
||||
left_join: i in Interface,
|
||||
on: sd.id == i.snmp_device_id,
|
||||
preload: [snmp_device: {sd, interfaces: i}]
|
||||
)
|
||||
)
|
||||
|
||||
by_ip = Map.new(devices, fn d -> {d.ip_address, d.id} end)
|
||||
|
||||
by_name =
|
||||
devices
|
||||
|> Enum.filter(&(&1.name != nil))
|
||||
|> Map.new(&{String.downcase(&1.name), &1.id})
|
||||
|
||||
by_mac =
|
||||
devices
|
||||
|> Enum.flat_map(fn device ->
|
||||
case device.snmp_device do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
sd ->
|
||||
sd.interfaces
|
||||
|> Enum.filter(&(&1.if_phys_address != nil))
|
||||
|> Enum.map(&{&1.if_phys_address, device.id})
|
||||
end
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
%{by_ip: by_ip, by_name: by_name, by_mac: by_mac}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds a device ID by IP address from the lookup.
|
||||
"""
|
||||
def find_device_by_ip(lookup, ip), do: Map.get(lookup.by_ip, ip)
|
||||
|
||||
@doc """
|
||||
Finds a device ID by name (case-insensitive) from the lookup.
|
||||
"""
|
||||
def find_device_by_name(lookup, name) when is_binary(name), do: Map.get(lookup.by_name, String.downcase(name))
|
||||
|
||||
def find_device_by_name(_lookup, _name), do: nil
|
||||
|
||||
@doc """
|
||||
Finds a device ID by MAC address from the lookup.
|
||||
"""
|
||||
def find_device_by_mac(lookup, mac), do: Map.get(lookup.by_mac, mac)
|
||||
|
||||
@doc """
|
||||
Resolves a device ID from evidence fields, trying MAC first, then IP, then name.
|
||||
"""
|
||||
def resolve_device(lookup, %{mac: mac, ip: ip, name: name}) do
|
||||
find_device_by_mac(lookup, mac) ||
|
||||
find_device_by_ip(lookup, ip) ||
|
||||
find_device_by_name(lookup, name)
|
||||
end
|
||||
|
||||
def resolve_device(_lookup, _), do: nil
|
||||
end
|
||||
137
test/towerops/topology_test.exs
Normal file
137
test/towerops/topology_test.exs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
defmodule Towerops.TopologyTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.Interface
|
||||
alias Towerops.Topology
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
{:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})
|
||||
|
||||
{:ok, router} =
|
||||
Towerops.Devices.create_device(
|
||||
%{
|
||||
name: "Core-Router",
|
||||
ip_address: "10.0.0.1",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
},
|
||||
bypass_limits: true
|
||||
)
|
||||
|
||||
{:ok, switch} =
|
||||
Towerops.Devices.create_device(
|
||||
%{
|
||||
name: "Main-Switch",
|
||||
ip_address: "10.0.0.2",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
},
|
||||
bypass_limits: true
|
||||
)
|
||||
|
||||
# Create SNMP device and interface with a known MAC for the router
|
||||
router_snmp =
|
||||
%Device{}
|
||||
|> Device.changeset(%{device_id: router.id, sys_name: "core-router"})
|
||||
|> Repo.insert!()
|
||||
|
||||
router_iface =
|
||||
%Interface{}
|
||||
|> Interface.changeset(%{
|
||||
snmp_device_id: router_snmp.id,
|
||||
if_index: 1,
|
||||
if_name: "eth0",
|
||||
if_phys_address: "aa:bb:cc:00:11:22"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
%{
|
||||
organization: organization,
|
||||
site: site,
|
||||
router: router,
|
||||
switch: switch,
|
||||
router_snmp: router_snmp,
|
||||
router_iface: router_iface
|
||||
}
|
||||
end
|
||||
|
||||
describe "build_device_lookup/1" do
|
||||
test "matches device by IP address", %{organization: org, switch: switch} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
assert Topology.find_device_by_ip(lookup, "10.0.0.2") == switch.id
|
||||
end
|
||||
|
||||
test "matches device by name (case-insensitive)", %{organization: org, router: router} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
assert Topology.find_device_by_name(lookup, "core-router") == router.id
|
||||
assert Topology.find_device_by_name(lookup, "CORE-ROUTER") == router.id
|
||||
end
|
||||
|
||||
test "matches device by MAC address", %{organization: org, router: router} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
assert Topology.find_device_by_mac(lookup, "aa:bb:cc:00:11:22") == router.id
|
||||
end
|
||||
|
||||
test "returns nil for unknown IP", %{organization: org} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
assert Topology.find_device_by_ip(lookup, "192.168.99.99") == nil
|
||||
end
|
||||
|
||||
test "returns nil for unknown MAC", %{organization: org} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
assert Topology.find_device_by_mac(lookup, "ff:ff:ff:ff:ff:ff") == nil
|
||||
end
|
||||
|
||||
test "returns nil for unknown name", %{organization: org} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
assert Topology.find_device_by_name(lookup, "nonexistent") == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve_device/2" do
|
||||
test "resolves by MAC first", %{organization: org, router: router} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
|
||||
result =
|
||||
Topology.resolve_device(lookup, %{
|
||||
mac: "aa:bb:cc:00:11:22",
|
||||
ip: "10.0.0.2",
|
||||
name: "Main-Switch"
|
||||
})
|
||||
|
||||
# MAC matches router, even though IP and name match switch
|
||||
assert result == router.id
|
||||
end
|
||||
|
||||
test "falls back to IP when MAC is nil", %{organization: org, switch: switch} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
result = Topology.resolve_device(lookup, %{mac: nil, ip: "10.0.0.2", name: nil})
|
||||
assert result == switch.id
|
||||
end
|
||||
|
||||
test "falls back to name when MAC and IP are nil", %{organization: org, router: router} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
result = Topology.resolve_device(lookup, %{mac: nil, ip: nil, name: "Core-Router"})
|
||||
assert result == router.id
|
||||
end
|
||||
|
||||
test "returns nil when nothing matches", %{organization: org} do
|
||||
lookup = Topology.build_device_lookup(org.id)
|
||||
result = Topology.resolve_device(lookup, %{mac: nil, ip: nil, name: nil})
|
||||
assert result == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue