netx/netx_api/topology_service.py
oliver bfc10ee8cf Add ZTE LLDP brief parsing and UME NEs to topology palette.
ZXROS uses show lldp neighbor brief; topology library can switch between managed and UME inventory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 00:18:07 +08:00

459 lines
15 KiB
Python

"""Topology map CRUD, graph save, and LLDP/CDP edge discovery."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE
from .ne_exec import execute_managed_ne_commands
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
from .topology_schemas import (
TopologyDiscoverNeResult,
TopologyDiscoverOut,
TopologyDiscoverRequest,
TopologyEdgeIn,
TopologyEdgeOut,
TopologyGraphOut,
TopologyGraphPut,
TopologyMapCreate,
TopologyMapOut,
TopologyMapUpdate,
TopologyNodeIn,
TopologyNodeOut,
)
def _utcnow() -> datetime:
return datetime.utcnow()
def _map_out(m: TopologyMap, *, node_count: int = 0, edge_count: int = 0) -> TopologyMapOut:
return TopologyMapOut(
id=m.id,
name=m.name,
remark=m.remark or "",
node_count=node_count,
edge_count=edge_count,
created_at=m.created_at,
updated_at=m.updated_at,
)
def _get_map_or_404(db: Session, map_id: str) -> TopologyMap:
mid = str(map_id or "").strip()
row = db.get(TopologyMap, mid) if mid else None
if row is None:
raise HTTPException(status_code=404, detail="topology_map_not_found")
return row
def list_maps(db: Session) -> dict[str, Any]:
rows = db.query(TopologyMap).order_by(TopologyMap.updated_at.desc()).all()
items: list[TopologyMapOut] = []
for m in rows:
nc = db.query(TopologyNode).filter(TopologyNode.map_id == m.id).count()
ec = db.query(TopologyEdge).filter(TopologyEdge.map_id == m.id).count()
items.append(_map_out(m, node_count=nc, edge_count=ec))
return {"total": len(items), "items": [i.model_dump() for i in items]}
def create_map(db: Session, body: TopologyMapCreate) -> TopologyMapOut:
name = str(body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
now = _utcnow()
row = TopologyMap(
id=uuid4().hex,
name=name[:256],
remark=str(body.remark or "")[:1024],
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
db.refresh(row)
return _map_out(row)
def update_map(db: Session, map_id: str, body: TopologyMapUpdate) -> TopologyMapOut:
row = _get_map_or_404(db, map_id)
if body.name is not None:
name = str(body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name_required")
row.name = name[:256]
if body.remark is not None:
row.remark = str(body.remark or "")[:1024]
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
nc = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).count()
ec = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).count()
return _map_out(row, node_count=nc, edge_count=ec)
def delete_map(db: Session, map_id: str) -> dict[str, Any]:
row = _get_map_or_404(db, map_id)
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
db.delete(row)
db.commit()
return {"ok": True, "map_id": map_id, "deleted": True}
def _ne_lookup(db: Session, ne_ids: set[str]) -> dict[str, ManagedNE]:
if not ne_ids:
return {}
rows = db.query(ManagedNE).filter(ManagedNE.id.in_(list(ne_ids))).all()
return {r.id: r for r in rows}
def _ume_lookup(db: Session, ume_ids: set[str]) -> dict[str, UmeInventoryNE]:
ids = {str(x).strip() for x in ume_ids if str(x).strip()}
if not ids:
return {}
rows = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id.in_(list(ids))).all()
return {str(r.ne_id): r for r in rows}
def _node_out(
n: TopologyNode,
ne: ManagedNE | None,
ume: UmeInventoryNE | None = None,
) -> TopologyNodeOut:
label = (n.label or "").strip()
ne_name = ""
ne_ip = ""
vendor = ""
protocol = ""
connect_status = ""
if ne is not None:
if not label:
label = (ne.name or ne.ip_address or n.id)[:256]
ne_name = ne.name or ""
ne_ip = ne.ip_address or ""
vendor = ne.vendor or ""
protocol = ne.protocol or ""
connect_status = ne.connect_status or ""
elif ume is not None:
ume_name = (ume.host_name or ume.ne_name or ume.user_label or "").strip()
if not label:
label = (ume_name or ume.ip_address or n.id)[:256]
ne_name = ume_name
ne_ip = ume.ip_address or ""
vendor = (ume.vendor or "ZTE").strip() or "ZTE"
connect_status = ume.connection_status or ""
return TopologyNodeOut(
id=n.id,
map_id=n.map_id,
managed_ne_id=n.managed_ne_id or "",
ume_ne_id=n.ume_ne_id or "",
label=label,
x=float(n.x or 0),
y=float(n.y or 0),
ne_name=ne_name,
ne_ip=ne_ip,
vendor=vendor,
protocol=protocol,
connect_status=connect_status,
)
def _edge_out(e: TopologyEdge) -> TopologyEdgeOut:
return TopologyEdgeOut(
id=e.id,
map_id=e.map_id,
source_node_id=e.source_node_id,
target_node_id=e.target_node_id,
source_port=e.source_port or "",
target_port=e.target_port or "",
source=e.source or "manual",
discovered_at=e.discovered_at,
)
def get_graph(db: Session, map_id: str) -> TopologyGraphOut:
row = _get_map_or_404(db, map_id)
nodes = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
edges = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all()
nes = _ne_lookup(db, {str(n.managed_ne_id or "") for n in nodes if n.managed_ne_id})
umes = _ume_lookup(db, {str(n.ume_ne_id or "") for n in nodes if n.ume_ne_id})
return TopologyGraphOut(
map=_map_out(row, node_count=len(nodes), edge_count=len(edges)),
nodes=[
_node_out(
n,
nes.get(str(n.managed_ne_id or "")),
umes.get(str(n.ume_ne_id or "")),
)
for n in nodes
],
edges=[_edge_out(e) for e in edges],
)
def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraphOut:
row = _get_map_or_404(db, map_id)
nodes_in = list(body.nodes or [])
edges_in = list(body.edges or [])
if len(nodes_in) > 2000:
raise HTTPException(status_code=400, detail="too_many_nodes")
if len(edges_in) > 5000:
raise HTTPException(status_code=400, detail="too_many_edges")
node_ids = set()
for n in nodes_in:
nid = str(n.id or "").strip()
if not nid:
raise HTTPException(status_code=400, detail="node_id_required")
if nid in node_ids:
raise HTTPException(status_code=400, detail=f"duplicate_node_id:{nid}")
node_ids.add(nid)
for e in edges_in:
sid = str(e.source_node_id or "").strip()
tid = str(e.target_node_id or "").strip()
if sid not in node_ids or tid not in node_ids:
raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes")
src = str(e.source or "manual").strip().lower() or "manual"
if src not in {"manual", "lldp", "cdp"}:
raise HTTPException(status_code=400, detail="invalid_edge_source")
now = _utcnow()
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
for n in nodes_in:
db.add(
TopologyNode(
id=str(n.id).strip(),
map_id=row.id,
managed_ne_id=str(n.managed_ne_id or "").strip(),
ume_ne_id=str(n.ume_ne_id or "").strip(),
label=str(n.label or "").strip()[:256],
x=float(n.x or 0),
y=float(n.y or 0),
created_at=now,
updated_at=now,
)
)
for e in edges_in:
db.add(
TopologyEdge(
id=str(e.id).strip() or uuid4().hex,
map_id=row.id,
source_node_id=str(e.source_node_id).strip(),
target_node_id=str(e.target_node_id).strip(),
source_port=str(e.source_port or "").strip()[:128],
target_port=str(e.target_port or "").strip()[:128],
source=str(e.source or "manual").strip().lower() or "manual",
discovered_at=now if str(e.source or "").lower() in {"lldp", "cdp"} else None,
created_at=now,
updated_at=now,
)
)
row.updated_at = now
db.commit()
return get_graph(db, row.id)
def _norm_key(s: str) -> str:
return re_sub_host(str(s or "").strip().lower())
def re_sub_host(s: str) -> str:
# Strip domain / trailing punctuation for hostname matching.
t = s.split(".")[0].strip().lower()
return t.rstrip(".,;:")
def _match_neighbor_to_node(
hit: NeighborHit,
*,
nodes: list[TopologyNode],
nes: dict[str, ManagedNE],
self_node_id: str,
) -> TopologyNode | None:
name_key = _norm_key(hit.remote_name)
ip_key = str(hit.remote_ip or "").strip()
for n in nodes:
if n.id == self_node_id:
continue
ne = nes.get(str(n.managed_ne_id or ""))
candidates = [
_norm_key(n.label or ""),
_norm_key(ne.name if ne else ""),
str(ne.ip_address if ne else "").strip(),
]
if ip_key and ip_key in candidates:
return n
if name_key and name_key in {_norm_key(c) for c in candidates if c}:
return n
# Also match managed NE name without case
if ne and name_key and name_key == _norm_key(ne.name):
return n
return None
def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]:
if a <= b:
return (a, b, local_port, remote_port)
return (b, a, remote_port, local_port)
def discover_neighbors(
db: Session,
map_id: str,
body: TopologyDiscoverRequest,
) -> TopologyDiscoverOut:
row = _get_map_or_404(db, map_id)
nodes = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
edges = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all()
nes = _ne_lookup(db, {str(n.managed_ne_id or "") for n in nodes if n.managed_ne_id})
filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()}
scan_nodes = [
n
for n in nodes
if n.managed_ne_id
and n.managed_ne_id in nes
and (not filter_ids or n.managed_ne_id in filter_ids)
]
# Index existing edges for upsert (undirected + ports).
existing: dict[tuple[str, str, str, str], TopologyEdge] = {}
for e in edges:
key = _edge_pair_key(
e.source_node_id,
e.target_node_id,
(e.source_port or "").strip(),
(e.target_port or "").strip(),
)
existing[key] = e
results: list[TopologyDiscoverNeResult] = []
added = 0
updated = 0
now = _utcnow()
proto_req = str(body.protocol or "auto").strip().lower() or "auto"
for n in scan_nodes:
ne = nes.get(n.managed_ne_id)
if ne is None:
continue
cmd, proto_tag = pick_neighbor_command(
protocol=proto_req,
vendor=ne.vendor or "",
device_type=ne.device_type or "",
)
if not cmd:
results.append(
TopologyDiscoverNeResult(
ne_id=ne.id,
ne_name=ne.name or "",
ne_ip=ne.ip_address or "",
ok=False,
error="no_command_for_vendor",
)
)
continue
exec_out = execute_managed_ne_commands(
db,
[cmd],
ne_id=ne.id,
read_timeout_sec=60,
)
if not exec_out.get("ok"):
results.append(
TopologyDiscoverNeResult(
ne_id=ne.id,
ne_name=ne.name or "",
ne_ip=ne.ip_address or "",
ok=False,
command=cmd,
error=str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
)
)
continue
raw = str(exec_out.get("output") or "")
hits = parse_neighbor_output(
raw,
protocol=proto_tag,
vendor=ne.vendor or "",
device_type=ne.device_type or "",
)
ne_added = 0
ne_updated = 0
for hit in hits:
peer = _match_neighbor_to_node(
hit, nodes=nodes, nes=nes, self_node_id=n.id
)
if peer is None:
continue
local_port = (hit.local_port or "").strip()[:128]
remote_port = (hit.remote_port or "").strip()[:128]
key = _edge_pair_key(n.id, peer.id, local_port, remote_port)
edge_proto = hit.protocol if hit.protocol in {"lldp", "cdp"} else proto_tag
cur = existing.get(key)
if cur is not None:
# Never downgrade manual edges; refresh discovery metadata only for discovered.
if (cur.source or "manual") == "manual":
continue
cur.source = edge_proto
cur.source_port = local_port if cur.source_node_id == n.id else remote_port
cur.target_port = remote_port if cur.source_node_id == n.id else local_port
cur.discovered_at = now
cur.updated_at = now
ne_updated += 1
updated += 1
continue
# Prefer orientation: scanning node as source.
new_edge = TopologyEdge(
id=uuid4().hex,
map_id=row.id,
source_node_id=n.id,
target_node_id=peer.id,
source_port=local_port,
target_port=remote_port,
source=edge_proto,
discovered_at=now,
created_at=now,
updated_at=now,
)
db.add(new_edge)
existing[key] = new_edge
ne_added += 1
added += 1
results.append(
TopologyDiscoverNeResult(
ne_id=ne.id,
ne_name=ne.name or "",
ne_ip=ne.ip_address or "",
ok=True,
command=cmd,
neighbors=len(hits),
edges_added=ne_added,
edges_updated=ne_updated,
raw_preview=raw[:800],
)
)
row.updated_at = now
db.commit()
graph = get_graph(db, row.id)
return TopologyDiscoverOut(
map_id=row.id,
protocol=proto_req,
scanned=len(results),
edges_added=added,
edges_updated=updated,
results=results,
graph=graph,
)