netx/netx_api/biz_state/compare_service.py
2026-09-17 16:25:52 +08:00

503 lines
17 KiB
Python

"""Compare templates, port mappings, jobs, and runs."""
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 (
BizCompareJob,
BizCompareRun,
BizCompareTemplate,
BizPortMapping,
BizPortMappingRow,
BizStateBatch,
BizStateLldpNeighbor,
)
from ..timeutil import utcnow_naive
from .compare_engine import compare_rows, mapping_stats
from .profiles import metric_field_map
def _utcnow() -> datetime:
return utcnow_naive()
def _default_lldp_template_fields() -> dict[str, list[str]]:
fields = metric_field_map().get("lldp_neighbor") or []
keys = [f.name for f in fields if f.is_key]
ifaces = [f.name for f in fields if f.is_interface]
compare = [f.name for f in fields if f.name not in keys or f.name in ifaces]
# Prefer comparing identity+meta that aren't pure key-only if listed
compare = [f.name for f in fields if f.role in ("identity", "state", "meta") or f.is_key]
# Deduplicate while keeping order
seen: set[str] = set()
cmp_out: list[str] = []
for n in compare:
if n not in seen:
seen.add(n)
cmp_out.append(n)
return {
"key_fields": keys or ["local_if", "remote_sys", "remote_if"],
"iface_fields": ifaces or ["local_if"],
"compare_fields": cmp_out or ["remote_sys", "remote_if", "remote_ip", "protocol"],
}
def ensure_default_lldp_template(db: Session) -> BizCompareTemplate:
row = (
db.query(BizCompareTemplate)
.filter(BizCompareTemplate.metric_id == "lldp_neighbor", BizCompareTemplate.name == "LLDP default")
.one_or_none()
)
if row:
return row
defs = _default_lldp_template_fields()
row = BizCompareTemplate(
id=uuid4().hex,
name="LLDP default",
metric_id="lldp_neighbor",
key_fields=defs["key_fields"],
iface_fields=defs["iface_fields"],
compare_fields=defs["compare_fields"],
ignore_fields=[],
note="Built-in template for LLDP neighbor cutover compare",
created_at=_utcnow(),
updated_at=_utcnow(),
)
db.add(row)
db.commit()
db.refresh(row)
return row
def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
return {
"id": t.id,
"name": t.name,
"metric_id": t.metric_id,
"key_fields": list(t.key_fields or []),
"iface_fields": list(t.iface_fields or []),
"compare_fields": list(t.compare_fields or []),
"ignore_fields": list(t.ignore_fields or []),
"note": t.note,
"updated_at": t.updated_at.isoformat() + "Z" if t.updated_at else None,
}
def list_templates(db: Session) -> list[dict[str, Any]]:
ensure_default_lldp_template(db)
rows = db.query(BizCompareTemplate).order_by(BizCompareTemplate.name.asc()).all()
return [_template_out(t) for t in rows]
def create_template(db: Session, body: dict[str, Any]) -> dict[str, Any]:
metric_id = str(body.get("metric_id") or "").strip()
if not metric_id:
raise HTTPException(status_code=400, detail="metric_id_required")
key_fields = [str(x) for x in (body.get("key_fields") or []) if str(x).strip()]
if not key_fields:
raise HTTPException(status_code=400, detail="key_fields_required")
t = BizCompareTemplate(
id=uuid4().hex,
name=str(body.get("name") or metric_id)[:256],
metric_id=metric_id,
key_fields=key_fields,
iface_fields=[str(x) for x in (body.get("iface_fields") or []) if str(x).strip()],
compare_fields=[str(x) for x in (body.get("compare_fields") or []) if str(x).strip()],
ignore_fields=[str(x) for x in (body.get("ignore_fields") or []) if str(x).strip()],
note=str(body.get("note") or "")[:512],
created_at=_utcnow(),
updated_at=_utcnow(),
)
db.add(t)
db.commit()
return _template_out(t)
def update_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]:
t = db.get(BizCompareTemplate, template_id)
if not t:
raise HTTPException(status_code=404, detail="template_not_found")
if "name" in body:
t.name = str(body.get("name") or "")[:256]
if "key_fields" in body:
keys = [str(x) for x in (body.get("key_fields") or []) if str(x).strip()]
if not keys:
raise HTTPException(status_code=400, detail="key_fields_required")
t.key_fields = keys
if "iface_fields" in body:
t.iface_fields = [str(x) for x in (body.get("iface_fields") or []) if str(x).strip()]
if "compare_fields" in body:
t.compare_fields = [str(x) for x in (body.get("compare_fields") or []) if str(x).strip()]
if "ignore_fields" in body:
t.ignore_fields = [str(x) for x in (body.get("ignore_fields") or []) if str(x).strip()]
if "note" in body:
t.note = str(body.get("note") or "")[:512]
t.updated_at = _utcnow()
db.commit()
return _template_out(t)
def delete_template(db: Session, template_id: str) -> None:
t = db.get(BizCompareTemplate, template_id)
if not t:
raise HTTPException(status_code=404, detail="template_not_found")
db.delete(t)
db.commit()
def _mapping_out(db: Session, m: BizPortMapping) -> dict[str, Any]:
rows = (
db.query(BizPortMappingRow)
.filter(BizPortMappingRow.mapping_id == m.id)
.order_by(BizPortMappingRow.before_if.asc())
.all()
)
return {
"id": m.id,
"name": m.name,
"note": m.note,
"rows": [{"id": r.id, "before_if": r.before_if, "after_if": r.after_if} for r in rows],
"updated_at": m.updated_at.isoformat() + "Z" if m.updated_at else None,
}
def list_mappings(db: Session) -> list[dict[str, Any]]:
rows = db.query(BizPortMapping).order_by(BizPortMapping.name.asc()).all()
return [_mapping_out(db, m) for m in rows]
def create_mapping(db: Session, body: dict[str, Any]) -> dict[str, Any]:
m = BizPortMapping(
id=uuid4().hex,
name=str(body.get("name") or "port map")[:256],
note=str(body.get("note") or "")[:512],
created_at=_utcnow(),
updated_at=_utcnow(),
)
db.add(m)
db.flush()
_replace_mapping_rows(db, m.id, list(body.get("rows") or []))
db.commit()
return _mapping_out(db, m)
def update_mapping(db: Session, mapping_id: str, body: dict[str, Any]) -> dict[str, Any]:
m = db.get(BizPortMapping, mapping_id)
if not m:
raise HTTPException(status_code=404, detail="mapping_not_found")
if "name" in body:
m.name = str(body.get("name") or "")[:256]
if "note" in body:
m.note = str(body.get("note") or "")[:512]
if "rows" in body:
_replace_mapping_rows(db, m.id, list(body.get("rows") or []))
m.updated_at = _utcnow()
db.commit()
return _mapping_out(db, m)
def _replace_mapping_rows(db: Session, mapping_id: str, rows_in: list[dict[str, Any]]) -> None:
db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).delete()
seen: set[str] = set()
for raw in rows_in:
before = str(raw.get("before_if") or "").strip()
after = str(raw.get("after_if") or "").strip()
if not before or not after:
continue
if before in seen:
raise HTTPException(status_code=400, detail=f"duplicate_before_if:{before}")
seen.add(before)
db.add(
BizPortMappingRow(
id=uuid4().hex,
mapping_id=mapping_id,
before_if=before[:128],
after_if=after[:128],
)
)
def delete_mapping(db: Session, mapping_id: str) -> None:
m = db.get(BizPortMapping, mapping_id)
if not m:
raise HTTPException(status_code=404, detail="mapping_not_found")
db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).delete()
db.delete(m)
db.commit()
def _port_map_dict(db: Session, mapping_id: str) -> dict[str, str]:
if not mapping_id:
return {}
rows = db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).all()
return {str(r.before_if): str(r.after_if) for r in rows if r.before_if and r.after_if}
def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dict[str, Any]]:
if metric_id != "lldp_neighbor":
raise HTTPException(status_code=400, detail=f"unsupported_metric:{metric_id}")
rows = (
db.query(BizStateLldpNeighbor)
.filter(BizStateLldpNeighbor.batch_id == batch_id)
.all()
)
return [
{
"local_if": n.local_if,
"remote_sys": n.remote_sys,
"remote_if": n.remote_if,
"remote_ip": n.remote_ip,
"protocol": n.protocol,
}
for n in rows
]
def validate_mapping(
db: Session,
*,
mapping_id: str,
before_batch_id: str,
after_batch_id: str,
template_id: str = "",
) -> dict[str, Any]:
tpl = db.get(BizCompareTemplate, template_id) if template_id else ensure_default_lldp_template(db)
if not tpl:
raise HTTPException(status_code=404, detail="template_not_found")
before = _load_metric_rows(db, batch_id=before_batch_id, metric_id=tpl.metric_id)
after = _load_metric_rows(db, batch_id=after_batch_id, metric_id=tpl.metric_id)
pmap = _port_map_dict(db, mapping_id)
return mapping_stats(
before_rows=before,
after_rows=after,
iface_fields=list(tpl.iface_fields or []),
port_map=pmap,
)
def _job_out(j: BizCompareJob) -> dict[str, Any]:
return {
"id": j.id,
"name": j.name,
"template_id": j.template_id,
"mapping_id": j.mapping_id,
"before_task_id": j.before_task_id,
"after_task_id": j.after_task_id,
"before_batch_id": j.before_batch_id,
"after_batch_id": j.after_batch_id,
"mode": j.mode,
"status": j.status,
"note": j.note,
"updated_at": j.updated_at.isoformat() + "Z" if j.updated_at else None,
}
def list_jobs(db: Session) -> list[dict[str, Any]]:
rows = db.query(BizCompareJob).order_by(BizCompareJob.updated_at.desc()).all()
return [_job_out(j) for j in rows]
def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]:
ensure_default_lldp_template(db)
template_id = str(body.get("template_id") or "").strip()
if not template_id:
tpl = ensure_default_lldp_template(db)
template_id = tpl.id
else:
if not db.get(BizCompareTemplate, template_id):
raise HTTPException(status_code=404, detail="template_not_found")
j = BizCompareJob(
id=uuid4().hex,
name=str(body.get("name") or "compare")[:256],
template_id=template_id,
mapping_id=str(body.get("mapping_id") or ""),
before_task_id=str(body.get("before_task_id") or ""),
after_task_id=str(body.get("after_task_id") or ""),
before_batch_id=str(body.get("before_batch_id") or ""),
after_batch_id=str(body.get("after_batch_id") or ""),
mode=str(body.get("mode") or "manual")[:16],
status="ready",
note=str(body.get("note") or "")[:512],
created_at=_utcnow(),
updated_at=_utcnow(),
)
if j.mode == "auto":
j.status = "auto"
db.add(j)
db.commit()
return _job_out(j)
def update_job(db: Session, job_id: str, body: dict[str, Any]) -> dict[str, Any]:
j = db.get(BizCompareJob, job_id)
if not j:
raise HTTPException(status_code=404, detail="job_not_found")
for key in (
"name",
"template_id",
"mapping_id",
"before_task_id",
"after_task_id",
"before_batch_id",
"after_batch_id",
"mode",
"status",
"note",
):
if key in body and body.get(key) is not None:
setattr(j, key, str(body.get(key) or ""))
j.updated_at = _utcnow()
db.commit()
return _job_out(j)
def delete_job(db: Session, job_id: str) -> None:
j = db.get(BizCompareJob, job_id)
if not j:
raise HTTPException(status_code=404, detail="job_not_found")
db.query(BizCompareRun).filter(BizCompareRun.job_id == job_id).delete()
db.delete(j)
db.commit()
def _resolve_after_batch(db: Session, job: BizCompareJob) -> str:
if job.mode != "auto":
return str(job.after_batch_id or "")
task_id = str(job.after_task_id or "")
if not task_id:
return str(job.after_batch_id or "")
latest = (
db.query(BizStateBatch)
.filter(
BizStateBatch.task_id == task_id,
BizStateBatch.status.in_(("success", "partial")),
)
.order_by(BizStateBatch.started_at.desc())
.first()
)
return str(latest.id) if latest else ""
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
j = db.get(BizCompareJob, job_id)
if not j:
raise HTTPException(status_code=404, detail="job_not_found")
tpl = db.get(BizCompareTemplate, j.template_id)
if not tpl:
raise HTTPException(status_code=404, detail="template_not_found")
before_batch_id = str(j.before_batch_id or "")
after_batch_id = str(force_after_batch_id or "").strip() or _resolve_after_batch(db, j)
if not before_batch_id or not after_batch_id:
raise HTTPException(status_code=400, detail="before_and_after_batch_required")
if not db.get(BizStateBatch, before_batch_id) or not db.get(BizStateBatch, after_batch_id):
raise HTTPException(status_code=404, detail="batch_not_found")
key_fields = list(tpl.key_fields or [])
iface_fields = list(tpl.iface_fields or [])
compare_fields = list(tpl.compare_fields or [])
ignore = set(str(x) for x in (tpl.ignore_fields or []))
compare_fields = [f for f in compare_fields if f not in ignore]
if not compare_fields:
compare_fields = [f for f in key_fields if f not in ignore]
before_rows = _load_metric_rows(db, batch_id=before_batch_id, metric_id=tpl.metric_id)
after_rows = _load_metric_rows(db, batch_id=after_batch_id, metric_id=tpl.metric_id)
pmap = _port_map_dict(db, j.mapping_id)
result = compare_rows(
before_rows=before_rows,
after_rows=after_rows,
key_fields=key_fields,
iface_fields=iface_fields,
compare_fields=compare_fields,
port_map=pmap,
)
run = BizCompareRun(
id=uuid4().hex,
job_id=j.id,
template_id=tpl.id,
mapping_id=j.mapping_id,
before_batch_id=before_batch_id,
after_batch_id=after_batch_id,
metric_id=tpl.metric_id,
status="success",
summary_json=result["summary"],
diffs_json=result["diffs"],
mapping_stats_json=result["mapping_stats"],
message="",
created_at=_utcnow(),
)
db.add(run)
j.updated_at = _utcnow()
if j.mode == "manual":
j.after_batch_id = after_batch_id
db.commit()
return get_run(db, run.id)
def get_run(db: Session, run_id: str) -> dict[str, Any]:
r = db.get(BizCompareRun, run_id)
if not r:
raise HTTPException(status_code=404, detail="run_not_found")
return {
"id": r.id,
"job_id": r.job_id,
"template_id": r.template_id,
"mapping_id": r.mapping_id,
"before_batch_id": r.before_batch_id,
"after_batch_id": r.after_batch_id,
"metric_id": r.metric_id,
"status": r.status,
"summary": r.summary_json or {},
"diffs": r.diffs_json or [],
"mapping_stats": r.mapping_stats_json or {},
"message": r.message,
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
}
def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
rows = (
db.query(BizCompareRun)
.filter(BizCompareRun.job_id == job_id)
.order_by(BizCompareRun.created_at.desc())
.limit(max(1, min(100, int(limit))))
.all()
)
return [
{
"id": r.id,
"before_batch_id": r.before_batch_id,
"after_batch_id": r.after_batch_id,
"status": r.status,
"summary": r.summary_json or {},
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
}
for r in rows
]
def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int:
"""When a new after batch lands, run auto jobs pinned to that after task."""
jobs = (
db.query(BizCompareJob)
.filter(BizCompareJob.mode == "auto", BizCompareJob.after_task_id == task_id)
.all()
)
n = 0
for j in jobs:
if not j.before_batch_id:
continue
try:
run_compare(db, j.id, force_after_batch_id=batch_id)
n += 1
except Exception:
continue
return n