Optimize cutover compare/monitor per the 2026-10-12 plan.

Correctness (A): normalize port-map names with the template rules (snapshot v2,
v1 replays unchanged); protect mappings/templates that are referenced; stop
dropping edited mapping text; accept pulled-but-not-shutdown ports as migrated;
validate baseline ownership on project create; one canonical key form; fix
built-in template tolerances and upgrade old packaged values in place.

Cross-vendor identity (B): single identity pipeline for every path, per-side and
per-vendor interface rules, value maps, filters after normalization, mapping
pre-flight for compare and cutover projects, SQL eligibility fixes.

Data quality (V): no_data / suspect_parse / not_collected sheet states, command
slice coverage, directional compare modes, value-sorted duplicate pairing,
before-side raw names in results and CSV.

Robustness (H): bounded auto-run history, background evaluate/finish with
polling, manual route tables out of the acceptance gate, baseline row cache,
scoped duplicate-key failures, merged HF tasks with a per-NE session cap,
same-device cutover, explicit scope_by_port_map, read-only GETs, resolvable red
tickets.

Frontend: pre-flight report, mapping table view, template editor support for the
new rules, data-state banners. Docs: canonical field contract.

Not included: Huawei status parsers (no real samples), SQL-path route compare.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 11:51:55 +08:00
parent fff7d63b9b
commit 315539e0fd
59 changed files with 6483 additions and 487 deletions

12
tests/conftest.py Normal file
View file

@ -0,0 +1,12 @@
"""Shared test hygiene."""
import pytest
@pytest.fixture(autouse=True)
def _fresh_baseline_row_cache():
"""Test databases reuse batch ids; the process-wide baseline cache must not leak between tests."""
from netx_api.biz_migration.service import BASELINE_ROWS
BASELINE_ROWS.invalidate()
yield
BASELINE_ROWS.invalidate()

View file

@ -143,7 +143,7 @@ def test_template_usage_counts_are_batched(db, monkeypatch):
event.listen(db.get_bind(), "before_cursor_execute", lambda c, cur, statement, p, ctx, many: sql.append(statement))
counts = {t["id"]: t["job_count"] for t in svc.list_templates(db)}
assert counts == {used["id"]: 2, unused["id"]: 0}
assert len(sql) == 2 # one template query and one grouped reference query
assert len(sql) == 3 # template query + one grouped query each for jobs and monitor templates
@pytest.mark.parametrize("status", ["queued", "running", "failed", "cancelled", "partial"])
@ -188,3 +188,93 @@ def test_legacy_invalid_template_cannot_execute_as_partial_or_fail_open(db, bad)
assert exc.value.detail["error"] == "invalid_template"
assert "metrics[1]" in exc.value.detail["path"]
assert db.query(BizCompareRun).count() == 0
def _builtin_sheet(row, metric_id):
return next(s for s in svc.template_metrics(row) if s["metric_id"] == metric_id)
def _rule(sheet, field):
return next(r for r in sheet["field_rules"] if r["field"] == field)
def test_packaged_status_template_uses_corrected_defaults(db):
row = svc.ensure_default_zte_status_template(db)
detail = _builtin_sheet(row, "interface_detail")
assert _rule(detail, "output_bps")["compare"] == "percent"
for f in ("input_bps", "output_bps", "in_util", "out_util"):
assert f not in detail["compare_fields"] and f in detail["display_fields"]
rx = _rule(_builtin_sheet(row, "optical_brief"), "rx_power")
assert (rx["compare"], rx["tolerance"]) == ("numeric", 2)
assert _builtin_sheet(row, "l2vpn_mac")["iface_fields"] == ["ac_port"]
def _store_old_packaged_values(db, row):
sheets = svc.template_metrics(row)
for s in sheets:
if s["metric_id"] == "interface_detail":
_rule(s, "output_bps").update(compare="numeric", tolerance=5)
s["compare_fields"] = list(svc._INTERFACE_DETAIL_OLD_COMPARE)
elif s["metric_id"] == "optical_brief":
_rule(s, "rx_power").update(compare="percent", tolerance=5)
elif s["metric_id"] == "l2vpn_mac":
s["iface_fields"] = []
svc._apply_sheets_to_row(row, sheets)
db.commit()
def test_old_packaged_values_are_upgraded_in_place(db):
row = svc.ensure_default_zte_status_template(db)
_store_old_packaged_values(db, row)
row = svc.ensure_default_zte_status_template(db)
detail = _builtin_sheet(row, "interface_detail")
assert _rule(detail, "output_bps")["compare"] == "percent"
assert "output_bps" not in detail["compare_fields"]
assert _rule(_builtin_sheet(row, "optical_brief"), "rx_power")["compare"] == "numeric"
assert _builtin_sheet(row, "l2vpn_mac")["iface_fields"] == ["ac_port"]
def test_operator_customised_values_survive_upgrade(db):
row = svc.ensure_default_zte_status_template(db)
_store_old_packaged_values(db, row)
sheets = svc.template_metrics(row)
for s in sheets:
if s["metric_id"] == "optical_brief":
_rule(s, "rx_power").update(compare="percent", tolerance=1) # operator tightened it
elif s["metric_id"] == "interface_detail":
s["compare_fields"] = ["admin", "output_bps"] # operator trimmed the list
elif s["metric_id"] == "l2vpn_mac":
s["iface_fields"] = ["neighbor"]
svc._apply_sheets_to_row(row, sheets)
db.commit()
row = svc.ensure_default_zte_status_template(db)
rx = _rule(_builtin_sheet(row, "optical_brief"), "rx_power")
assert (rx["compare"], rx["tolerance"]) == ("percent", 1)
assert _builtin_sheet(row, "interface_detail")["compare_fields"] == ["admin", "output_bps"]
assert _builtin_sheet(row, "l2vpn_mac")["iface_fields"] == ["neighbor"]
def test_packaged_arp_filter_uses_canonical_entry_type_and_old_filter_is_upgraded(db):
from netx_api.biz_state.compare_rules import arp_dynamic_row_filters
row = svc.ensure_default_zte_status_template(db)
assert _builtin_sheet(row, "arp")["row_filters"] == arp_dynamic_row_filters()
sheets = svc.template_metrics(row)
for s in sheets:
if s["metric_id"] == "arp":
s["row_filters"] = [{"op": "age_timer", "field": "age", "value": ""}]
svc._apply_sheets_to_row(row, sheets)
db.commit()
assert _builtin_sheet(svc.ensure_default_zte_status_template(db), "arp")["row_filters"] == arp_dynamic_row_filters()
def test_customised_arp_filter_is_left_alone(db):
row = svc.ensure_default_zte_status_template(db)
sheets = svc.template_metrics(row)
mine = [{"op": "eq", "field": "entry_type", "value": "static"}]
for s in sheets:
if s["metric_id"] == "arp":
s["row_filters"] = mine
svc._apply_sheets_to_row(row, sheets)
db.commit()
assert _builtin_sheet(svc.ensure_default_zte_status_template(db), "arp")["row_filters"] == mine

View file

@ -0,0 +1,284 @@
"""H2/H3/H5: background evaluation, manual route tables, duplicate-key scope."""
from __future__ import annotations
import unittest
from unittest import mock
from fastapi import HTTPException
from sqlalchemy.orm import sessionmaker
from netx_api.biz_migration import service as mig
from netx_api.models import BizMigrationBatch, BizMigrationRun, BizStateBatchCommand, BizStateMetricRow
import tests.test_biz_migration_evidence as evidence
class _Inline:
"""threading.Thread stand-in: remember the target, run it on demand."""
started: list = []
def __init__(self, target=None, **_):
self.target = target
def start(self):
_Inline.started.append(self.target)
class _Fixture(unittest.TestCase):
def setUp(self) -> None:
self.ctx = evidence.EvidencePersistTests("test_evaluate_persists_display_ab_and_evidence_with_command")
self.ctx.setUp()
self.db = self.ctx.db
self.factory = sessionmaker(bind=self.db.get_bind(), expire_on_commit=False)
self.session_patch = mock.patch("netx_api.db.SessionLocal", self.factory)
self.session_patch.start()
_Inline.started = []
proj = self.ctx._create_project()
c = self.ctx
for bid, tid in (("bl_old", c.old_p.id), ("bl_new", c.new_p.id), ("cur_old", proj["old_hf_task_id"]),
("cur_new", proj["new_hf_task_id"])):
c._mk_batch(bid, tid)
c._seed_if_row(batch_id="bl_old", task_id=c.old_p.id, ne_id="ne-old", command_id="c1",
raw_command="show interface brief", interface="GE-old")
c._seed_if_row(batch_id="cur_new", task_id=proj["new_hf_task_id"], ne_id="ne-new", command_id="c2",
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"})
self.batch = mig.create_batch(self.db, proj["id"], {"batch_label": "w", "expect_set": {"ports": ["gei-old"]}})
mig.patch_batch(self.db, self.batch["id"], {"status": "active"})
self.project_id = proj["id"]
def tearDown(self) -> None:
self.session_patch.stop()
self.ctx.tearDown()
def run_background(self):
for target in list(_Inline.started):
target()
_Inline.started.clear()
class AsyncEvaluateTests(_Fixture):
def test_evaluate_returns_a_running_run_immediately_then_completes(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_evaluate(self.db, self.batch["id"])
self.assertEqual(started["status"], "running")
self.assertEqual(self.db.get(BizMigrationRun, started["id"]).status, "running")
self.assertEqual(len(_Inline.started), 1)
self.run_background()
self.db.expire_all()
run = self.db.get(BizMigrationRun, started["id"])
self.assertEqual(run.status, "success")
self.assertEqual(run.purpose, "manual")
self.assertIn("sheet_cards", run.summary_json)
self.assertEqual(self.db.query(BizMigrationRun).count(), 1) # the claim row was filled, not duplicated
def test_second_request_while_one_runs_is_409(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
mig.enqueue_evaluate(self.db, self.batch["id"])
with self.assertRaises(HTTPException) as exc:
mig.enqueue_evaluate(self.db, self.batch["id"])
with self.assertRaises(HTTPException) as exc_finish:
mig.enqueue_finish(self.db, self.batch["id"])
self.assertEqual(exc.exception.status_code, 409)
self.assertEqual(exc.exception.detail, "evaluation_in_progress")
self.assertEqual(exc_finish.exception.status_code, 409)
def test_failure_is_recorded_on_the_run_and_releases_the_project(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_evaluate(self.db, self.batch["id"])
with mock.patch.object(mig, "run_evaluate", side_effect=HTTPException(status_code=400, detail="boom")):
self.run_background()
self.db.expire_all()
run = self.db.get(BizMigrationRun, started["id"])
self.assertEqual((run.status, run.message), ("failed", "boom"))
with mock.patch.object(mig.threading, "Thread", _Inline):
mig.enqueue_evaluate(self.db, self.batch["id"]) # no longer blocked
def test_restart_cancels_orphaned_runs(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_evaluate(self.db, self.batch["id"])
self.assertEqual(mig.recover_interrupted_evaluations_on_startup(self.db), 1)
self.db.expire_all()
run = self.db.get(BizMigrationRun, started["id"])
self.assertEqual((run.status, run.message), ("cancelled", "interrupted_by_restart"))
# a cancelled claim must not be resurrected by a late worker
with self.assertRaises(HTTPException) as exc:
mig.run_evaluate(self.db, batch_id=self.batch["id"], _run_id=started["id"])
self.assertEqual(exc.exception.status_code, 409)
def test_background_evaluation_locks_the_project_only_for_the_final_write(self):
from sqlalchemy.orm import Query
calls: list[str] = []
original = Query.with_for_update
def spy(self_, *a, **k):
calls.append("lock")
return original(self_, *a, **k)
with mock.patch.object(mig.threading, "Thread", _Inline):
mig.enqueue_evaluate(self.db, self.batch["id"]) # claim = one short lock
with mock.patch.object(Query, "with_for_update", spy):
self.run_background()
self.assertEqual(len(calls), 1, "computation must be unlocked; only the write takes the project lock")
calls.clear()
with mock.patch.object(Query, "with_for_update", spy):
self.db.query(BizMigrationRun).delete()
self.db.commit()
mig.run_evaluate(self.db, batch_id=self.batch["id"]) # synchronous callers keep the old behaviour
self.assertGreaterEqual(len(calls), 1)
def test_finish_runs_in_the_background_and_closes_the_wave_only_on_success(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_finish(self.db, self.batch["id"])
self.assertEqual((started["status"], started["run"]["purpose"]), ("running", "acceptance"))
self.assertEqual(self.db.get(BizMigrationBatch, self.batch["id"]).status, "active")
self.run_background()
self.db.expire_all()
batch = self.db.get(BizMigrationBatch, self.batch["id"])
self.assertEqual(batch.status, "review")
self.assertEqual(batch.accept_run_id, started["run_id"])
self.assertEqual(self.db.get(BizMigrationRun, started["run_id"]).status, "success")
def test_finish_failure_keeps_the_wave_operable(self):
with mock.patch.object(mig.threading, "Thread", _Inline):
started = mig.enqueue_finish(self.db, self.batch["id"])
with mock.patch.object(mig, "run_evaluate", side_effect=HTTPException(status_code=400, detail="x")):
self.run_background()
self.db.expire_all()
self.assertEqual(self.db.get(BizMigrationBatch, self.batch["id"]).status, "active")
self.assertEqual(self.db.get(BizMigrationRun, started["run_id"]).status, "failed")
def test_auto_monitor_stays_out_of_a_running_evaluation(self):
from netx_api.biz_migration import auto_monitor
with mock.patch.object(mig.threading, "Thread", _Inline):
mig.enqueue_evaluate(self.db, self.batch["id"])
with mock.patch.object(mig, "run_evaluate") as run_evaluate:
auto_monitor.try_auto_monitor_for_task(self.db, self.ctx.old_p.id)
run_evaluate.assert_not_called()
class ManualRouteAndDuplicateScopeTests(_Fixture):
"""Share the fixture, add route sheets / duplicate rows to the compare template."""
def _add_sheet(self, sheet):
self.ctx.ct.metrics_json = [*self.ctx.ct.metrics_json, sheet]
self.ctx.mt.collect_metric_ids_json = []
self.db.commit()
def test_uncollected_route_table_is_manual_pending_and_does_not_fail_acceptance(self):
self._add_sheet({"sheet_id": "bgp_route", "title": "routes", "metric_id": "bgp_route",
"key_fields": ["network"], "iface_fields": [], "compare_fields": [], "row_filters": [],
"field_rules": []})
out = mig.run_evaluate(self.db, batch_id=self.batch["id"], acceptance=True, purpose="acceptance")
card = next(c for c in out["summary"]["sheet_cards"] if c["metric_id"] == "bgp_route")
self.assertTrue(card["manual_pending"])
self.assertFalse(card.get("current_missing"))
self.assertIn("routes", out["summary"]["manual_pending_metrics"])
self.assertNotIn("routes", out["summary"]["current_missing_metrics"])
def test_duplicate_keys_outside_the_wave_are_warnings_not_failures(self):
c = self.ctx
for i, cmd in enumerate(("d1", "d2")):
self.db.add(BizStateMetricRow(id=f"dup-{i}", batch_id="bl_old", metric_id="interface_brief", seq=10 + i,
data_json={"interface": "GE-other", "admin": "up", "phy": "up", "prot": "up"}))
self.db.commit()
out = mig.run_evaluate(self.db, batch_id=self.batch["id"], acceptance=True, purpose="acceptance")
summary = out["summary"]
self.assertEqual(summary["duplicate_keys"], 0)
self.assertEqual(summary["duplicate_warnings"][0]["count"] >= 1, True)
def test_duplicate_keys_inside_the_wave_still_fail(self):
for i in range(2):
self.db.add(BizStateMetricRow(id=f"dup-{i}", batch_id="bl_old", metric_id="interface_brief", seq=10 + i,
data_json={"interface": "GE-old", "admin": "up", "phy": "up", "prot": "up"}))
self.db.commit()
out = mig.run_evaluate(self.db, batch_id=self.batch["id"], acceptance=True, purpose="acceptance")
self.assertGreaterEqual(out["summary"]["duplicate_keys"], 1)
self.assertFalse(out["summary"]["coverage_complete"])
class BaselineCacheTests(_Fixture):
def test_second_evaluation_does_not_read_baseline_rows_again(self):
mig.BASELINE_ROWS.invalidate()
real = mig._load_metric_rows
loaded: list[str] = []
def spy(db, *, batch_id, metric_id, **kw):
loaded.append(batch_id)
return real(db, batch_id=batch_id, metric_id=metric_id, **kw)
with mock.patch.object(mig, "_load_metric_rows", spy):
mig.run_evaluate(self.db, batch_id=self.batch["id"])
first = list(loaded)
loaded.clear()
mig.run_evaluate(self.db, batch_id=self.batch["id"])
self.assertIn("bl_old", first)
self.assertNotIn("bl_old", loaded)
self.assertNotIn("bl_new", loaded)
self.assertIn("cur_new", loaded) # current samples are always re-read
def test_lru_is_bounded_by_total_rows_and_can_be_invalidated(self):
cache = mig.BaselineRowCache(max_rows=5)
rows = lambda n: [{"i": i} for i in range(n)] # noqa: E731
cache.get_or_load("a", "m", lambda: rows(3))
cache.get_or_load("b", "m", lambda: rows(3)) # evicts a (3 + 3 > 5)
calls: list[str] = []
cache.get_or_load("a", "m", lambda: calls.append("a") or rows(3))
self.assertEqual(calls, ["a"])
cache.get_or_load("big", "m", lambda: rows(9)) # larger than the cap: never cached
cache.get_or_load("big", "m", lambda: calls.append("big") or rows(9))
self.assertEqual(calls, ["a", "big"])
cache.invalidate("a")
cache.get_or_load("a", "m", lambda: calls.append("a2") or rows(1))
self.assertEqual(calls[-1], "a2")
class RedTicketResolvableTests(_Fixture):
def _diff(self, run_id, key, color, verdict):
from netx_api.models import BizMigrationDiff
self.db.add(BizMigrationDiff(id=f"d-{run_id}-{key}", run_id=run_id, metric_id="interface_brief", color=color,
verdict=verdict, key_json={"match_old_key": key, "sheet_id": "interface_brief",
"key_str": key}))
def test_ticket_for_a_key_that_turned_green_is_flagged_not_closed(self):
from netx_api.models import BizMigrationRedTicket
pid = self.project_id
for key in ("gei-1", "gei-2"):
self.db.add(BizMigrationRedTicket(
id=f"t-{key}", project_id=pid, batch_id="old", run_id="r0", metric_id="interface_brief",
match_key_str=key, key_str=key, status="open", detail_json={"sheet_id": "interface_brief", "match_old_key": key}))
self.db.add(BizMigrationRun(id="r1", project_id=pid, batch_id=self.batch["id"], purpose="acceptance"))
self._diff("r1", "gei-1", "green", "migrated")
self._diff("r1", "gei-2", "red", "anomaly")
self.db.commit()
mig._persist_red_tickets_from_run(self.db, project_id=pid, batch_id=self.batch["id"], run_id="r1")
self.db.commit()
from netx_api.models import BizMigrationRedTicket as T
flagged = {t.id: mig._red_ticket_to_dict(t) for t in self.db.query(T)}
self.assertTrue(flagged["t-gei-1"]["resolvable"])
self.assertFalse(flagged["t-gei-2"]["resolvable"])
self.assertEqual(flagged["t-gei-1"]["status"], "open")
out = mig.resolve_resolvable_red_tickets(self.db, pid)
self.assertEqual(out["ids"], ["t-gei-1"])
self.assertEqual(self.db.get(T, "t-gei-1").status, "resolved")
self.assertEqual(self.db.get(T, "t-gei-2").status, "open")
class UncoveredSliceTests(_Fixture):
def test_a_slice_one_side_never_collected_is_not_collected_not_lost(self):
sheet = {"sheet_id": "bgp_peer.ipv4", "metric_id": "bgp_peer", "key_fields": ["neighbor"],
"iface_fields": [], "compare_fields": ["state"],
"row_filters": [{"op": "eq", "field": "afi", "value": "ipv4"}], "field_rules": []}
for batch, profile in (("cur_old", "zte.bgp_ipv4_summary"), ("cur_new", "zte.bgp_vpnv4_summary")):
self.db.add(BizStateBatchCommand(id=f"slice-{batch}", batch_id=batch, metric_id="bgp_peer",
profile_id=profile, parse_status="ok", row_count=0, raw_line_count=0))
self.db.commit()
self.assertEqual(mig._uncovered_slice_sides(self.db, sheet, "bgp_peer", "cur_old", "cur_new"), ["new"])
self.assertEqual(mig._uncovered_slice_sides(self.db, {**sheet, "row_filters": []}, "bgp_peer",
"cur_old", "cur_new"), [])

View file

@ -144,16 +144,34 @@ class HfDecoupleFlowTests(unittest.TestCase):
self.assertNotEqual(out["old_hf_task_id"], "old_p")
self.assertNotEqual(out["new_hf_task_id"], "new_p")
def test_same_interval_metrics_are_separate_paired_tasks(self) -> None:
def test_same_interval_light_metrics_share_one_paired_task(self) -> None:
out = self._create_project(collect_metric_ids=["interface_brief", "arp"])
old, new = out["old_hf_bindings"], out["new_hf_bindings"]
self.assertEqual(len(old), 2)
self.assertEqual({b["interval_sec"] for b in old}, {60})
for o, n in zip(old, new):
self.assertEqual(len(o["metric_ids"]), 1)
self.assertTrue(o["collect_group_id"])
self.assertEqual(o["collect_group_id"], n["collect_group_id"])
self.assertEqual(o["metric_ids"], n["metric_ids"])
self.assertEqual((len(old), len(new)), (1, 1))
self.assertEqual(old[0]["metric_ids"], ["interface_brief", "arp"])
self.assertEqual(old[0]["interval_sec"], 60)
self.assertTrue(old[0]["collect_group_id"])
self.assertEqual(old[0]["collect_group_id"], new[0]["collect_group_id"])
from netx_api.models import BizStateTaskItem
items = self.db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == old[0]["task_id"]).count()
self.assertEqual(items, 2) # one SSH login runs both commands
def test_different_interval_or_lane_or_manual_route_stay_apart(self) -> None:
out = self._create_project(collect_metric_ids=["interface_brief", "arp", "interface_detail", "ip_route"],
metric_interval_sec={"arp": 300})
by_metrics = {tuple(b["metric_ids"]): b for b in out["old_hf_bindings"]}
self.assertEqual(set(by_metrics), {("interface_brief",), ("arp",), ("interface_detail",), ("ip_route",)})
self.assertEqual(by_metrics[("arp",)]["interval_sec"], 300)
self.assertTrue(by_metrics[("ip_route",)]["manual_only"])
self.assertEqual(len({b["collect_group_id"] for b in out["old_hf_bindings"]}), 4)
def test_pair_lookup_works_for_a_metric_inside_a_merged_task(self) -> None:
out = self._create_project(collect_metric_ids=["interface_brief", "arp"])
proj = self.db.get(BizMigrationProject, out["id"])
self.assertEqual(mig._hf_task_id_for_metric(proj, "old", "arp"), out["old_hf_bindings"][0]["task_id"])
self.assertEqual(mig._hf_task_id_for_metric(proj, "new", "interface_brief"),
out["new_hf_bindings"][0]["task_id"])
def test_dynamic_routes_manual_only_and_removal_cancels_queued_pairs(self) -> None:
from netx_api.models import BizStateTaskItem, BizStateTaskItemBinding
@ -563,3 +581,77 @@ class HfDecoupleFlowTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class SliceUnionAndSameDeviceTests(HfDecoupleFlowTests):
"""V2 (both devices collect the same slices) and H7 (same device on both sides)."""
def _portrait_items(self, task_id, *profile_ids):
from netx_api.models import BizStateTaskItem
for i, pid in enumerate(profile_ids):
self.db.add(BizStateTaskItem(id=f"{task_id}-{i}", task_id=task_id, source_profile_id=pid,
kind="catalog", enabled=True, sort_order=i))
self.db.commit()
def _hf_profiles(self, task_id):
from netx_api.models import BizStateTaskItem
return sorted(r.source_profile_id for r in
self.db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == task_id))
def test_union_gives_the_device_that_lacks_a_slice_this_vendors_profile(self) -> None:
old = [{"source_profile_id": "zte.bgp_ipv4_summary", "bindings": []},
{"source_profile_id": "zte.bgp_vpnv4_summary", "bindings": []}]
new = [{"source_profile_id": "zte.bgp_vpnv4_summary", "bindings": []}]
old2, new2 = mig._union_slice_items("bgp_peer", old, new, "zte", "zte")
self.assertEqual({i["source_profile_id"] for i in new2}, {"zte.bgp_ipv4_summary", "zte.bgp_vpnv4_summary"})
self.assertEqual(old2, old)
def test_missing_vendor_profile_is_a_clear_error_not_a_silent_gap(self) -> None:
old = [{"source_profile_id": "zte.bgp_ipv4_summary", "bindings": []}]
new = [{"source_profile_id": "zte.bgp_vpnv4_summary", "bindings": []}]
with self.assertRaises(Exception) as ctx:
mig._union_slice_items("bgp_peer", old, new, "zte", "huawei")
self.assertEqual(ctx.exception.detail, "no_profile_for_slice:bgp_peer:afi=ipv4:huawei")
def test_wholemetric_profiles_need_no_union(self) -> None:
item = [{"source_profile_id": "zte.interface_brief", "bindings": []}]
self.assertEqual(mig._union_slice_items("interface_brief", item, [], "zte", "zte"), (item, []))
def test_project_hf_tasks_collect_the_union_of_both_portraits(self) -> None:
self._portrait_items("old_p", "zte.bgp_ipv4_summary", "zte.bgp_vpnv4_summary")
self._portrait_items("new_p", "zte.bgp_vpnv4_summary")
out = self._create_project(collect_metric_ids=["bgp_peer"])
want = ["zte.bgp_ipv4_summary", "zte.bgp_vpnv4_summary"]
self.assertEqual(self._hf_profiles(out["old_hf_bindings"][0]["task_id"]), want)
self.assertEqual(self._hf_profiles(out["new_hf_bindings"][0]["task_id"]), want)
def test_same_device_project_uses_one_task_for_both_sides(self) -> None:
self.new_portrait.ne_id = self.old_portrait.ne_id
self.db.commit()
out = self._create_project(collect_metric_ids=["interface_brief", "arp"])
old, new = out["old_hf_bindings"], out["new_hf_bindings"]
self.assertEqual(len(old), 1)
self.assertEqual(old[0]["task_id"], new[0]["task_id"])
self.assertEqual(old[0]["collect_group_id"], new[0]["collect_group_id"])
self.assertEqual(self.db.query(BizStateTask).filter(BizStateTask.purpose == mig.PURPOSE_CUTOVER_HF).count(), 1)
def test_same_device_sample_is_that_devices_latest_finished_round(self) -> None:
self.new_portrait.ne_id = self.old_portrait.ne_id
self.db.commit()
out = self._create_project(collect_metric_ids=["interface_brief"])
proj = self.db.get(BizMigrationProject, out["id"])
tid = out["old_hf_bindings"][0]["task_id"]
self.assertEqual(mig._current_pair_for_metric(self.db, proj, "interface_brief")[2]["sample_issues"],
["collect_pair_missing"])
batch = self._mk_batch("solo-1", tid)
old, new, meta = mig._current_pair_for_metric(self.db, proj, "interface_brief")
self.assertIs(old, new)
self.assertEqual(old.id, batch.id)
self.assertEqual(meta["old_collect_status"], "success")
def test_two_devices_are_still_rejected_when_they_are_in_fact_one(self) -> None:
# different NE ids but resolving to one task id must keep failing loudly
out = self._create_project(collect_metric_ids=["interface_brief"])
self.assertNotEqual(out["old_hf_bindings"][0]["task_id"], out["new_hf_bindings"][0]["task_id"])

View file

@ -406,3 +406,21 @@ def test_diff_filters_apply_before_pagination_and_escape_wildcards(db):
def test_invalid_correction_rules_fail_before_evaluation(condition):
with pytest.raises(HTTPException):
validate_monitor_rules({}, [{"metric_id": "arp", "success": [{"old_groups": [[condition]], "new": ["added"]}]}])
@pytest.mark.parametrize("raw", [None, True, " v "])
def test_odd_key_values_still_match_expected_key(raw):
"""A6: diff-derived key strings must use the same form as the expect set."""
from netx_api.biz_migration.monitor_templates import preset_override_for_metric
from netx_api.biz_state.compare_rules import scalar_text
old = {"ip": "a", "vrf": raw, "mac": "m", "port": "old"}
new = {"ip": "a", "vrf": raw, "mac": "m", "port": "new"}
out = evaluate(
key_fields=["ip", "vrf"], compare_fields=["mac"], iface_fields=[],
old_baseline_rows=[old], old_current_rows=[], new_baseline_rows=[], new_current_rows=[new],
port_map={}, window_active=False, acceptance=True,
sheet_override=preset_override_for_metric("arp"),
expect=parse_expect_set({"items": [{"metric_id": "arp", "key": ["a", scalar_text(raw).strip()]}]}),
)
assert [r["verdict"] for r in out["rows"]] == ["migrated"]

View file

@ -0,0 +1,156 @@
"""Default monitor presets: port success condition and legacy upgrade (plan A4)."""
from copy import deepcopy
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_migration import monitor_templates as mt
from netx_api.biz_migration.evaluate import dual_verdict_ex
from netx_api.db import Base
from netx_api.models import BizCompareTemplate, BizMonitorTemplate
NEW_UP = {"admin": "up", "phy": "up"}
OLD_SIDES = {
"fibre_pulled_no_shutdown": ("changed", {"admin": "up", "phy": "down"}),
"shutdown_link_still_up": ("changed", {"admin": "down", "phy": "up"}),
"both_down": ("changed", {"admin": "down", "phy": "down"}),
"port_deleted": ("removed", {}),
}
def verdict(old_kind, old_row, new_row, *, acceptance, ov=None):
ov = ov or mt.preset_override_for_metric("interface_brief")
return dual_verdict_ex(
old_kind=old_kind, new_kind="changed", in_expect=True,
window_active=not acceptance, acceptance=acceptance,
old_status="none", new_status="up",
success_patterns=ov["success"], anomaly_patterns=ov["anomaly"],
old_row=old_row, new_row=new_row,
)
@pytest.mark.parametrize("acceptance", [False, True], ids=["window", "acceptance"])
@pytest.mark.parametrize("case", OLD_SIDES)
def test_old_port_out_of_service_with_new_up_is_migrated(case, acceptance):
kind, row = OLD_SIDES[case]
v, color, _hit = verdict(kind, row, NEW_UP, acceptance=acceptance)
assert (v, color) == ("migrated", "green")
@pytest.mark.parametrize("acceptance", [False, True], ids=["window", "acceptance"])
def test_new_port_phy_down_is_never_green(acceptance):
v, color, _ = verdict("changed", {"admin": "down", "phy": "down"},
{"admin": "up", "phy": "down"}, acceptance=acceptance)
assert color != "green"
def test_old_port_still_in_service_is_not_migrated():
v, color, _ = verdict("unchanged", {"admin": "up", "phy": "up"}, NEW_UP, acceptance=True)
assert color != "green"
def test_legacy_form_still_requires_both_down():
legacy = mt.preset_override_for_metric("interface_brief", legacy_port_and=True)
v, color, _ = verdict("changed", {"admin": "up", "phy": "down"}, NEW_UP,
acceptance=True, ov=legacy)
assert color != "green" # the pre-A4 bug this task fixes
@pytest.fixture()
def db():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
yield session
session.close()
engine.dispose()
def _template(db, overrides):
db.add(BizMonitorTemplate(id="m", name="m", compare_template_id="", sheet_overrides_json=overrides))
db.commit()
def test_upgrade_replaces_exact_legacy_preset_only(db):
legacy = mt.preset_override_for_metric("interface_brief", legacy_port_and=True)
arp = mt.preset_override_for_metric("arp")
_template(db, [legacy, arp])
assert mt.upgrade_legacy_port_overrides(db) == 1
stored = db.get(BizMonitorTemplate, "m").sheet_overrides_json
assert stored[0] == mt.preset_override_for_metric("interface_brief")
assert stored[1] == arp
assert mt.upgrade_legacy_port_overrides(db) == 0 # idempotent
def test_customised_override_is_kept_and_warned(db):
custom = mt.preset_override_for_metric("interface_brief", legacy_port_and=True)
custom["down_values"] = ["down", "lowerlayerdown"]
_template(db, [deepcopy(custom)])
assert mt.upgrade_legacy_port_overrides(db) == 0
assert db.get(BizMonitorTemplate, "m").sheet_overrides_json == [custom]
db.add(BizCompareTemplate(id="ct", name="ct", metrics_json=[
{"sheet_id": "interface_brief", "metric_id": "interface_brief",
"key_fields": ["interface"], "compare_fields": ["admin"]}]))
db.commit()
warnings = mt.validate_sheet_overrides(db, compare_template_id="ct", sheet_overrides=[custom])
assert {"sheet_id": "", "metric_id": "interface_brief",
"reason": "port_success_legacy_and_semantics"} in warnings
assert not any(w["reason"] == "port_success_legacy_and_semantics" for w in
mt.validate_sheet_overrides(db, compare_template_id="ct", sheet_overrides=[
mt.preset_override_for_metric("interface_brief")]))
def test_key_tuple_is_the_single_canonical_key_form():
from netx_api.biz_state.compare_engine import key_tuple
assert key_tuple({"a": None, "b": True, "c": " x "}, ["a", "b", "c", "missing"]) == ("", "true", "x", "")
# ---- H8: scope_by_port_map is declared, not guessed from the metric name ----------------------
def _scoped(override):
from netx_api.biz_migration.evaluate import evaluate_metric_dual, parse_expect_set
return evaluate_metric_dual(
metric_id="custom_t", key_fields=["ifname"], iface_fields=["ifname"], compare_fields=[],
old_baseline_rows=[{"ifname": "a"}], old_current_rows=[], new_baseline_rows=[],
new_current_rows=[{"ifname": "b"}], port_map={"a": "b"}, expect=parse_expect_set({}),
window_active=True, sheet_override=override, allow_mapping_scope=True,
)
def test_a_port_map_scopes_an_interface_sheet_unless_the_override_says_otherwise():
assert _scoped({})["progress_total"] == 1
assert _scoped({"scope_by_port_map": True})["progress_total"] == 1
out = _scoped({"scope_by_port_map": False})
assert out["progress_total"] == 0 and out["effective_scope_by_port_map"] is False
def test_presets_turn_scoping_off_for_bgp_and_pseudowire_only():
assert mt.preset_override_for_metric("bgp_peer")["scope_by_port_map"] is False
assert mt.preset_override_for_metric("l2vpn_pw")["scope_by_port_map"] is False
assert "scope_by_port_map" not in mt.preset_override_for_metric("interface_brief")
assert "scope_by_port_map" not in mt.preset_override_for_metric("arp")
def test_stored_overrides_get_the_old_name_based_default_written_down(db):
_template(db, [{"metric_id": "bgp_peer", "sheet_id": "bgp_peer.vpnv4"}, {"metric_id": "arp"},
{"metric_id": "bgp_peer", "sheet_id": "x", "scope_by_port_map": True}])
assert mt.fill_scope_by_port_map(db) == 1
stored = db.get(BizMonitorTemplate, "m").sheet_overrides_json
assert stored[0]["scope_by_port_map"] is False
assert "scope_by_port_map" not in stored[1]
assert stored[2]["scope_by_port_map"] is True # an explicit choice is kept
assert mt.fill_scope_by_port_map(db) == 0
def test_scope_flag_must_be_boolean():
from fastapi import HTTPException
from netx_api.biz_migration.validation import validate_monitor_rules
with pytest.raises(HTTPException):
validate_monitor_rules({}, [{"metric_id": "arp", "scope_by_port_map": "no"}])
validate_monitor_rules({}, [{"metric_id": "arp", "scope_by_port_map": False}])

View file

@ -0,0 +1,96 @@
"""H1: bounded auto-run history and run-aware batch protection."""
from datetime import timedelta
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_state.retention import protected_batch_map, purge_auto_runs
from netx_api.db import Base
from netx_api.models import (
BizCompareDiff, BizCompareJob, BizCompareRun, BizMigrationBatch, BizMigrationDiff, BizMigrationRedTicket,
BizMigrationRun, BizStateBatch, BizStateTask,
)
from netx_api.timeutil import utcnow_naive
@pytest.fixture()
def db():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
yield session
session.close()
engine.dispose()
def _runs(db, n, *, purpose="auto", batch="wave", prefix="r"):
base = utcnow_naive() - timedelta(hours=10)
for i in range(n):
db.add(BizMigrationRun(id=f"{prefix}{i}", project_id="p", batch_id=batch, purpose=purpose,
old_batch_id=f"o-{prefix}{i}", new_batch_id=f"n-{prefix}{i}",
created_at=base + timedelta(minutes=i), summary_json={}))
db.add(BizMigrationDiff(id=f"d-{prefix}{i}", run_id=f"{prefix}{i}", metric_id="m"))
db.commit()
def test_only_the_newest_auto_runs_survive_and_acceptance_runs_stay(db):
_runs(db, 400)
_runs(db, 1, purpose="acceptance", prefix="acc")
out = purge_auto_runs(db, migration_keep=300)
assert out["migration_runs"] == 100
assert db.query(BizMigrationRun).count() == 301
assert db.get(BizMigrationRun, "acc0") is not None
assert db.get(BizMigrationRun, "r399") is not None and db.get(BizMigrationRun, "r99") is None
assert db.query(BizMigrationDiff).count() == 301 # diffs follow their run
def test_runs_pinned_by_red_tickets_or_acceptance_are_never_dropped(db):
_runs(db, 12)
db.add(BizMigrationRedTicket(id="t", project_id="p", batch_id="wave", run_id="r0"))
db.add(BizMigrationBatch(id="wave", project_id="p", accept_run_id="r1"))
db.commit()
purge_auto_runs(db, migration_keep=5)
assert db.get(BizMigrationRun, "r0") is not None and db.get(BizMigrationRun, "r1") is not None
assert db.get(BizMigrationRun, "r2") is None and db.query(BizMigrationRun).count() == 7
def test_keep_is_per_wave(db):
_runs(db, 6, batch="w1", prefix="a")
_runs(db, 6, batch="w2", prefix="b")
assert purge_auto_runs(db, migration_keep=4)["migration_runs"] == 4
assert db.query(BizMigrationRun).filter_by(batch_id="w1").count() == 4
def test_compare_keeps_manual_runs_and_trims_auto_runs_of_auto_jobs(db):
db.add(BizCompareJob(id="job", name="j", mode="auto"))
base = utcnow_naive() - timedelta(hours=5)
for i in range(8):
db.add(BizCompareRun(id=f"c{i}", job_id="job", status="success", created_at=base + timedelta(minutes=i),
summary_json={"purpose": "auto" if i != 0 else "manual"}))
db.add(BizCompareDiff(id=f"cd{i}", run_id=f"c{i}", metric_id="m"))
db.commit()
out = purge_auto_runs(db, compare_keep=3)
assert out["compare_runs"] == 4 # c1..c4; c0 is manual and survives, c5..c7 are the newest
assert {r.id for r in db.query(BizCompareRun)} == {"c0", "c5", "c6", "c7"}
assert db.query(BizCompareDiff).count() == 4
def _batch(db, bid, task="t"):
db.add(BizStateBatch(id=bid, task_id=task, status="success"))
def test_protection_follows_surviving_runs_and_all_metric_batches(db):
db.add(BizStateTask(id="t", source="managed", ne_id="n"))
for bid in ("o-r0", "n-r0", "hf-a", "hf-b", "o-r1", "n-r1", "o-acc0", "n-acc0"):
_batch(db, bid)
db.commit()
_runs(db, 2)
_runs(db, 1, purpose="acceptance", prefix="acc")
acc = db.get(BizMigrationRun, "acc0")
acc.summary_json = {"metric_batches": {"arp": {"old_batch_id": "hf-a", "new_batch_id": "hf-b"}}}
db.commit()
reasons = protected_batch_map(db)
assert "hf-a" in reasons and "hf-b" in reasons # beyond the first binding
purge_auto_runs(db, migration_keep=1) # r0 goes away
assert "o-r0" not in protected_batch_map(db) and "o-r1" in protected_batch_map(db)

View file

@ -95,7 +95,8 @@ class BizStateClaimTests(unittest.TestCase):
self.assertEqual(r2.get("reason"), "already_collecting")
def test_claim_ne_mutex_skips_same_ne(self) -> None:
with patch.object(claim_mod, "max_concurrent_tasks", return_value=10):
# Session cap 1 reproduces the classic "one collection per NE" mutex.
with patch.object(claim_mod, "max_concurrent_tasks", return_value=10), patch.object(claim_mod, "max_sessions_per_ne", return_value=1):
ra = claim_mod.enqueue_collect("ta", manual=True)
rb = claim_mod.enqueue_collect("tb", manual=True)
rc = claim_mod.enqueue_collect("tc", manual=True)
@ -111,6 +112,47 @@ class BizStateClaimTests(unittest.TestCase):
self.assertEqual(self.db.get(BizStateBatch, rb["batch_id"]).status, "queued")
self.assertEqual(self.db.get(BizStateBatch, rc["batch_id"]).status, "running")
def test_a_ne_runs_up_to_its_session_cap_and_no_more(self) -> None:
self.db.add_all([BizStateTask(id=f"x{i}", source="managed", ne_id="ne-a", status="running") for i in range(3)])
for i in range(3):
self.db.add(BizStateTaskItem(id=uuid4().hex, task_id=f"x{i}", source_profile_id="zte.arp",
kind="catalog", enabled=True))
self.db.commit()
with patch.object(claim_mod, "max_concurrent_tasks", return_value=10), patch.object(claim_mod, "max_sessions_per_ne", return_value=2):
queued = [claim_mod.enqueue_collect(f"x{i}", manual=True)["batch_id"] for i in range(3)]
first = claim_mod.claim_queued_batches(10)
again = claim_mod.claim_queued_batches(10)
self.assertEqual(len(first), 2) # three batches queued for ne-a, only two sessions allowed
self.assertEqual(again, [])
self.db.expire_all()
states = sorted(self.db.get(BizStateBatch, b).status for b in queued)
self.assertEqual(states, ["queued", "running", "running"])
def test_default_session_cap_is_two(self) -> None:
self.assertEqual(claim_mod.max_sessions_per_ne(), 2)
def test_single_task_group_is_a_valid_round_for_a_same_device_cutover(self) -> None:
task = self.db.get(BizStateTask, "ta")
task.collect_group_id = "solo"
self.db.commit()
with patch.object(claim_mod, "max_concurrent_tasks", return_value=10):
r = claim_mod.enqueue_collect("ta", manual=True)
self.assertTrue(r["queued"], r)
claimed = claim_mod.claim_queued_batches(10)
self.assertEqual([c["task_id"] for c in claimed], ["ta"])
def test_a_pair_needs_both_sides_before_it_is_claimed(self) -> None:
for tid, group in (("ta", "pair"), ("tc", "pair")):
self.db.get(BizStateTask, tid).collect_group_id = group
self.db.commit()
with patch.object(claim_mod, "max_concurrent_tasks", return_value=10):
r = claim_mod.enqueue_collect("ta", manual=True)
self.assertTrue(r["queued"], r)
self.db.expire_all()
self.db.query(BizStateBatch).filter(BizStateBatch.task_id == "tc").delete()
self.db.commit()
self.assertEqual(claim_mod.claim_queued_batches(10), [])
def test_claim_respects_slot_ceiling(self) -> None:
with patch.object(claim_mod, "max_concurrent_tasks", return_value=1):
claim_mod.enqueue_collect("ta", manual=True)

View file

@ -90,6 +90,33 @@ class CompareSnapshotTests(unittest.TestCase):
self.assertEqual(detail["sheets"][0]["compare_fields"], ["state"])
self.assertEqual(self.run.summary_json["config_snapshot"], original)
def _use_raw_port_map(self, version: int):
# Map written exactly as the device prints it (not the normalized form).
self.pair.before_if, self.pair.after_if = "GE-1", "GE-2"
self.db.commit()
self.run = _create_running_run(
self.db, job=self.job, tpl=self.tpl, before_batch_id="before",
after_batch_id="after", sheets_cfg=template_metrics(self.tpl),
)
snap = deepcopy(self.run.summary_json)
snap["config_snapshot"]["version"] = version
self.run.summary_json = snap
self.db.commit()
def test_v2_run_normalizes_raw_port_map_names(self):
self._use_raw_port_map(2)
detail = self.execute()
self.assertEqual(detail["summary"]["unchanged"], 1)
self.assertEqual(detail["summary"]["added"], 0)
self.assertEqual(detail["summary"]["removed"], 0)
def test_v1_run_keeps_legacy_port_map_matching(self):
self._use_raw_port_map(1)
detail = self.execute()
self.assertEqual(detail["summary"]["unchanged"], 0)
self.assertEqual(detail["summary"]["added"], 1)
self.assertEqual(detail["summary"]["removed"], 1)
def test_compact_hydration_restores_original_normalized_values(self):
self.execute()
self.edit_current_settings()
@ -98,6 +125,15 @@ class CompareSnapshotTests(unittest.TestCase):
self.assertEqual(diff["mapped_before"]["interface"], "gei-2.100")
self.assertEqual(diff["after"]["interface"], "gei-2.100")
def test_diff_rows_expose_the_before_side_name_as_printed(self):
self.execute()
diff = list_run_diffs(self.db, self.run.id, metric_id="sheet", kind="unchanged")["items"][0]
self.assertEqual(diff["key"]["interface"], "gei-2.100")
self.assertEqual(diff["raw_key_before"], {"id": "1", "interface": "GE-1.100"})
with zipfile.ZipFile(io.BytesIO(export_run_zip(self.db, self.run.id))) as archive:
rows = list(csv.DictReader(io.StringIO(archive.read("tables/sheet.csv").decode("utf-8-sig"))))
self.assertEqual(rows[0]["interface__raw_before"], "GE-1.100")
def test_live_search_does_not_inherit_new_filters_or_transforms(self):
with patch("netx_api.biz_state.compare_service._UNCHANGED_SAMPLE_MAX", 0):
self.execute()
@ -132,7 +168,7 @@ class CompareSnapshotTests(unittest.TestCase):
self.edit_current_settings()
detail = get_run(self.db, self.run.id)
self.assertEqual(detail["job_name"], "Original job")
self.assertEqual(detail["config_snapshot_version"], 1)
self.assertEqual(detail["config_snapshot_version"], 2)
self.assertEqual(detail["template"]["iface_normalize_rules"], [{"from": "GE", "to": "gei"}])
self.assertNotIn("config_snapshot", detail["summary"])
detail["template"]["iface_normalize_rules"].clear()

View file

@ -65,7 +65,9 @@ def test_pair_claim_requires_two_slots(db):
assert len(claim.claim_queued_batches(2)) == 2
def test_busy_device_keeps_both_queued_and_no_ssh_overlap(db):
def test_busy_device_keeps_both_queued_and_no_ssh_overlap(db, monkeypatch):
# These cases are about one collection per device; the cap defaults to 2.
monkeypatch.setattr(claim, "max_sessions_per_ne", lambda: 1)
db.add(BizStateBatch(id="portrait", task_id="other", ne_id="old", status="running"))
db.commit()
claim.enqueue_collect("old")
@ -125,7 +127,9 @@ def test_inline_pair_uses_two_workers_without_waiting_for_one_side(db, monkeypat
assert set(synchronized) == {"old", "new"}
def test_faster_metrics_claim_before_manual_route_round(db):
def test_faster_metrics_claim_before_manual_route_round(db, monkeypatch):
# These cases are about one collection per device; the cap defaults to 2.
monkeypatch.setattr(claim, "max_sessions_per_ne", lambda: 1)
for tid in ("ro", "rn"):
db.add(BizStateTask(id=tid, ne_id="old" if tid == "ro" else "new",
status="paused", collect_group_id="routes", collect_manual_only=True))
@ -137,7 +141,9 @@ def test_faster_metrics_claim_before_manual_route_round(db):
assert {job["task_id"] for job in jobs} == {"old", "new"}
def test_manual_route_round_cannot_starve_behind_continuous_fast_metrics(db):
def test_manual_route_round_cannot_starve_behind_continuous_fast_metrics(db, monkeypatch):
# These cases are about one collection per device; the cap defaults to 2.
monkeypatch.setattr(claim, "max_sessions_per_ne", lambda: 1)
for tid in ("ro", "rn"):
db.add(BizStateTask(id=tid, ne_id="old" if tid == "ro" else "new",
status="paused", collect_group_id="routes", collect_manual_only=True))

View file

@ -0,0 +1,167 @@
"""Mapping pre-flight (B5), N:1 reporting (B7) and command-slice coverage (V2)."""
import pytest
from fastapi import HTTPException
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 compare_service as svc
from netx_api.biz_state.compare_quality import classify_sheet, covers_slice, sheet_slice
from netx_api.db import Base
from netx_api.models import (
BizCompareTemplate, BizMigrationProject, BizMonitorTemplate, BizPortMapping, BizPortMappingRow,
BizStateBatch, BizStateBatchCommand, BizStateMetricRow, BizStateTask,
)
@pytest.fixture()
def db():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
yield session
session.close()
engine.dispose()
SHEET = {"sheet_id": "ifb", "metric_id": "iface_t", "key_fields": ["interface"], "iface_fields": ["interface"],
"compare_fields": ["state"], "field_rules": [], "row_filters": []}
def _batches(db, before_ifaces, after_ifaces, *, receipts=True):
db.add_all([BizStateTask(id="t1", source="managed", ne_id="a", vendor="ZTE"),
BizStateTask(id="t2", source="managed", ne_id="b", vendor="ZTE"),
BizStateBatch(id="b1", task_id="t1", status="success"),
BizStateBatch(id="b2", task_id="t2", status="success"),
BizCompareTemplate(id="tpl", name="t", metrics_json=[SHEET],
iface_normalize_json=[{"from": "GE", "to": "gei"}])])
for batch, names in (("b1", before_ifaces), ("b2", after_ifaces)):
for i, name in enumerate(names):
db.add(BizStateMetricRow(id=f"{batch}-{i}", batch_id=batch, metric_id="iface_t", seq=i,
data_json={"interface": name, "state": "up"}))
if receipts:
db.add(BizStateBatchCommand(id=f"c-{batch}", batch_id=batch, metric_id="iface_t",
profile_id="zte.x", parse_status="ok", row_count=len(names),
raw_line_count=len(names) + 3))
db.commit()
def _preflight(db, **extra):
return svc.preflight_compare(db, {"template_id": "tpl", "before_batch_id": "b1", "after_batch_id": "b2", **extra})
def test_exact_parent_same_name_and_suspect_are_classified(db):
_batches(db, ["GE0/1/0/1", "GE0/1/0/2.100", "GE0/1/0/3", "GE0/1/0/9"],
["gei0/2/0/1", "gei0/2/0/2.100", "gei0/1/0/3"])
report = _preflight(db, mapping_rows=[{"before_if": "GE0/1/0/1", "after_if": "GE0/2/0/1"},
{"before_if": "GE0/1/0/2", "after_if": "GE0/2/0/2"}])
(sheet,) = report["sheets"]
assert sheet["mapped_exact"] == ["gei0/1/0/1"]
assert sheet["mapped_by_parent"] == ["gei0/1/0/2.100"]
assert sheet["identity_same_name"] == ["gei0/1/0/3"]
assert sheet["suspect_unmapped"] == ["gei0/1/0/9"]
assert report["coverage"] == {"mapped": 2, "suspect_unmapped": 1, "identity_same_name": 1, "rate": 66.7}
assert report["verdict"] == "risk" and "suspect_unmapped" in report["risk_reasons"]
def test_unsaved_rows_are_enough_and_clean_map_is_ready(db):
_batches(db, ["GE0/1"], ["gei0/2"])
report = _preflight(db, mapping_rows=[{"before_if": "GE0/1", "after_if": "GE0/2"}])
assert report["verdict"] == "ready" and report["coverage"]["rate"] == 100.0
assert report["mapping"]["unused_map_keys"] == []
def test_unused_keys_missing_targets_and_n_to_1_are_listed(db):
_batches(db, ["GE0/1", "GE0/2"], ["gei0/9"])
report = _preflight(db, mapping_rows=[
{"before_if": "GE0/1", "after_if": "GE0/9"}, {"before_if": "GE0/2", "after_if": "GE0/9"},
{"before_if": "GE0/77", "after_if": "GE0/88"}])
m = report["mapping"]
assert m["n_to_1_targets"] == [{"target": "gei0/9", "sources": ["gei0/1", "gei0/2"]}]
assert m["unused_map_keys"] == ["gei0/77"]
assert m["map_targets_missing_on_after"] == ["gei0/88"]
assert report["verdict"] == "risk"
def test_duplicate_before_rows_are_reported(db):
_batches(db, ["GE0/1"], ["gei0/2"])
report = _preflight(db, mapping_rows=[{"before_if": "GE0/1", "after_if": "GE0/2"},
{"before_if": "GE0/1", "after_if": "GE0/3"}])
assert report["mapping"]["duplicate_before"][0]["dropped_to"] == "GE0/3"
assert report["verdict"] == "risk"
def test_saved_mapping_id_is_used_when_no_rows_given(db):
_batches(db, ["GE0/1"], ["gei0/2"])
db.add_all([BizPortMapping(id="m", name="m"),
BizPortMappingRow(mapping_id="m", before_if="GE0/1", after_if="GE0/2")])
db.commit()
assert _preflight(db, mapping_id="m")["coverage"]["mapped"] == 1
def test_missing_batch_is_404(db):
_batches(db, ["GE0/1"], ["gei0/2"])
with pytest.raises(HTTPException) as exc:
svc.preflight_compare(db, {"template_id": "tpl", "before_batch_id": "nope", "after_batch_id": "b2"})
assert exc.value.status_code == 404
def test_n_to_1_is_rejected_by_monitoring_with_the_colliding_rows():
with pytest.raises(HTTPException) as exc:
mig._reject_ambiguous_mapping({"a": "x", "b": "x", "c": "y"})
assert exc.value.status_code == 400
assert exc.value.detail["code"] == "mapping_target_ambiguous"
assert exc.value.detail["n_to_1"] == [{"target": "x", "sources": ["a", "b"]}]
assert "a, b -> x" in exc.value.detail["message"]
mig._reject_ambiguous_mapping({"a": "x", "b": "y"})
def test_project_preflight_uses_the_portrait_baselines(db):
_batches(db, ["GE0/1", "GE0/5"], ["gei0/2"])
db.add_all([BizPortMapping(id="m", name="m"),
BizPortMappingRow(mapping_id="m", before_if="GE0/1", after_if="GE0/2"),
BizMonitorTemplate(id="mt", name="mt", compare_template_id="tpl"),
BizMigrationProject(id="p", name="p", mapping_id="m", monitor_template_id="mt", old_task_id="t1",
new_task_id="t2", old_baseline_batch_id="b1", new_baseline_batch_id="b2")])
db.commit()
report = mig.preflight_project(db, "p")
assert report["sheets"][0]["suspect_unmapped"] == ["gei0/5"]
assert report["verdict"] == "risk"
# ---- V2: slices ---------------------------------------------------------------------------
def receipts(*profile_ids, status="ok"):
return [{"profile_id": p, "parse_status": status, "row_count": 1, "raw_line_count": 4} for p in profile_ids]
def test_sheet_slice_reads_only_top_level_eq_filters():
s = {"row_filters": [{"op": "eq", "field": "afi", "value": "IPv4"},
{"op": "contains", "field": "x", "value": "y"},
{"any": [{"op": "eq", "field": "z", "value": "1"}]}]}
assert sheet_slice(s) == {"afi": "ipv4"}
def test_missing_afi_command_means_not_collected_not_lost():
only_vpnv4 = receipts("zte.bgp_vpnv4_summary")
both = receipts("zte.bgp_vpnv4_summary", "zte.bgp_ipv4_summary")
state, reasons = classify_sheet(before=both, after=only_vpnv4, before_rows=3, after_rows=0,
wanted_slice={"afi": "ipv4"})
assert state == "not_collected" and reasons == ["after:slice_not_collected"]
ok, _ = classify_sheet(before=both, after=both, before_rows=3, after_rows=3, wanted_slice={"afi": "ipv4"})
assert ok == "ok"
def test_command_without_declared_slice_covers_everything():
assert covers_slice(receipts("custom.unknown"), {"afi": "ipv4"})
assert not covers_slice(receipts("zte.bgp_vpnv6_summary"), {"afi": "ipv4"})
assert not covers_slice(receipts("zte.bgp_ipv4_summary", status="failed"), {"afi": "ipv4"})
def test_command_coverage_compares_what_commands_produce(db):
_batches(db, ["GE0/1"], ["gei0/2"])
db.add(BizStateBatchCommand(id="x", batch_id="b1", metric_id="bgp_peer", profile_id="zte.bgp_ipv4_summary",
parse_status="ok"))
db.commit()
cov = _preflight(db, mapping_rows=[])["command_coverage"]
assert cov["only_before"] == ["bgp_peer{afi=ipv4}"]

View file

@ -0,0 +1,163 @@
"""Sheet data quality (V1) and directional compare modes (V3)."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_state import compare_service as svc
from netx_api.biz_state.compare_rules import explain_diff, values_equal
from netx_api.biz_state.identity_pipeline import build_pipeline
from netx_api.db import Base
from netx_api.models import BizStateBatch, BizStateBatchCommand, BizStateMetricRow, BizStateTask
SHEET = {"sheet_id": "peers", "metric_id": "bgp_peer", "key_fields": ["neighbor"], "iface_fields": [],
"compare_fields": ["state"], "field_rules": [], "row_filters": []}
@pytest.fixture()
def db():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
session.add_all([BizStateTask(id="t", source="managed", ne_id="n"),
BizStateBatch(id="b1", task_id="t", status="success"),
BizStateBatch(id="b2", task_id="t", status="success")])
session.commit()
yield session
session.close()
engine.dispose()
def collect(db, batch, rows, *, lines=None, status="ok", command=True):
if command:
db.add(BizStateBatchCommand(id=f"c-{batch}", batch_id=batch, metric_id="bgp_peer", profile_id="zte.x",
parse_status=status, row_count=len(rows),
raw_line_count=len(rows) + 2 if lines is None else lines))
for i, peer in enumerate(rows):
db.add(BizStateMetricRow(id=f"{batch}-{i}", batch_id=batch, metric_id="bgp_peer", seq=i,
data_json={"neighbor": peer, "state": "up"}))
db.commit()
def run(db):
return svc._run_sheet(db, sheet=SHEET, before_batch_id="b1", after_batch_id="b2",
pipeline=build_pipeline(SHEET, iface_rules=[], port_map={}))["summary"]
def enrich(summary):
sheet = {"sheet_id": "peers", "metric_id": "bgp_peer", "status": "done", "summary": summary}
return svc._enrich_summary({k: summary.get(k, 0) for k in
("added", "removed", "changed", "unchanged", "before_count", "after_count")}, [sheet])
def test_empty_against_empty_is_no_data_not_a_hundred_percent(db):
collect(db, "b1", [], lines=0)
collect(db, "b2", [], lines=0)
summary = run(db)
assert summary["data_state"] == "no_data"
out = enrich(summary)
assert out["pass_rate"] is None and out["sheet_cards"][0]["pass_rate"] is None
assert out["ok"] is None
def test_unparsed_output_is_suspect_parse_and_leaves_the_verdict_open(db):
collect(db, "b1", [f"p{i}" for i in range(10)])
collect(db, "b2", [], lines=40) # 40 CLI lines, 0 rows parsed
summary = run(db)
assert summary["data_state"] == "suspect_parse"
assert summary["data_state_reasons"] == ["after:parsed_0_of_40_lines"]
out = enrich(summary)
assert out["sheet_cards"][0]["removed"] == 10 # still shown
assert out["ok"] is None and out["suspect_sheets"] == 1
def test_side_without_any_command_is_not_collected_and_leaves_the_totals(db):
collect(db, "b1", ["a", "b"])
db.add(BizStateBatchCommand(id="other", batch_id="b2", metric_id="other_metric", profile_id="zte.y",
parse_status="ok", row_count=1, raw_line_count=1))
db.commit()
summary = run(db)
assert summary["data_state"] == "not_collected"
out = enrich(summary)
assert out["sheet_cards"][0]["pass_rate"] is None
assert out["fail_count"] == 0 and out["ok"] is None and out["unjudged_sheets"] == 1
def test_batches_without_receipts_are_trusted(db):
collect(db, "b1", ["a"], command=False)
collect(db, "b2", ["a"], command=False)
summary = run(db)
assert summary["data_state"] == "ok"
assert enrich(summary)["pass_rate"] == 100.0
def test_normal_comparison_keeps_the_verdict(db):
collect(db, "b1", ["a", "b"])
collect(db, "b2", ["a"])
out = enrich(run(db))
assert out["ok"] is False and out["fail_count"] == 1 and out["pass_rate"] == 50.0
# ---- V3 -----------------------------------------------------------------------------------
NOT_WORSE = {"compare": "not_worse", "rank": ["up", "down"]}
def test_not_worse_accepts_recovery_and_rejects_degradation():
assert values_equal("down", "up", rule=NOT_WORSE)
assert values_equal("up", "up", rule=NOT_WORSE)
assert not values_equal("up", "down", rule=NOT_WORSE)
assert explain_diff("up", "down", rule=NOT_WORSE) == "worse: up→down"
def test_not_worse_falls_back_to_equality_for_unranked_values():
assert not values_equal("up", "flapping", rule=NOT_WORSE)
assert values_equal("flapping", "flapping", rule=NOT_WORSE)
def test_numeric_min_allows_growth_and_bounded_loss():
rule = {"compare": "numeric_min", "tolerance": 5}
assert values_equal("1000", "1200", rule=rule)
assert values_equal("1000", "960", rule=rule)
assert not values_equal("1000", "900", rule=rule)
assert explain_diff("1000", "900", rule=rule) == "drop 10% > 5%"
def test_numeric_max_is_the_mirror_image():
rule = {"compare": "numeric_max", "tolerance": 10}
assert values_equal("100", "60", rule=rule)
assert values_equal("100", "110", rule=rule)
assert not values_equal("100", "150", rule=rule)
assert explain_diff("100", "150", rule=rule) == "rise 50% > 10%"
def test_directional_modes_pass_validation_and_keep_their_rank():
from netx_api.biz_state.compare_validation import validate_template_body
sheet = {"metric_id": "m", "key_fields": ["k"], "compare_fields": ["s"],
"field_rules": [{"field": "s", "compare": "not_worse", "rank": ["up", "down"]}]}
validate_template_body({"metrics": [sheet]})
normalized = svc._normalize_sheet(sheet)
assert normalized["field_rules"][0]["rank"] == ["up", "down"]
with pytest.raises(Exception):
validate_template_body({"metrics": [{**sheet, "field_rules": [{"field": "s", "compare": "not_worse"}]}]})
def test_directional_compare_runs_end_to_end_and_blocks_sql(db):
from tests.test_identity_pipeline import pg_db
from netx_api.biz_state.compare_engine import compare_rows
from netx_api.biz_state.compare_sql import sql_compare_skip_reason
sheet = {**SHEET, "field_rules": [{"field": "state", **NOT_WORSE}]}
out = compare_rows(before_rows=[{"neighbor": "a", "state": "down"}, {"neighbor": "b", "state": "up"}],
after_rows=[{"neighbor": "a", "state": "up"}, {"neighbor": "b", "state": "down"}],
key_fields=["neighbor"], iface_fields=[], compare_fields=["state"],
field_rules=sheet["field_rules"])
assert (out["summary"]["unchanged"], out["summary"]["changed"]) == (1, 1)
assert sql_compare_skip_reason(pg_db(), sheet) == "directional_compare"
def test_sorted_pairing_is_the_default_for_new_runs_only():
assert build_pipeline(SHEET, iface_rules=[], port_map={}).duplicate_pairing == "sorted"
assert build_pipeline({**SHEET, "duplicate_pairing": "ordered"}, iface_rules=[],
port_map={}).duplicate_pairing == "ordered"
assert build_pipeline(SHEET, iface_rules=[], port_map={}, legacy=True).duplicate_pairing == "ordered"

View file

@ -0,0 +1,204 @@
"""Reference protection and input validation for compare/cutover (plan A2, A5)."""
import pytest
from fastapi import HTTPException
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 compare_service as svc
from netx_api.db import Base
from netx_api.models import (
BizCompareJob, BizCompareTemplate, BizMigrationProject, BizMonitorTemplate,
BizPortMapping, BizPortMappingRow, BizStateBatch, BizStateTask,
)
@pytest.fixture()
def db():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
yield session
session.close()
engine.dispose()
def _template(db, tid="tpl"):
db.add(BizCompareTemplate(id=tid, name=f"name-{tid}", metrics_json=[]))
db.commit()
def _mapping(db, mid="map"):
db.add(BizPortMapping(id=mid, name=f"map-{mid}"))
db.add(BizPortMappingRow(mapping_id=mid, before_if="a", after_if="b"))
db.commit()
def test_mapping_used_by_job_cannot_be_deleted(db):
_mapping(db)
db.add(BizCompareJob(id="j", name="job-1", template_id="tpl", mapping_id="map"))
db.commit()
with pytest.raises(HTTPException) as exc:
svc.delete_mapping(db, "map")
assert exc.value.status_code == 409
assert exc.value.detail["code"] == "mapping_in_use"
assert exc.value.detail["jobs"] == ["job-1"]
assert db.get(BizPortMapping, "map") is not None
def test_mapping_used_by_project_cannot_be_deleted(db):
_mapping(db)
db.add(BizMigrationProject(id="p", name="proj-1", mapping_id="map"))
db.commit()
with pytest.raises(HTTPException) as exc:
svc.delete_mapping(db, "map")
assert exc.value.status_code == 409
assert exc.value.detail["projects"] == ["proj-1"]
assert "proj-1" in exc.value.detail["message"]
def test_unreferenced_mapping_is_deleted_with_rows(db):
_mapping(db)
svc.delete_mapping(db, "map")
assert db.get(BizPortMapping, "map") is None
assert db.query(BizPortMappingRow).count() == 0
def test_list_mappings_reports_reference_counts(db):
_mapping(db)
db.add_all([BizCompareJob(id="j1", template_id="tpl", mapping_id="map"),
BizCompareJob(id="j2", template_id="tpl", mapping_id="map"),
BizMigrationProject(id="p", name="p", mapping_id="map")])
db.commit()
(item,) = svc.list_mappings(db)
assert (item["job_count"], item["project_count"]) == (2, 1)
def test_template_used_by_monitor_template_cannot_be_deleted(db):
_template(db)
db.add(BizMonitorTemplate(id="mt", name="monitor-1", compare_template_id="tpl"))
db.commit()
with pytest.raises(HTTPException) as exc:
svc.delete_template(db, "tpl")
assert exc.value.status_code == 409
assert exc.value.detail["code"] == "template_in_use"
assert exc.value.detail["monitor_templates"] == ["monitor-1"]
assert exc.value.detail["jobs"] == []
def test_template_used_by_job_reports_job_names(db):
_template(db)
db.add(BizCompareJob(id="j", name="job-1", template_id="tpl"))
db.commit()
with pytest.raises(HTTPException) as exc:
svc.delete_template(db, "tpl")
assert exc.value.detail["jobs"] == ["job-1"]
def test_list_templates_counts_monitor_templates(db, monkeypatch):
monkeypatch.setattr(svc, "ensure_default_templates", lambda db: None)
monkeypatch.setattr(svc, "upgrade_builtin_split_sheets", lambda db: None)
_template(db)
db.add_all([BizMonitorTemplate(id="m1", compare_template_id="tpl"),
BizMonitorTemplate(id="m2", compare_template_id="tpl")])
db.commit()
(item,) = svc.list_templates(db)
assert item["monitor_template_count"] == 2
def test_job_with_deleted_mapping_is_rejected_not_silently_unmapped(db):
_template(db)
db.add(BizCompareJob(id="j", template_id="tpl", mapping_id="gone",
before_batch_id="b", after_batch_id="a"))
db.commit()
with pytest.raises(HTTPException) as exc:
svc._validate_compare_job(db, "j")
assert exc.value.status_code == 400
assert exc.value.detail == "mapping_missing"
def _project_tasks(db):
db.add_all([BizStateTask(id="old_p", source="managed", ne_id="ne-old", status="running"),
BizStateTask(id="new_p", source="managed", ne_id="ne-new", status="running"),
BizStateTask(id="other", source="managed", ne_id="ne-x", status="running")])
db.add_all([BizStateBatch(id="old_ok", task_id="old_p", status="success"),
BizStateBatch(id="new_ok", task_id="new_p", status="partial"),
BizStateBatch(id="foreign", task_id="other", status="success"),
BizStateBatch(id="old_failed", task_id="old_p", status="failed")])
db.commit()
def _body(**extra):
return {"name": "n", "old_task_id": "old_p", "new_task_id": "new_p", **extra}
@pytest.mark.parametrize("extra,code", [
({"old_baseline_batch_id": "foreign"}, "old_baseline_invalid"),
({"new_baseline_batch_id": "foreign"}, "new_baseline_invalid"),
({"old_baseline_batch_id": "old_failed"}, "old_baseline_invalid"),
({"new_baseline_batch_id": "old_ok"}, "new_baseline_invalid"),
])
def test_create_project_rejects_baseline_from_wrong_task(db, extra, code):
_project_tasks(db)
with pytest.raises(HTTPException) as exc:
mig.create_project(db, _body(**extra))
assert exc.value.status_code == 400
assert exc.value.detail == code
assert db.query(BizMigrationProject).count() == 0
def test_baseline_ownership_accepts_success_and_partial(db):
_project_tasks(db)
mig._validate_baseline_ownership(db, "old", "old_ok", "old_p")
mig._validate_baseline_ownership(db, "new", "new_ok", "new_p")
mig._validate_baseline_ownership(db, "old", "", "old_p")
# ---- H9: reads do not write; project lists do not fan out --------------------------------------
def _project(db, n, **extra):
db.add(BizStateTask(id=f"to{n}", source="managed", ne_id=f"o{n}", status="running"))
db.add(BizStateTask(id=f"tn{n}", source="managed", ne_id=f"n{n}", status="running"))
db.add(BizStateBatch(id=f"bo{n}", task_id=f"to{n}", status="success"))
db.add(BizStateBatch(id=f"bn{n}", task_id=f"tn{n}", status="success"))
db.add(BizMigrationProject(id=f"p{n}", name=f"p{n}", old_task_id=f"to{n}", new_task_id=f"tn{n}",
old_baseline_batch_id=f"bo{n}", new_baseline_batch_id=f"bn{n}", **extra))
def _statements(db, work):
from sqlalchemy import event
seen: list[str] = []
listener = lambda conn, cur, stmt, params, ctx, many: seen.append(stmt) # noqa: E731
event.listen(db.get_bind(), "before_cursor_execute", listener)
try:
work()
finally:
event.remove(db.get_bind(), "before_cursor_execute", listener)
return seen
def test_get_project_with_unbound_monitor_template_does_not_write(db):
db.add_all([BizCompareTemplate(id="ct", name="ZTE status default", metrics_json=[]),
BizMonitorTemplate(id="mt", name="默认割接监控", compare_template_id="ct")])
_project(db, 1)
db.commit()
seen = _statements(db, lambda: mig.get_project(db, "p1"))
assert not [s for s in seen if s.lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE"))]
assert db.get(BizMigrationProject, "p1").monitor_template_id == ""
def test_project_list_query_count_does_not_grow_with_projects(db):
db.add_all([BizCompareTemplate(id="ct", name="c", metrics_json=[]),
BizMonitorTemplate(id="mt", name="默认割接监控", compare_template_id="ct")])
for n in range(3):
_project(db, n, monitor_template_id="mt")
db.commit()
few = len(_statements(db, lambda: mig.list_projects(db)))
db.expire_all()
for n in range(3, 15):
_project(db, n, monitor_template_id="mt")
db.commit()
db.expire_all()
many = len(_statements(db, lambda: mig.list_projects(db)))
assert many <= few + 2, (few, many)

View file

@ -0,0 +1,341 @@
"""Identity pipeline: one match key everywhere, per-side rules, value maps, post-normalize filters."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_migration.evaluate import evaluate_metric_dual, parse_expect_set
from netx_api.biz_state import compare_service as svc
from netx_api.biz_state.compare_engine import compare_rows
from netx_api.biz_state.compare_sql import dup_pairing_order_sql, sql_compare_skip_reason
from netx_api.biz_state.identity_pipeline import build_pipeline, legacy_pipeline
from netx_api.db import Base
from netx_api.models import (
BizCompareRun, BizCompareTemplate, BizStateBatch, BizStateBatchCommand, BizStateMetricRow, BizStateTask,
)
ZTE_RULES = [{"from": "GE", "to": "gei"}, {"from": "XGE", "to": "xgei"}]
def sheet(**over):
base = {
"sheet_id": "s", "metric_id": "iface_t", "key_fields": ["interface"], "iface_fields": ["interface"],
"compare_fields": ["state"], "field_rules": [], "row_filters": [],
}
return {**base, **over}
def pg_db():
db = MagicMock()
db.get_bind.return_value = MagicMock(dialect=MagicMock())
db.get_bind.return_value.dialect.name = "postgresql"
return db
# ---- B2: per-side rules + vendor canonical -------------------------------------------------
def test_huawei_long_and_short_names_are_the_same_interface():
p = build_pipeline(sheet(), iface_rules=ZTE_RULES, port_map={}, before_vendor="huawei", after_vendor="huawei")
a = p.match_key({"interface": "GigabitEthernet0/0/1"}, "before")
b = p.match_key({"interface": "GE0/0/1"}, "before")
assert a == b == ("gei0/0/1",)
def test_inner_spaces_are_removed():
p = build_pipeline(sheet(), iface_rules=[], port_map={}, before_vendor="cisco")
assert p.match_key({"interface": "Gigabit Ethernet 0/0/1"}, "before") == p.match_key(
{"interface": "GigabitEthernet0/0/1"}, "before")
def test_cisco_and_h3c_canonical_forms():
p = build_pipeline(sheet(), iface_rules=[], port_map={}, before_vendor="cisco", after_vendor="h3c")
assert p.normalize({"interface": "TenGigE0/0/0/1"}, "before")["interface"] == "Te0/0/0/1"
assert p.normalize({"interface": "Ten-GigabitEthernet1/0/1"}, "after")["interface"] == "XGE1/0/1"
def test_builtin_canonical_can_be_switched_off_per_sheet():
p = build_pipeline(sheet(builtin_vendor_canonical=False), iface_rules=[], port_map={}, before_vendor="huawei")
assert p.normalize({"interface": "GigabitEthernet0/0/1"}, "before")["interface"] == "GigabitEthernet0/0/1"
def test_side_scoped_rule_only_touches_its_side():
rules = [{"from": "GE", "to": "gei", "side": "before"}]
p = build_pipeline(sheet(), iface_rules=rules, port_map={}, before_vendor="huawei", after_vendor="zte")
assert p.normalize({"interface": "GE0/1"}, "before")["interface"] == "gei0/1"
assert p.normalize({"interface": "GE0/1"}, "after")["interface"] == "GE0/1"
def test_vendor_scoped_rule_follows_the_batch_vendor():
rules = [{"from": "Eth-Trunk", "to": "ae", "vendor": "huawei"}]
p = build_pipeline(sheet(), iface_rules=rules, port_map={}, before_vendor="huawei", after_vendor="zte")
assert p.normalize({"interface": "Eth-Trunk1"}, "before")["interface"] == "ae1"
assert p.normalize({"interface": "Eth-Trunk1"}, "after")["interface"] == "Eth-Trunk1"
def test_port_map_keys_use_before_rules_and_values_use_after_rules():
rules = [{"from": "GE", "to": "gei", "side": "after"}]
p = build_pipeline(sheet(), iface_rules=rules, port_map={"GE0/1": "GE0/2"},
before_vendor="huawei", after_vendor="zte")
# before-side name stays "GE0/1" (rule is after-only); after-side value is normalized.
assert p.port_map == {"GigabitEthernet0/1".replace("GigabitEthernet", "GE"): "gei0/2"}
row = p.transform({"interface": "GigabitEthernet0/1"}, "before")
assert row["interface"] == "gei0/2"
def test_zte_to_huawei_numbering_change_with_alias_rules():
p = build_pipeline(
sheet(), iface_rules=ZTE_RULES, port_map={"GE0/1/0/1": "GigabitEthernet0/2/0/1"},
before_vendor="zte", after_vendor="huawei",
)
out = compare_rows(
before_rows=[{"interface": "gei0/1/0/1", "state": "up"}],
after_rows=[{"interface": "GE0/2/0/1", "state": "up"}],
key_fields=["interface"], iface_fields=["interface"], compare_fields=["state"], pipeline=p,
)
assert out["summary"]["unchanged"] == 1
# ---- B3: value maps ----------------------------------------------------------------------
def test_value_map_aligns_vendor_state_names():
rules = [{"field": "state", "value_map": {"estab": "established", "*down": "down"}}]
p = build_pipeline(sheet(iface_fields=[], key_fields=["peer"], field_rules=rules), iface_rules=[],
port_map={})
out = compare_rows(
before_rows=[{"peer": "1.1.1.1", "state": "Estab"}, {"peer": "2.2.2.2", "state": "*down"}],
after_rows=[{"peer": "1.1.1.1", "state": "established"}, {"peer": "2.2.2.2", "state": "down"}],
key_fields=["peer"], iface_fields=[], compare_fields=["state"], pipeline=p,
)
assert out["summary"]["unchanged"] == 2 and out["summary"]["changed"] == 0
def test_value_map_can_be_scoped_to_one_side():
rules = [{"field": "state", "side": "before", "value_map": {"estab": "established"}}]
p = build_pipeline(sheet(iface_fields=[], key_fields=["peer"], field_rules=rules), iface_rules=[], port_map={})
assert p.normalize({"peer": "a", "state": "Estab"}, "before")["state"] == "established"
assert p.normalize({"peer": "a", "state": "Estab"}, "after")["state"] == "Estab"
def test_mac_key_with_different_formats_pairs():
rules = [{"field": "mac", "normalize": "mac"}]
p = build_pipeline(sheet(metric_id="mac_t", iface_fields=[], key_fields=["mac"], compare_fields=["port"],
field_rules=rules), iface_rules=[], port_map={})
out = compare_rows(
before_rows=[{"mac": "00e0-fc12-3456", "port": "1"}], after_rows=[{"mac": "00e0.fc12.3456", "port": "1"}],
key_fields=["mac"], iface_fields=[], compare_fields=["port"], pipeline=p,
)
assert out["summary"]["unchanged"] == 1 and out["summary"]["added"] == out["summary"]["removed"] == 0
def test_legacy_pipeline_never_normalizes_keys():
rules = [{"field": "mac", "normalize": "mac"}]
p = legacy_pipeline(key_fields=["mac"], iface_fields=[], compare_fields=["port"], field_rules=rules)
out = compare_rows(
before_rows=[{"mac": "00e0-fc12-3456", "port": "1"}], after_rows=[{"mac": "00e0.fc12.3456", "port": "1"}],
key_fields=["mac"], iface_fields=[], compare_fields=["port"], field_rules=rules, pipeline=p,
)
assert out["summary"]["added"] == out["summary"]["removed"] == 1
def test_value_map_and_key_normalize_block_sql():
s = sheet(metric_id="bgp_route", iface_fields=[], key_fields=["mac"],
field_rules=[{"field": "state", "value_map": {"a": "b"}}], compare_fields=["state"])
p = build_pipeline(s, iface_rules=[], port_map={})
assert sql_compare_skip_reason(pg_db(), s, pipeline=p) == "value_map"
s2 = sheet(metric_id="m", iface_fields=[], key_fields=["mac"], compare_fields=[],
field_rules=[{"field": "mac", "normalize": "lower"}])
assert sql_compare_skip_reason(pg_db(), s2, pipeline=build_pipeline(s2, iface_rules=[], port_map={})) \
== "key_normalize"
# ---- B4: filters run after normalization -----------------------------------------------------
CONTAINS_GEI = [{"field": "interface", "op": "contains", "value": "gei"}]
def test_v2_filter_sees_normalized_names_but_legacy_sees_raw():
s = sheet(row_filters=CONTAINS_GEI)
v2 = build_pipeline(s, iface_rules=ZTE_RULES, port_map={}, before_vendor="huawei")
assert [raw["interface"] for raw, _ in v2.prepare([{"interface": "GE0/0/1"}], "before")] == ["GE0/0/1"]
old = legacy_pipeline(key_fields=["interface"], iface_fields=["interface"], compare_fields=[],
iface_normalize_rules=ZTE_RULES, row_filters=CONTAINS_GEI)
assert old.prepare([{"interface": "GE0/0/1"}], "before") == [] # the cross-vendor bug, kept for v1
def test_filter_can_target_one_side():
s = sheet(iface_fields=[], row_filters=[{"field": "type", "op": "eq", "value": "dynamic", "side": "before"}])
p = build_pipeline(s, iface_rules=[], port_map={})
rows = [{"interface": "a", "type": "static"}]
assert p.prepare(rows, "before") == []
assert len(p.prepare(rows, "after")) == 1
def test_pushdown_only_for_filters_that_read_untransformed_fields():
s = sheet(row_filters=[CONTAINS_GEI[0], {"field": "afi", "op": "eq", "value": "ipv4"}])
p = build_pipeline(s, iface_rules=ZTE_RULES, port_map={})
assert p.pushdown_filters("before") == [{"field": "afi", "op": "eq", "value": "ipv4"}]
assert sql_compare_skip_reason(pg_db(), sheet(metric_id="m", iface_fields=["interface"], key_fields=["id"],
compare_fields=[], row_filters=CONTAINS_GEI,
ignore_port_changes=False),
pipeline=build_pipeline(sheet(row_filters=CONTAINS_GEI), iface_rules=[],
port_map={})) == "row_filters_post_normalize"
# ---- B6: a port map no longer pushes interface-free sheets to Python ----------------------------
def test_port_map_only_blocks_sql_for_sheets_that_use_interfaces():
route = {"metric_id": "bgp_route", "key_fields": ["network"], "iface_fields": [],
"compare_fields": ["path"], "field_rules": [], "row_filters": []}
assert sql_compare_skip_reason(pg_db(), route, port_map={"a": "b"}) == ""
brief = {**route, "metric_id": "interface_brief", "key_fields": ["interface"], "iface_fields": ["interface"]}
assert sql_compare_skip_reason(pg_db(), brief, port_map={"a": "b"}) == "port_map"
# a map on a sheet whose interface column is only displayed does not matter either
shown = {**route, "iface_fields": ["interface"]}
assert sql_compare_skip_reason(pg_db(), shown, port_map={"a": "b"}) == ""
# ---- V4: duplicate keys pair by value ----------------------------------------------------------
def test_same_key_rows_pair_by_value_not_output_order():
s = sheet(iface_fields=[], key_fields=["prefix"], compare_fields=["hop"])
rows_b = [{"prefix": "p", "hop": "a"}, {"prefix": "p", "hop": "b"}]
rows_a = [{"prefix": "p", "hop": "b"}, {"prefix": "p", "hop": "a"}]
kw = dict(before_rows=rows_b, after_rows=rows_a, key_fields=["prefix"], iface_fields=[], compare_fields=["hop"])
new = compare_rows(**kw, pipeline=build_pipeline(s, iface_rules=[], port_map={}))
assert new["summary"]["unchanged"] == 2 and new["summary"]["changed"] == 0
old = compare_rows(**kw) # ordered, legacy
assert old["summary"]["changed"] == 2
def test_sql_dup_order_mirrors_python_sort_key():
sql = dup_pairing_order_sql(["hop", "metric"], {"hop": {"normalize": "lower"}})
assert 'lower(trim(both from coalesce(data_json->>\'hop\', \'\'))) COLLATE "C" ASC' in sql.replace("(", "", 1) \
or 'COLLATE "C"' in sql
assert sql.endswith(", ")
assert dup_pairing_order_sql([], {}) == ""
# ---- B7: partial maps ---------------------------------------------------------------------------
def test_ignore_port_policy_keeps_unmapped_ports_matching_by_content():
s = sheet(iface_fields=["interface"], key_fields=["interface", "ip"], compare_fields=["mac"],
unmapped_port_policy="ignore_port")
p = build_pipeline(s, iface_rules=[], port_map={"gei-1": "gei-9"})
out = compare_rows(
before_rows=[{"interface": "gei-1", "ip": "10.0.0.1", "mac": "m1"},
{"interface": "gei-2", "ip": "10.0.0.2", "mac": "m2"}],
after_rows=[{"interface": "gei-9", "ip": "10.0.0.1", "mac": "m1"},
{"interface": "gei-7", "ip": "10.0.0.2", "mac": "m2"}],
key_fields=["interface", "ip"], iface_fields=["interface"], compare_fields=["mac"], pipeline=p,
)
assert out["summary"]["unchanged"] == 2
by_name = build_pipeline({**s, "unmapped_port_policy": "by_name"}, iface_rules=[], port_map={"gei-1": "gei-9"})
out2 = compare_rows(
before_rows=[{"interface": "gei-2", "ip": "10.0.0.2", "mac": "m2"}],
after_rows=[{"interface": "gei-7", "ip": "10.0.0.2", "mac": "m2"}],
key_fields=["interface", "ip"], iface_fields=["interface"], compare_fields=["mac"], pipeline=by_name,
)
assert out2["summary"]["added"] == out2["summary"]["removed"] == 1
# ---- B1: every path derives the same match key --------------------------------------------------
@pytest.fixture()
def db():
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, expire_on_commit=False)()
yield session
session.close()
engine.dispose()
def _seed_two_batches(db):
db.add_all([
BizStateTask(id="t-old", source="managed", ne_id="o", vendor="Huawei", device_type="huawei"),
BizStateTask(id="t-new", source="managed", ne_id="n", vendor="ZTE", device_type="zte_zxros"),
BizStateBatch(id="b-old", task_id="t-old", status="success", vendor="Huawei"),
BizStateBatch(id="b-new", task_id="t-new", status="success", vendor="ZTE"),
])
db.add_all([
BizStateMetricRow(id="r-old", batch_id="b-old", metric_id="iface_t", seq=0,
data_json={"interface": "GigabitEthernet0/1/0/1", "state": "up"}),
BizStateMetricRow(id="r-new", batch_id="b-new", metric_id="iface_t", seq=0,
data_json={"interface": "gei-0/2/0/1", "state": "up"}),
])
db.commit()
def test_all_paths_agree_on_the_match_key(db):
_seed_two_batches(db)
raw_map = {"GigabitEthernet0/1/0/1": "gei-0/2/0/1"}
rules = [{"from": "GE", "to": "gei"}]
s = sheet(sheet_id="s")
tpl = BizCompareTemplate(id="tpl", name="t", metrics_json=[s], iface_normalize_json=rules)
db.add(tpl)
db.commit()
from netx_api.biz_state.compare_service import _create_running_run, template_metrics
from netx_api.models import BizCompareJob, BizPortMapping, BizPortMappingRow
db.add_all([BizPortMapping(id="m", name="m"),
BizPortMappingRow(mapping_id="m", before_if="GigabitEthernet0/1/0/1", after_if="gei-0/2/0/1"),
BizCompareJob(id="j", name="j", template_id="tpl", mapping_id="m")])
db.commit()
job = db.get(BizCompareJob, "j")
run = _create_running_run(db, job=job, tpl=tpl, before_batch_id="b-old", after_batch_id="b-new",
sheets_cfg=template_metrics(tpl))
assert run.summary_json["config_snapshot"]["vendors"] == {"before": "huawei", "after": "zte"}
pipe = svc._run_pipeline(db, run, s)
expected = ("gei-0/2/0/1",)
# 1. execution
result = compare_rows(before_rows=[{"interface": "GigabitEthernet0/1/0/1", "state": "up"}],
after_rows=[{"interface": "gei-0/2/0/1", "state": "up"}],
key_fields=["interface"], iface_fields=["interface"], compare_fields=["state"],
pipeline=pipe)
assert result["summary"]["unchanged"] == 1
# 2. pipeline directly
assert pipe.match_key({"interface": "GigabitEthernet0/1/0/1"}, "before") == expected
assert pipe.match_key({"interface": "gei-0/2/0/1"}, "after") == expected
# 3. search key groups
rows, complete = svc._load_search_key_groups(
db, batch_id="b-old", metric_id="iface_t", keys={expected}, match_keys=["interface"],
pipeline=pipe, side="before")
assert [r["interface"] for r in rows] == ["GigabitEthernet0/1/0/1"] and complete == {expected}
# 4. hydrate
item = {"before": {"interface": "GigabitEthernet0/1/0/1"}, "after": {}, "mapped_before": {},
"after_row_id": "r-new", "before_row_id": ""}
hydrated = svc._hydrate_diff_rows(db, [item], run=run, sheet=s)[0]
assert hydrated["mapped_before"]["interface"] == hydrated["after"]["interface"] == "gei-0/2/0/1"
# 5. cutover evaluation (old = before, new = after)
dual = evaluate_metric_dual(
metric_id="iface_t", sheet_id="s", key_fields=["interface"], iface_fields=["interface"],
compare_fields=["state"], old_baseline_rows=[{"interface": "GigabitEthernet0/1/0/1", "state": "up"}],
old_current_rows=[], new_baseline_rows=[{"interface": "gei-0/2/0/1", "state": "down"}],
new_current_rows=[{"interface": "gei-0/2/0/1", "state": "up"}], port_map=pipe.port_map,
expect=parse_expect_set({"items": [{"metric_id": "iface_t", "key": "GE0/1/0/1"}]}),
window_active=False, acceptance=True, pipeline=pipe,
)
(row,) = dual["rows"]
assert (row["match_old_key"], row["match_new_key"]) == ("gei0/1/0/1", "gei-0/2/0/1")
assert row["old_key"] == "GigabitEthernet0/1/0/1" and row["verdict"] == "migrated"
# 6. baseline expect picker
assert pipe.transform({"interface": "GigabitEthernet0/1/0/1"}, "before")["interface"] == "gei-0/2/0/1"
# ---- v1 replay must not change -----------------------------------------------------------------
def test_v1_snapshot_run_replays_with_legacy_semantics(db):
_seed_two_batches(db)
s = sheet(sheet_id="s", row_filters=CONTAINS_GEI)
run = BizCompareRun(id="r1", job_id="j", template_id="", before_batch_id="b-old", after_batch_id="b-new",
summary_json={"config_snapshot": {
"version": 1, "template": {"iface_normalize_rules": [{"from": "GE", "to": "gei"}]},
"sheets": [s], "port_map": {"gei-0/1/0/1": "gei-0/2/0/1"}}})
pipe = svc._run_pipeline(db, run, s)
assert pipe.legacy and pipe.duplicate_pairing == "ordered"
assert pipe.port_map == {"gei-0/1/0/1": "gei-0/2/0/1"}
assert pipe.prepare([{"interface": "GE0/1"}], "before") == []

View file

@ -9,6 +9,7 @@ from netx_api.biz_state.iface_normalize import (
default_zte_iface_normalize_rules,
normalize_iface_name,
normalize_iface_rules,
normalize_port_map,
resolve_mapped_iface,
)
@ -71,6 +72,32 @@ class PortMapSubifTests(unittest.TestCase):
self.assertEqual(row["interface"], "xgei-0/1/0/1.55")
class NormalizePortMapTests(unittest.TestCase):
def test_keys_and_values_use_template_rules(self) -> None:
out, conflicts = normalize_port_map(
{"GE0/1/0/1": "GE0/2/0/1"},
normalize_iface_rules([{"from": "GE", "to": "gei"}]),
)
self.assertEqual(out, {"gei0/1/0/1": "gei0/2/0/1"})
self.assertEqual(conflicts, [])
def test_conflicting_keys_are_reported_first_wins(self) -> None:
out, conflicts = normalize_port_map(
{"GE0/1": "gei0/2", "gei0/1": "gei0/3"},
normalize_iface_rules([{"from": "GE", "to": "gei"}]),
)
self.assertEqual(out, {"gei0/1": "gei0/2"})
self.assertEqual(len(conflicts), 1)
self.assertEqual(conflicts[0]["dropped_to"], "gei0/3")
def test_same_target_duplicate_is_not_a_conflict(self) -> None:
_, conflicts = normalize_port_map(
{"GE0/1": "gei0/2", "gei0/1": "gei0/2"},
normalize_iface_rules([{"from": "GE", "to": "gei"}]),
)
self.assertEqual(conflicts, [])
class ComparePipelineTests(unittest.TestCase):
def test_normalize_then_map_matches(self) -> None:
before = [{"interface": "GE-0/0/0/1.100", "admin": "up"}]