netx/netx_api/topology_discover_common.py
oliver 2068ce3d04 Split topology discover into target, scan, and job modules.
Keep the discover facade stable for routers/tests while fixing the missing infer_device_type_vendor import on UME target resolution.

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

206 lines
7.3 KiB
Python

"""Discover job serializers, scan-target resolution, and history prune."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .models import ManagedNE, TopoDiscoverJob, TopoDiscoverJobItem, UmeInventoryNE
from .topology_common import _RAW_PREVIEW_MAX
from .topology_schemas import (
FabricDiscoverJobItemOut,
FabricDiscoverJobOut,
FabricDiscoverRequest,
FabricDiscoverUnmatched,
)
def _raw_preview(raw: str, *, limit: int = _RAW_PREVIEW_MAX) -> str:
text = str(raw or "")
if len(text) <= limit:
return text
return f"{text[:limit]}\n...[truncated preview {limit}/{len(text)} chars]"
def _job_out(db: Session, job: TopoDiscoverJob, *, include_items: bool = True) -> FabricDiscoverJobOut:
items_out: list[FabricDiscoverJobItemOut] = []
if include_items:
items = (
db.query(TopoDiscoverJobItem)
.filter(TopoDiscoverJobItem.job_id == job.id)
.order_by(TopoDiscoverJobItem.created_at.asc())
.all()
)
for it in items:
unmatched = [
FabricDiscoverUnmatched.model_validate(x) for x in (it.unmatched_json or [])[:40]
]
items_out.append(
FabricDiscoverJobItemOut(
id=it.id,
job_id=it.job_id,
ne_id=it.ne_id or "",
ume_ne_id=it.ume_ne_id or "",
fabric_node_id=it.fabric_node_id or "",
ne_name=it.ne_name or "",
ne_ip=it.ne_ip or "",
ok=bool(it.ok),
command=it.command or "",
neighbors=int(it.neighbors or 0),
edges_added=int(it.edges_added or 0),
edges_updated=int(it.edges_updated or 0),
unmatched_count=int(it.unmatched_count or 0),
unmatched=unmatched,
parser_key=it.parser_key or "",
parser_stub=bool(it.parser_stub),
error=it.error or "",
raw_preview=it.raw_preview or "",
)
)
return FabricDiscoverJobOut(
id=job.id,
scope=job.scope,
trigger_mode=str(getattr(job, "trigger_mode", None) or "manual"),
status=job.status,
total=int(job.total or 0),
done=int(job.done or 0),
edges_added=int(job.edges_added or 0),
edges_updated=int(job.edges_updated or 0),
edges_stale=int(job.edges_stale or 0),
edges_missing=int(job.edges_stale or 0),
error=job.error or "",
started_at=job.started_at,
ended_at=job.ended_at,
items=items_out,
)
def get_discover_job(db: Session, job_id: str) -> FabricDiscoverJobOut:
job = db.get(TopoDiscoverJob, str(job_id or "").strip())
if job is None:
raise HTTPException(status_code=404, detail="discover_job_not_found")
return _job_out(db, job)
def _ume_target_dict(db: Session, uid: str, default_profile: Any) -> dict[str, str] | None:
ume = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id == uid).one_or_none()
if ume is None:
return None
if default_profile is not None:
dtype, vendor = infer_device_type_vendor(str(ume.ne_type or ""), default_profile)
else:
dtype, vendor = "zte_zxros", (ume.vendor or "ZTE")
name = (ume.host_name or ume.ne_name or ume.user_label or ume.ip_address or uid).strip()
return {
"ne_id": uid,
"ume_ne_id": uid,
"ne_name": name,
"ne_ip": ume.ip_address or "",
"vendor": vendor or (ume.vendor or "ZTE"),
"device_type": dtype or "zte_zxros",
}
def _resolve_scan_targets(
db: Session, body: FabricDiscoverRequest
) -> list[dict[str, str]]:
scope = str(body.scope or "ne_ids").strip().lower() or "ne_ids"
default_profile = get_default_profile(db)
targets: list[dict[str, str]] = []
if scope == "all_inventory":
for ne in db.query(ManagedNE).all():
targets.append(
{
"ne_id": ne.id,
"ume_ne_id": "",
"ne_name": ne.name or "",
"ne_ip": ne.ip_address or "",
"vendor": ne.vendor or "",
"device_type": ne.device_type or "",
}
)
return targets
managed_ids = [str(x).strip() for x in (body.managed_ne_ids or []) if str(x).strip()]
ume_ids = [str(x).strip() for x in (body.ume_ne_ids or []) if str(x).strip()]
if managed_ids or ume_ids:
seen: set[str] = set()
for mid in managed_ids:
if mid in seen:
continue
ne = db.get(ManagedNE, mid)
if ne is None:
continue
seen.add(mid)
targets.append(
{
"ne_id": ne.id,
"ume_ne_id": "",
"ne_name": ne.name or "",
"ne_ip": ne.ip_address or "",
"vendor": ne.vendor or "",
"device_type": ne.device_type or "",
}
)
for uid in ume_ids:
key = f"ume:{uid}"
if key in seen:
continue
row = _ume_target_dict(db, uid, default_profile)
if row is None:
continue
seen.add(key)
targets.append(row)
if not targets:
raise HTTPException(status_code=400, detail="ne_ids_required")
return targets
# Legacy mixed ne_ids: prefer ManagedNE, leftover treated as UME.
filter_ids = {str(x).strip() for x in (body.ne_ids or []) if str(x).strip()}
if not filter_ids:
raise HTTPException(status_code=400, detail="ne_ids_required")
for mid in list(filter_ids):
ne = db.get(ManagedNE, mid)
if ne is not None:
targets.append(
{
"ne_id": ne.id,
"ume_ne_id": "",
"ne_name": ne.name or "",
"ne_ip": ne.ip_address or "",
"vendor": ne.vendor or "",
"device_type": ne.device_type or "",
}
)
filter_ids.discard(mid)
for uid in list(filter_ids):
row = _ume_target_dict(db, uid, default_profile)
if row is not None:
targets.append(row)
return targets
def prune_discover_jobs(db: Session, *, keep: int = 30) -> int:
"""Delete finished discover jobs beyond ``keep`` (newest kept). Open jobs always retained."""
keep = max(0, min(200, int(keep)))
finished = (
db.query(TopoDiscoverJob)
.filter(TopoDiscoverJob.status.in_(["done", "failed"]))
.order_by(TopoDiscoverJob.created_at.desc())
.all()
)
to_drop = finished if keep == 0 else finished[keep:]
if not to_drop:
return 0
dropped = 0
for job in to_drop:
db.query(TopoDiscoverJobItem).filter(TopoDiscoverJobItem.job_id == job.id).delete(
synchronize_session=False
)
db.delete(job)
dropped += 1
if dropped:
db.commit()
return dropped