mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Add biz_state Phase2 cutover compare (templates, port maps, jobs, UI).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4fa1b9b4dc
commit
1e2cc98d49
15 changed files with 1525 additions and 1 deletions
|
|
@ -448,6 +448,13 @@ def _run_collect_session(
|
|||
else:
|
||||
batch.status = "success"
|
||||
db.commit()
|
||||
if batch.status in ("success", "partial"):
|
||||
try:
|
||||
from .compare_service import try_auto_compare_for_task
|
||||
|
||||
try_auto_compare_for_task(db, task_id, batch_id)
|
||||
except Exception:
|
||||
_log.exception("biz_state auto compare hook failed task=%s", task_id)
|
||||
|
||||
_purge_old_batches(db, task_id=task_id, keep=retention)
|
||||
finally:
|
||||
|
|
|
|||
166
netx_api/biz_state/compare_engine.py
Normal file
166
netx_api/biz_state/compare_engine.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Pure compare engine: same-table row diff with optional port mapping on before side."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def apply_port_map(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
iface_fields: list[str],
|
||||
port_map: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
out = dict(row)
|
||||
for f in iface_fields:
|
||||
val = str(out.get(f) or "").strip()
|
||||
if val and val in port_map:
|
||||
out[f] = port_map[val]
|
||||
return out
|
||||
|
||||
|
||||
def row_key(row: dict[str, Any], key_fields: list[str]) -> tuple[str, ...]:
|
||||
return tuple(str(row.get(k) or "").strip() for k in key_fields)
|
||||
|
||||
|
||||
def mapping_stats(
|
||||
*,
|
||||
before_rows: list[dict[str, Any]],
|
||||
after_rows: list[dict[str, Any]],
|
||||
iface_fields: list[str],
|
||||
port_map: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""hit/miss/unused for port mapping validation."""
|
||||
before_ifaces: set[str] = set()
|
||||
after_ifaces: set[str] = set()
|
||||
for f in iface_fields:
|
||||
for r in before_rows:
|
||||
v = str(r.get(f) or "").strip()
|
||||
if v:
|
||||
before_ifaces.add(v)
|
||||
for r in after_rows:
|
||||
v = str(r.get(f) or "").strip()
|
||||
if v:
|
||||
after_ifaces.add(v)
|
||||
|
||||
mapped_before = set(port_map.keys())
|
||||
mapped_after = set(port_map.values())
|
||||
hit_before = sorted(mapped_before & before_ifaces)
|
||||
miss_before = sorted(mapped_before - before_ifaces)
|
||||
unused = sorted(mapped_before - before_ifaces) # same as miss for before presence
|
||||
hit_after = sorted(mapped_after & after_ifaces)
|
||||
miss_after = sorted(mapped_after - after_ifaces)
|
||||
return {
|
||||
"before_iface_count": len(before_ifaces),
|
||||
"after_iface_count": len(after_ifaces),
|
||||
"map_pairs": len(port_map),
|
||||
"hit_before": hit_before,
|
||||
"miss_before": miss_before,
|
||||
"hit_after": hit_after,
|
||||
"miss_after": miss_after,
|
||||
"unused_before_keys": unused,
|
||||
"ok": not miss_before and not miss_after,
|
||||
}
|
||||
|
||||
|
||||
def compare_rows(
|
||||
*,
|
||||
before_rows: list[dict[str, Any]],
|
||||
after_rows: list[dict[str, Any]],
|
||||
key_fields: list[str],
|
||||
iface_fields: list[str],
|
||||
compare_fields: list[str],
|
||||
port_map: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return summary + diffs list.
|
||||
|
||||
Diff kinds: added | removed | changed | unchanged
|
||||
"""
|
||||
if not key_fields:
|
||||
raise ValueError("key_fields required")
|
||||
pmap = dict(port_map or {})
|
||||
|
||||
before_mapped: list[dict[str, Any]] = [
|
||||
apply_port_map(r, iface_fields=iface_fields, port_map=pmap) for r in before_rows
|
||||
]
|
||||
|
||||
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
|
||||
for r in after_rows:
|
||||
after_index[row_key(r, key_fields)] = r
|
||||
|
||||
before_keys: set[tuple[str, ...]] = set()
|
||||
diffs: list[dict[str, Any]] = []
|
||||
added = removed = changed = unchanged = 0
|
||||
|
||||
for orig, mapped in zip(before_rows, before_mapped):
|
||||
k = row_key(mapped, key_fields)
|
||||
before_keys.add(k)
|
||||
after = after_index.get(k)
|
||||
if after is None:
|
||||
removed += 1
|
||||
diffs.append(
|
||||
{
|
||||
"kind": "removed",
|
||||
"key": {f: mapped.get(f, "") for f in key_fields},
|
||||
"before": orig,
|
||||
"after": None,
|
||||
"mapped_before": mapped,
|
||||
"changes": {},
|
||||
}
|
||||
)
|
||||
continue
|
||||
field_changes: dict[str, dict[str, Any]] = {}
|
||||
for f in compare_fields:
|
||||
bv = mapped.get(f, "")
|
||||
av = after.get(f, "")
|
||||
# Compare using mapped before for iface fields already rewritten
|
||||
if str(bv) != str(av):
|
||||
field_changes[f] = {"before": bv, "after": av}
|
||||
if field_changes:
|
||||
changed += 1
|
||||
diffs.append(
|
||||
{
|
||||
"kind": "changed",
|
||||
"key": {f: mapped.get(f, "") for f in key_fields},
|
||||
"before": orig,
|
||||
"after": after,
|
||||
"mapped_before": mapped,
|
||||
"changes": field_changes,
|
||||
}
|
||||
)
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
for k, after in after_index.items():
|
||||
if k in before_keys:
|
||||
continue
|
||||
added += 1
|
||||
diffs.append(
|
||||
{
|
||||
"kind": "added",
|
||||
"key": {f: after.get(f, "") for f in key_fields},
|
||||
"before": None,
|
||||
"after": after,
|
||||
"mapped_before": None,
|
||||
"changes": {},
|
||||
}
|
||||
)
|
||||
|
||||
stats = mapping_stats(
|
||||
before_rows=before_rows,
|
||||
after_rows=after_rows,
|
||||
iface_fields=iface_fields,
|
||||
port_map=pmap,
|
||||
)
|
||||
return {
|
||||
"summary": {
|
||||
"before_count": len(before_rows),
|
||||
"after_count": len(after_rows),
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"changed": changed,
|
||||
"unchanged": unchanged,
|
||||
},
|
||||
"diffs": diffs,
|
||||
"mapping_stats": stats,
|
||||
}
|
||||
503
netx_api/biz_state/compare_service.py
Normal file
503
netx_api/biz_state/compare_service.py
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
"""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
|
||||
|
|
@ -20,6 +20,8 @@ def apply_biz_state_schema(conn: Connection) -> None:
|
|||
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_task_id ON biz_state_batch (task_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)",
|
||||
):
|
||||
try:
|
||||
_run_sql(conn, sql)
|
||||
|
|
|
|||
|
|
@ -163,3 +163,141 @@ def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingR
|
|||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="biz_state_{batch_id}.zip"'},
|
||||
)
|
||||
|
||||
|
||||
# ---- Phase2: templates / port maps / compare jobs ----
|
||||
|
||||
from .biz_state import compare_service as cmp_svc # noqa: E402
|
||||
|
||||
|
||||
class TemplateIn(BaseModel):
|
||||
name: str = ""
|
||||
metric_id: str = "lldp_neighbor"
|
||||
key_fields: list[str] = Field(default_factory=list)
|
||||
iface_fields: list[str] = Field(default_factory=list)
|
||||
compare_fields: list[str] = Field(default_factory=list)
|
||||
ignore_fields: list[str] = Field(default_factory=list)
|
||||
note: str = ""
|
||||
|
||||
|
||||
class MappingRowIn(BaseModel):
|
||||
before_if: str
|
||||
after_if: str
|
||||
|
||||
|
||||
class MappingIn(BaseModel):
|
||||
name: str = ""
|
||||
note: str = ""
|
||||
rows: list[MappingRowIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ValidateMappingIn(BaseModel):
|
||||
mapping_id: str
|
||||
before_batch_id: str
|
||||
after_batch_id: str
|
||||
template_id: str = ""
|
||||
|
||||
|
||||
class CompareJobIn(BaseModel):
|
||||
name: str = ""
|
||||
template_id: str = ""
|
||||
mapping_id: str = ""
|
||||
before_task_id: str = ""
|
||||
after_task_id: str = ""
|
||||
before_batch_id: str = ""
|
||||
after_batch_id: str = ""
|
||||
mode: str = "manual"
|
||||
note: str = ""
|
||||
|
||||
|
||||
@router.get("/compare/templates")
|
||||
def api_list_templates(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return {"items": cmp_svc.list_templates(db)}
|
||||
|
||||
|
||||
@router.post("/compare/templates")
|
||||
def api_create_template(body: TemplateIn, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return cmp_svc.create_template(db, body.model_dump())
|
||||
|
||||
|
||||
@router.patch("/compare/templates/{template_id}")
|
||||
def api_patch_template(
|
||||
template_id: str, body: TemplateIn, db: Session = Depends(get_db)
|
||||
) -> dict[str, Any]:
|
||||
return cmp_svc.update_template(db, template_id, body.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.delete("/compare/templates/{template_id}")
|
||||
def api_delete_template(template_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
cmp_svc.delete_template(db, template_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/compare/mappings")
|
||||
def api_list_mappings(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return {"items": cmp_svc.list_mappings(db)}
|
||||
|
||||
|
||||
@router.post("/compare/mappings")
|
||||
def api_create_mapping(body: MappingIn, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return cmp_svc.create_mapping(db, body.model_dump())
|
||||
|
||||
|
||||
@router.patch("/compare/mappings/{mapping_id}")
|
||||
def api_patch_mapping(
|
||||
mapping_id: str, body: MappingIn, db: Session = Depends(get_db)
|
||||
) -> dict[str, Any]:
|
||||
return cmp_svc.update_mapping(db, mapping_id, body.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.delete("/compare/mappings/{mapping_id}")
|
||||
def api_delete_mapping(mapping_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
cmp_svc.delete_mapping(db, mapping_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/compare/mappings/validate")
|
||||
def api_validate_mapping(body: ValidateMappingIn, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return cmp_svc.validate_mapping(
|
||||
db,
|
||||
mapping_id=body.mapping_id,
|
||||
before_batch_id=body.before_batch_id,
|
||||
after_batch_id=body.after_batch_id,
|
||||
template_id=body.template_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compare/jobs")
|
||||
def api_list_jobs(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return {"items": cmp_svc.list_jobs(db)}
|
||||
|
||||
|
||||
@router.post("/compare/jobs")
|
||||
def api_create_job(body: CompareJobIn, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return cmp_svc.create_job(db, body.model_dump())
|
||||
|
||||
|
||||
@router.patch("/compare/jobs/{job_id}")
|
||||
def api_patch_job(job_id: str, body: CompareJobIn, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return cmp_svc.update_job(db, job_id, body.model_dump(exclude_unset=True))
|
||||
|
||||
|
||||
@router.delete("/compare/jobs/{job_id}")
|
||||
def api_delete_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
cmp_svc.delete_job(db, job_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/compare/jobs/{job_id}/run")
|
||||
def api_run_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return cmp_svc.run_compare(db, job_id)
|
||||
|
||||
|
||||
@router.get("/compare/jobs/{job_id}/runs")
|
||||
def api_list_runs(job_id: str, limit: int = 20, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return {"items": cmp_svc.list_runs(db, job_id, limit=limit)}
|
||||
|
||||
|
||||
@router.get("/compare/runs/{run_id}")
|
||||
def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return cmp_svc.get_run(db, run_id)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ from .managed_ne import (
|
|||
UmeCliOverride,
|
||||
)
|
||||
from .biz_state import (
|
||||
BizCompareJob,
|
||||
BizCompareRun,
|
||||
BizCompareTemplate,
|
||||
BizPortMapping,
|
||||
BizPortMappingRow,
|
||||
BizStateBatch,
|
||||
BizStateBatchCommand,
|
||||
BizStateCommandOverride,
|
||||
|
|
@ -130,4 +135,9 @@ __all__ = [
|
|||
"BizStateLldpNeighbor",
|
||||
"BizStateEvent",
|
||||
"BizStateCommandOverride",
|
||||
"BizCompareTemplate",
|
||||
"BizPortMapping",
|
||||
"BizPortMappingRow",
|
||||
"BizCompareJob",
|
||||
"BizCompareRun",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -149,3 +149,85 @@ class BizStateCommandOverride(Base):
|
|||
sample_output: Mapped[str] = mapped_column(Text, default="")
|
||||
enabled: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
|
||||
|
||||
class BizCompareTemplate(Base):
|
||||
"""Global compare template: which fields are keys / interfaces / compared."""
|
||||
|
||||
__tablename__ = "biz_compare_template"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
metric_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
key_fields: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
iface_fields: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
compare_fields: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
ignore_fields: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
note: Mapped[str] = mapped_column(String(512), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
|
||||
|
||||
class BizPortMapping(Base):
|
||||
"""Named port mapping set for cutover (before_if → after_if)."""
|
||||
|
||||
__tablename__ = "biz_port_mapping"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
note: Mapped[str] = mapped_column(String(512), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
|
||||
|
||||
class BizPortMappingRow(Base):
|
||||
__tablename__ = "biz_port_mapping_row"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("mapping_id", "before_if", name="uq_biz_port_map_before"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
before_if: Mapped[str] = mapped_column(String(128), default="")
|
||||
after_if: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class BizCompareJob(Base):
|
||||
"""Cutover compare job linking template, port map, and before/after batches."""
|
||||
|
||||
__tablename__ = "biz_compare_job"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
template_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
before_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
after_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
before_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
after_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
# manual: fixed after_batch; auto: after_batch_id empty → use latest after task batch
|
||||
mode: Mapped[str] = mapped_column(String(16), default="manual", index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto
|
||||
note: Mapped[str] = mapped_column(String(512), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||
|
||||
|
||||
class BizCompareRun(Base):
|
||||
"""One execution of a compare job."""
|
||||
|
||||
__tablename__ = "biz_compare_run"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
job_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
template_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
mapping_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
before_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
after_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
metric_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="success", index=True)
|
||||
summary_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||
diffs_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
mapping_stats_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||
message: Mapped[str] = mapped_column(String(1024), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue