add device matcher for preseem AP to towerops device linking
This commit is contained in:
parent
bf03a5a9a1
commit
78040c0df7
2 changed files with 406 additions and 0 deletions
154
lib/towerops/preseem/device_matcher.ex
Normal file
154
lib/towerops/preseem/device_matcher.ex
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
defmodule Towerops.Preseem.DeviceMatcher do
|
||||
@moduledoc """
|
||||
Attempts to match Preseem access points to Towerops devices.
|
||||
|
||||
Matching strategy (ordered by confidence):
|
||||
1. MAC address (through interface -> snmp_device -> device)
|
||||
2. IP address (direct device match)
|
||||
3. Hostname (normalized name comparison)
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Preseem.AccessPoint
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||
alias Towerops.Snmp.Interface
|
||||
|
||||
@doc """
|
||||
Finds all unmatched access points for an organization and tries to match each one.
|
||||
|
||||
Returns `{:ok, matched_count}` where matched_count is the number of APs
|
||||
that were successfully matched to a device.
|
||||
"""
|
||||
def match_unmatched(organization_id) do
|
||||
unmatched_aps =
|
||||
AccessPoint
|
||||
|> where(organization_id: ^organization_id, match_confidence: "unmatched")
|
||||
|> Repo.all()
|
||||
|
||||
matched_count =
|
||||
Enum.count(unmatched_aps, fn ap ->
|
||||
case match_access_point(ap) do
|
||||
{:ok, matched_ap} -> matched_ap.device_id != nil
|
||||
_ -> false
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, matched_count}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts to match a single access point against devices in the same organization.
|
||||
|
||||
Skips APs with `match_confidence == "manual"` (user manually linked).
|
||||
Returns `{:ok, access_point}` with updated match_confidence and device_id.
|
||||
"""
|
||||
def match_access_point(%AccessPoint{match_confidence: "manual"} = ap) do
|
||||
{:ok, ap}
|
||||
end
|
||||
|
||||
def match_access_point(%AccessPoint{} = ap) do
|
||||
org_id = ap.organization_id
|
||||
|
||||
result =
|
||||
try_mac_match(ap, org_id) ||
|
||||
try_ip_match(ap, org_id) ||
|
||||
try_hostname_match(ap, org_id)
|
||||
|
||||
case result do
|
||||
{:single, device_id, confidence} ->
|
||||
update_match(ap, device_id, confidence)
|
||||
|
||||
:ambiguous ->
|
||||
update_match(ap, nil, "ambiguous")
|
||||
|
||||
nil ->
|
||||
{:ok, ap}
|
||||
end
|
||||
end
|
||||
|
||||
defp try_mac_match(%AccessPoint{mac_address: nil}, _org_id), do: nil
|
||||
defp try_mac_match(%AccessPoint{mac_address: ""}, _org_id), do: nil
|
||||
|
||||
defp try_mac_match(%AccessPoint{mac_address: mac}, org_id) do
|
||||
normalized_mac = normalize_mac(mac)
|
||||
|
||||
# Query interface MAC addresses through interface -> snmp_device -> device chain,
|
||||
# then filter in Elixir since DB stores MACs in various formats
|
||||
device_ids =
|
||||
Interface
|
||||
|> join(:inner, [i], sd in SnmpDevice, on: i.snmp_device_id == sd.id)
|
||||
|> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id)
|
||||
|> where([_i, _sd, d], d.organization_id == ^org_id)
|
||||
|> where([i, _sd, _d], not is_nil(i.if_phys_address))
|
||||
|> select([i, _sd, d], {d.id, i.if_phys_address})
|
||||
|> Repo.all()
|
||||
|> Enum.filter(fn {_id, phys_addr} -> normalize_mac(phys_addr) == normalized_mac end)
|
||||
|> Enum.map(fn {id, _} -> id end)
|
||||
|> Enum.uniq()
|
||||
|
||||
evaluate_matches(device_ids, "auto_mac")
|
||||
end
|
||||
|
||||
defp try_ip_match(%AccessPoint{ip_address: nil}, _org_id), do: nil
|
||||
defp try_ip_match(%AccessPoint{ip_address: ""}, _org_id), do: nil
|
||||
|
||||
defp try_ip_match(%AccessPoint{ip_address: ip}, org_id) do
|
||||
device_ids =
|
||||
Device
|
||||
|> where(organization_id: ^org_id, ip_address: ^ip)
|
||||
|> select([d], d.id)
|
||||
|> Repo.all()
|
||||
|
||||
evaluate_matches(device_ids, "auto_ip")
|
||||
end
|
||||
|
||||
defp try_hostname_match(%AccessPoint{name: nil}, _org_id), do: nil
|
||||
defp try_hostname_match(%AccessPoint{name: ""}, _org_id), do: nil
|
||||
|
||||
defp try_hostname_match(%AccessPoint{name: ap_name}, org_id) do
|
||||
normalized_ap_name = normalize_hostname(ap_name)
|
||||
|
||||
device_ids =
|
||||
Device
|
||||
|> where(organization_id: ^org_id)
|
||||
|> where([d], not is_nil(d.name))
|
||||
|> select([d], {d.id, d.name})
|
||||
|> Repo.all()
|
||||
|> Enum.filter(fn {_id, name} -> normalize_hostname(name) == normalized_ap_name end)
|
||||
|> Enum.map(fn {id, _} -> id end)
|
||||
|
||||
evaluate_matches(device_ids, "auto_hostname")
|
||||
end
|
||||
|
||||
defp evaluate_matches([], _confidence), do: nil
|
||||
defp evaluate_matches([device_id], confidence), do: {:single, device_id, confidence}
|
||||
defp evaluate_matches([_ | _], _confidence), do: :ambiguous
|
||||
|
||||
defp update_match(ap, device_id, confidence) do
|
||||
ap
|
||||
|> AccessPoint.changeset(%{device_id: device_id, match_confidence: confidence})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def normalize_mac(mac) when is_binary(mac) do
|
||||
mac
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[:\-\.]/, "")
|
||||
end
|
||||
|
||||
def normalize_mac(_), do: ""
|
||||
|
||||
@doc false
|
||||
def normalize_hostname(name) when is_binary(name) do
|
||||
name
|
||||
|> String.downcase()
|
||||
|> String.split(".")
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
def normalize_hostname(_), do: ""
|
||||
end
|
||||
252
test/towerops/preseem/device_matcher_test.exs
Normal file
252
test/towerops/preseem/device_matcher_test.exs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
defmodule Towerops.Preseem.DeviceMatcherTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.DevicesFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Preseem.AccessPoint
|
||||
alias Towerops.Preseem.DeviceMatcher
|
||||
alias Towerops.Preseem.Sync
|
||||
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||
alias Towerops.Snmp.Interface
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
defp create_device_with_mac(org, ip_address, mac_address, opts \\ []) do
|
||||
name = Keyword.get(opts, :name, "Device #{System.unique_integer([:positive])}")
|
||||
|
||||
device = device_fixture(%{organization_id: org.id, ip_address: ip_address, name: name})
|
||||
|
||||
snmp_device =
|
||||
%SnmpDevice{}
|
||||
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: name})
|
||||
|> Repo.insert!()
|
||||
|
||||
_interface =
|
||||
%Interface{}
|
||||
|> Interface.changeset(%{
|
||||
snmp_device_id: snmp_device.id,
|
||||
if_index: 1,
|
||||
if_name: "eth0",
|
||||
if_phys_address: mac_address
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
device
|
||||
end
|
||||
|
||||
defp create_preseem_ap(org_id, attrs) do
|
||||
ap_data =
|
||||
Map.merge(
|
||||
%{
|
||||
"id" => "preseem-#{System.unique_integer([:positive])}",
|
||||
"name" => "Test AP"
|
||||
},
|
||||
attrs
|
||||
)
|
||||
|
||||
{:ok, ap} = Sync.upsert_access_point(org_id, ap_data)
|
||||
ap
|
||||
end
|
||||
|
||||
describe "match_access_point/1" do
|
||||
test "matches by MAC address through interface -> snmp_device -> device", %{org: org} do
|
||||
_device = create_device_with_mac(org, "10.0.0.1", "aa:bb:cc:dd:ee:ff")
|
||||
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-mac-match",
|
||||
"name" => "MAC Match AP",
|
||||
"mac_address" => "AA:BB:CC:DD:EE:FF"
|
||||
})
|
||||
|
||||
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
|
||||
assert matched_ap.match_confidence == "auto_mac"
|
||||
assert matched_ap.device_id
|
||||
end
|
||||
|
||||
test "normalizes MAC addresses for matching (strips separators)", %{org: org} do
|
||||
_device = create_device_with_mac(org, "10.0.0.2", "11:22:33:44:55:66")
|
||||
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-mac-normalize",
|
||||
"name" => "MAC Normalize AP",
|
||||
"mac_address" => "11-22-33-44-55-66"
|
||||
})
|
||||
|
||||
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
|
||||
assert matched_ap.match_confidence == "auto_mac"
|
||||
end
|
||||
|
||||
test "matches by IP address when MAC does not match", %{org: org} do
|
||||
_device = device_fixture(%{organization_id: org.id, ip_address: "10.0.0.50", name: "IP Device"})
|
||||
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-ip-match",
|
||||
"name" => "IP Match AP",
|
||||
"mac_address" => "FF:FF:FF:FF:FF:FF",
|
||||
"ip_address" => "10.0.0.50"
|
||||
})
|
||||
|
||||
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
|
||||
assert matched_ap.match_confidence == "auto_ip"
|
||||
assert matched_ap.device_id
|
||||
end
|
||||
|
||||
test "matches by hostname when MAC and IP do not match", %{org: org} do
|
||||
_device =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
ip_address: "192.168.99.99",
|
||||
name: "tower1-sector-a"
|
||||
})
|
||||
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-hostname-match",
|
||||
"name" => "Tower1-Sector-A.local",
|
||||
"mac_address" => "FF:FF:FF:FF:FF:FE",
|
||||
"ip_address" => "172.16.0.99"
|
||||
})
|
||||
|
||||
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
|
||||
assert matched_ap.match_confidence == "auto_hostname"
|
||||
assert matched_ap.device_id
|
||||
end
|
||||
|
||||
test "returns ambiguous when multiple devices match by MAC", %{org: org} do
|
||||
mac = "aa:aa:aa:aa:aa:aa"
|
||||
_device1 = create_device_with_mac(org, "10.0.1.1", mac, name: "Device A")
|
||||
_device2 = create_device_with_mac(org, "10.0.1.2", mac, name: "Device B")
|
||||
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-ambiguous-mac",
|
||||
"name" => "Ambiguous MAC AP",
|
||||
"mac_address" => "AA:AA:AA:AA:AA:AA"
|
||||
})
|
||||
|
||||
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
|
||||
assert matched_ap.match_confidence == "ambiguous"
|
||||
assert matched_ap.device_id == nil
|
||||
end
|
||||
|
||||
test "returns ambiguous when multiple devices match by IP", %{org: org} do
|
||||
_device1 =
|
||||
device_fixture(%{organization_id: org.id, ip_address: "10.0.2.1", name: "IP Device 1"})
|
||||
|
||||
_device2 =
|
||||
device_fixture(%{organization_id: org.id, ip_address: "10.0.2.1", name: "IP Device 2"})
|
||||
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-ambiguous-ip",
|
||||
"name" => "Ambiguous IP AP",
|
||||
"ip_address" => "10.0.2.1"
|
||||
})
|
||||
|
||||
assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap)
|
||||
assert matched_ap.match_confidence == "ambiguous"
|
||||
assert matched_ap.device_id == nil
|
||||
end
|
||||
|
||||
test "skips manually matched access points", %{org: org} do
|
||||
device = device_fixture(%{organization_id: org.id, ip_address: "10.0.3.1", name: "Manual Device"})
|
||||
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-manual",
|
||||
"name" => "Manual AP",
|
||||
"ip_address" => "10.0.3.1"
|
||||
})
|
||||
|
||||
# Manually set the match
|
||||
ap
|
||||
|> AccessPoint.changeset(%{device_id: device.id, match_confidence: "manual"})
|
||||
|> Repo.update!()
|
||||
|
||||
ap = Repo.get!(AccessPoint, ap.id)
|
||||
|
||||
assert {:ok, unchanged_ap} = DeviceMatcher.match_access_point(ap)
|
||||
assert unchanged_ap.match_confidence == "manual"
|
||||
assert unchanged_ap.device_id == device.id
|
||||
end
|
||||
|
||||
test "returns unmatched when no devices match", %{org: org} do
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-no-match",
|
||||
"name" => "Unmatched AP",
|
||||
"mac_address" => "00:00:00:00:00:01",
|
||||
"ip_address" => "172.31.255.255"
|
||||
})
|
||||
|
||||
assert {:ok, unmatched_ap} = DeviceMatcher.match_access_point(ap)
|
||||
assert unmatched_ap.match_confidence == "unmatched"
|
||||
assert unmatched_ap.device_id == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "match_unmatched/1" do
|
||||
test "processes all unmatched APs in an organization", %{org: org} do
|
||||
_device1 = device_fixture(%{organization_id: org.id, ip_address: "10.0.10.1", name: "Bulk Device 1"})
|
||||
_device2 = device_fixture(%{organization_id: org.id, ip_address: "10.0.10.2", name: "Bulk Device 2"})
|
||||
|
||||
_ap1 =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-bulk-1",
|
||||
"name" => "Bulk AP 1",
|
||||
"ip_address" => "10.0.10.1"
|
||||
})
|
||||
|
||||
_ap2 =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-bulk-2",
|
||||
"name" => "Bulk AP 2",
|
||||
"ip_address" => "10.0.10.2"
|
||||
})
|
||||
|
||||
_ap3 =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-bulk-3",
|
||||
"name" => "Bulk AP 3 No Match",
|
||||
"ip_address" => "172.16.0.100"
|
||||
})
|
||||
|
||||
assert {:ok, matched_count} = DeviceMatcher.match_unmatched(org.id)
|
||||
assert matched_count == 2
|
||||
|
||||
# Verify the matched APs have device_ids
|
||||
aps = Repo.all(from ap in AccessPoint, where: ap.organization_id == ^org.id, order_by: :preseem_id)
|
||||
matched_aps = Enum.filter(aps, &(&1.device_id != nil))
|
||||
assert length(matched_aps) == 2
|
||||
end
|
||||
|
||||
test "does not reprocess already matched APs", %{org: org} do
|
||||
device = device_fixture(%{organization_id: org.id, ip_address: "10.0.11.1", name: "Already Matched"})
|
||||
|
||||
ap =
|
||||
create_preseem_ap(org.id, %{
|
||||
"id" => "ap-already-matched",
|
||||
"name" => "Already Matched AP",
|
||||
"ip_address" => "10.0.11.1"
|
||||
})
|
||||
|
||||
# Manually match it first
|
||||
ap
|
||||
|> AccessPoint.changeset(%{device_id: device.id, match_confidence: "manual"})
|
||||
|> Repo.update!()
|
||||
|
||||
assert {:ok, matched_count} = DeviceMatcher.match_unmatched(org.id)
|
||||
assert matched_count == 0
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue