netx/netx_api/topology_classify_slices.py
oliver 4ec890c407 Split topology classify and config sync services by domain.
Keep public facades stable while moving rules/apply/slices and policy/cycles/snapshots into focused modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 17:21:50 +08:00

344 lines
12 KiB
Python

"""Topology slice map preview/generation and fabric search."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .models import TopoFabricEdge, TopoFabricNode, TopoFolder, TopoView, TopoViewNode
from .topology_classify_common import _SLICE_TEMPLATES, _utcnow
from .topology_membership import (
VIEW_KIND_CUSTOM,
VIEW_ROLE_ACCESS,
VIEW_ROLE_AGGREGATION,
VIEW_ROLE_CORE,
merge_filter_with_membership,
normalize_view_role,
)
from .topology_schemas import (
FabricNodeOut,
SliceGenerateOut,
SliceGenerateRequest,
SliceMapPlan,
TopologyViewCreate,
)
def _active_neighbors(db: Session, seed_ids: set[str], *, hops: int = 1) -> set[str]:
if not seed_ids or hops <= 0:
return set()
frontier = set(seed_ids)
found: set[str] = set()
for _ in range(hops):
if not frontier:
break
rows = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == "physical",
TopoFabricEdge.status == "active",
(TopoFabricEdge.a_node_id.in_(frontier) | TopoFabricEdge.b_node_id.in_(frontier)),
)
.all()
)
nxt: set[str] = set()
for e in rows:
for a, b in ((e.a_node_id, e.b_node_id), (e.b_node_id, e.a_node_id)):
if a in frontier and b not in seed_ids and b not in found:
nxt.add(str(b))
found |= nxt
frontier = nxt
return found
def _connected_components(db: Session, node_ids: list[str]) -> list[list[str]]:
ids = [str(x) for x in node_ids if str(x)]
if not ids:
return []
id_set = set(ids)
adj: dict[str, set[str]] = {i: set() for i in ids}
rows = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == "physical",
TopoFabricEdge.status == "active",
TopoFabricEdge.a_node_id.in_(ids),
TopoFabricEdge.b_node_id.in_(ids),
)
.all()
)
for e in rows:
a, b = str(e.a_node_id), str(e.b_node_id)
if a in id_set and b in id_set:
adj[a].add(b)
adj[b].add(a)
seen: set[str] = set()
comps: list[list[str]] = []
for nid in ids:
if nid in seen:
continue
stack = [nid]
seen.add(nid)
comp: list[str] = []
while stack:
cur = stack.pop()
comp.append(cur)
for nb in adj.get(cur, ()):
if nb not in seen:
seen.add(nb)
stack.append(nb)
comps.append(sorted(comp))
return comps
def _nodes_in_region(db: Session, folder_id: str, *, role: str = "") -> list[TopoFabricNode]:
q = db.query(TopoFabricNode).filter(TopoFabricNode.region_folder_id == folder_id)
if role:
q = q.filter(TopoFabricNode.role == role)
return q.order_by(TopoFabricNode.name.asc()).all()
def preview_slices(db: Session, body: SliceGenerateRequest) -> SliceGenerateOut:
folder = db.get(TopoFolder, body.folder_id)
if folder is None or str(folder.kind or "") != "region":
raise HTTPException(status_code=400, detail="folder_not_found")
template = str(body.template or "").strip().lower()
if template not in _SLICE_TEMPLATES:
raise HTTPException(status_code=400, detail="template_invalid")
max_nodes = max(1, min(2000, int(body.max_nodes or 300)))
plans: list[SliceMapPlan] = []
overlap_ids: set[str] = set()
seen_in_maps: dict[str, int] = {}
def _track(ids: list[str]) -> None:
for i in ids:
seen_in_maps[i] = seen_in_maps.get(i, 0) + 1
if template == "core_only":
cores = _nodes_in_region(db, folder.id, role=VIEW_ROLE_CORE)
comps = _connected_components(db, [n.id for n in cores]) or [
[n.id] for n in cores
]
for idx, comp in enumerate(comps, start=1):
if len(comp) > max_nodes:
raise HTTPException(
status_code=400,
detail=f"slice_exceeds_max_nodes:{len(comp)}>{max_nodes}",
)
name = f"Core-{idx}" if len(comps) > 1 else "Core"
plans.append(
SliceMapPlan(
name=name,
role=VIEW_ROLE_CORE,
seed_fabric_node_ids=comp,
member_fabric_node_ids=comp,
node_count=len(comp),
)
)
_track(comp)
elif template == "core_agg":
cores = _nodes_in_region(db, folder.id, role=VIEW_ROLE_CORE)
comps = _connected_components(db, [n.id for n in cores]) or [
[n.id] for n in cores
]
for idx, comp in enumerate(comps, start=1):
peers = _active_neighbors(db, set(comp), hops=1)
agg_ids = [
p
for p in peers
if (fn := db.get(TopoFabricNode, p)) is not None
and str(fn.role or "") == VIEW_ROLE_AGGREGATION
and str(fn.region_folder_id or "") == folder.id
]
members = sorted(set(comp) | set(agg_ids))
if len(members) > max_nodes:
raise HTTPException(
status_code=400,
detail=f"slice_exceeds_max_nodes:{len(members)}>{max_nodes}",
)
name = f"CoreAgg-{idx}" if len(comps) > 1 else "Core+Agg"
plans.append(
SliceMapPlan(
name=name,
role=VIEW_ROLE_CORE,
seed_fabric_node_ids=comp,
member_fabric_node_ids=members,
node_count=len(members),
)
)
_track(members)
else: # agg_access
aggs = _nodes_in_region(db, folder.id, role=VIEW_ROLE_AGGREGATION)
comps = _connected_components(db, [n.id for n in aggs]) or [
[n.id] for n in aggs
]
for idx, comp in enumerate(comps, start=1):
peers = _active_neighbors(db, set(comp), hops=1)
acc_ids = [
p
for p in peers
if (fn := db.get(TopoFabricNode, p)) is not None
and str(fn.role or "") == VIEW_ROLE_ACCESS
and str(fn.region_folder_id or "") == folder.id
]
members = sorted(set(comp) | set(acc_ids))
if len(members) > max_nodes:
raise HTTPException(
status_code=400,
detail=f"slice_exceeds_max_nodes:{len(members)}>{max_nodes}",
)
name = f"AggAccess-{idx}" if len(comps) > 1 else "Agg+Access"
plans.append(
SliceMapPlan(
name=name,
role=VIEW_ROLE_AGGREGATION,
seed_fabric_node_ids=comp,
member_fabric_node_ids=members,
node_count=len(members),
)
)
_track(members)
overlap_ids = {nid for nid, cnt in seen_in_maps.items() if cnt > 1}
return SliceGenerateOut(
folder_id=folder.id,
template=template,
dry_run=True,
maps=plans,
map_count=len(plans),
overlap_node_count=len(overlap_ids),
created_view_ids=[],
)
def generate_slices(db: Session, body: SliceGenerateRequest) -> SliceGenerateOut:
from .topology_service import create_view, _place_fabric_ids_on_view
preview = preview_slices(db, body)
if body.dry_run:
return preview
created: list[str] = []
for plan in preview.maps:
view = create_view(
db,
TopologyViewCreate(
name=plan.name,
folder_id=body.folder_id,
kind=VIEW_KIND_CUSTOM,
role=plan.role,
remark=f"slice:{body.template}",
),
)
mem = {
"mode": "hybrid",
"seed_fabric_node_ids": list(plan.member_fabric_node_ids),
"expand_hops": 0,
"max_nodes": int(body.max_nodes or 300),
"frozen": True,
"managed_ne_ids": [],
"tags_any": [],
"vendors": [],
"device_types": [],
"keyword": "",
}
row = db.get(TopoView, view.id)
assert row is not None
row.filter = merge_filter_with_membership(
dict(row.filter or {}), role=normalize_view_role(plan.role), membership=mem
)
_place_fabric_ids_on_view(db, row, list(plan.member_fabric_node_ids), existing=set())
row.updated_at = _utcnow()
db.commit()
created.append(view.id)
# Optionally seed physical overview with cores only
if body.seed_physical_cores:
from .topology_service import ensure_region_physical_view
phys = ensure_region_physical_view(db, body.folder_id, commit=True)
cores = [n.id for n in _nodes_in_region(db, body.folder_id, role=VIEW_ROLE_CORE)]
existing = {
vn.fabric_node_id
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == phys.id).all()
}
to_add = [c for c in cores if c not in existing][: int(body.max_nodes or 300)]
if to_add:
_place_fabric_ids_on_view(db, phys, to_add, existing=existing)
mem = merge_filter_with_membership(
dict(phys.filter or {}),
role=VIEW_ROLE_CORE,
kind="physical",
membership={
**dict((phys.filter or {}).get("membership") or {}),
"frozen": True,
"max_nodes": int(body.max_nodes or 500),
},
)
phys.filter = mem
phys.updated_at = _utcnow()
db.commit()
return SliceGenerateOut(
folder_id=body.folder_id,
template=str(body.template),
dry_run=False,
maps=preview.maps,
map_count=len(preview.maps),
overlap_node_count=preview.overlap_node_count,
created_view_ids=created,
)
def search_fabric_nodes_with_views(
db: Session,
*,
keyword: str = "",
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
from .topology_service import _node_out
q = db.query(TopoFabricNode)
kw = str(keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(
(TopoFabricNode.name.ilike(like))
| (TopoFabricNode.ip.ilike(like))
| (TopoFabricNode.vendor.ilike(like))
)
total = q.count()
rows = (
q.order_by(TopoFabricNode.name.asc())
.offset(max(0, (page - 1) * page_size))
.limit(page_size)
.all()
)
node_ids = [n.id for n in rows]
placements: dict[str, list[dict[str, Any]]] = {nid: [] for nid in node_ids}
if node_ids:
vnodes = (
db.query(TopoViewNode, TopoView, TopoFolder)
.join(TopoView, TopoView.id == TopoViewNode.view_id)
.outerjoin(TopoFolder, TopoFolder.id == TopoView.folder_id)
.filter(TopoViewNode.fabric_node_id.in_(node_ids))
.all()
)
for vn, view, folder in vnodes:
placements.setdefault(vn.fabric_node_id, []).append(
{
"view_id": view.id,
"view_name": view.name,
"folder_id": view.folder_id or "",
"folder_name": (folder.name if folder else "") or "",
"kind": view.kind or "custom",
}
)
items = []
for n in rows:
d = _node_out(n).model_dump()
d["views"] = placements.get(n.id, [])
items.append(d)
return {"total": total, "page": page, "page_size": page_size, "items": items}