Add fabric-node delete on classify inventory for orphans and placeholders.

Allow removing detached or LLDP/WebCRT fabric rows from classify and slices without touching managed/UME inventory; keep UME-only nodes protected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
hansjone 2026-08-02 11:44:35 +08:00
parent a5a3a4f156
commit 53ddb3b520
10 changed files with 391 additions and 11 deletions

View file

@ -1,17 +1,35 @@
"""Detach fabric ↔ inventory links without deleting topology placements/edges."""
"""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
from .models import ManagedNE, TopoFabricNode, TopoView, UmeInventoryNE
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",
}
)
def _utcnow() -> datetime:
@ -152,6 +170,105 @@ def reconcile_dangling_fabric_links(db: Session) -> dict[str, int]:
}
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_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]}",
)
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)
db.commit()
try:
from .topology_service import refresh_fabric_stats
refresh_fabric_stats(db)
except Exception: # noqa: BLE001
pass
return {
"deleted": len(rows),
"edges_deleted": len(edge_ids),
"placements_deleted": int(placements or 0),
}
def enrich_fabric_node_dicts(
db: Session, items: list[dict[str, Any]]
) -> list[dict[str, Any]]:
@ -190,4 +307,12 @@ def enrich_fabric_node_dicts(
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