From 5d705fa7bba37cb77049850ad793d9c8fc7fd6c1 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 6 Aug 2026 19:33:16 +0800 Subject: [PATCH] Parse onsite UME TopoNode FDN names when nodeId is missing. Live TopoNodes use MD=...;ME= / SBN= without nodeId; extract those uuids as stable PKs and parent refs so reconcile drops old name:hash rows. Co-authored-by: Cursor --- netx_api/ume_sync_topology.py | 63 +++++++++++++++++++++++++++-- tests/test_ume_sync.py | 74 ++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/netx_api/ume_sync_topology.py b/netx_api/ume_sync_topology.py index ae62f6a..8c266c0 100644 --- a/netx_api/ume_sync_topology.py +++ b/netx_api/ume_sync_topology.py @@ -26,18 +26,34 @@ _TOPOLOGY_SYNC_LOCK = threading.Lock() _UUID_RE = re.compile( r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" ) +# Onsite FDN: MD=ZTE/UME(BN);ME=0ab0b408-... or ;SBN=47173499-... +_ME_EQ_RE = re.compile( + r"(?:^|[;,/])ME=([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})", + re.IGNORECASE, +) +_SBN_EQ_RE = re.compile( + r"(?:^|[;,/])SBN=([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})", + re.IGNORECASE, +) _ME_BRACE_RE = re.compile( r"ME\{([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}", re.IGNORECASE, ) +_SBN_BRACE_RE = re.compile( + r"SBN\{([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}", + re.IGNORECASE, +) _PTP_RE = re.compile(r"PTP=\{([^}]*)\}", re.IGNORECASE) def extract_me_uuid(text: str) -> str: - """Extract managed-element uuid from TP ref or TOPO_NODE_ME* name.""" + """Extract managed-element uuid from FDN / TP ref / TOPO_NODE_ME* name.""" s = str(text or "").strip() if not s: return "" + m = _ME_EQ_RE.search(s) + if m: + return m.group(1) m = _ME_BRACE_RE.search(s) if m: return m.group(1) @@ -48,6 +64,26 @@ def extract_me_uuid(text: str) -> str: m2 = _UUID_RE.search(rest) if m2: return m2.group(0) + return "" + + +def extract_topo_object_uuid(text: str) -> str: + """Extract ME/SBN uuid from onsite FDN name (preferred stable node_id).""" + s = str(text or "").strip() + if not s: + return "" + for cre in (_ME_EQ_RE, _SBN_EQ_RE, _ME_BRACE_RE, _SBN_BRACE_RE): + m = cre.search(s) + if m: + return m.group(1) + low = s.upper() + for prefix in ("TOPO_NODE_ME", "TOPO_NODE_SBN"): + if prefix in low: + idx = low.find(prefix) + rest = s[idx + len(prefix) :] + m2 = _UUID_RE.search(rest) + if m2: + return m2.group(0) m3 = _UUID_RE.search(s) return m3.group(0) if m3 else "" @@ -86,17 +122,27 @@ def _link_id_from_row(row: dict[str, Any]) -> str: name = _s(_pick(row, "name")) if not name: return "" + # Prefer endpoint ME uuids embedded in TL name when linkId is absent. + obj = extract_topo_object_uuid(name) + if obj: + # Keep uniqueness for nameless links by hashing full name but avoid bare name: when possible + digest = hashlib.sha1(name.encode("utf-8", errors="replace")).hexdigest()[:40] + return f"tl:{digest}"[:128] digest = hashlib.sha1(name.encode("utf-8", errors="replace")).hexdigest()[:40] return f"name:{digest}"[:128] def _node_id_from_row(row: dict[str, Any]) -> str: + """Stable PK: nodeId if present, else ME=/SBN= uuid from onsite FDN ``name``.""" nid = _s(_pick(row, "nodeId", "node-id", "node_id", "id")) if nid: return nid[:128] name = _s(_pick(row, "name")) if not name: return "" + obj = extract_topo_object_uuid(name) + if obj: + return obj[:128] digest = hashlib.sha1(name.encode("utf-8", errors="replace")).hexdigest()[:40] return f"name:{digest}"[:128] @@ -105,7 +151,16 @@ def _ume_ne_id_for_topo_node(*, node_type: str, name: str) -> str: nt = str(node_type or "").strip().upper() if nt != "TOPO_NODE_ME": return "" - return extract_me_uuid(name) + return extract_me_uuid(name) or extract_topo_object_uuid(name) + + +def _normalize_parent_node(raw: str) -> str: + """Store parent as ME/SBN uuid when parentNode is an onsite FDN string.""" + s = _s(raw) + if not s: + return "" + obj = extract_topo_object_uuid(s) + return (obj or s)[:512] def _stale_running_sec() -> int: @@ -263,7 +318,9 @@ def _upsert_topo_node(work: Session, node_id: str, row: dict[str, Any], *, now) existing.node_type = node_type[:64] existing.user_label = _s(_pick(row, "userLabel", "user-label", "user_label"))[:512] existing.owner = _s(_pick(row, "owner"))[:64] - existing.parent_node = _s(_pick(row, "parentNode", "parent-node", "parent_node"))[:512] + existing.parent_node = _normalize_parent_node( + _s(_pick(row, "parentNode", "parent-node", "parent_node")) + ) existing.x_pos = _as_optional_int(_pick(row, "xPos", "x-pos", "x_pos")) existing.y_pos = _as_optional_int(_pick(row, "yPos", "y-pos", "y_pos")) existing.ume_ne_id = _ume_ne_id_for_topo_node(node_type=node_type, name=name)[:128] diff --git a/tests/test_ume_sync.py b/tests/test_ume_sync.py index ab9369d..a74f918 100644 --- a/tests/test_ume_sync.py +++ b/tests/test_ume_sync.py @@ -38,7 +38,12 @@ from netx_api.ume_sync_service import ( sync_inventory_full, sync_topology_full, ) -from netx_api.ume_sync_topology import extract_me_uuid, extract_ptp +from netx_api.ume_sync_topology import ( + extract_me_uuid, + extract_ptp, + extract_topo_object_uuid, + _node_id_from_row, +) from fastapi import HTTPException @@ -1109,6 +1114,73 @@ class UmeTopologySyncTests(unittest.TestCase): "7e8ac1c7-9d34-42d3-adfb-b1031e7c145a", ) + def test_onsite_fdn_name_without_node_id(self): + """Live UME often omits nodeId; name is MD=...;ME= / SBN=.""" + me_name = "MD=ZTE/UME(BN);ME=0ab0b408-0910-483e-94ca-c3dde489d848" + sbn_name = "MD=ZTE/UME(BN);SBN=47173499-c8bb-4a51-bef3-aea04ecf5c4c" + self.assertEqual(extract_me_uuid(me_name), "0ab0b408-0910-483e-94ca-c3dde489d848") + self.assertEqual(extract_me_uuid(sbn_name), "") + self.assertEqual(extract_topo_object_uuid(me_name), "0ab0b408-0910-483e-94ca-c3dde489d848") + self.assertEqual(extract_topo_object_uuid(sbn_name), "47173499-c8bb-4a51-bef3-aea04ecf5c4c") + row = { + "xPos": -2082, + "parentNode": sbn_name, + "owner": "ZTE", + "yPos": -1521, + "userLabel": "KND-RAEA-EN1-Z20HS", + "name": me_name, + "nodeType": "TOPO_NODE_ME", + } + self.assertEqual(_node_id_from_row(row), "0ab0b408-0910-483e-94ca-c3dde489d848") + + def test_sync_topology_onsite_fdn_payload(self): + class _Diag: + latency_ms = 1 + + me_uuid = "0ab0b408-0910-483e-94ca-c3dde489d848" + sbn_uuid = "47173499-c8bb-4a51-bef3-aea04ecf5c4c" + + class _C: + def get_topo_nodes(self): + return ( + [ + { + "name": f"MD=ZTE/UME(BN);SBN={sbn_uuid}", + "nodeType": "TOPO_NODE_SBN", + "owner": "ZTE", + "userLabel": "subnet", + "xPos": 1, + "yPos": 2, + "parentNode": "topLevel", + }, + { + "xPos": -2082, + "parentNode": f"MD=ZTE/UME(BN);SBN={sbn_uuid}", + "owner": "ZTE", + "yPos": -1521, + "userLabel": "KND-RAEA-EN1-Z20HS", + "name": f"MD=ZTE/UME(BN);ME={me_uuid}", + "nodeType": "TOPO_NODE_ME", + }, + ], + _Diag(), + ) + + def get_topological_links(self): + return ([], _Diag()) + + job = sync_topology_full(self.db, _C(), trigger_mode="manual") + self.assertEqual(job.status, "done") + self.assertEqual(job.inserted_count, 2) + sbn = self.db.get(UmeTopoNode, sbn_uuid) + me = self.db.get(UmeTopoNode, me_uuid) + self.assertIsNotNone(sbn) + self.assertIsNotNone(me) + self.assertEqual(me.ume_ne_id, me_uuid) + self.assertEqual(me.parent_node, sbn_uuid) + self.assertEqual(me.x_pos, -2082) + self.assertEqual(me.user_label, "KND-RAEA-EN1-Z20HS") + def test_sync_topology_upsert_and_reconcile(self): class _Diag: latency_ms = 1