Persist cutover evidence cards with device-raw A/B keys for board and AI.

Evaluate keeps match keys for normalize/map pairing while diffs, reds, and the board show collected ports plus show-command provenance; add monitor-context and command-raw read APIs with biz-monitor:read scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-19 22:48:37 +08:00
parent 285c19e5a3
commit c1b1c0f75d
17 changed files with 1141 additions and 57 deletions

View file

@ -1160,5 +1160,56 @@ class PortMapScopeTests(unittest.TestCase):
self.assertTrue(any(r["key_str"] == "1.1.1.1" and r["in_expect"] for r in out["rows"]))
class DisplayRawABTests(unittest.TestCase):
"""Board/AI must show device-raw A/B, not post-map BB."""
def test_normalize_and_map_keeps_display_ab(self):
# Old device shows GE-…; template normalizes GE→gei; map gei-old → xgei-new.
# New device collected xgei-new. Internal match is gei-old↔xgei-new;
# display must stay GE-old / xgei-new (never xgei-new / xgei-new).
rules = [{"from": "GE", "to": "gei"}]
out = evaluate_metric_dual(
metric_id="interface_brief",
key_fields=["interface"],
iface_fields=["interface"],
compare_fields=["admin", "phy", "prot"],
old_baseline_rows=[
{"interface": "GE-old", "admin": "up", "phy": "up", "prot": "up"}
],
old_current_rows=[],
new_baseline_rows=None,
new_current_rows=[
{"interface": "xgei-new", "admin": "up", "phy": "up", "prot": "up"}
],
port_map={"gei-old": "xgei-new"},
expect=parse_expect_set({"ports": ["gei-old"]}),
window_active=True,
sheet_override=PORT_OVERRIDE,
iface_normalize_rules=rules,
)
migrated = [r for r in out["rows"] if r["verdict"] == "migrated"]
self.assertTrue(migrated, out["rows"])
row = migrated[0]
self.assertEqual(row["old_key"], "GE-old")
self.assertEqual(row["new_key"], "xgei-new")
self.assertEqual(row["key_str"], "GE-old")
self.assertEqual(row["new_key_str"], "xgei-new")
self.assertEqual(row["match_old_key"], "gei-old")
self.assertEqual(row["match_new_key"], "xgei-new")
self.assertEqual(row["old"].get("interface"), "GE-old")
self.assertEqual(row["new"].get("interface"), "xgei-new")
pm = row["evidence"]["port_map"]
self.assertTrue(pm["applied"])
self.assertEqual(pm["display_before"], "GE-old")
self.assertEqual(pm["display_after"], "xgei-new")
self.assertEqual(pm["match_before"], "gei-old")
self.assertEqual(pm["match_after"], "xgei-new")
old_iface = row["evidence"]["old"]["iface"][0]
self.assertEqual(old_iface["raw"], "GE-old")
self.assertEqual(old_iface["normalized"], "gei-old")
self.assertTrue(old_iface["mapped"])
self.assertEqual(old_iface["map_to"], "xgei-new")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,393 @@
"""Integration: evaluate persists evidence cards (raw A/B + command) on diffs/reds."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from unittest import mock
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_migration import service as mig
from netx_api.biz_state import service as biz_svc
from netx_api.db import Base
from netx_api.models import (
BizCompareTemplate,
BizMigrationDiff,
BizMigrationRedTicket,
BizMonitorTemplate,
BizPortMapping,
BizPortMappingRow,
BizStateBatch,
BizStateBatchCommand,
BizStateMetricRow,
BizStateTask,
)
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _stub_catalog(*_a, **_k):
return {
"source_profile_id": "zte.interface_brief",
"kind": "catalog",
"enabled": True,
"title": "IF",
}
def _stub_ne_meta(*_a, **_k):
return {
"ne_name": "ne",
"ne_ip": "10.0.0.1",
"vendor": "zte",
"device_type": "router",
}
PORT_SHEET = {
"sheet_id": "interface_brief",
"title": "ports",
"metric_id": "interface_brief",
"key_fields": ["interface"],
"iface_fields": ["interface"],
"compare_fields": ["admin", "phy", "prot"],
"row_filters": [],
"field_rules": [],
}
PORT_OVERRIDES = [
{
"metric_id": "interface_brief",
"sheet_id": "interface_brief",
"status_fields": ["admin", "phy", "prot"],
"down_values": ["down"],
"up_values": ["up"],
"success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}],
"anomaly": [],
}
]
NORM_RULES = [{"from": "GE", "to": "gei"}]
class EvidencePersistTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
TestingSession = sessionmaker(
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
)
Base.metadata.create_all(bind=engine)
self.db = TestingSession()
self.ct = BizCompareTemplate(
id="ct1",
name="port-cmp",
metric_id="interface_brief",
key_fields=["interface"],
iface_fields=["interface"],
compare_fields=["admin", "phy", "prot"],
metrics_json=[PORT_SHEET],
iface_normalize_json=NORM_RULES,
)
self.db.add(self.ct)
self.mt = BizMonitorTemplate(
id="mt1",
name="port-mon",
compare_template_id="ct1",
collect_metric_ids_json=["interface_brief"],
defaults_json={"out_of_expect": "strict"},
sheet_overrides_json=PORT_OVERRIDES,
)
self.db.add(self.mt)
self.mapping = BizPortMapping(id="map1", name="ports", note="")
self.db.add(self.mapping)
self.db.flush()
self.db.add(
BizPortMappingRow(
id="mr1", mapping_id="map1", before_if="gei-old", after_if="xgei-new"
)
)
self.old_p = self._mk_task("old_p", ne_id="ne-old", ne_name="OLD-RTR")
self.new_p = self._mk_task("new_p", ne_id="ne-new", ne_name="NEW-RTR")
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def _mk_task(self, tid: str, *, ne_id: str, ne_name: str) -> BizStateTask:
t = BizStateTask(
id=tid,
source="managed",
ne_id=ne_id,
ne_name=ne_name,
ne_ip="10.0.0.1",
vendor="zte",
device_type="router",
purpose="",
status="running",
interval_sec=3600,
retention_days=30,
)
self.db.add(t)
return t
def _mk_batch(self, bid: str, task_id: str) -> BizStateBatch:
now = _utcnow()
b = BizStateBatch(
id=bid,
task_id=task_id,
status="success",
started_at=now,
ended_at=now,
row_count=1,
command_count=1,
)
self.db.add(b)
self.db.commit()
return b
def _seed_if_row(
self,
*,
batch_id: str,
task_id: str,
ne_id: str,
command_id: str,
raw_command: str,
interface: str,
admin: str = "up",
phy: str = "up",
prot: str = "up",
) -> None:
now = _utcnow()
self.db.add(
BizStateBatchCommand(
id=command_id,
batch_id=batch_id,
profile_id="zte.interface_brief",
parser_id="zte.interface_brief",
metric_id="interface_brief",
raw_command=raw_command,
parse_status="ok",
row_count=1,
raw_text=f"Interface {interface} {admin} {phy} {prot}\n",
created_at=now,
)
)
self.db.add(
BizStateMetricRow(
id=f"row-{command_id}",
batch_id=batch_id,
batch_command_id=command_id,
task_id=task_id,
ne_id=ne_id,
metric_id="interface_brief",
seq=0,
data_json={
"interface": interface,
"admin": admin,
"phy": phy,
"prot": prot,
},
collected_at=now,
)
)
self.db.commit()
def _create_project(self) -> dict:
body = {
"name": "evidence-night",
"old_task_id": self.old_p.id,
"new_task_id": self.new_p.id,
"monitor_template_id": self.mt.id,
"mapping_id": self.mapping.id,
"collect_now": False,
}
with mock.patch("netx_api.biz_state.collect_runner.dispatch_collect"), mock.patch.object(
mig, "_catalog_item_for_metric", side_effect=_stub_catalog
), mock.patch.object(biz_svc, "_ne_meta", side_effect=_stub_ne_meta):
return mig.create_project(self.db, body)
def test_evaluate_persists_display_ab_and_evidence_with_command(self) -> None:
proj = self._create_project()
old_hf = proj["old_hf_task_id"]
new_hf = proj["new_hf_task_id"]
self._mk_batch("bl_old", self.old_p.id)
self._mk_batch("bl_new", self.new_p.id)
self._mk_batch("cur_old", old_hf)
self._mk_batch("cur_new", new_hf)
# Baseline old: raw GE-old (normalize → gei-old)
self._seed_if_row(
batch_id="bl_old",
task_id=self.old_p.id,
ne_id="ne-old",
command_id="cmd-bl-old",
raw_command="show interface brief",
interface="GE-old",
)
# New baseline empty → port_mapped mode uses mapped old
# Old current: gone (no rows) — migration off old
# New current: xgei-new up
self._seed_if_row(
batch_id="cur_new",
task_id=new_hf,
ne_id="ne-new",
command_id="cmd-cur-new",
raw_command="show interface brief",
interface="xgei-new",
)
mig.patch_project(
self.db,
proj["id"],
{"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"},
)
batch = mig.create_batch(
self.db,
proj["id"],
{"batch_label": "wave1", "expect_set": {"ports": ["gei-old"]}},
)
# Activate window so dual verdict uses migrating/migrated path
mig.patch_batch(self.db, batch["id"], {"status": "active"})
out = mig.run_evaluate(self.db, batch_id=batch["id"])
run_id = out["id"]
self.assertTrue(run_id)
diffs = (
self.db.query(BizMigrationDiff)
.filter(BizMigrationDiff.run_id == run_id)
.order_by(BizMigrationDiff.seq.asc())
.all()
)
self.assertTrue(diffs, "expected persisted diffs")
d = diffs[0]
kj = d.key_json if isinstance(d.key_json, dict) else {}
self.assertEqual(kj.get("old_key"), "GE-old")
self.assertEqual(kj.get("new_key"), "xgei-new")
self.assertEqual(kj.get("key_str"), "GE-old")
self.assertEqual(kj.get("new_key_str"), "xgei-new")
self.assertEqual(kj.get("match_old_key"), "gei-old")
self.assertEqual(kj.get("match_new_key"), "xgei-new")
# Display rows stay raw A/B, not mapped BB
self.assertEqual((d.old_json or {}).get("interface"), "GE-old")
self.assertEqual((d.new_json or {}).get("interface"), "xgei-new")
ev = kj.get("evidence") or {}
pm = ev.get("port_map") or {}
self.assertTrue(pm.get("applied"))
self.assertEqual(pm.get("display_before"), "GE-old")
self.assertEqual(pm.get("display_after"), "xgei-new")
self.assertEqual(pm.get("match_before"), "gei-old")
self.assertEqual(pm.get("match_after"), "xgei-new")
old_iface = (ev.get("old") or {}).get("iface") or []
self.assertTrue(old_iface)
self.assertEqual(old_iface[0].get("raw"), "GE-old")
self.assertEqual(old_iface[0].get("normalized"), "gei-old")
self.assertTrue(old_iface[0].get("mapped"))
self.assertEqual(old_iface[0].get("map_to"), "xgei-new")
# Enrichment: device + show command on new side (current row)
new_ev = ev.get("new") or {}
self.assertEqual((new_ev.get("command") or {}).get("raw_command"), "show interface brief")
self.assertEqual((new_ev.get("command") or {}).get("parse_status"), "ok")
self.assertEqual((new_ev.get("device") or {}).get("ne_name"), "NEW-RTR")
self.assertEqual((new_ev.get("collect") or {}).get("batch_id"), "cur_new")
old_ev = ev.get("old") or {}
self.assertEqual((old_ev.get("command") or {}).get("raw_command"), "show interface brief")
self.assertEqual((old_ev.get("collect") or {}).get("batch_id"), "bl_old")
# API serialize path
api = mig.diff_to_dict(d)
self.assertEqual(api["old_key"], "GE-old")
self.assertEqual(api["new_key"], "xgei-new")
self.assertIn("evidence", api)
self.assertEqual(
api["evidence"]["new"]["command"]["raw_command"], "show interface brief"
)
listed = mig.list_run_diffs(self.db, run_id, color="", limit=50)
self.assertGreaterEqual(listed["total"], 1)
item = listed["items"][0]
self.assertEqual(item["old_key"], "GE-old")
self.assertEqual(item["new_key"], "xgei-new")
self.assertEqual(
item["evidence"]["old"]["iface"][0]["raw"], "GE-old"
)
def test_finish_batch_red_tickets_carry_evidence(self) -> None:
"""Acceptance red path: anomaly persists evidence onto red tickets."""
proj = self._create_project()
old_hf = proj["old_hf_task_id"]
new_hf = proj["new_hf_task_id"]
self._mk_batch("bl_old", self.old_p.id)
self._mk_batch("bl_new", self.new_p.id)
self._mk_batch("cur_old", old_hf)
self._mk_batch("cur_new", new_hf)
# Expect port still up on old (not migrated) + missing/down on new → red unfinished/anomaly
self._seed_if_row(
batch_id="bl_old",
task_id=self.old_p.id,
ne_id="ne-old",
command_id="cmd-bl-old2",
raw_command="show interface brief",
interface="GE-old",
)
self._seed_if_row(
batch_id="cur_old",
task_id=old_hf,
ne_id="ne-old",
command_id="cmd-cur-old2",
raw_command="show interface brief",
interface="GE-old",
admin="up",
phy="up",
prot="up",
)
# new side: no matching port → unfinished on acceptance
mig.patch_project(
self.db,
proj["id"],
{"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"},
)
batch = mig.create_batch(
self.db,
proj["id"],
{"batch_label": "wave-red", "expect_set": {"ports": ["gei-old"]}},
)
mig.patch_batch(self.db, batch["id"], {"status": "active"})
finished = mig.finish_batch(self.db, batch["id"], mark_done=False)
self.assertIn("red_tickets", finished)
tickets = (
self.db.query(BizMigrationRedTicket)
.filter(BizMigrationRedTicket.project_id == proj["id"])
.all()
)
self.assertTrue(tickets, f"expected red tickets, finish={finished}")
t = tickets[0]
detail = t.detail_json if isinstance(t.detail_json, dict) else {}
self.assertIn("evidence", detail)
self.assertEqual(detail.get("old_key") or t.key_str, "GE-old")
# API
listed = mig.list_red_tickets(self.db, proj["id"])
self.assertGreaterEqual(listed["open_count"], 1)
item = listed["items"][0]
self.assertEqual(item.get("old_key") or item.get("key_str"), "GE-old")
self.assertIn("evidence", item)
if __name__ == "__main__":
unittest.main()

View file

@ -40,6 +40,7 @@ class ScopeUnitTests(unittest.TestCase):
self.assertNotIn(SCOPE_WEBCRT, MCP_DEFAULT_SCOPES)
self.assertNotIn(SCOPE_SQL, MCP_DEFAULT_SCOPES)
self.assertIn("ne:exec", MCP_DEFAULT_SCOPES)
self.assertIn("biz-monitor:read", MCP_DEFAULT_SCOPES)
def test_token_intersection(self) -> None:
user = {"alarms:read", "ne:read", "ne:exec", "sql:query"}
@ -54,7 +55,18 @@ class ScopeUnitTests(unittest.TestCase):
self.assertEqual(required_scope_for_request("POST", "/v1/managed-ne"), "ne:write")
self.assertEqual(required_scope_for_request("POST", "/v1/topology/fabric/paths"), "ne:read")
self.assertEqual(required_scope_for_request("POST", "/v1/topology/fabric/edges"), "ne:write")
self.assertEqual(
required_scope_for_request("GET", "/v1/biz-migration/monitor-context"),
"biz-monitor:read",
)
self.assertEqual(
required_scope_for_request("GET", "/v1/biz-state/batches/abc"),
"biz-monitor:read",
)
self.assertEqual(
required_scope_for_request("POST", "/v1/biz-migration/projects"),
"ops:write",
)
class SqlGuardTests(unittest.TestCase):
def test_allows_cte(self) -> None: