mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 12:40:44 +08:00
Harden UME/LLDP link ports and canvas styles around UME authority.
Resolve ports from EQ+PTP first, enrich from LLDP when compatible, miss only on valid LLDP evidence, and paint UME solid blue / LLDP dashed / missing red. Also localize new root maps as Root map in English. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
77832b271d
commit
137597cd25
16 changed files with 736 additions and 152 deletions
|
|
@ -491,6 +491,26 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
self.assertEqual(len(rm.views), 2)
|
||||
self.assertTrue(all(not getattr(v, "children", None) for v in rm.views))
|
||||
|
||||
def test_manual_root_map_name_follows_locale(self) -> None:
|
||||
"""English UI creates Root map; zh (default) keeps 根图."""
|
||||
en_root = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="West-EN", kind="region", locale="en")
|
||||
)
|
||||
tree = svc.get_topology_tree(self.db)
|
||||
assert tree.root is not None
|
||||
west = next(c for c in tree.root.children if c.id == en_root.id)
|
||||
self.assertEqual(len(west.children), 1)
|
||||
self.assertEqual(west.children[0].name, "Root map")
|
||||
self.assertEqual(west.children[0].views[0].name, "Root map")
|
||||
|
||||
zh_root = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="East-ZH", kind="region", locale="zh")
|
||||
)
|
||||
tree2 = svc.get_topology_tree(self.db)
|
||||
assert tree2.root is not None
|
||||
east = next(c for c in tree2.root.children if c.id == zh_root.id)
|
||||
self.assertEqual(east.children[0].name, "根图")
|
||||
|
||||
def test_site_physical_and_custom_flat(self) -> None:
|
||||
top = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="Site-R", kind="region")
|
||||
|
|
@ -1774,6 +1794,76 @@ Management Addresses:
|
|||
self.db.delete(ne_b)
|
||||
self.db.commit()
|
||||
|
||||
def test_ume_edge_not_marked_missing_by_lldp_miss(self) -> None:
|
||||
"""Valid LLDP scan on endpoint must not red-line UME-authority edges."""
|
||||
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="xxvgei-1/1/0/1",
|
||||
b_port="xxvgei-1/1/0/2",
|
||||
source="ume",
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(edge.source, "ume")
|
||||
|
||||
newly, purged = svc._apply_missing_and_purge(
|
||||
self.db,
|
||||
scanned_ok={fa.id},
|
||||
touched_edge_ids=set(),
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(newly, 0)
|
||||
self.assertEqual(purged, 0)
|
||||
self.db.refresh(edge)
|
||||
self.assertEqual(edge.status, "active")
|
||||
self.assertEqual(edge.source, "ume")
|
||||
|
||||
# Dual provenance still UME-protected; LLDP mark is cleared.
|
||||
edge.attrs = {"sources": ["lldp", "ume"]}
|
||||
edge.source = "ume"
|
||||
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, 0)
|
||||
self.db.refresh(edge)
|
||||
self.assertEqual(edge.status, "active")
|
||||
self.assertEqual(edge.source, "ume")
|
||||
self.assertNotIn("lldp", (edge.attrs or {}).get("sources", []))
|
||||
self.assertIn("ume", (edge.attrs or {}).get("sources", []))
|
||||
|
||||
# Pure LLDP on same node still miss-eligible.
|
||||
lldp_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.db.refresh(lldp_edge)
|
||||
self.assertEqual(lldp_edge.status, "missing")
|
||||
self.db.refresh(edge)
|
||||
self.assertEqual(edge.status, "active")
|
||||
|
||||
self.db.delete(ne_a)
|
||||
self.db.delete(ne_b)
|
||||
self.db.commit()
|
||||
|
||||
def test_delete_managed_purges_orphan_fabric_and_edges(self) -> None:
|
||||
"""Managed-only delete → detach then purge fabric node + incident edges."""
|
||||
from netx_api import ne_service
|
||||
|
|
@ -1879,7 +1969,7 @@ Management Addresses:
|
|||
b_node_id=peer.id,
|
||||
a_port="Gi1/0",
|
||||
b_port="Gi1/1",
|
||||
source="lldp",
|
||||
source="ume",
|
||||
)
|
||||
self.db.commit()
|
||||
edge_id = str(edge.id)
|
||||
|
|
@ -1927,7 +2017,7 @@ Management Addresses:
|
|||
b_node_id=peer.id,
|
||||
a_port="Eth1",
|
||||
b_port="Eth2",
|
||||
source="lldp",
|
||||
source="ume",
|
||||
)
|
||||
self.db.commit()
|
||||
edge_id = str(edge.id)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from netx_api.ume_port_normalize import (
|
|||
extract_ifnames_from_user_label,
|
||||
port_keys_compatible,
|
||||
port_suffix_from_tp_ref,
|
||||
prefer_richer_ifname,
|
||||
resolve_link_ifnames,
|
||||
)
|
||||
from netx_api.ume_topology_apply import (
|
||||
|
|
@ -69,10 +70,78 @@ class UmePortNormalizeTests(unittest.TestCase):
|
|||
self.assertEqual(a, "1/1/0/28")
|
||||
self.assertEqual(z, "1/1/0/28")
|
||||
|
||||
def test_prefer_richer_ifname(self):
|
||||
from netx_api.ume_port_normalize import prefer_richer_ifname
|
||||
|
||||
self.assertEqual(
|
||||
prefer_richer_ifname("1/1/0/32", "xxvgei-1/1/0/32"),
|
||||
"xxvgei-1/1/0/32",
|
||||
)
|
||||
self.assertEqual(
|
||||
prefer_richer_ifname("xxvgei-1/1/0/32", "1/1/0/32"),
|
||||
"xxvgei-1/1/0/32",
|
||||
)
|
||||
self.assertEqual(
|
||||
prefer_richer_ifname("1/1/0/32", "xxvgei-1/1/0/28"),
|
||||
"1/1/0/32",
|
||||
)
|
||||
|
||||
def test_port_compatible(self):
|
||||
self.assertTrue(port_keys_compatible("xxvgei-1/1/0/32", "1/1/0/32"))
|
||||
self.assertTrue(port_keys_compatible("xxvgei-1/1/0/32", "xxvgei-1/1/0/32"))
|
||||
self.assertFalse(port_keys_compatible("xxvgei-1/1/0/32", "xxvgei-1/1/0/28"))
|
||||
# Must not false-match via endswith (11/… vs 1/…).
|
||||
self.assertFalse(port_keys_compatible("11/1/0/1", "1/1/0/1"))
|
||||
self.assertFalse(port_keys_compatible("xxvgei-11/1/0/1", "1/1/0/1"))
|
||||
|
||||
def test_no_tp_does_not_invent_port_pair(self):
|
||||
a, z = resolve_link_ifnames(
|
||||
a_end_tp_ref="ME{a}",
|
||||
z_end_tp_ref="ME{b}",
|
||||
user_label="NE-A_xxvgei-1/1/0/32_NE-B_xxvgei-1/1/0/28",
|
||||
)
|
||||
self.assertEqual(a, "")
|
||||
self.assertEqual(z, "")
|
||||
|
||||
def test_tp_first_ignores_swapped_neighbor_ports_in_label(self):
|
||||
"""Real UME label embeds the *peer* port beside each end — order swaps A/Z.
|
||||
|
||||
TP pins local ports (A=14, Z=16); media prefix is taken from whichever
|
||||
token matches that numeric tail (even if it appears under the other end).
|
||||
"""
|
||||
label = (
|
||||
"RSFRL22-RMP01-SMGD[0-1-1]-25GE:14(NNI | RSFRLR1-RMP01 xxvgei-1/1/0/16 "
|
||||
"| CLARO | FO | RSCSL15-RSNHO06-01)_RSFRLR1-RMP01-SMGD[0-1-1]-25GE:16"
|
||||
"(NNI | RSFRL22-RMP01 xxvgei-1/1/0/14 | CLARO | FO | RSCSL15-RSNHO06-01)"
|
||||
)
|
||||
a_tp = (
|
||||
"ME{4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2},EQ={/r=0/sh=1/sl=1},PTP={/p=1_14}"
|
||||
)
|
||||
z_tp = (
|
||||
"ME{b1215371-491a-4641-a1fd-9d59c8002e77},EQ={/r=0/sh=1/sl=1},PTP={/p=1_16}"
|
||||
)
|
||||
tokens = extract_ifnames_from_user_label(label)
|
||||
# Label order is neighbor-first (16 then 14) — must not drive A/Z.
|
||||
self.assertEqual(tokens[0], "xxvgei-1/1/0/16")
|
||||
self.assertEqual(tokens[1], "xxvgei-1/1/0/14")
|
||||
self.assertEqual(port_suffix_from_tp_ref(a_tp), "1/1/0/14")
|
||||
self.assertEqual(port_suffix_from_tp_ref(z_tp), "1/1/0/16")
|
||||
a, z = resolve_link_ifnames(
|
||||
a_end_tp_ref=a_tp, z_end_tp_ref=z_tp, user_label=label
|
||||
)
|
||||
self.assertEqual(a, "xxvgei-1/1/0/14")
|
||||
self.assertEqual(z, "xxvgei-1/1/0/16")
|
||||
|
||||
def test_tp_suffix_without_matching_media_stays_bare(self):
|
||||
a, z = resolve_link_ifnames(
|
||||
a_end_tp_ref="ME{a},EQ={/r=0/sh=1/sl=1},PTP={/p=1_14}",
|
||||
z_end_tp_ref="ME{b},EQ={/r=0/sh=1/sl=1},PTP={/p=1_16}",
|
||||
user_label="no-cli-tokens-here",
|
||||
)
|
||||
self.assertEqual(a, "1/1/0/14")
|
||||
self.assertEqual(z, "1/1/0/16")
|
||||
self.assertTrue(port_keys_compatible(a, "xxvgei-1/1/0/14"))
|
||||
self.assertTrue(port_keys_compatible(z, "cgei-1/1/0/16"))
|
||||
|
||||
|
||||
class UmeFabricApplyTests(unittest.TestCase):
|
||||
|
|
@ -241,6 +310,99 @@ class UmeFabricApplyTests(unittest.TestCase):
|
|||
self.assertEqual(len(manuals), 1)
|
||||
self.assertEqual(manuals[0].status, "active")
|
||||
|
||||
def test_apply_reresolves_and_upgrades_fabric_ports(self):
|
||||
"""Apply always re-resolves dock ifnames and upgrades compatible Fabric ports."""
|
||||
from netx_api.ume_port_normalize import numeric_port_tail
|
||||
|
||||
self._seed_ume()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
link = self.db.query(UmeTopoLink).one()
|
||||
edge = self.db.query(TopoFabricEdge).one()
|
||||
# Simulate stale bare dock + fabric ports after an older resolve.
|
||||
link.a_ifname = "1/1/0/32"
|
||||
link.z_ifname = "1/1/0/28"
|
||||
edge.a_port = numeric_port_tail(edge.a_port)
|
||||
edge.b_port = numeric_port_tail(edge.b_port)
|
||||
self.db.commit()
|
||||
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
self.db.refresh(link)
|
||||
self.db.refresh(edge)
|
||||
self.assertEqual({link.a_ifname, link.z_ifname}, {"xxvgei-1/1/0/32", "xxvgei-1/1/0/28"})
|
||||
self.assertEqual({edge.a_port, edge.b_port}, {"xxvgei-1/1/0/32", "xxvgei-1/1/0/28"})
|
||||
|
||||
def test_lldp_upgrades_bare_ume_ports_and_keeps_ume_primary(self):
|
||||
from netx_api.ume_port_normalize import numeric_port_tail
|
||||
|
||||
self._seed_ume()
|
||||
# Apply creates bare-capable UME edge with media from label.
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
edge = self.db.query(TopoFabricEdge).one()
|
||||
# Force bare ports as if UME had no media prefix (keep A/B assignment).
|
||||
rich_a, rich_b = edge.a_port, edge.b_port
|
||||
edge.a_port = numeric_port_tail(edge.a_port)
|
||||
edge.b_port = numeric_port_tail(edge.b_port)
|
||||
edge.source = "ume"
|
||||
edge.attrs = {"sources": ["ume"], "ume_link_id": "link-1"}
|
||||
self.db.commit()
|
||||
a = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
.one()
|
||||
)
|
||||
b = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
.one()
|
||||
)
|
||||
edge2, action = upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=a.id,
|
||||
b_node_id=b.id,
|
||||
a_port=rich_a,
|
||||
b_port=rich_b,
|
||||
source="lldp",
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(action, "updated")
|
||||
self.assertIsNotNone(edge2)
|
||||
assert edge2 is not None
|
||||
self.assertEqual(edge2.id, edge.id)
|
||||
self.assertEqual(edge2.source, "ume")
|
||||
self.assertIn("lldp", (edge2.attrs or {}).get("sources", []))
|
||||
ports = {edge2.a_port, edge2.b_port}
|
||||
self.assertEqual(ports, {"xxvgei-1/1/0/32", "xxvgei-1/1/0/28"})
|
||||
|
||||
def test_lldp_can_add_edge_between_ume_nes_when_ume_has_no_link(self):
|
||||
"""UME dump may omit a physical link — LLDP may still create it (dashed)."""
|
||||
self._seed_ume()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
a = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
.one()
|
||||
)
|
||||
b = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
.one()
|
||||
)
|
||||
before = self.db.query(TopoFabricEdge).count()
|
||||
edge, action = upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=a.id,
|
||||
b_node_id=b.id,
|
||||
a_port="xxvgei-1/1/0/99",
|
||||
b_port="xxvgei-1/1/0/98",
|
||||
source="lldp",
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(action, "added")
|
||||
self.assertIsNotNone(edge)
|
||||
assert edge is not None
|
||||
self.assertEqual(edge.source, "lldp")
|
||||
self.assertEqual(self.db.query(TopoFabricEdge).count(), before + 1)
|
||||
|
||||
def test_gap_needs_apply_when_dock_only(self):
|
||||
self._seed_ume()
|
||||
self.db.commit()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue