mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Move scheduled discovery under Network → LLDP links, mark absent edges missing after one successful scan, purge only after four miss cycles, and expose unmatched/raw job detail. Co-authored-by: Cursor <cursoragent@cursor.com>
703 lines
23 KiB
Python
703 lines
23 KiB
Python
"""Unit tests for fabric topology + LLDP parsers (no CDP discovery)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from uuid import uuid4
|
|
|
|
from netx_api import topology_lldp as lldp
|
|
from netx_api import topology_service as svc
|
|
from netx_api.db import Base, SessionLocal, engine
|
|
from netx_api.device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE
|
|
from netx_api.models import (
|
|
ManagedNE,
|
|
TopoDiscoverJob,
|
|
TopoDiscoverJobItem,
|
|
TopoFabricEdge,
|
|
TopoFabricNode,
|
|
TopoView,
|
|
TopoViewEdgeStyle,
|
|
TopoViewNode,
|
|
)
|
|
from netx_api.topology_schemas import (
|
|
FabricDiscoverRequest,
|
|
TopologyViewCreate,
|
|
ViewNodesAdd,
|
|
ViewPositionsPatch,
|
|
ViewNodeIn,
|
|
)
|
|
|
|
|
|
CISCO_LLDP_BRIEF = """
|
|
Capability codes:
|
|
(R) Router, (B) Bridge
|
|
|
|
Device ID Local Intf Hold-time Capability Port ID
|
|
R1 Gi0/0 120 R Gi0/1
|
|
R3 Gi0/1 120 R Gi0/0
|
|
"""
|
|
|
|
CISCO_LLDP_DETAIL = """
|
|
------------------------------------------------
|
|
Local Intf: Gi0/1
|
|
Chassis id: 707b.5c6e.d130
|
|
Port id: Ethernet1/0/1
|
|
System Name: r1
|
|
Management Addresses:
|
|
IP: 192.168.0.1
|
|
"""
|
|
|
|
HUAWEI_LLDP = """
|
|
GigabitEthernet0/0/1 has 1 neighbor(s):
|
|
|
|
Neighbor index : 1
|
|
Chassis ID : 00e0-fc12-3456
|
|
Port ID : GigabitEthernet0/0/2
|
|
System name : r1
|
|
Management address : 192.168.0.127
|
|
Local Interface: GigabitEthernet0/0/1
|
|
"""
|
|
|
|
ZTE_LLDP_BRIEF = """
|
|
Local Interface Chassis ID Port ID System Name
|
|
gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1
|
|
"""
|
|
|
|
|
|
class LldpParserTests(unittest.TestCase):
|
|
def test_cisco_brief(self) -> None:
|
|
hits = lldp.parse_cisco_lldp(CISCO_LLDP_BRIEF)
|
|
self.assertGreaterEqual(len(hits), 1)
|
|
self.assertTrue(any(h.remote_name.upper().startswith("R1") for h in hits))
|
|
|
|
def test_huawei(self) -> None:
|
|
hits = lldp.parse_huawei_lldp(HUAWEI_LLDP)
|
|
self.assertGreaterEqual(len(hits), 1)
|
|
self.assertEqual(hits[0].remote_name.lower(), "r1")
|
|
|
|
def test_pick_command_lldp_only(self) -> None:
|
|
cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios")
|
|
self.assertEqual(tag, "lldp")
|
|
self.assertIn("lldp", cmd.lower())
|
|
|
|
|
|
class FabricTopologyTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
def setUp(self) -> None:
|
|
self.db = SessionLocal()
|
|
# Shared SQLite DB across tests — wipe fabric/view state for isolation.
|
|
for model in (
|
|
TopoViewEdgeStyle,
|
|
TopoViewNode,
|
|
TopoView,
|
|
TopoFabricEdge,
|
|
TopoFabricNode,
|
|
TopoDiscoverJobItem,
|
|
TopoDiscoverJob,
|
|
):
|
|
self.db.query(model).delete()
|
|
self.db.commit()
|
|
|
|
def tearDown(self) -> None:
|
|
self.db.close()
|
|
|
|
def test_view_crud_and_positions(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
ne = ManagedNE(
|
|
id=f"ne-{suffix}",
|
|
name=f"R-{suffix}",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
ip_address=f"10.0.0.{(int(suffix[:2], 16) % 200) + 1}",
|
|
)
|
|
self.db.add(ne)
|
|
self.db.commit()
|
|
|
|
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
|
|
graph = svc.add_nodes_to_view(
|
|
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne.id])
|
|
)
|
|
self.assertEqual(len(graph.nodes), 1)
|
|
fid = graph.nodes[0].fabric_node_id
|
|
graph2 = svc.patch_view_positions(
|
|
self.db,
|
|
view.id,
|
|
ViewPositionsPatch(positions=[ViewNodeIn(fabric_node_id=fid, x=120, y=80)]),
|
|
)
|
|
self.assertEqual(graph2.nodes[0].x, 120)
|
|
self.assertEqual(graph2.nodes[0].y, 80)
|
|
|
|
summary = svc.get_fabric_summary(self.db)
|
|
self.assertGreaterEqual(summary.node_count, 1)
|
|
|
|
svc.delete_view(self.db, view.id)
|
|
self.db.delete(ne)
|
|
self.db.commit()
|
|
|
|
def test_lldp_discover_writes_fabric_edge(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
ne_a = ManagedNE(
|
|
id=f"nea-{suffix}",
|
|
name="R2",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}",
|
|
)
|
|
ne_b = ManagedNE(
|
|
id=f"neb-{suffix}",
|
|
name="R1",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 100) + 101}",
|
|
)
|
|
self.db.add(ne_a)
|
|
self.db.add(ne_b)
|
|
self.db.commit()
|
|
# Ensure fabric nodes exist for matching
|
|
svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
|
svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
|
self.db.commit()
|
|
|
|
fake_exec = {
|
|
"ok": True,
|
|
"output": CISCO_LLDP_DETAIL,
|
|
"commands": ["show lldp neighbors detail"],
|
|
}
|
|
with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec):
|
|
job = svc.start_discover_job(
|
|
self.db,
|
|
FabricDiscoverRequest(scope="ne_ids", ne_ids=[ne_a.id], concurrency=1),
|
|
)
|
|
# Wait for background thread
|
|
for _ in range(50):
|
|
out = svc.get_discover_job(self.db, job.id)
|
|
if out.status in {"done", "failed"}:
|
|
break
|
|
time.sleep(0.05)
|
|
out = svc.get_discover_job(self.db, job.id)
|
|
self.assertEqual(out.status, "done", out.error)
|
|
edges = svc.list_fabric_edges(self.db, page=1, page_size=50)
|
|
self.assertGreaterEqual(edges["total"], 1)
|
|
|
|
self.db.delete(ne_a)
|
|
self.db.delete(ne_b)
|
|
self.db.commit()
|
|
|
|
def test_merge_duplicate_and_project_skips_orphans(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
ne_a = ManagedNE(
|
|
id=f"nea-{suffix}",
|
|
name="R2",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 10}",
|
|
)
|
|
ne_b = ManagedNE(
|
|
id=f"neb-{suffix}",
|
|
name="R1",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 100}",
|
|
)
|
|
self.db.add(ne_a)
|
|
self.db.add(ne_b)
|
|
self.db.commit()
|
|
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
|
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
|
orphan = TopoFabricNode(
|
|
id=uuid4().hex,
|
|
managed_ne_id=None,
|
|
ume_ne_id=None,
|
|
name="r1",
|
|
ip="",
|
|
vendor="",
|
|
device_type="",
|
|
attrs={"from_lldp_unmatched": True},
|
|
)
|
|
# Simulate raced duplicate of R2 (same managed id not allowed by constraint —
|
|
# use name/ip collision path with a second orphan twin).
|
|
orphan_r2 = TopoFabricNode(
|
|
id=uuid4().hex,
|
|
managed_ne_id=None,
|
|
ume_ne_id=None,
|
|
name="R2",
|
|
ip=ne_a.ip_address,
|
|
vendor="",
|
|
device_type="",
|
|
attrs={"from_lldp_unmatched": True},
|
|
)
|
|
self.db.add(orphan)
|
|
self.db.add(orphan_r2)
|
|
self.db.commit()
|
|
# Path: orphan_r2 -- fb -- fa -- orphan (the bug shape)
|
|
svc.upsert_fabric_edge(
|
|
self.db, a_node_id=orphan_r2.id, b_node_id=fb.id, a_port="g0", b_port="g1", source="lldp"
|
|
)
|
|
svc.upsert_fabric_edge(
|
|
self.db, a_node_id=fb.id, b_node_id=fa.id, a_port="g2", b_port="g3", source="lldp"
|
|
)
|
|
svc.upsert_fabric_edge(
|
|
self.db, a_node_id=fa.id, b_node_id=orphan.id, a_port="g4", b_port="g5", source="lldp"
|
|
)
|
|
self.db.commit()
|
|
|
|
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
|
|
graph = svc.add_nodes_to_view(
|
|
self.db,
|
|
view.id,
|
|
ViewNodesAdd(fabric_node_ids=[fa.id, fb.id, orphan.id, orphan_r2.id]),
|
|
)
|
|
self.assertEqual(len(graph.nodes), 4)
|
|
|
|
merged = svc.merge_duplicate_fabric_nodes(self.db)
|
|
self.assertGreaterEqual(merged["merged"], 2)
|
|
self.db.expire_all()
|
|
self.assertIsNone(self.db.get(TopoFabricNode, orphan.id))
|
|
self.assertIsNone(self.db.get(TopoFabricNode, orphan_r2.id))
|
|
|
|
# Re-place inventory nodes only, then project should stay at 2.
|
|
graph2 = svc.add_nodes_to_view(
|
|
self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id, ne_b.id])
|
|
)
|
|
# View may still have stale placements pointing at deleted ids — project cleans.
|
|
projected = svc.project_fabric_neighbors_to_view(self.db, view.id)
|
|
self.assertEqual(len(projected.nodes), 2)
|
|
labels = sorted((n.name or n.label).upper() for n in projected.nodes)
|
|
self.assertEqual(labels, ["R1", "R2"])
|
|
self.assertGreaterEqual(len(projected.edges), 1)
|
|
endpoint_ids = {n.fabric_node_id for n in projected.nodes}
|
|
for e in projected.edges:
|
|
self.assertIn(e.a_node_id, endpoint_ids)
|
|
self.assertIn(e.b_node_id, endpoint_ids)
|
|
|
|
self.db.delete(ne_a)
|
|
self.db.delete(ne_b)
|
|
self.db.commit()
|
|
|
|
def test_match_hit_prefers_inventory(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
ne = ManagedNE(
|
|
id=f"ne-{suffix}",
|
|
name="R1",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 20}",
|
|
)
|
|
self.db.add(ne)
|
|
self.db.commit()
|
|
inv = svc.ensure_fabric_node_for_managed(self.db, ne)
|
|
orphan = TopoFabricNode(
|
|
id=uuid4().hex,
|
|
name="r1",
|
|
ip="",
|
|
attrs={"from_lldp_unmatched": True},
|
|
)
|
|
self.db.add(orphan)
|
|
self.db.commit()
|
|
hit = lldp.NeighborHit(
|
|
remote_name="r1",
|
|
remote_ip="",
|
|
local_port="Gi0/0",
|
|
remote_port="Gi0/1",
|
|
)
|
|
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
|
|
self.assertEqual(peer.id, inv.id)
|
|
self.db.delete(ne)
|
|
self.db.commit()
|
|
|
|
def test_lldp_unmatched_creates_ssh_placeholder_ne(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
peer_name = f"Peer-{suffix}"
|
|
ne_a = ManagedNE(
|
|
id=f"nea-{suffix}",
|
|
name=f"Core-{suffix}",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 40}",
|
|
source="manual",
|
|
)
|
|
self.db.add(ne_a)
|
|
self.db.commit()
|
|
svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
|
self.db.commit()
|
|
|
|
lldp_out = f"""
|
|
------------------------------------------------
|
|
Local Intf: Gi0/1
|
|
Chassis id: 707b.5c6e.d130
|
|
Port id: Ethernet1/0/1
|
|
System Name: {peer_name}
|
|
Management Addresses:
|
|
IP: 198.51.100.200
|
|
"""
|
|
fake_exec = {
|
|
"ok": True,
|
|
"output": lldp_out,
|
|
"commands": ["show lldp neighbors detail"],
|
|
}
|
|
with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec):
|
|
job = svc.start_discover_job(
|
|
self.db,
|
|
FabricDiscoverRequest(
|
|
scope="ne_ids", ne_ids=[ne_a.id], concurrency=1, auto_add_unmatched=True
|
|
),
|
|
)
|
|
for _ in range(50):
|
|
out = svc.get_discover_job(self.db, job.id)
|
|
if out.status in {"done", "failed"}:
|
|
break
|
|
time.sleep(0.05)
|
|
out = svc.get_discover_job(self.db, job.id)
|
|
self.assertEqual(out.status, "done", out.error)
|
|
|
|
placeholders = (
|
|
self.db.query(ManagedNE)
|
|
.filter(ManagedNE.source == LLDP_DISCOVERED_NE_SOURCE)
|
|
.all()
|
|
)
|
|
placeholder = next(
|
|
(p for p in placeholders if svc._norm_host(p.name) == svc._norm_host(peer_name)),
|
|
None,
|
|
)
|
|
self.assertIsNotNone(placeholder)
|
|
assert placeholder is not None
|
|
self.assertEqual(placeholder.ip_address, "")
|
|
self.assertEqual(placeholder.username, "")
|
|
self.assertEqual(placeholder.password_enc, "")
|
|
self.assertEqual(placeholder.protocol, "ssh")
|
|
self.assertEqual(placeholder.device_type, "generic")
|
|
self.assertEqual(placeholder.source_ref, "198.51.100.200")
|
|
|
|
view = svc.create_view(self.db, TopologyViewCreate(name=f"V-{suffix}"))
|
|
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[ne_a.id]))
|
|
projected = svc.project_fabric_neighbors_to_view(self.db, view.id)
|
|
self.assertEqual(len(projected.nodes), 2)
|
|
self.assertGreaterEqual(len(projected.edges), 1)
|
|
|
|
self.db.delete(ne_a)
|
|
self.db.delete(placeholder)
|
|
self.db.commit()
|
|
|
|
def test_match_prefers_real_ne_over_webcrt_same_ip(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
ip = f"198.51.100.{(int(suffix[:2], 16) % 80) + 30}"
|
|
real = ManagedNE(
|
|
id=f"real-{suffix}",
|
|
name="R2",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
ip_address=ip,
|
|
source="manual",
|
|
)
|
|
webcrt = ManagedNE(
|
|
id=f"wcrt-{suffix}",
|
|
name=f"{ip} (1)",
|
|
vendor="Other",
|
|
device_type="generic",
|
|
ip_address=ip,
|
|
source=WEBCRT_NE_SOURCE,
|
|
)
|
|
self.db.add(real)
|
|
self.db.add(webcrt)
|
|
self.db.commit()
|
|
inv = svc.ensure_fabric_node_for_managed(self.db, real)
|
|
ghost = svc.ensure_fabric_node_for_managed(self.db, webcrt)
|
|
self.db.commit()
|
|
hit = lldp.NeighborHit(remote_name="", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1")
|
|
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
|
|
self.assertEqual(peer.id, inv.id)
|
|
self.assertNotEqual(peer.id, ghost.id)
|
|
|
|
merged = svc.merge_duplicate_fabric_nodes(self.db)
|
|
self.assertGreaterEqual(merged["merged"], 1)
|
|
self.db.expire_all()
|
|
self.assertIsNone(self.db.get(TopoFabricNode, ghost.id))
|
|
self.db.delete(real)
|
|
self.db.delete(webcrt)
|
|
self.db.commit()
|
|
|
|
def test_neighborhood(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
ne_a = ManagedNE(
|
|
id=f"nea-{suffix}",
|
|
name=f"A-{suffix}",
|
|
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}",
|
|
vendor="ZTE",
|
|
device_type="zte_zxros",
|
|
)
|
|
ne_b = ManagedNE(
|
|
id=f"neb-{suffix}",
|
|
name=f"B-{suffix}",
|
|
ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}",
|
|
vendor="ZTE",
|
|
device_type="zte_zxros",
|
|
)
|
|
self.db.add(ne_a)
|
|
self.db.add(ne_b)
|
|
self.db.commit()
|
|
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
|
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
|
self.db.commit()
|
|
svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fb.id,
|
|
a_port="gei-0/1/0/1",
|
|
b_port="gei-0/1/0/2",
|
|
source="lldp",
|
|
)
|
|
self.db.commit()
|
|
nb = svc.get_fabric_neighborhood(self.db, fa.id, depth=1)
|
|
self.assertEqual(len(nb.nodes), 2)
|
|
self.assertEqual(len(nb.edges), 1)
|
|
self.db.delete(ne_a)
|
|
self.db.delete(ne_b)
|
|
self.db.commit()
|
|
|
|
def _pair_nodes(self, suffix: str) -> tuple[TopoFabricNode, TopoFabricNode, ManagedNE, ManagedNE]:
|
|
ne_a = ManagedNE(
|
|
id=f"nea-{suffix}",
|
|
name=f"A-{suffix}",
|
|
ip_address=f"198.51.100.{(int(suffix[:2], 16) % 80) + 10}",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
)
|
|
ne_b = ManagedNE(
|
|
id=f"neb-{suffix}",
|
|
name=f"B-{suffix}",
|
|
ip_address=f"198.51.100.{(int(suffix[2:4], 16) % 80) + 100}",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
)
|
|
self.db.add(ne_a)
|
|
self.db.add(ne_b)
|
|
self.db.commit()
|
|
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
|
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
|
self.db.commit()
|
|
return fa, fb, ne_a, ne_b
|
|
|
|
def test_edge_missing_after_one_absent_cycle(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
|
edge, _ = svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fb.id,
|
|
a_port="Gi0/0",
|
|
b_port="Gi0/1",
|
|
source="lldp",
|
|
)
|
|
self.db.commit()
|
|
|
|
newly, purged = svc._apply_missing_and_purge(
|
|
self.db,
|
|
scanned_ok={fa.id},
|
|
touched_edge_ids=set(),
|
|
)
|
|
self.db.commit()
|
|
self.assertEqual(newly, 1)
|
|
self.assertEqual(purged, 0)
|
|
self.db.refresh(edge)
|
|
self.assertEqual(edge.status, "missing")
|
|
self.assertEqual(int((edge.attrs or {}).get("miss_count") or 0), 1)
|
|
|
|
self.db.delete(ne_a)
|
|
self.db.delete(ne_b)
|
|
self.db.commit()
|
|
|
|
def test_edge_purge_after_four_missing_cycles(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
|
edge, _ = svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fb.id,
|
|
a_port="Gi0/0",
|
|
b_port="Gi0/1",
|
|
source="lldp",
|
|
)
|
|
edge_id = edge.id
|
|
self.db.commit()
|
|
|
|
for cycle in range(1, 5):
|
|
newly, purged = svc._apply_missing_and_purge(
|
|
self.db,
|
|
scanned_ok={fa.id},
|
|
touched_edge_ids=set(),
|
|
)
|
|
self.db.commit()
|
|
if cycle < 4:
|
|
self.assertEqual(purged, 0)
|
|
row = self.db.get(TopoFabricEdge, edge_id)
|
|
self.assertIsNotNone(row)
|
|
self.assertEqual(int((row.attrs or {}).get("miss_count") or 0), cycle)
|
|
else:
|
|
self.assertEqual(purged, 1)
|
|
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
|
|
|
|
self.db.delete(ne_a)
|
|
self.db.delete(ne_b)
|
|
self.db.commit()
|
|
|
|
def test_peer_replace_marks_old_edge_missing(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
|
ne_c = ManagedNE(
|
|
id=f"nec-{suffix}",
|
|
name=f"C-{suffix}",
|
|
ip_address=f"198.51.100.{(int(suffix[4:6], 16) % 80) + 50}",
|
|
vendor="Cisco",
|
|
device_type="cisco_ios",
|
|
)
|
|
self.db.add(ne_c)
|
|
self.db.commit()
|
|
fc = svc.ensure_fabric_node_for_managed(self.db, ne_c)
|
|
self.db.commit()
|
|
|
|
old, _ = svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fb.id,
|
|
a_port="Gi0/0",
|
|
b_port="Gi0/1",
|
|
source="lldp",
|
|
)
|
|
new, _ = svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fc.id,
|
|
a_port="Gi0/0",
|
|
b_port="Gi0/2",
|
|
source="lldp",
|
|
)
|
|
self.db.commit()
|
|
|
|
handled = svc._mark_replaced_port_peers(
|
|
self.db,
|
|
self_id=fa.id,
|
|
local_port="Gi0/0",
|
|
peer_id=fc.id,
|
|
new_edge_id=new.id,
|
|
)
|
|
self.db.commit()
|
|
self.assertIn(old.id, handled)
|
|
self.db.refresh(old)
|
|
self.assertEqual(old.status, "missing")
|
|
self.assertEqual((old.attrs or {}).get("replaced_by_edge_id"), new.id)
|
|
self.assertEqual(int((old.attrs or {}).get("miss_count") or 0), 1)
|
|
|
|
# Same-job: replaced id in touched → no second miss bump.
|
|
newly, purged = svc._apply_missing_and_purge(
|
|
self.db,
|
|
scanned_ok={fa.id},
|
|
touched_edge_ids={new.id, *handled},
|
|
)
|
|
self.db.commit()
|
|
self.assertEqual(newly, 0)
|
|
self.assertEqual(purged, 0)
|
|
self.db.refresh(old)
|
|
self.assertEqual(int((old.attrs or {}).get("miss_count") or 0), 1)
|
|
|
|
self.db.delete(ne_a)
|
|
self.db.delete(ne_b)
|
|
self.db.delete(ne_c)
|
|
self.db.commit()
|
|
|
|
def test_edge_reactivate_clears_miss_attrs(self) -> None:
|
|
suffix = uuid4().hex[:8]
|
|
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
|
edge, _ = svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fb.id,
|
|
a_port="Gi0/0",
|
|
b_port="Gi0/1",
|
|
source="lldp",
|
|
)
|
|
self.db.commit()
|
|
svc._apply_missing_and_purge(
|
|
self.db, scanned_ok={fa.id}, touched_edge_ids=set()
|
|
)
|
|
self.db.commit()
|
|
self.db.refresh(edge)
|
|
self.assertEqual(edge.status, "missing")
|
|
|
|
edge2, action = svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fb.id,
|
|
a_port="Gi0/0",
|
|
b_port="Gi0/1",
|
|
source="lldp",
|
|
)
|
|
self.db.commit()
|
|
self.assertEqual(action, "updated")
|
|
self.assertEqual(edge2.id, edge.id)
|
|
self.assertEqual(edge2.status, "active")
|
|
attrs = edge2.attrs or {}
|
|
self.assertNotIn("miss_count", attrs)
|
|
self.assertNotIn("first_missing_at", attrs)
|
|
self.assertNotIn("replaced_by_edge_id", attrs)
|
|
|
|
self.db.delete(ne_a)
|
|
self.db.delete(ne_b)
|
|
self.db.commit()
|
|
|
|
def test_scan_fail_does_not_mark_missing(self) -> None:
|
|
"""Endpoint not in scanned_ok (SSH/parse fail) → leave edge active."""
|
|
suffix = uuid4().hex[:8]
|
|
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
|
edge, _ = svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fb.id,
|
|
a_port="Gi0/0",
|
|
b_port="Gi0/1",
|
|
source="lldp",
|
|
)
|
|
self.db.commit()
|
|
|
|
newly, purged = svc._apply_missing_and_purge(
|
|
self.db,
|
|
scanned_ok=set(), # scan failed — nothing judged
|
|
touched_edge_ids=set(),
|
|
)
|
|
self.db.commit()
|
|
self.assertEqual(newly, 0)
|
|
self.assertEqual(purged, 0)
|
|
self.db.refresh(edge)
|
|
self.assertEqual(edge.status, "active")
|
|
|
|
# Manual edges never auto-missing.
|
|
manual, _ = svc.upsert_fabric_edge(
|
|
self.db,
|
|
a_node_id=fa.id,
|
|
b_node_id=fb.id,
|
|
a_port="Gi1/0",
|
|
b_port="Gi1/1",
|
|
source="manual",
|
|
)
|
|
self.db.commit()
|
|
newly, purged = svc._apply_missing_and_purge(
|
|
self.db,
|
|
scanned_ok={fa.id},
|
|
touched_edge_ids=set(),
|
|
)
|
|
self.db.commit()
|
|
self.db.refresh(manual)
|
|
self.assertEqual(manual.status, "active")
|
|
self.assertEqual(manual.source, "manual")
|
|
|
|
self.db.delete(ne_a)
|
|
self.db.delete(ne_b)
|
|
self.db.commit()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|