netx/netx_api/topology_fabric_peers.py
oliver 241093343e Add topology-canvas placeholder NE create (source=topology).
Toolbar and pane context menu create placeholders that follow the same setup path as LLDP in NE management and WebCRT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:42:51 +08:00

275 lines
8.5 KiB
Python

"""Fabric inventory matching, LLDP peer index, and discovered NE ensure."""
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import and_, func, or_, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE, is_placeholder_ne_source
from .models import (
ManagedNE,
TopoFabricEdge,
TopoFabricNode,
TopoFabricStats,
TopoViewEdgeStyle,
TopoViewNode,
UmeInventoryNE,
)
from .topology_common import (
PAGE_DEFAULT,
PAGE_MAX,
VIEW_GRAPH_EDGE_HARD_CAP,
_ADV_NS_FABRIC_MANAGED,
_ADV_NS_FABRIC_UME,
_EDGE_STATUS_MISSING,
_EDGE_STATUS_MISSING_COMPAT,
_MISS_PURGE_AFTER_CYCLES,
_advisory_xact_lock,
_clear_miss_attrs,
_empty_to_none,
_is_deadlock_error,
_is_postgres,
_norm_host,
_normalize_edge_status,
_purge_edge_if_due,
_set_edge_missing,
_sleep_deadlock_backoff,
_utcnow,
_edge_attrs,
)
from .topology_lldp import NeighborHit, normalize_ifname
from .topology_schemas import (
FabricEdgeOut,
FabricNeighborhoodOut,
FabricNodeOut,
FabricSummaryOut,
)
from .topology_fabric_nodes import ensure_fabric_node_for_managed
def _is_inventory_node(n: TopoFabricNode) -> bool:
return bool(str(n.managed_ne_id or "").strip() or str(n.ume_ne_id or "").strip())
def _managed_source(db: Session, ne_id: str | None) -> str:
mid = str(ne_id or "").strip()
if not mid:
return ""
ne = db.get(ManagedNE, mid)
if ne is None:
return ""
return str(ne.source or "").strip().lower()
def _ne_inventory_score(ne: ManagedNE) -> int:
"""Prefer real inventory over placeholders; never prefer WebCRT twins."""
src = str(ne.source or "").strip().lower()
if src == WEBCRT_NE_SOURCE:
return 0
if is_placeholder_ne_source(src):
return 1
return 2
def _fabric_match_score(db: Session, n: TopoFabricNode) -> int:
"""Higher = prefer when collapsing LLDP hits / duplicate IPs.
WebCRT quick-connect intentionally allows duplicate IPs as separate ManagedNE
rows; those must lose to real inventory NEs with the same address.
LLDP / topology placeholders (SSH shell, empty creds) rank above WebCRT, below real NEs.
"""
if str(n.ume_ne_id or "").strip():
return 3
mid = str(n.managed_ne_id or "").strip()
if not mid:
return 0
src = _managed_source(db, mid)
if src == WEBCRT_NE_SOURCE:
return 1
if is_placeholder_ne_source(src):
return 2
return 4
def _pick_managed_ne(
db: Session, *, ip: str = "", name_key: str = ""
) -> ManagedNE | None:
"""Pick inventory NE. Name matching uses hostname key (not LLDP mgmt IP)."""
rows: list[ManagedNE] = []
if name_key:
key = _norm_host(name_key) or str(name_key or "").strip().lower()
if key:
candidates = (
db.query(ManagedNE)
.filter(
or_(
func.lower(ManagedNE.name) == key,
func.lower(ManagedNE.name).like(f"{key}.%"),
)
)
.all()
)
rows = [ne for ne in candidates if _norm_host(ne.name or "") == key]
elif ip:
# Kept for non-LLDP callers; LLDP peer match must not use this path.
rows = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).all()
if not rows:
return None
rows.sort(key=_ne_inventory_score, reverse=True)
best = rows[0]
# Only-WebCRT IP collision must not become a topology peer — treat as unmatched
# so discover can create an LLDP placeholder instead.
if _ne_inventory_score(best) == 0:
return None
return best
def ensure_lldp_discovered_managed_ne(
db: Session,
*,
remote_name: str = "",
remote_ip: str = "",
placeholder_by_name: dict[str, ManagedNE] | None = None,
) -> ManagedNE:
"""SSH placeholder ManagedNE for an LLDP neighbor not in inventory.
Intentionally empty IP / username / password — operator fills them later.
LLDP management IP (if any) is kept in ``source_ref`` / remark only.
"""
display = (str(remote_name or "").strip() or str(remote_ip or "").strip() or "unknown")[:256]
name_key = _norm_host(display)
ip_hint = str(remote_ip or "").strip()[:128]
now = _utcnow()
cache = placeholder_by_name
if cache is None:
cache = {}
for ne in (
db.query(ManagedNE)
.filter(ManagedNE.source == LLDP_DISCOVERED_NE_SOURCE)
.all()
):
nk = _norm_host(ne.name or "")
if nk and nk not in cache:
cache[nk] = ne
if name_key and name_key in cache:
ne = cache[name_key]
if ip_hint and not str(ne.source_ref or "").strip():
ne.source_ref = ip_hint
ne.updated_at = now
return ne
row = ManagedNE(
id=uuid4().hex,
name=display,
vendor="Other",
device_type="generic",
ip_address="",
port=22,
protocol="ssh",
username="",
password_enc="",
enable_secret_enc="",
connect_status="unknown",
tags="",
remark=(f"LLDP discovered" + (f"; seen_mgmt_ip={ip_hint}" if ip_hint else ""))[:1024],
source=LLDP_DISCOVERED_NE_SOURCE,
source_ref=ip_hint,
created_at=now,
updated_at=now,
)
db.add(row)
db.flush()
if name_key:
cache[name_key] = row
if placeholder_by_name is not None and name_key:
placeholder_by_name[name_key] = row
return row
class _FabricPeerIndex:
"""In-memory name index for one discover target (avoids O(nodes) per neighbor).
Identity is System Name / Device ID only. LLDP Management Address is often a
physical-interface IP and must not be used to pick the peer NE.
"""
def __init__(self, db: Session, self_id: str) -> None:
self.db = db
self.self_id = self_id
self.by_name: dict[str, list[TopoFabricNode]] = {}
self.placeholder_by_name: dict[str, ManagedNE] = {}
for n in db.query(TopoFabricNode).filter(TopoFabricNode.id != self_id).all():
nk = _norm_host(n.name or "")
if nk:
self.by_name.setdefault(nk, []).append(n)
for ne in (
db.query(ManagedNE).filter(ManagedNE.source == LLDP_DISCOVERED_NE_SOURCE).all()
):
nk = _norm_host(ne.name or "")
if nk and nk not in self.placeholder_by_name:
self.placeholder_by_name[nk] = ne
def _best(self, matched: list[TopoFabricNode]) -> TopoFabricNode:
# Prefer real inventory; ties → older fabric row (stable across rediscovers).
matched.sort(
key=lambda n: (
-_fabric_match_score(self.db, n),
n.created_at.timestamp() if n.created_at else 0.0,
n.id,
)
)
return matched[0]
def match(self, hit: NeighborHit) -> TopoFabricNode | None:
name_key = _norm_host(hit.remote_name)
if not name_key:
return None
matched = list(self.by_name.get(name_key) or [])
if matched:
return self._best(matched)
ne = _pick_managed_ne(self.db, name_key=name_key)
if ne is not None:
node = ensure_fabric_node_for_managed(self.db, ne)
self._remember(node)
return node
return None
def _remember(self, node: TopoFabricNode) -> None:
if not node or node.id == self.self_id:
return
nk = _norm_host(node.name or "")
if nk:
bucket = self.by_name.setdefault(nk, [])
if node not in bucket:
bucket.append(node)
def ensure_placeholder(self, *, remote_name: str, remote_ip: str) -> TopoFabricNode:
placeholder = ensure_lldp_discovered_managed_ne(
self.db,
remote_name=remote_name,
remote_ip=remote_ip,
placeholder_by_name=self.placeholder_by_name,
)
peer = ensure_fabric_node_for_managed(self.db, placeholder)
self._remember(peer)
return peer
def _match_hit_to_fabric_node(
db: Session, hit: NeighborHit, *, self_id: str
) -> TopoFabricNode | None:
return _FabricPeerIndex(db, self_id).match(hit)