mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 01:00:53 +08:00
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>
657 lines
29 KiB
Python
657 lines
29 KiB
Python
"""Integration: portrait vs cutover-HF task slot decoupling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timedelta
|
|
from unittest import mock
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from netx_api.db import Base
|
|
from netx_api.models import (
|
|
BizMigrationProject,
|
|
BizMonitorTemplate,
|
|
BizStateBatch,
|
|
BizStateBatchCommand,
|
|
BizStateTask,
|
|
)
|
|
from netx_api.biz_migration import service as mig
|
|
from netx_api.biz_state import service as biz_svc
|
|
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
class HfDecoupleFlowTests(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.Session = TestingSession
|
|
|
|
self.mt = BizMonitorTemplate(
|
|
id="mt1",
|
|
name="port",
|
|
compare_template_id="",
|
|
collect_metric_ids_json=["interface_brief"],
|
|
defaults_json={},
|
|
sheet_overrides_json=[],
|
|
)
|
|
self.db.add(self.mt)
|
|
|
|
self.old_portrait = self._mk_task("old_p", ne_id="ne-old", note="画像全量", purpose="")
|
|
self.new_portrait = self._mk_task("new_p", ne_id="ne-new", note="画像全量", purpose="")
|
|
self.db.commit()
|
|
|
|
def tearDown(self) -> None:
|
|
self.db.close()
|
|
|
|
def _mk_task(
|
|
self,
|
|
tid: str,
|
|
*,
|
|
ne_id: str,
|
|
note: str = "",
|
|
purpose: str = "",
|
|
interval_sec: int = 3600,
|
|
vendor: str = "zte",
|
|
device_type: str = "router",
|
|
) -> BizStateTask:
|
|
t = BizStateTask(
|
|
id=tid,
|
|
source="managed",
|
|
ne_id=ne_id,
|
|
ne_name=ne_id,
|
|
ne_ip="10.0.0.1",
|
|
vendor=vendor,
|
|
device_type=device_type,
|
|
note=note,
|
|
purpose=purpose,
|
|
status="running",
|
|
interval_sec=interval_sec,
|
|
retention_days=30,
|
|
)
|
|
self.db.add(t)
|
|
return t
|
|
|
|
def _mk_batch(self, bid: str, task_id: str) -> BizStateBatch:
|
|
task = self.db.get(BizStateTask, task_id)
|
|
now = datetime.utcnow()
|
|
b = BizStateBatch(
|
|
id=bid,
|
|
task_id=task_id,
|
|
collect_group_id=task.collect_group_id or "",
|
|
collect_round_id="test-" + task.collect_group_id if task.collect_group_id else "",
|
|
status="success",
|
|
started_at=now,
|
|
ended_at=now,
|
|
row_count=1,
|
|
)
|
|
self.db.add(b)
|
|
for mid in ("interface_brief", "arp"):
|
|
self.db.add(BizStateBatchCommand(id=f"receipt-{bid}-{mid}", batch_id=bid,
|
|
metric_id=mid, parse_status="ok", row_count=0))
|
|
self.db.commit()
|
|
return b
|
|
|
|
def _create_project(self, **extra) -> dict:
|
|
body = {
|
|
"name": "cutover-night-1",
|
|
"old_task_id": self.old_portrait.id,
|
|
"new_task_id": self.new_portrait.id,
|
|
"monitor_template_id": self.mt.id,
|
|
"collect_now": False,
|
|
**extra,
|
|
}
|
|
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 _ensure(self, pid: str, **kw):
|
|
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.ensure_highfreq(self.db, pid, collect_now=False, **kw)
|
|
|
|
def test_create_keeps_portrait_slots(self) -> None:
|
|
out = self._create_project()
|
|
self.assertEqual(out["old_task_id"], "old_p")
|
|
self.assertEqual(out["new_task_id"], "new_p")
|
|
# One-shot create always spawns HF
|
|
self.assertTrue(out["old_hf_task_id"])
|
|
self.assertTrue(out["new_hf_task_id"])
|
|
self.assertNotEqual(out["old_hf_task_id"], "old_p")
|
|
self.assertNotEqual(out["new_hf_task_id"], "new_p")
|
|
|
|
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), 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
|
|
|
|
for tid in ("old_p", "new_p"):
|
|
self.db.add(BizStateTaskItem(id="route-item-" + tid, task_id=tid, source_profile_id="zte.ip_route_vrf", kind="catalog", enabled=True))
|
|
self.db.add(BizStateTaskItemBinding(id="route-bind-" + tid, item_id="route-item-" + tid, placeholder="vrf", value="customer-A"))
|
|
self.db.commit()
|
|
out = self._create_project()
|
|
with mock.patch.object(biz_svc, "_ne_meta", side_effect=_stub_ne_meta):
|
|
updated = mig.patch_project(self.db, out["id"], {"collect_metric_ids": ["interface_brief", "ip_route"]})
|
|
routes = [b for side in ("old", "new") for b in updated[f"{side}_hf_bindings"] if "ip_route" in b["metric_ids"]]
|
|
self.assertEqual(len(routes), 2)
|
|
for idx, binding in enumerate(routes):
|
|
task = self.db.get(BizStateTask, binding["task_id"])
|
|
self.assertTrue(task.collect_manual_only)
|
|
self.assertEqual(task.status, "paused")
|
|
task.collect_running = True
|
|
self.db.add(BizStateBatch(id=f"queued-route-{idx}", task_id=task.id, status="queued", collect_round_id="route-round"))
|
|
self.db.commit()
|
|
with mock.patch("netx_api.biz_state.collect_stop.SessionLocal", self.Session), mock.patch.object(biz_svc, "_ne_meta", side_effect=_stub_ne_meta):
|
|
shrunk = mig.patch_project(self.db, out["id"], {"collect_metric_ids": ["interface_brief"]})
|
|
self.db.expire_all()
|
|
self.assertEqual(len(shrunk["old_hf_bindings"]), 1)
|
|
for idx in range(2):
|
|
self.assertEqual(self.db.get(BizStateBatch, f"queued-route-{idx}").status, "cancelled")
|
|
|
|
def test_route_profile_preserves_portrait_cli_variant_and_bindings(self) -> None:
|
|
from netx_api.models import BizStateTaskItem, BizStateTaskItemBinding
|
|
|
|
self.db.add(BizStateTaskItem(id="vrf-item", task_id="old_p", source_profile_id="zte.ip_route_vrf", kind="catalog", enabled=True))
|
|
self.db.add(BizStateTaskItemBinding(id="vrf-bind", item_id="vrf-item", placeholder="vrf", value="customer-A"))
|
|
self.db.commit()
|
|
out = self._create_project(collect_metric_ids=["interface_brief", "ip_route"])
|
|
binding = next(b for b in out["old_hf_bindings"] if b["metric_ids"] == ["ip_route"])
|
|
item = self.db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == binding["task_id"]).one()
|
|
self.assertEqual(item.source_profile_id, "zte.ip_route_vrf")
|
|
copied = self.db.query(BizStateTaskItemBinding).filter(BizStateTaskItemBinding.item_id == item.id).one()
|
|
self.assertEqual((copied.placeholder, copied.value), ("vrf", "customer-A"))
|
|
|
|
def test_invalid_new_metric_does_not_save_broken_selection(self) -> None:
|
|
out = self._create_project()
|
|
with self.assertRaises(Exception):
|
|
mig.patch_project(self.db, out["id"], {"collect_metric_ids": ["interface_brief", "not-a-metric"]})
|
|
self.db.rollback()
|
|
current = self.db.get(BizMigrationProject, out["id"])
|
|
self.assertEqual(mig.resolve_collect_metric_ids(self.db, current), ["interface_brief"])
|
|
|
|
def test_ensure_highfreq_does_not_overwrite_portrait(self) -> None:
|
|
proj = self._create_project()
|
|
res = self._ensure(proj["id"], interval_sec=60)
|
|
self.assertEqual(res["project"]["old_task_id"], "old_p")
|
|
self.assertEqual(res["project"]["new_task_id"], "new_p")
|
|
self.assertTrue(res["project"]["old_hf_task_id"])
|
|
self.assertNotEqual(res["project"]["old_hf_task_id"], "old_p")
|
|
old_hf = self.db.get(BizStateTask, res["project"]["old_hf_task_id"])
|
|
assert old_hf is not None
|
|
self.assertEqual(old_hf.purpose, mig.PURPOSE_CUTOVER_HF)
|
|
self.assertEqual(old_hf.interval_sec, 60)
|
|
old_p = self.db.get(BizStateTask, "old_p")
|
|
assert old_p is not None
|
|
self.assertEqual(old_p.note, "画像全量")
|
|
|
|
def test_evaluate_current_from_hf_not_portrait(self) -> None:
|
|
proj = self._create_project()
|
|
pid = proj["id"]
|
|
old_hf_id = proj["old_hf_task_id"]
|
|
new_hf_id = proj["new_hf_task_id"]
|
|
self._mk_batch("bl_old", "old_p")
|
|
self._mk_batch("bl_new", "new_p")
|
|
self._mk_batch("cur_old", old_hf_id)
|
|
self._mk_batch("cur_new", new_hf_id)
|
|
self._mk_batch("portrait_cur_old", "old_p")
|
|
self._mk_batch("portrait_cur_new", "new_p")
|
|
mig.patch_project(
|
|
self.db,
|
|
pid,
|
|
{"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"},
|
|
)
|
|
batch = mig.create_batch(
|
|
self.db, pid, {"batch_label": "n1", "expect_set": {"ports": ["gei-0/1"]}}
|
|
)
|
|
with mock.patch.object(mig, "resolve_evaluate_sheets", return_value=([mig.port_sheet_def()], [], {}, [])):
|
|
out = mig.run_evaluate(self.db, batch_id=batch["id"])
|
|
self.assertEqual(out["old_batch_id"], "cur_old")
|
|
self.assertEqual(out["new_batch_id"], "cur_new")
|
|
|
|
def test_collect_now_requires_hf_not_portrait(self) -> None:
|
|
# Project without HF: build manually (create always spawns HF now)
|
|
p = BizMigrationProject(
|
|
id="nohf",
|
|
name="nohf",
|
|
old_task_id=self.old_portrait.id,
|
|
new_task_id=self.new_portrait.id,
|
|
monitor_template_id=self.mt.id,
|
|
)
|
|
self.db.add(p)
|
|
self.db.commit()
|
|
with mock.patch("netx_api.biz_state.collect_runner.dispatch_collect") as disp:
|
|
out = mig.collect_project_now(self.db, p.id)
|
|
disp.assert_not_called()
|
|
old = out["old"]
|
|
new = out["new"]
|
|
if isinstance(old, list):
|
|
self.assertEqual(old[0].get("error"), "hf_task_missing")
|
|
else:
|
|
self.assertEqual(old.get("error"), "hf_task_missing")
|
|
if isinstance(new, list):
|
|
self.assertEqual(new[0].get("error"), "hf_task_missing")
|
|
else:
|
|
self.assertEqual(new.get("error"), "hf_task_missing")
|
|
|
|
def test_collect_now_dispatches_hf_only(self) -> None:
|
|
proj = self._create_project()
|
|
with mock.patch("netx_api.biz_state.collect_runner.dispatch_collect") as disp:
|
|
out = mig.collect_project_now(self.db, proj["id"])
|
|
self.assertTrue(any(x.get("ok") for x in out["old"]))
|
|
self.assertTrue(any(x.get("ok") for x in out["new"]))
|
|
called = {c.args[0] for c in disp.call_args_list}
|
|
self.assertEqual(
|
|
called,
|
|
{proj["old_hf_task_id"]}, # one dispatch atomically enqueues the pair
|
|
)
|
|
self.assertNotIn("old_p", called)
|
|
|
|
def test_project_collect_override(self) -> None:
|
|
proj = self._create_project()
|
|
mig.patch_project(self.db, proj["id"], {"collect_metric_ids": ["interface_brief"]})
|
|
p = self.db.get(BizMigrationProject, proj["id"])
|
|
assert p is not None
|
|
self.assertEqual(mig.resolve_collect_metric_ids(self.db, p), ["interface_brief"])
|
|
|
|
def test_empty_monitor_collect_expands_to_sheets(self) -> None:
|
|
self.mt.collect_metric_ids_json = []
|
|
self.db.commit()
|
|
sheets = [
|
|
{"metric_id": "interface_brief", "key_fields": ["if_name"]},
|
|
{"metric_id": "arp", "key_fields": ["ip"]},
|
|
{"metric_id": "bgp_summary", "key_fields": ["peer"]},
|
|
]
|
|
with mock.patch.object(
|
|
mig, "resolve_evaluate_sheets", return_value=(sheets, [], {}, [])
|
|
):
|
|
proj = self._create_project(collect_metric_ids=[])
|
|
p = self.db.get(BizMigrationProject, proj["id"])
|
|
assert p is not None
|
|
self.assertEqual(
|
|
mig.resolve_collect_metric_ids(self.db, p),
|
|
["interface_brief", "arp", "bgp_summary"],
|
|
)
|
|
self.assertEqual(
|
|
proj.get("collect_metric_ids_effective"),
|
|
["interface_brief", "arp", "bgp_summary"],
|
|
)
|
|
|
|
def test_legacy_migrate_moves_hf_out_of_portrait_slot(self) -> None:
|
|
hf = self._mk_task(
|
|
"legacy_hf",
|
|
ne_id=self.old_portrait.ne_id,
|
|
note="割接高频/interface_brief/x",
|
|
purpose=mig.PURPOSE_CUTOVER_HF,
|
|
interval_sec=60,
|
|
)
|
|
self.db.commit()
|
|
p = BizMigrationProject(
|
|
id="leg1",
|
|
name="legacy",
|
|
old_task_id=hf.id,
|
|
new_task_id=self.new_portrait.id,
|
|
monitor_template_id=self.mt.id,
|
|
)
|
|
self.db.add(p)
|
|
self.db.commit()
|
|
mig.migrate_project_hf_slots(self.db, p, commit=True)
|
|
self.db.refresh(p)
|
|
self.assertEqual(p.old_hf_task_id, "legacy_hf")
|
|
self.assertEqual(p.old_task_id, "old_p")
|
|
|
|
def test_hf_window_paused_when_project_done(self) -> None:
|
|
out = self._create_project()
|
|
mig.patch_project(self.db, out["id"], {"status": "done"})
|
|
p = self.db.get(BizMigrationProject, out["id"])
|
|
assert p is not None
|
|
self.assertEqual(mig._hf_window_status(p), "paused")
|
|
old_hf = self.db.get(BizStateTask, out["old_hf_task_id"])
|
|
assert old_hf is not None
|
|
self.assertEqual(old_hf.status, "paused")
|
|
|
|
def test_hf_window_paused_when_ended(self) -> None:
|
|
proj = self._create_project(
|
|
hf_end_at=(datetime.utcnow() - timedelta(hours=1)).isoformat() + "Z",
|
|
)
|
|
p = self.db.get(BizMigrationProject, proj["id"])
|
|
assert p is not None
|
|
self.assertEqual(mig._hf_window_status(p), "paused")
|
|
self.assertTrue(str(proj.get("hf_end_at") or "").endswith("Z"))
|
|
|
|
def test_ensure_pauses_orphan_interval_tasks(self) -> None:
|
|
proj = self._create_project(
|
|
collect_metric_ids=["interface_brief", "arp"],
|
|
metric_interval_sec={"arp": 120},
|
|
hf_interval_sec=60,
|
|
)
|
|
bindings = proj.get("old_hf_bindings") or []
|
|
self.assertGreaterEqual(len(bindings), 2)
|
|
orphan_id = next(b["task_id"] for b in bindings if int(b["interval_sec"]) == 120)
|
|
# Shrink to single interval group
|
|
mig.patch_project(
|
|
self.db,
|
|
proj["id"],
|
|
{"collect_metric_ids": ["interface_brief"], "metric_interval_sec": {}},
|
|
)
|
|
self._ensure(proj["id"], interval_sec=60)
|
|
orphan = self.db.get(BizStateTask, orphan_id)
|
|
assert orphan is not None
|
|
self.assertEqual(orphan.status, "paused")
|
|
|
|
def test_parse_dt_offset_to_naive_utc(self) -> None:
|
|
# +08:00 wall 16:00 → UTC 08:00
|
|
dt = mig._parse_dt("2026-09-19T16:00:00+08:00")
|
|
assert dt is not None
|
|
self.assertEqual(dt.tzinfo, None)
|
|
self.assertEqual(dt.hour, 8)
|
|
self.assertEqual(mig._dt_iso(dt), "2026-09-19T08:00:00Z")
|
|
# Z suffix
|
|
dt2 = mig._parse_dt("2026-09-19T08:00:00Z")
|
|
assert dt2 is not None
|
|
self.assertEqual(dt2, dt)
|
|
|
|
def test_list_tasks_purpose_filter(self) -> None:
|
|
self._mk_task("hf1", ne_id="ne-x", purpose=mig.PURPOSE_CUTOVER_HF, note="割接高频/x")
|
|
self.db.commit()
|
|
portrait = biz_svc.list_tasks(self.db, purpose="portrait")
|
|
hf = biz_svc.list_tasks(self.db, purpose=mig.PURPOSE_CUTOVER_HF)
|
|
self.assertTrue(any(x["id"] == "hf1" for x in hf))
|
|
self.assertFalse(any(x["id"] == "hf1" for x in portrait))
|
|
self.assertTrue(any(x["id"] == "old_p" for x in portrait))
|
|
|
|
def test_second_ensure_reuses_hf_slot(self) -> None:
|
|
proj = self._create_project()
|
|
r1 = self._ensure(proj["id"])
|
|
r2 = self._ensure(proj["id"])
|
|
self.assertEqual(r1["project"]["old_hf_task_id"], r2["project"]["old_hf_task_id"])
|
|
self.assertFalse(r2["old_created"])
|
|
self.assertFalse(r2["new_created"])
|
|
|
|
def test_create_from_ne_spawns_hf(self) -> None:
|
|
out = self._create_project(
|
|
old_task_id="",
|
|
new_task_id="",
|
|
old_ne={"source": "managed", "ne_id": "ne-old"},
|
|
new_ne={"source": "managed", "ne_id": "ne-new"},
|
|
)
|
|
self.assertTrue(out["old_hf_task_id"])
|
|
self.assertTrue(out["new_hf_task_id"])
|
|
old_hf = self.db.get(BizStateTask, out["old_hf_task_id"])
|
|
assert old_hf is not None
|
|
self.assertEqual(old_hf.purpose, mig.PURPOSE_CUTOVER_HF)
|
|
self.assertEqual(old_hf.ne_id, "ne-old")
|
|
|
|
def test_metric_intervals_split_hf_bindings(self) -> None:
|
|
out = self._create_project(
|
|
collect_metric_ids=["interface_brief", "arp"],
|
|
metric_interval_sec={"arp": 120},
|
|
hf_interval_sec=60,
|
|
)
|
|
bindings = out.get("old_hf_bindings") or []
|
|
self.assertGreaterEqual(len(bindings), 2)
|
|
intervals = sorted(int(b["interval_sec"]) for b in bindings)
|
|
self.assertEqual(intervals, [60, 120])
|
|
task_ids = {b["task_id"] for b in bindings}
|
|
self.assertEqual(len(task_ids), 2)
|
|
|
|
def test_pin_batch_only_applies_to_matching_hf_task(self) -> None:
|
|
proj = self._create_project(
|
|
collect_metric_ids=["interface_brief", "arp"],
|
|
metric_interval_sec={"arp": 120},
|
|
hf_interval_sec=60,
|
|
)
|
|
bindings = proj.get("old_hf_bindings") or []
|
|
tid_60 = next(b["task_id"] for b in bindings if int(b["interval_sec"]) == 60)
|
|
tid_120 = next(b["task_id"] for b in bindings if int(b["interval_sec"]) == 120)
|
|
self._mk_batch("pin60", tid_60)
|
|
self._mk_batch("cur120", tid_120)
|
|
p = self.db.get(BizMigrationProject, proj["id"])
|
|
assert p is not None
|
|
# Pin points at 60s task; arp (120s) must still use its own latest batch
|
|
got_if = mig._current_batch_for_metric(
|
|
self.db, p, "old", "interface_brief", pinned_batch_id="pin60"
|
|
)
|
|
got_arp = mig._current_batch_for_metric(
|
|
self.db, p, "old", "arp", pinned_batch_id="pin60"
|
|
)
|
|
assert got_if is not None and got_arp is not None
|
|
self.assertEqual(got_if.id, "pin60")
|
|
self.assertEqual(got_arp.id, "cur120")
|
|
|
|
def test_catalog_rejects_placeholder_metrics(self) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
fake = SimpleNamespace(
|
|
metric_id="bgp_vrf",
|
|
kind="collect",
|
|
profile_id="zte.bgp_vrf",
|
|
title="BGP VRF",
|
|
placeholders=[SimpleNamespace(name="vrf")],
|
|
)
|
|
with mock.patch(
|
|
"netx_api.biz_state.profiles.profiles_for_vendor", return_value=[fake]
|
|
), mock.patch(
|
|
"netx_api.lldp_shared.resolve_vendor_key", return_value="zte"
|
|
):
|
|
with self.assertRaises(Exception) as cm:
|
|
mig._catalog_item_for_metric(
|
|
vendor="zte", device_type="router", metric_id="bgp_vrf"
|
|
)
|
|
detail = getattr(cm.exception, "detail", str(cm.exception))
|
|
self.assertIn("metric_needs_bindings:bgp_vrf", str(detail))
|
|
|
|
def test_evaluate_marks_current_missing_no_cross_task_fallback(self) -> None:
|
|
proj = self._create_project(
|
|
collect_metric_ids=["interface_brief", "arp"],
|
|
metric_interval_sec={"arp": 120},
|
|
hf_interval_sec=60,
|
|
)
|
|
pid = proj["id"]
|
|
bindings = proj.get("old_hf_bindings") or []
|
|
tid_60 = next(b["task_id"] for b in bindings if int(b["interval_sec"]) == 60)
|
|
new_bindings = proj.get("new_hf_bindings") or []
|
|
new_60 = next(b["task_id"] for b in new_bindings if int(b["interval_sec"]) == 60)
|
|
self._mk_batch("bl_old", "old_p")
|
|
self._mk_batch("bl_new", "new_p")
|
|
self._mk_batch("cur_old_60", tid_60)
|
|
self._mk_batch("cur_new_60", new_60)
|
|
# deliberately no batch on 120s tasks
|
|
mig.patch_project(
|
|
self.db,
|
|
pid,
|
|
{"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"},
|
|
)
|
|
batch = mig.create_batch(
|
|
self.db, pid, {"batch_label": "n1", "expect_set": {"ports": ["gei-0/1"]}}
|
|
)
|
|
sheets = [
|
|
{"metric_id": "interface_brief", "key_fields": ["interface"], "sheet_id": "if"},
|
|
{"metric_id": "arp", "key_fields": ["ip"], "sheet_id": "arp"},
|
|
]
|
|
with mock.patch.object(
|
|
mig, "resolve_evaluate_sheets", return_value=(sheets, [], {}, [])
|
|
), mock.patch.object(mig, "evaluate_metric_dual") as ev:
|
|
ev.return_value = {
|
|
"progress_ok": 0,
|
|
"progress_total": 0,
|
|
"anomaly": 0,
|
|
"anomaly_in_expect": 0,
|
|
"old_summary": {},
|
|
"new_summary": {},
|
|
"rows": [],
|
|
"new_baseline_mode": "provided",
|
|
"new_baseline_missing": False,
|
|
}
|
|
out = mig.run_evaluate(self.db, batch_id=batch["id"])
|
|
cards = (out.get("summary") or {}).get("sheet_cards") or []
|
|
by_mid = {c["metric_id"]: c for c in cards}
|
|
self.assertFalse(by_mid["interface_brief"].get("current_missing"))
|
|
self.assertTrue(by_mid["arp"].get("current_missing"))
|
|
self.assertIn("arp", " ".join((out.get("summary") or {}).get("current_missing_metrics") or []))
|
|
|
|
def test_baseline_expect_filters_to_collect_metrics(self) -> None:
|
|
proj = self._create_project(collect_metric_ids=["interface_brief"])
|
|
self._mk_batch("bl_old2", "old_p")
|
|
mig.patch_project(self.db, proj["id"], {"old_baseline_batch_id": "bl_old2"})
|
|
sheets = [
|
|
{"metric_id": "interface_brief", "key_fields": ["interface"], "sheet_id": "if"},
|
|
{"metric_id": "arp", "key_fields": ["ip"], "sheet_id": "arp"},
|
|
]
|
|
with mock.patch.object(
|
|
mig, "resolve_evaluate_sheets", return_value=(sheets, [], {}, [])
|
|
), mock.patch.object(mig, "_load_metric_rows", return_value=[]):
|
|
out = mig.list_baseline_expect_objects(self.db, proj["id"])
|
|
mids = [s["metric_id"] for s in out.get("sheets") or []]
|
|
self.assertEqual(mids, ["interface_brief"])
|
|
|
|
def test_project_to_dict_is_readonly(self) -> None:
|
|
hf = self._mk_task(
|
|
"legacy_hf2",
|
|
ne_id=self.old_portrait.ne_id,
|
|
note="割接高频/x",
|
|
purpose=mig.PURPOSE_CUTOVER_HF,
|
|
)
|
|
self.db.commit()
|
|
p = BizMigrationProject(
|
|
id="ro1",
|
|
name="ro",
|
|
old_task_id=hf.id,
|
|
new_task_id=self.new_portrait.id,
|
|
monitor_template_id=self.mt.id,
|
|
)
|
|
self.db.add(p)
|
|
self.db.commit()
|
|
d = mig.project_to_dict(self.db, p)
|
|
self.assertEqual(d["old_task_id"], "legacy_hf2")
|
|
self.assertEqual(d["old_hf_task_id"], "")
|
|
self.db.refresh(p)
|
|
self.assertEqual(p.old_task_id, "legacy_hf2")
|
|
|
|
|
|
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"])
|