Skip LLDP self-loop edges instead of failing the whole discover apply.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-04 15:44:46 +08:00
parent 925726002f
commit 8991bb31d1
4 changed files with 364 additions and 287 deletions

View file

@ -222,6 +222,9 @@ def _apply_discover_hits(
source="lldp", source="lldp",
now=now, now=now,
) )
if edge is None or action == "skipped_self_loop":
# Device advertising itself (or peer resolved to same fabric node).
continue
touched.append(edge.id) touched.append(edge.id)
replaced.extend( replaced.extend(
_mark_replaced_port_peers( _mark_replaced_port_peers(

View file

@ -72,12 +72,17 @@ def upsert_fabric_edge(
source: str = "lldp", source: str = "lldp",
layer: str = "physical", layer: str = "physical",
now: datetime | None = None, now: datetime | None = None,
) -> tuple[TopoFabricEdge, str]: ) -> tuple[TopoFabricEdge | None, str]:
"""Return (edge, action) where action is added|updated|kept_manual.""" """Return (edge, action) where action is added|updated|kept_manual|skipped_self_loop.
Self-loops are skipped (``(None, \"skipped_self_loop\")``) so LLDP discovery can
ignore a device advertising itself without aborting the rest of the scan.
Manual edge APIs should treat that action as a client error.
"""
now = now or _utcnow() now = now or _utcnow()
a, b, ap, bp = _normalize_endpoints(a_node_id, b_node_id, a_port, b_port) a, b, ap, bp = _normalize_endpoints(a_node_id, b_node_id, a_port, b_port)
if a == b: if a == b:
raise HTTPException(status_code=400, detail="edge_self_loop") return None, "skipped_self_loop"
layer_v = str(layer or "physical").strip() or "physical" layer_v = str(layer or "physical").strip() or "physical"
src = str(source or "lldp").strip().lower() or "lldp" src = str(source or "lldp").strip().lower() or "lldp"
if src == "stale": if src == "stale":

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import get_db from .db import get_db
@ -154,6 +154,8 @@ def api_fabric_manual_edge(
b_port=body.b_port, b_port=body.b_port,
source="manual", source="manual",
) )
if edge is None or action == "skipped_self_loop":
raise HTTPException(status_code=400, detail="edge_self_loop")
db.commit() db.commit()
refresh_fabric_stats(db) refresh_fabric_stats(db)
return { return {

View file

@ -249,6 +249,73 @@ class FabricTopologyTests(unittest.TestCase):
self.assertEqual(calls["n"], 2) self.assertEqual(calls["n"], 2)
self.assertGreaterEqual(int(out.get("edges_added") or 0), 1) self.assertGreaterEqual(int(out.get("edges_added") or 0), 1)
def test_apply_discover_hits_skips_self_loop(self) -> None:
"""LLDP advertising itself must not fail the whole apply; other peers still count."""
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"nea-{suffix}",
name=f"R-self-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 20}",
)
peer_ne = ManagedNE(
id=f"neb-{suffix}",
name=f"r-peer-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 120}",
)
self.db.add(ne)
self.db.add(peer_ne)
self.db.commit()
node = svc.ensure_fabric_node_for_managed(self.db, ne)
peer = svc.ensure_fabric_node_for_managed(self.db, peer_ne)
self.db.commit()
hits = [
lldp.NeighborHit(
remote_name=ne.name,
remote_ip=ne.ip_address,
local_port="Gi0/0",
remote_port="Gi0/0",
),
lldp.NeighborHit(
remote_name=peer_ne.name,
remote_ip=peer_ne.ip_address,
local_port="Gi0/1",
remote_port="Ethernet1/0/1",
),
]
out = svc._apply_discover_hits(
self.db,
fabric_node_id=node.id,
hits=hits,
auto_add_unmatched=False,
)
self.assertTrue(out.get("ok"), out)
self.assertEqual(int(out.get("edges_added") or 0), 1)
edges = (
self.db.query(TopoFabricEdge)
.filter(
(TopoFabricEdge.a_node_id == node.id) | (TopoFabricEdge.b_node_id == node.id)
)
.all()
)
self.assertEqual(len(edges), 1)
ids = {edges[0].a_node_id, edges[0].b_node_id}
self.assertEqual(ids, {node.id, peer.id})
skipped, action = svc.upsert_fabric_edge(
self.db,
a_node_id=node.id,
b_node_id=node.id,
a_port="Lo0",
b_port="Lo0",
source="lldp",
)
self.assertIsNone(skipped)
self.assertEqual(action, "skipped_self_loop")
def _region(self, name: str = "Test-Region") -> str: def _region(self, name: str = "Test-Region") -> str:
return svc.create_folder( return svc.create_folder(
self.db, TopologyFolderCreate(name=name, kind="region") self.db, TopologyFolderCreate(name=name, kind="region")