mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Queue fabric edge deletes until Save with undo-safe pending state; hard-delete topology/LLDP placeholders from the canvas; purge fully orphaned fabric nodes after inventory detach, with a periodic reconcile GC. Co-authored-by: Cursor <cursoragent@cursor.com>
471 lines
16 KiB
Python
471 lines
16 KiB
Python
"""Detach fabric ↔ inventory links; purge orphan / non-managed fabric nodes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.orm.attributes import flag_modified
|
|
|
|
from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE, is_placeholder_ne_source
|
|
from .models import (
|
|
ManagedNE,
|
|
TopoFabricEdge,
|
|
TopoFabricNode,
|
|
TopoView,
|
|
TopoViewEdgeStyle,
|
|
TopoViewNode,
|
|
UmeInventoryNE,
|
|
)
|
|
|
|
UME_SYNC_SOURCE = "ume_sync"
|
|
# ManagedNE.source values that are not "real" ops inventory (placeholders / sessions).
|
|
_NON_INVENTORY_MANAGED_SOURCES = frozenset(
|
|
{
|
|
LLDP_DISCOVERED_NE_SOURCE,
|
|
WEBCRT_NE_SOURCE,
|
|
"lldp",
|
|
"webcrt",
|
|
"topology",
|
|
}
|
|
)
|
|
|
|
|
|
def _utcnow() -> datetime:
|
|
return datetime.utcnow()
|
|
|
|
|
|
def _norm_ids(ids: list[str] | None) -> list[str]:
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw in ids or []:
|
|
s = str(raw or "").strip()
|
|
if not s or s in seen:
|
|
continue
|
|
seen.add(s)
|
|
out.append(s)
|
|
return out
|
|
|
|
|
|
def fabric_link_status(n: TopoFabricNode) -> str:
|
|
mid = str(n.managed_ne_id or "").strip()
|
|
uid = str(n.ume_ne_id or "").strip()
|
|
if mid and uid:
|
|
return "both"
|
|
if mid:
|
|
return "managed"
|
|
if uid:
|
|
return "ume"
|
|
return "orphaned"
|
|
|
|
|
|
def managed_source_label(source: str | None) -> str:
|
|
src = str(source or "").strip().lower()
|
|
if src == UME_SYNC_SOURCE:
|
|
return "ume_sync"
|
|
if is_placeholder_ne_source(src):
|
|
if src in {"topology"}:
|
|
return "topology"
|
|
return "lldp"
|
|
if src == WEBCRT_NE_SOURCE or src == "webcrt":
|
|
return "webcrt"
|
|
if not src:
|
|
return "manual"
|
|
return src
|
|
|
|
|
|
def _strip_membership_ids(
|
|
db: Session,
|
|
*,
|
|
managed_ne_ids: set[str] | None = None,
|
|
ume_ne_ids: set[str] | None = None,
|
|
) -> int:
|
|
"""Remove deleted inventory ids from view membership JSON. Returns views touched."""
|
|
mids = managed_ne_ids or set()
|
|
uids = ume_ne_ids or set()
|
|
if not mids and not uids:
|
|
return 0
|
|
touched = 0
|
|
for view in db.query(TopoView).all():
|
|
filt = dict(view.filter or {})
|
|
mem = filt.get("membership")
|
|
if not isinstance(mem, dict):
|
|
continue
|
|
changed = False
|
|
if mids and isinstance(mem.get("managed_ne_ids"), list):
|
|
next_m = [str(x).strip() for x in mem["managed_ne_ids"] if str(x).strip() not in mids]
|
|
if next_m != [str(x).strip() for x in mem["managed_ne_ids"] if str(x).strip()]:
|
|
mem["managed_ne_ids"] = next_m
|
|
changed = True
|
|
if uids and isinstance(mem.get("ume_ne_ids"), list):
|
|
next_u = [str(x).strip() for x in mem["ume_ne_ids"] if str(x).strip() not in uids]
|
|
if next_u != [str(x).strip() for x in mem["ume_ne_ids"] if str(x).strip()]:
|
|
mem["ume_ne_ids"] = next_u
|
|
changed = True
|
|
if not changed:
|
|
continue
|
|
filt["membership"] = mem
|
|
view.filter = filt
|
|
flag_modified(view, "filter")
|
|
view.updated_at = _utcnow()
|
|
touched += 1
|
|
return touched
|
|
|
|
|
|
def detach_fabric_from_managed(db: Session, managed_ne_ids: list[str]) -> dict[str, int]:
|
|
"""Clear fabric.managed_ne_id for deleted ManagedNEs; purge nodes that become fully orphaned."""
|
|
ids = _norm_ids(managed_ne_ids)
|
|
if not ids:
|
|
return {
|
|
"detached_nodes": 0,
|
|
"membership_views": 0,
|
|
"purged_orphans": 0,
|
|
"edges_deleted": 0,
|
|
"placements_deleted": 0,
|
|
}
|
|
now = _utcnow()
|
|
rows = (
|
|
db.query(TopoFabricNode)
|
|
.filter(TopoFabricNode.managed_ne_id.in_(ids))
|
|
.all()
|
|
)
|
|
fabric_ids = [str(r.id) for r in rows]
|
|
for row in rows:
|
|
row.managed_ne_id = None
|
|
row.updated_at = now
|
|
views = _strip_membership_ids(db, managed_ne_ids=set(ids))
|
|
db.flush()
|
|
purged = purge_fully_orphaned_fabric_nodes(db, fabric_ids)
|
|
return {
|
|
"detached_nodes": len(rows),
|
|
"membership_views": views,
|
|
"purged_orphans": int(purged.get("deleted") or 0),
|
|
"edges_deleted": int(purged.get("edges_deleted") or 0),
|
|
"placements_deleted": int(purged.get("placements_deleted") or 0),
|
|
}
|
|
|
|
|
|
def detach_fabric_from_ume(db: Session, ume_ne_ids: list[str]) -> dict[str, int]:
|
|
"""Clear fabric.ume_ne_id for deleted UME inventory rows; purge nodes that become fully orphaned."""
|
|
ids = _norm_ids(ume_ne_ids)
|
|
if not ids:
|
|
return {
|
|
"detached_nodes": 0,
|
|
"membership_views": 0,
|
|
"purged_orphans": 0,
|
|
"edges_deleted": 0,
|
|
"placements_deleted": 0,
|
|
}
|
|
now = _utcnow()
|
|
rows = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.in_(ids)).all()
|
|
fabric_ids = [str(r.id) for r in rows]
|
|
for row in rows:
|
|
row.ume_ne_id = None
|
|
row.updated_at = now
|
|
views = _strip_membership_ids(db, ume_ne_ids=set(ids))
|
|
db.flush()
|
|
purged = purge_fully_orphaned_fabric_nodes(db, fabric_ids)
|
|
return {
|
|
"detached_nodes": len(rows),
|
|
"membership_views": views,
|
|
"purged_orphans": int(purged.get("deleted") or 0),
|
|
"edges_deleted": int(purged.get("edges_deleted") or 0),
|
|
"placements_deleted": int(purged.get("placements_deleted") or 0),
|
|
}
|
|
|
|
|
|
def purge_fully_orphaned_fabric_nodes(
|
|
db: Session,
|
|
fabric_node_ids: list[str] | None = None,
|
|
) -> dict[str, int]:
|
|
"""Hard-delete fabric nodes with no managed_ne_id and no ume_ne_id (cascades edges/placements).
|
|
|
|
When ``fabric_node_ids`` is None, sweeps the whole fabric table (GC).
|
|
Caller commits.
|
|
"""
|
|
if fabric_node_ids is None:
|
|
rows = [
|
|
n
|
|
for n in db.query(TopoFabricNode).all()
|
|
if not str(n.managed_ne_id or "").strip() and not str(n.ume_ne_id or "").strip()
|
|
]
|
|
else:
|
|
ids = _norm_ids(fabric_node_ids)
|
|
if not ids:
|
|
return {"deleted": 0, "edges_deleted": 0, "placements_deleted": 0}
|
|
candidates = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(ids)).all()
|
|
rows = [
|
|
n
|
|
for n in candidates
|
|
if not str(n.managed_ne_id or "").strip() and not str(n.ume_ne_id or "").strip()
|
|
]
|
|
return _delete_fabric_node_rows(db, rows)
|
|
|
|
|
|
def reconcile_dangling_fabric_links(
|
|
db: Session,
|
|
*,
|
|
sweep_orphans: bool = True,
|
|
) -> dict[str, int]:
|
|
"""Unbind fabric ids that no longer exist in managed_ne / ume_inventory_ne.
|
|
|
|
Detach paths purge nodes that become fully orphaned. When ``sweep_orphans`` is True
|
|
(default), also GC any remaining fully orphaned fabric nodes.
|
|
"""
|
|
managed_alive = {str(x[0]) for x in db.query(ManagedNE.id).all() if str(x[0] or "").strip()}
|
|
ume_alive = {
|
|
str(x[0]) for x in db.query(UmeInventoryNE.ne_id).all() if str(x[0] or "").strip()
|
|
}
|
|
dangling_m: list[str] = []
|
|
dangling_u: list[str] = []
|
|
for n in db.query(TopoFabricNode).all():
|
|
mid = str(n.managed_ne_id or "").strip()
|
|
uid = str(n.ume_ne_id or "").strip()
|
|
if mid and mid not in managed_alive:
|
|
dangling_m.append(mid)
|
|
if uid and uid not in ume_alive:
|
|
dangling_u.append(uid)
|
|
m_stats = detach_fabric_from_managed(db, dangling_m)
|
|
u_stats = detach_fabric_from_ume(db, dangling_u)
|
|
gc = (
|
|
purge_fully_orphaned_fabric_nodes(db, None)
|
|
if sweep_orphans
|
|
else {"deleted": 0, "edges_deleted": 0, "placements_deleted": 0}
|
|
)
|
|
purged = (
|
|
int(m_stats.get("purged_orphans") or 0)
|
|
+ int(u_stats.get("purged_orphans") or 0)
|
|
+ int(gc.get("deleted") or 0)
|
|
)
|
|
edges_deleted = (
|
|
int(m_stats.get("edges_deleted") or 0)
|
|
+ int(u_stats.get("edges_deleted") or 0)
|
|
+ int(gc.get("edges_deleted") or 0)
|
|
)
|
|
return {
|
|
"dangling_managed_refs": len(set(dangling_m)),
|
|
"dangling_ume_refs": len(set(dangling_u)),
|
|
"detached_managed_nodes": int(m_stats.get("detached_nodes") or 0),
|
|
"detached_ume_nodes": int(u_stats.get("detached_nodes") or 0),
|
|
"membership_views": int(m_stats.get("membership_views") or 0)
|
|
+ int(u_stats.get("membership_views") or 0),
|
|
"purged_orphans": purged,
|
|
"edges_deleted": edges_deleted,
|
|
"gc_orphans": int(gc.get("deleted") or 0),
|
|
}
|
|
|
|
|
|
def fabric_node_is_deletable(db: Session, n: TopoFabricNode) -> bool:
|
|
"""True for orphaned / dangling / LLDP·WebCRT placeholder fabric rows.
|
|
|
|
UME-only and real managed inventory (manual / ume_sync) cannot be deleted here.
|
|
"""
|
|
mid = str(n.managed_ne_id or "").strip()
|
|
uid = str(n.ume_ne_id or "").strip()
|
|
if not mid:
|
|
# Orphaned only — keep UME-linked fabric rows.
|
|
return not uid
|
|
row = db.get(ManagedNE, mid)
|
|
if row is None:
|
|
return True
|
|
src = str(row.source or "").strip().lower()
|
|
return src in _NON_INVENTORY_MANAGED_SOURCES
|
|
|
|
|
|
def _strip_fabric_ids_from_membership(db: Session, fabric_ids: set[str]) -> int:
|
|
if not fabric_ids:
|
|
return 0
|
|
touched = 0
|
|
for view in db.query(TopoView).all():
|
|
filt = dict(view.filter or {})
|
|
mem = filt.get("membership")
|
|
if not isinstance(mem, dict):
|
|
continue
|
|
changed = False
|
|
for key in ("seed_fabric_node_ids", "member_fabric_node_ids"):
|
|
raw = mem.get(key)
|
|
if not isinstance(raw, list):
|
|
continue
|
|
next_ids = [str(x).strip() for x in raw if str(x).strip() not in fabric_ids]
|
|
if next_ids != [str(x).strip() for x in raw if str(x).strip()]:
|
|
mem[key] = next_ids
|
|
changed = True
|
|
if not changed:
|
|
continue
|
|
filt["membership"] = mem
|
|
view.filter = filt
|
|
flag_modified(view, "filter")
|
|
view.updated_at = _utcnow()
|
|
touched += 1
|
|
return touched
|
|
|
|
|
|
def _delete_fabric_node_rows(db: Session, rows: list[TopoFabricNode]) -> dict[str, int]:
|
|
"""Delete fabric rows + incident edges + placements. Caller commits."""
|
|
ids = [str(r.id) for r in rows]
|
|
if not ids:
|
|
return {"deleted": 0, "edges_deleted": 0, "placements_deleted": 0}
|
|
edge_ids = [
|
|
str(e.id)
|
|
for e in db.query(TopoFabricEdge)
|
|
.filter(
|
|
(TopoFabricEdge.a_node_id.in_(ids)) | (TopoFabricEdge.b_node_id.in_(ids))
|
|
)
|
|
.all()
|
|
]
|
|
if edge_ids:
|
|
db.query(TopoViewEdgeStyle).filter(
|
|
TopoViewEdgeStyle.fabric_edge_id.in_(edge_ids)
|
|
).delete(synchronize_session=False)
|
|
db.query(TopoFabricEdge).filter(TopoFabricEdge.id.in_(edge_ids)).delete(
|
|
synchronize_session=False
|
|
)
|
|
placements = (
|
|
db.query(TopoViewNode)
|
|
.filter(TopoViewNode.fabric_node_id.in_(ids))
|
|
.delete(synchronize_session=False)
|
|
)
|
|
_strip_fabric_ids_from_membership(db, set(ids))
|
|
for r in rows:
|
|
db.delete(r)
|
|
return {
|
|
"deleted": len(rows),
|
|
"edges_deleted": len(edge_ids),
|
|
"placements_deleted": int(placements or 0),
|
|
}
|
|
|
|
|
|
def delete_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, int]:
|
|
"""Hard-delete fabric nodes (placements + edges). Does not touch managed/UME inventory."""
|
|
ids = _norm_ids(fabric_node_ids)
|
|
if not ids:
|
|
raise HTTPException(status_code=400, detail="fabric_node_ids_required")
|
|
rows = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(ids)).all()
|
|
found = {str(r.id): r for r in rows}
|
|
missing = [i for i in ids if i not in found]
|
|
if missing:
|
|
raise HTTPException(status_code=404, detail=f"fabric_node_not_found:{missing[0]}")
|
|
blocked = [i for i, r in found.items() if not fabric_node_is_deletable(db, r)]
|
|
if blocked:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"fabric_node_not_deletable:{blocked[0]}",
|
|
)
|
|
|
|
out = _delete_fabric_node_rows(db, rows)
|
|
db.commit()
|
|
try:
|
|
from .topology_service import refresh_fabric_stats
|
|
|
|
refresh_fabric_stats(db)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return out
|
|
|
|
|
|
def purge_placeholder_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, int]:
|
|
"""Hard-delete LLDP/topology placeholders: ManagedNE + fabric node + edges + placements.
|
|
|
|
Rejects real inventory (manual / ume_sync), WebCRT sessions, and UME-only fabric rows.
|
|
"""
|
|
ids = _norm_ids(fabric_node_ids)
|
|
if not ids:
|
|
raise HTTPException(status_code=400, detail="fabric_node_ids_required")
|
|
rows = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(ids)).all()
|
|
found = {str(r.id): r for r in rows}
|
|
missing = [i for i in ids if i not in found]
|
|
if missing:
|
|
raise HTTPException(status_code=404, detail=f"fabric_node_not_found:{missing[0]}")
|
|
|
|
managed_rows: list[ManagedNE] = []
|
|
managed_ids: set[str] = set()
|
|
for fid, fab in found.items():
|
|
mid = str(fab.managed_ne_id or "").strip()
|
|
uid = str(fab.ume_ne_id or "").strip()
|
|
if not mid:
|
|
if uid:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"fabric_node_not_placeholder:{fid}",
|
|
)
|
|
# Orphan fabric — allow purge (same as fabric delete).
|
|
continue
|
|
mrow = db.get(ManagedNE, mid)
|
|
if mrow is None:
|
|
continue
|
|
if not is_placeholder_ne_source(mrow.source):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"fabric_node_not_placeholder:{fid}",
|
|
)
|
|
if mid not in managed_ids:
|
|
managed_ids.add(mid)
|
|
managed_rows.append(mrow)
|
|
|
|
out = _delete_fabric_node_rows(db, rows)
|
|
for mrow in managed_rows:
|
|
db.delete(mrow)
|
|
membership_views = _strip_membership_ids(db, managed_ne_ids=managed_ids)
|
|
db.commit()
|
|
try:
|
|
from .topology_service import refresh_fabric_stats
|
|
|
|
refresh_fabric_stats(db)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return {
|
|
**out,
|
|
"managed_deleted": len(managed_rows),
|
|
"membership_views": membership_views,
|
|
}
|
|
|
|
|
|
def enrich_fabric_node_dicts(
|
|
db: Session, items: list[dict[str, Any]]
|
|
) -> list[dict[str, Any]]:
|
|
"""Add link_status / alive flags / managed_source onto FabricNodeOut dicts."""
|
|
if not items:
|
|
return items
|
|
mids = [str(x.get("managed_ne_id") or "").strip() for x in items]
|
|
uids = [str(x.get("ume_ne_id") or "").strip() for x in items]
|
|
mids_f = [x for x in mids if x]
|
|
uids_f = [x for x in uids if x]
|
|
managed_by_id: dict[str, ManagedNE] = {}
|
|
if mids_f:
|
|
for row in db.query(ManagedNE).filter(ManagedNE.id.in_(mids_f)).all():
|
|
managed_by_id[str(row.id)] = row
|
|
ume_alive: set[str] = set()
|
|
if uids_f:
|
|
ume_alive = {
|
|
str(x[0])
|
|
for x in db.query(UmeInventoryNE.ne_id)
|
|
.filter(UmeInventoryNE.ne_id.in_(uids_f))
|
|
.all()
|
|
if str(x[0] or "").strip()
|
|
}
|
|
for item in items:
|
|
mid = str(item.get("managed_ne_id") or "").strip()
|
|
uid = str(item.get("ume_ne_id") or "").strip()
|
|
if mid and uid:
|
|
item["link_status"] = "both"
|
|
elif mid:
|
|
item["link_status"] = "managed"
|
|
elif uid:
|
|
item["link_status"] = "ume"
|
|
else:
|
|
item["link_status"] = "orphaned"
|
|
mrow = managed_by_id.get(mid) if mid else None
|
|
item["managed_alive"] = bool(mrow)
|
|
item["ume_alive"] = bool(uid and uid in ume_alive)
|
|
item["managed_source"] = managed_source_label(mrow.source) if mrow else ""
|
|
if not mid:
|
|
item["deletable"] = not bool(uid)
|
|
elif mrow is None:
|
|
item["deletable"] = True
|
|
else:
|
|
item["deletable"] = (
|
|
str(mrow.source or "").strip().lower() in _NON_INVENTORY_MANAGED_SOURCES
|
|
)
|
|
return items
|