mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:43:17 +08:00
Decouple cutover HF from portrait and tighten ops UI.
Portrait stays on biz-monitor; cutover create/ensure owns purpose=cutover_hf slots with project collect override, multi-interval bindings, window pause, and industrial detail UX. Also expand ZTE collect/config parsers and TextFSM for route/BGP/L2VPN/VRRP coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a65c9fc420
commit
285c19e5a3
67 changed files with 9510 additions and 723 deletions
499
tests/test_biz_migration_hf_decouple.py
Normal file
499
tests/test_biz_migration_hf_decouple.py
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
"""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,
|
||||
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.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:
|
||||
now = datetime.utcnow()
|
||||
b = BizStateBatch(
|
||||
id=bid,
|
||||
task_id=task_id,
|
||||
status="success",
|
||||
started_at=now,
|
||||
ended_at=now,
|
||||
row_count=1,
|
||||
)
|
||||
self.db.add(b)
|
||||
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_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=([], [], {}, [])):
|
||||
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"], proj["new_hf_task_id"]},
|
||||
)
|
||||
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()
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import unittest
|
||||
|
||||
from netx_api.biz_state.compare_engine import compare_rows, mapping_stats
|
||||
|
|
@ -118,6 +119,26 @@ class CompareEngineTests(unittest.TestCase):
|
|||
self.assertEqual(out["summary"]["removed"], 0)
|
||||
self.assertTrue(out["mapping_stats"].get("ignore_port_changes"))
|
||||
|
||||
def test_empty_port_map_keeps_iface_when_required_for_uniqueness(self) -> None:
|
||||
"""OSPF/VRRP-style: same neighbor_id on many interfaces must not collapse."""
|
||||
before = [
|
||||
{"process_id": "1", "neighbor_id": "2.2.2.2", "interface": "sg1", "address": "10.0.0.1"},
|
||||
{"process_id": "1", "neighbor_id": "2.2.2.2", "interface": "sg2", "address": "10.0.0.2"},
|
||||
]
|
||||
after = copy.deepcopy(before)
|
||||
out = compare_rows(
|
||||
before_rows=before,
|
||||
after_rows=after,
|
||||
key_fields=["process_id", "neighbor_id", "interface"],
|
||||
iface_fields=["interface"],
|
||||
compare_fields=["address"],
|
||||
port_map={},
|
||||
)
|
||||
self.assertEqual(out["summary"]["unchanged"], 2)
|
||||
self.assertEqual(out["summary"]["changed"], 0)
|
||||
self.assertIn("interface", out["summary"]["match_key_fields"])
|
||||
self.assertFalse(out["mapping_stats"].get("ignore_port_changes"))
|
||||
|
||||
def test_unchanged_rows_are_listed(self) -> None:
|
||||
before = [{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}]
|
||||
after = [{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}]
|
||||
|
|
@ -331,13 +352,46 @@ class CompareSheetDefaultsTests(unittest.TestCase):
|
|||
self.assertIn("bgp_peer.vpnv6", ids)
|
||||
self.assertIn("isis_adjacency.ipv4", ids)
|
||||
self.assertIn("isis_adjacency.ipv6", ids)
|
||||
# Same source metric may appear multiple times
|
||||
self.assertEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_peer"), 4)
|
||||
# Same source metric may appear multiple times (afi splits)
|
||||
self.assertEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_peer"), 6)
|
||||
self.assertIn("bgp_peer.evpn", ids)
|
||||
self.assertIn("bgp_peer.vpls", ids)
|
||||
self.assertIn("vrrp.ipv4", ids)
|
||||
self.assertIn("lldp_neighbor", ids)
|
||||
detail = next(s for s in sheets if sheet_key(s) == "interface_detail")
|
||||
self.assertEqual(detail["compare_fields"], ["admin"])
|
||||
self.assertIn("input_bps", detail["display_fields"])
|
||||
optical = next(s for s in sheets if sheet_key(s) == "optical_brief")
|
||||
self.assertEqual(optical["compare_fields"], ["status"])
|
||||
self.assertIn("rx_power", optical["display_fields"])
|
||||
bgp4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.ipv4")
|
||||
self.assertEqual(bgp4["compare_fields"], ["as_num", "state"])
|
||||
self.assertIn("pfx_rcd", bgp4["display_fields"])
|
||||
vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4")
|
||||
self.assertEqual(vpnv4["row_filters"], [{"field": "afi", "op": "eq", "value": "vpnv4"}])
|
||||
isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4")
|
||||
self.assertEqual(isis4["row_filters"][0]["op"], "contains")
|
||||
|
||||
def test_duplicate_match_keys_are_reported(self) -> None:
|
||||
before = [
|
||||
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"},
|
||||
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "9"},
|
||||
]
|
||||
after = [
|
||||
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"},
|
||||
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "2"},
|
||||
]
|
||||
out = compare_rows(
|
||||
before_rows=before,
|
||||
after_rows=after,
|
||||
key_fields=["local_if", "remote_sys", "remote_if"],
|
||||
iface_fields=["local_if"],
|
||||
compare_fields=["remote_ip"],
|
||||
port_map={"a": "a"},
|
||||
)
|
||||
self.assertEqual(out["summary"]["duplicate_keys_before"], 1)
|
||||
self.assertEqual(out["summary"]["duplicate_keys_after"], 1)
|
||||
|
||||
def test_normalize_allows_duplicate_metric_with_distinct_sheet_id(self) -> None:
|
||||
from netx_api.biz_state.compare_service import _normalize_sheet, sheet_key
|
||||
|
||||
|
|
|
|||
80
tests/test_iface_normalize.py
Normal file
80
tests/test_iface_normalize.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Unit tests for iface normalize + subinterface port-map inheritance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from netx_api.biz_state.compare_engine import apply_port_map, compare_rows
|
||||
from netx_api.biz_state.iface_normalize import (
|
||||
default_zte_iface_normalize_rules,
|
||||
normalize_iface_name,
|
||||
normalize_iface_rules,
|
||||
resolve_mapped_iface,
|
||||
)
|
||||
|
||||
|
||||
class IfaceNormalizeTests(unittest.TestCase):
|
||||
def test_prefix_rules_longest_first(self) -> None:
|
||||
rules = default_zte_iface_normalize_rules()
|
||||
self.assertEqual(normalize_iface_name("GE1.100", rules), "gei1.100")
|
||||
self.assertEqual(normalize_iface_name("SG11", rules), "smartgroup11")
|
||||
self.assertEqual(normalize_iface_name("XGE-0/1/0/1", rules), "xgei-0/1/0/1")
|
||||
self.assertEqual(normalize_iface_name("XXVGE-0/2/0/10", rules), "xxvgei-0/2/0/10")
|
||||
self.assertEqual(normalize_iface_name("CGE0/4/1/3", rules), "cgei0/4/1/3")
|
||||
|
||||
def test_does_not_mangle_canonical(self) -> None:
|
||||
rules = default_zte_iface_normalize_rules()
|
||||
self.assertEqual(normalize_iface_name("gei-0/0/0/1", rules), "gei-0/0/0/1")
|
||||
self.assertEqual(normalize_iface_name("smartgroup11", rules), "smartgroup11")
|
||||
self.assertEqual(normalize_iface_name("xgei-0/3/1/20.219", rules), "xgei-0/3/1/20.219")
|
||||
|
||||
def test_parse_text_rules(self) -> None:
|
||||
rules = normalize_iface_rules("GE,gei\n# comment\nSG\tsmartgroup\n")
|
||||
self.assertEqual(rules[0]["from"], "GE")
|
||||
self.assertEqual(rules[1]["from"], "SG")
|
||||
|
||||
|
||||
class PortMapSubifTests(unittest.TestCase):
|
||||
def test_exact_wins_over_parent(self) -> None:
|
||||
pmap = {
|
||||
"gei-0/0/0/1": "xgei-0/1/0/1",
|
||||
"gei-0/0/0/1.100": "xgei-0/1/0/9.100",
|
||||
}
|
||||
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1.100", pmap), "xgei-0/1/0/9.100")
|
||||
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1.200", pmap), "xgei-0/1/0/1.200")
|
||||
|
||||
def test_parent_inherits_suffix(self) -> None:
|
||||
pmap = {"gei-0/0/0/1": "xgei-0/1/0/1"}
|
||||
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1", pmap), "xgei-0/1/0/1")
|
||||
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1.100", pmap), "xgei-0/1/0/1.100")
|
||||
self.assertEqual(resolve_mapped_iface("gei-0/0/0/2.100", pmap), "gei-0/0/0/2.100")
|
||||
|
||||
def test_apply_port_map_row(self) -> None:
|
||||
row = apply_port_map(
|
||||
{"interface": "gei-0/0/0/1.55", "admin": "up"},
|
||||
iface_fields=["interface"],
|
||||
port_map={"gei-0/0/0/1": "xgei-0/1/0/1"},
|
||||
)
|
||||
self.assertEqual(row["interface"], "xgei-0/1/0/1.55")
|
||||
|
||||
|
||||
class ComparePipelineTests(unittest.TestCase):
|
||||
def test_normalize_then_map_matches(self) -> None:
|
||||
before = [{"interface": "GE-0/0/0/1.100", "admin": "up"}]
|
||||
after = [{"interface": "xgei-0/1/0/1.100", "admin": "up"}]
|
||||
out = compare_rows(
|
||||
before_rows=before,
|
||||
after_rows=after,
|
||||
key_fields=["interface"],
|
||||
iface_fields=["interface"],
|
||||
compare_fields=["admin"],
|
||||
port_map={"gei-0/0/0/1": "xgei-0/1/0/1"},
|
||||
iface_normalize_rules=default_zte_iface_normalize_rules(),
|
||||
)
|
||||
self.assertEqual(out["summary"]["unchanged"], 1)
|
||||
self.assertEqual(out["summary"]["added"], 0)
|
||||
self.assertEqual(out["summary"]["removed"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
417
tests/test_zte_config_intent.py
Normal file
417
tests/test_zte_config_intent.py
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
"""Synthetic tests for ZTE config-intent parsers (no live secrets / host data)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from netx_api.biz_state.command_match import match_command
|
||||
from netx_api.biz_state.compare_service import _default_zte_config_sheets, sheet_key
|
||||
from netx_api.biz_state.parsers.zte import (
|
||||
normalize_config_bgp_peer,
|
||||
normalize_config_interface,
|
||||
normalize_config_isis,
|
||||
normalize_config_l2vpn_pw,
|
||||
normalize_config_ospf,
|
||||
normalize_config_static_route,
|
||||
normalize_config_vrf,
|
||||
)
|
||||
from netx_api.biz_state.parsers.zte.config_bgp_peer import _is_ip_neighbor
|
||||
|
||||
from netx_api.biz_state.profiles import get_profile, metric_field_map, reload_profiles
|
||||
|
||||
|
||||
_CFG_VRF = """
|
||||
!<vrf>
|
||||
ip vrf CUST_A
|
||||
rd 100:1
|
||||
description "Customer A"
|
||||
address-family ipv4
|
||||
route-target export 100:1
|
||||
route-target import 100:1
|
||||
$
|
||||
address-family ipv6
|
||||
route-target export 100:2
|
||||
$
|
||||
$
|
||||
ip vrf CUST_B
|
||||
rd 200:2
|
||||
address-family ipv4
|
||||
route-target import 200:2
|
||||
$
|
||||
$
|
||||
!</vrf>
|
||||
"""
|
||||
|
||||
_CFG_IF = """
|
||||
!<if-intf>
|
||||
interface gei-0/0/0/1
|
||||
description uplink
|
||||
ip vrf forwarding CUST_A
|
||||
ip address 10.0.0.1 255.255.255.0
|
||||
ipv6 address 2001:db8::1/64
|
||||
mtu 9000
|
||||
$
|
||||
interface gei-0/0/0/2
|
||||
shutdown
|
||||
ip address 10.0.1.1 255.255.255.0
|
||||
$
|
||||
!</if-intf>
|
||||
"""
|
||||
|
||||
_CFG_BGP = """
|
||||
!<bgp>
|
||||
router bgp 65000
|
||||
neighbor 10.0.0.1 remote-as 65001
|
||||
neighbor 10.0.0.1 update-source loopback1
|
||||
neighbor 10.0.0.1 password cipher SKIPME
|
||||
neighbor 10.0.0.1 route-map RM_IN in
|
||||
neighbor 10.0.0.1 peer-group CORE_RR
|
||||
neighbor CORE_RR peer-group
|
||||
neighbor CORE_RR remote-as 65009
|
||||
neighbor FC00:1::1 remote-as 65002
|
||||
address-family vpnv4
|
||||
neighbor 10.0.0.1 activate
|
||||
neighbor 10.0.0.1 route-map RM_OUT out
|
||||
neighbor CORE_RR activate
|
||||
$
|
||||
address-family ipv4 vrf CUST_A
|
||||
neighbor 10.0.0.2 remote-as 65003
|
||||
neighbor 10.0.0.2 activate
|
||||
$
|
||||
address-family l2vpn evpn
|
||||
neighbor 10.0.0.1 activate
|
||||
$
|
||||
$
|
||||
!</bgp>
|
||||
"""
|
||||
|
||||
_CFG_L2VPN = """
|
||||
!<l2vpn>
|
||||
vpws VPN_A
|
||||
description "pw site a"
|
||||
access-point gei-0/0/0/1.100
|
||||
pseudo-wire pw1
|
||||
neighbour 10.0.0.1 vcid 1001
|
||||
encapsulation mpls
|
||||
$
|
||||
$
|
||||
$
|
||||
vpls VPN_B
|
||||
access-point smartgroup1
|
||||
pseudo-wire pw2
|
||||
neighbour 10.0.0.2 vcid 2002
|
||||
encapsulation mpls
|
||||
$
|
||||
$
|
||||
$
|
||||
!</l2vpn>
|
||||
"""
|
||||
|
||||
_CFG_STATIC_V4 = """
|
||||
!<static>
|
||||
ip route 0.0.0.0 0.0.0.0 10.0.0.1 name default_gw
|
||||
ip route 10.9.9.0 255.255.255.0 null1
|
||||
ip route 10.8.8.0 255.255.255.0 nexthop-vrf OTHER_VRF
|
||||
ip route vrf CUST_A 10.1.1.0 255.255.255.0 bvi12.100 10.1.1.1
|
||||
ip route vrf CUST_A 10.2.2.0 255.255.255.0 gei-0/0/0/1.10 10.2.2.1 bfd enable
|
||||
ip route vrf CUST_B 10.3.3.0 255.255.255.0 null1 metric 255
|
||||
ip route vrf CUST_A 10.4.4.0 255.255.255.0 smartgroup1.1 10.4.4.1 track LINK_A
|
||||
!</static>
|
||||
"""
|
||||
|
||||
_CFG_STATIC_V6 = """
|
||||
!<ipv6-static-route>
|
||||
ipv6 route ::/0 null1 track LINK_BFD
|
||||
ipv6 route vrf CUST_A 2001:db8:1::/64 2001:db8:1::1
|
||||
!</ipv6-static-route>
|
||||
"""
|
||||
|
||||
_CFG_OSPF = """
|
||||
!<ospfv2>
|
||||
router ospf 3 vrf CUST_A
|
||||
router-id 1.1.1.1
|
||||
area 0.0.0.0
|
||||
interface smartgroup1.10
|
||||
cost 20
|
||||
hello-interval 10
|
||||
dead-interval 40
|
||||
network point-to-point
|
||||
bfd interval 50 min-rx 50
|
||||
authentication message-digest
|
||||
message-digest-key 1 md5 encrypted SKIPME
|
||||
$
|
||||
$
|
||||
area 10.0.0.1
|
||||
nssa default-information-originate no-summary
|
||||
interface gei-0/0/0/1
|
||||
cost 10
|
||||
$
|
||||
$
|
||||
area 20.0.0.1
|
||||
stub no-summary
|
||||
$
|
||||
redistribute static route-map RM1
|
||||
redistribute connected
|
||||
$
|
||||
!</ospfv2>
|
||||
"""
|
||||
|
||||
_CFG_OSPF_V3 = """
|
||||
!<ospfv3>
|
||||
ipv6 router ospf 120 vrf CORE
|
||||
router-id 2.2.2.2
|
||||
redistribute connected
|
||||
area 0.0.0.0
|
||||
interface vlan918
|
||||
cost 10
|
||||
network point-to-point
|
||||
$
|
||||
$
|
||||
$
|
||||
!</ospfv3>
|
||||
"""
|
||||
|
||||
_CFG_ISIS = """
|
||||
!<isis>
|
||||
router isis 1
|
||||
area 62.0047
|
||||
system-id 1241.9509.6248
|
||||
router-id 1.1.1.1
|
||||
is-type level-2-only
|
||||
authentication encrypted SKIPME
|
||||
address-family ipv6
|
||||
multi-topology
|
||||
segment-routing srv6 locator MAIN
|
||||
end-sid func-code ::1
|
||||
$
|
||||
$
|
||||
interface loopback0
|
||||
ipv6 router isis
|
||||
passive-mode
|
||||
$
|
||||
interface smartgroup1
|
||||
circuit-type level-2-only
|
||||
ipv6 bfd-enable
|
||||
ipv6 metric 5
|
||||
ipv6 router isis
|
||||
network point-to-point
|
||||
hello-authentication encrypted SKIPME
|
||||
bfd-enable
|
||||
$
|
||||
$
|
||||
!</isis>
|
||||
"""
|
||||
|
||||
|
||||
class ZteConfigIntentTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
reload_profiles()
|
||||
|
||||
def test_profiles_and_field_schema(self) -> None:
|
||||
for pid, mid in (
|
||||
("zte.config_vrf", "config_vrf"),
|
||||
("zte.config_interface", "config_interface"),
|
||||
("zte.config_bgp_peer", "config_bgp_peer"),
|
||||
("zte.config_l2vpn_pw", "config_l2vpn_pw"),
|
||||
("zte.config_static_route", "config_static_route"),
|
||||
("zte.config_static_route_v6", "config_static_route"),
|
||||
("zte.config_ospf", "config_ospf"),
|
||||
("zte.config_ospf_v3", "config_ospf"),
|
||||
("zte.config_isis", "config_isis"),
|
||||
):
|
||||
p = get_profile(pid)
|
||||
self.assertIsNotNone(p)
|
||||
assert p is not None
|
||||
self.assertEqual(p.metric_id, mid)
|
||||
self.assertTrue(p.enabled)
|
||||
fields = metric_field_map().get(mid) or []
|
||||
self.assertTrue(any(f.is_key for f in fields))
|
||||
|
||||
def test_if_intf_cli_prefers_config_interface(self) -> None:
|
||||
hit = match_command(vendor_key="zte", command="show running-config if-intf")
|
||||
self.assertIsNotNone(hit)
|
||||
assert hit is not None
|
||||
self.assertEqual(hit.profile.profile_id, "zte.config_interface")
|
||||
|
||||
def test_config_vrf(self) -> None:
|
||||
rows = normalize_config_vrf(raw_text=_CFG_VRF, command="show running-config vrf")
|
||||
by = {r["vrf_name"]: r for r in rows}
|
||||
self.assertEqual(set(by), {"CUST_A", "CUST_B"})
|
||||
self.assertEqual(by["CUST_A"]["rd"], "100:1")
|
||||
self.assertIn("ipv4", by["CUST_A"]["address_families"])
|
||||
self.assertIn("ipv6", by["CUST_A"]["address_families"])
|
||||
self.assertIn("100:1", by["CUST_A"]["rt_export"])
|
||||
self.assertIn("100:2", by["CUST_A"]["rt_export"])
|
||||
self.assertEqual(by["CUST_A"]["description"], "Customer A")
|
||||
self.assertIn("200:2", by["CUST_B"]["rt_import"])
|
||||
|
||||
def test_config_interface(self) -> None:
|
||||
rows = normalize_config_interface(
|
||||
raw_text=_CFG_IF, command="show running-config if-intf"
|
||||
)
|
||||
by = {r["interface"]: r for r in rows}
|
||||
self.assertEqual(by["gei-0/0/0/1"]["vrf"], "CUST_A")
|
||||
self.assertEqual(by["gei-0/0/0/1"]["admin"], "up")
|
||||
self.assertIn("10.0.0.1", by["gei-0/0/0/1"]["ip_address"])
|
||||
self.assertIn("2001:db8::1/64", by["gei-0/0/0/1"]["ipv6_address"])
|
||||
self.assertEqual(by["gei-0/0/0/1"]["mtu"], "9000")
|
||||
self.assertEqual(by["gei-0/0/0/2"]["admin"], "down")
|
||||
|
||||
def test_config_bgp_peer_skips_password(self) -> None:
|
||||
rows = normalize_config_bgp_peer(
|
||||
raw_text=_CFG_BGP, command="show running-config bgp"
|
||||
)
|
||||
blob = " ".join(str(v) for r in rows for v in r.values())
|
||||
self.assertNotIn("SKIPME", blob)
|
||||
self.assertNotIn("password", blob.lower())
|
||||
|
||||
by = {(r["afi"], r["vrf"], r["neighbor"], r["peer_group"]): r for r in rows}
|
||||
self.assertIn(("vpnv4", "", "10.0.0.1", "CORE_RR"), by)
|
||||
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["remote_as"], "65001")
|
||||
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["activate"], "enable")
|
||||
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["update_source"], "loopback1")
|
||||
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["route_map_in"], "RM_IN")
|
||||
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["route_map_out"], "RM_OUT")
|
||||
# AF-scoped RM must not bleed into other address-families
|
||||
self.assertEqual(by[("l2vpn-evpn", "", "10.0.0.1", "CORE_RR")]["route_map_in"], "RM_IN")
|
||||
self.assertEqual(by[("l2vpn-evpn", "", "10.0.0.1", "CORE_RR")]["route_map_out"], "")
|
||||
# peer-group name is not an IP → neighbor empty, peer_group set
|
||||
self.assertIn(("vpnv4", "", "", "CORE_RR"), by)
|
||||
self.assertEqual(by[("vpnv4", "", "", "CORE_RR")]["remote_as"], "65009")
|
||||
self.assertEqual(by[("ipv4", "CUST_A", "10.0.0.2", "")]["remote_as"], "65003")
|
||||
self.assertIn(("l2vpn-evpn", "", "10.0.0.1", "CORE_RR"), by)
|
||||
# global IPv6 neighbor without AF activate
|
||||
self.assertIn(("global", "", "FC00:1::1", ""), by)
|
||||
# no name left in neighbor column
|
||||
self.assertTrue(all((not r["neighbor"]) or _is_ip_neighbor(r["neighbor"]) for r in rows))
|
||||
|
||||
def test_config_l2vpn_pw(self) -> None:
|
||||
rows = normalize_config_l2vpn_pw(
|
||||
raw_text=_CFG_L2VPN, command="show running-config l2vpn"
|
||||
)
|
||||
by = {(r["vpn_name"], r["pw_name"]): r for r in rows}
|
||||
self.assertEqual(by[("VPN_A", "pw1")]["peer"], "10.0.0.1")
|
||||
self.assertEqual(by[("VPN_A", "pw1")]["vcid"], "1001")
|
||||
self.assertEqual(by[("VPN_A", "pw1")]["vpn_type"], "vpws")
|
||||
self.assertEqual(by[("VPN_A", "pw1")]["access_point"], "gei-0/0/0/1.100")
|
||||
self.assertEqual(by[("VPN_B", "pw2")]["vpn_type"], "vpls")
|
||||
self.assertEqual(by[("VPN_B", "pw2")]["vcid"], "2002")
|
||||
|
||||
def test_config_interface_merges_duplicate_blocks(self) -> None:
|
||||
raw = """
|
||||
!<if-intf>
|
||||
interface te_tunnel36
|
||||
description LSP
|
||||
shutdown
|
||||
$
|
||||
interface te_tunnel36
|
||||
ip unnumbered loopback0
|
||||
$
|
||||
!</if-intf>
|
||||
"""
|
||||
rows = normalize_config_interface(raw_text=raw, command="show running-config if-intf")
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["admin"], "down")
|
||||
self.assertEqual(rows[0]["description"], "LSP")
|
||||
|
||||
def test_config_l2vpn_backup_pw_not_overwrite(self) -> None:
|
||||
raw = """
|
||||
!<l2vpn>
|
||||
vpws 18163
|
||||
access-point gei-0/0/1/9.1751
|
||||
pseudo-wire pw_primary
|
||||
neighbour 10.0.0.1 vcid 18163
|
||||
encapsulation raw
|
||||
$
|
||||
backup-pw pw_backup protect pw_primary
|
||||
neighbour 10.0.0.2 vcid 18163
|
||||
$
|
||||
$
|
||||
$
|
||||
!</l2vpn>
|
||||
"""
|
||||
rows = normalize_config_l2vpn_pw(raw_text=raw, command="show running-config l2vpn")
|
||||
by = {r["pw_name"]: r for r in rows}
|
||||
self.assertEqual(by["pw_primary"]["peer"], "10.0.0.1")
|
||||
self.assertEqual(by["pw_backup"]["peer"], "10.0.0.2")
|
||||
self.assertEqual(by["pw_primary"]["vcid"], "18163")
|
||||
|
||||
def test_config_static_route_v4(self) -> None:
|
||||
rows = normalize_config_static_route(
|
||||
raw_text=_CFG_STATIC_V4, command="show running-config static"
|
||||
)
|
||||
self.assertTrue(all(r["af"] == "ipv4" for r in rows))
|
||||
by = {(r["vrf"], r["prefix"], r["interface"], r["next_hop"]): r for r in rows}
|
||||
self.assertEqual(by[("", "0.0.0.0", "", "10.0.0.1")]["route_name"], "default_gw")
|
||||
self.assertEqual(by[("", "10.9.9.0", "null1", "")]["mask"], "255.255.255.0")
|
||||
self.assertEqual(by[("", "10.8.8.0", "", "")]["nexthop_vrf"], "OTHER_VRF")
|
||||
self.assertEqual(
|
||||
by[("CUST_A", "10.1.1.0", "bvi12.100", "10.1.1.1")]["mask"], "255.255.255.0"
|
||||
)
|
||||
self.assertEqual(by[("CUST_A", "10.2.2.0", "gei-0/0/0/1.10", "10.2.2.1")]["bfd"], "enable")
|
||||
self.assertEqual(by[("CUST_B", "10.3.3.0", "null1", "")]["metric"], "255")
|
||||
self.assertEqual(by[("CUST_A", "10.4.4.0", "smartgroup1.1", "10.4.4.1")]["track"], "LINK_A")
|
||||
|
||||
def test_config_static_route_v6(self) -> None:
|
||||
rows = normalize_config_static_route(
|
||||
raw_text=_CFG_STATIC_V6, command="show running-config ipv6-static-route"
|
||||
)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertTrue(all(r["af"] == "ipv6" for r in rows))
|
||||
by = {(r["vrf"], r["prefix"]): r for r in rows}
|
||||
self.assertEqual(by[("", "::/0")]["interface"], "null1")
|
||||
self.assertEqual(by[("", "::/0")]["track"], "LINK_BFD")
|
||||
self.assertEqual(by[("CUST_A", "2001:db8:1::/64")]["next_hop"], "2001:db8:1::1")
|
||||
|
||||
def test_compare_config_sheets(self) -> None:
|
||||
sheets = _default_zte_config_sheets()
|
||||
ids = [sheet_key(s) for s in sheets]
|
||||
self.assertIn("config_vrf", ids)
|
||||
self.assertIn("config_interface", ids)
|
||||
self.assertIn("config_bgp_peer", ids)
|
||||
self.assertIn("config_l2vpn_pw", ids)
|
||||
self.assertIn("config_static_route.ipv4", ids)
|
||||
self.assertIn("config_static_route.ipv6", ids)
|
||||
self.assertIn("config_ospf.ipv4", ids)
|
||||
self.assertIn("config_ospf.ipv6", ids)
|
||||
self.assertIn("config_isis", ids)
|
||||
for s in sheets:
|
||||
self.assertTrue(s.get("key_fields"))
|
||||
self.assertTrue(s.get("compare_fields") or s.get("key_fields"))
|
||||
|
||||
def test_config_ospf(self) -> None:
|
||||
rows = normalize_config_ospf(raw_text=_CFG_OSPF, command="show running-config ospfv2")
|
||||
blob = " ".join(str(v) for r in rows for v in r.values())
|
||||
self.assertNotIn("SKIPME", blob)
|
||||
by = {(r["area"], r["interface"]): r for r in rows}
|
||||
self.assertEqual(by[("0.0.0.0", "smartgroup1.10")]["cost"], "20")
|
||||
self.assertEqual(by[("0.0.0.0", "smartgroup1.10")]["network_type"], "point-to-point")
|
||||
self.assertEqual(by[("0.0.0.0", "smartgroup1.10")]["bfd"], "enable")
|
||||
self.assertEqual(by[("0.0.0.0", "smartgroup1.10")]["vrf"], "CUST_A")
|
||||
self.assertIn("static", by[("0.0.0.0", "smartgroup1.10")]["redistribute"])
|
||||
self.assertEqual(by[("10.0.0.1", "gei-0/0/0/1")]["area_type"], "nssa")
|
||||
self.assertEqual(by[("20.0.0.1", "")]["area_type"], "stub")
|
||||
v3 = normalize_config_ospf(raw_text=_CFG_OSPF_V3, command="show running-config ospfv3")
|
||||
self.assertEqual(len(v3), 1)
|
||||
self.assertEqual(v3[0]["af"], "ipv6")
|
||||
self.assertEqual(v3[0]["interface"], "vlan918")
|
||||
|
||||
def test_config_isis(self) -> None:
|
||||
rows = normalize_config_isis(raw_text=_CFG_ISIS, command="show running-config isis")
|
||||
blob = " ".join(str(v) for r in rows for v in r.values())
|
||||
self.assertNotIn("SKIPME", blob)
|
||||
by = {r["interface"]: r for r in rows}
|
||||
self.assertEqual(set(by), {"loopback0", "smartgroup1"})
|
||||
self.assertEqual(by["loopback0"]["passive"], "yes")
|
||||
self.assertEqual(by["loopback0"]["ipv6_enable"], "yes")
|
||||
self.assertEqual(by["loopback0"]["area"], "62.0047")
|
||||
self.assertEqual(by["smartgroup1"]["circuit_type"], "level-2-only")
|
||||
self.assertEqual(by["smartgroup1"]["ipv6_metric"], "5")
|
||||
self.assertEqual(by["smartgroup1"]["ipv6_bfd"], "enable")
|
||||
self.assertEqual(by["smartgroup1"]["bfd"], "enable")
|
||||
self.assertEqual(by["smartgroup1"]["network_type"], "point-to-point")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
248
tests/test_zte_config_real_dump.py
Normal file
248
tests/test_zte_config_real_dump.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""Regression: config-intent parsers vs real ``test/show-zte/show-config``.
|
||||
|
||||
Skipped when the dump is absent. Validates counts and field fidelity against
|
||||
raw MIM section lines (not synthetic fixtures).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from netx_api.biz_state.parsers.zte.config_bgp_peer import normalize_config_bgp_peer
|
||||
from netx_api.biz_state.parsers.zte.config_common import extract_mim_section
|
||||
from netx_api.biz_state.parsers.zte.config_interface import normalize_config_interface
|
||||
from netx_api.biz_state.parsers.zte.config_isis import normalize_config_isis
|
||||
from netx_api.biz_state.parsers.zte.config_l2vpn_pw import normalize_config_l2vpn_pw
|
||||
from netx_api.biz_state.parsers.zte.config_ospf import normalize_config_ospf
|
||||
from netx_api.biz_state.parsers.zte.config_static_route import (
|
||||
_V4_HEAD,
|
||||
_parse_v4,
|
||||
_parse_v4_rest,
|
||||
normalize_config_static_route,
|
||||
)
|
||||
from netx_api.biz_state.parsers.zte.config_vrf import normalize_config_vrf
|
||||
|
||||
_CONFIG = Path(__file__).resolve().parents[2] / "test" / "show-zte" / "show-config"
|
||||
|
||||
|
||||
@unittest.skipUnless(_CONFIG.is_file(), "test/show-zte/show-config not present")
|
||||
class ZteConfigRealDumpTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.text = _CONFIG.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
|
||||
def test_vrf_count_and_rd(self) -> None:
|
||||
body = extract_mim_section(self.text, "vrf")
|
||||
raw = re.findall(r"(?im)^\s*ip\s+vrf\s+(\S+)\s*$", body)
|
||||
rows = normalize_config_vrf(raw_text=self.text, command="show running-config vrf")
|
||||
self.assertEqual(len(rows), len(raw))
|
||||
by = {r["vrf_name"]: r for r in rows}
|
||||
cur = None
|
||||
expect_rd: dict[str, str] = {}
|
||||
for line in body.splitlines():
|
||||
m = re.match(r"(?i)^\s*ip\s+vrf\s+(\S+)\s*$", line)
|
||||
if m:
|
||||
cur = m.group(1)
|
||||
continue
|
||||
m = re.match(r"(?i)^\s*rd\s+(\S+)\s*$", line)
|
||||
if m and cur:
|
||||
expect_rd[cur] = m.group(1)
|
||||
for name, rd in expect_rd.items():
|
||||
self.assertEqual(by[name]["rd"], rd, msg=name)
|
||||
|
||||
def test_interface_merged_fields(self) -> None:
|
||||
body = extract_mim_section(self.text, "if-intf")
|
||||
rows = normalize_config_interface(
|
||||
raw_text=self.text, command="show running-config if-intf"
|
||||
)
|
||||
by = {r["interface"]: r for r in rows}
|
||||
exp: dict[str, dict] = {}
|
||||
cur = None
|
||||
block_ips: list[str] = []
|
||||
block_ip6s: list[str] = []
|
||||
block_admin = None
|
||||
block_vrf = ""
|
||||
block_mtu = ""
|
||||
|
||||
def commit(name: str) -> None:
|
||||
nonlocal block_admin, block_vrf, block_mtu, block_ips, block_ip6s
|
||||
e = exp[name]
|
||||
if block_admin is not None:
|
||||
e["admin"] = block_admin
|
||||
if block_vrf:
|
||||
e["vrf"] = block_vrf
|
||||
if block_mtu:
|
||||
e["mtu"] = block_mtu
|
||||
for ip in block_ips:
|
||||
if ip not in e["ips"]:
|
||||
e["ips"].append(ip)
|
||||
for ip in block_ip6s:
|
||||
if ip not in e["ip6s"]:
|
||||
e["ip6s"].append(ip)
|
||||
|
||||
for line in body.splitlines():
|
||||
m = re.match(r"(?i)^\s*interface\s+(\S+)\s*$", line)
|
||||
if m:
|
||||
if cur:
|
||||
commit(cur)
|
||||
cur = m.group(1)
|
||||
exp.setdefault(cur, {"vrf": "", "admin": "up", "ips": [], "ip6s": [], "mtu": ""})
|
||||
block_ips, block_ip6s = [], []
|
||||
block_admin, block_vrf, block_mtu = None, "", ""
|
||||
continue
|
||||
if not cur:
|
||||
continue
|
||||
if re.match(r"^\$\s*$", line):
|
||||
commit(cur)
|
||||
cur = None
|
||||
continue
|
||||
low = line.strip().lower()
|
||||
if low == "shutdown":
|
||||
block_admin = "down"
|
||||
elif low == "no shutdown":
|
||||
block_admin = "up"
|
||||
m = re.match(r"(?i)^\s*ip\s+vrf\s+forwarding\s+(\S+)\s*$", line)
|
||||
if m:
|
||||
block_vrf = m.group(1)
|
||||
continue
|
||||
m = re.match(r"(?i)^\s*ip\s+address\s+(\S+)(?:\s+(\S+))?\s*$", line)
|
||||
if m:
|
||||
a, mask = m.group(1), m.group(2) or ""
|
||||
block_ips.append(f"{a}/{mask}" if mask else a)
|
||||
continue
|
||||
m = re.match(r"(?i)^\s*ipv6\s+address\s+(\S+)\s*$", line)
|
||||
if m:
|
||||
block_ip6s.append(m.group(1))
|
||||
continue
|
||||
m = re.match(r"(?i)^\s*mtu\s+(\d+)\s*$", line)
|
||||
if m:
|
||||
block_mtu = m.group(1)
|
||||
if cur:
|
||||
commit(cur)
|
||||
|
||||
self.assertEqual(len(rows), len(exp))
|
||||
for name, e in exp.items():
|
||||
g = by[name]
|
||||
self.assertEqual(g["vrf"], e["vrf"], msg=name)
|
||||
self.assertEqual(g["admin"], e["admin"], msg=name)
|
||||
self.assertEqual(
|
||||
[x for x in (g["ip_address"] or "").split(",") if x],
|
||||
e["ips"],
|
||||
msg=name,
|
||||
)
|
||||
|
||||
def test_static_route_line_coverage(self) -> None:
|
||||
body = extract_mim_section(self.text, "static")
|
||||
raw = [ln for ln in body.splitlines() if re.match(r"(?i)^\s*ip\s+route\s+", ln)]
|
||||
rows = normalize_config_static_route(
|
||||
raw_text=self.text, command="show running-config static"
|
||||
)
|
||||
self.assertEqual(len(rows), len(raw))
|
||||
self.assertEqual(rows, _parse_v4(body))
|
||||
for ln in raw:
|
||||
m = _V4_HEAD.match(ln)
|
||||
self.assertIsNotNone(m, msg=ln[:120])
|
||||
assert m is not None
|
||||
extra = _parse_v4_rest(m.group("rest") or "")
|
||||
hit = any(
|
||||
r["vrf"] == (m.group("vrf") or "").strip()
|
||||
and r["prefix"] == m.group("prefix")
|
||||
and r["mask"] == m.group("mask")
|
||||
and r["next_hop"] == extra["next_hop"]
|
||||
and r["interface"] == extra["interface"]
|
||||
and r["nexthop_vrf"] == extra["nexthop_vrf"]
|
||||
for r in rows
|
||||
)
|
||||
self.assertTrue(hit, msg=ln[:140])
|
||||
bfd_raw = sum(1 for ln in raw if re.search(r"(?i)\bbfd\b", ln))
|
||||
self.assertEqual(sum(1 for r in rows if r["bfd"]), bfd_raw)
|
||||
|
||||
def test_static_route_v6(self) -> None:
|
||||
body = extract_mim_section(self.text, "ipv6-static-route")
|
||||
raw = [ln for ln in body.splitlines() if re.match(r"(?i)^\s*ipv6\s+route\s+", ln)]
|
||||
rows = normalize_config_static_route(
|
||||
raw_text=self.text, command="show running-config ipv6-static-route"
|
||||
)
|
||||
self.assertEqual(len(rows), len(raw))
|
||||
|
||||
def test_bgp_activate_and_no_secrets(self) -> None:
|
||||
body = extract_mim_section(self.text, "bgp")
|
||||
activates = {
|
||||
m.group(1)
|
||||
for m in re.finditer(
|
||||
r"(?im)^\s*neighbor\s+(\S+)\s+activate(?:\s+disable)?\s*$", body
|
||||
)
|
||||
}
|
||||
rows = normalize_config_bgp_peer(
|
||||
raw_text=self.text, command="show running-config bgp"
|
||||
)
|
||||
blob = " ".join(str(v) for r in rows for v in r.values()).lower()
|
||||
self.assertNotIn("password", blob)
|
||||
self.assertNotIn("cipher", blob)
|
||||
# Every activate token appears as neighbor (IP) or peer_group (name)
|
||||
covered = {r["neighbor"] for r in rows if r["neighbor"]} | {
|
||||
r["peer_group"] for r in rows if r["peer_group"]
|
||||
}
|
||||
self.assertTrue(activates <= covered)
|
||||
# neighbor column is IP-only
|
||||
for r in rows:
|
||||
if r["neighbor"]:
|
||||
self.assertTrue(
|
||||
re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", r["neighbor"])
|
||||
or ":" in r["neighbor"],
|
||||
msg=r["neighbor"],
|
||||
)
|
||||
|
||||
def test_l2vpn_neighbour_coverage(self) -> None:
|
||||
body = extract_mim_section(self.text, "l2vpn")
|
||||
neis = set(
|
||||
re.findall(r"(?im)^\s*neighbour\s+(\S+)\s+vcid\s+(\S+)\s*$", body)
|
||||
)
|
||||
rows = normalize_config_l2vpn_pw(
|
||||
raw_text=self.text, command="show running-config l2vpn"
|
||||
)
|
||||
parsed = {(r["peer"], r["vcid"]) for r in rows if r["peer"] and r["vcid"]}
|
||||
self.assertEqual(parsed & neis, neis)
|
||||
for r in rows:
|
||||
if r["peer"] and r["vcid"]:
|
||||
self.assertIn((r["peer"], r["vcid"]), neis)
|
||||
|
||||
def test_ospf_v2_interface_coverage(self) -> None:
|
||||
body = extract_mim_section(self.text, "ospfv2")
|
||||
procs = re.findall(
|
||||
r"(?im)^\s*router\s+ospf\s+(\S+)(?:\s+vrf\s+(\S+))?\s*$", body
|
||||
)
|
||||
ifaces = re.findall(r"(?im)^\s*interface\s+(\S+)\s*$", body)
|
||||
rows = normalize_config_ospf(
|
||||
raw_text=self.text, command="show running-config ospfv2"
|
||||
)
|
||||
self.assertTrue(all(r["af"] == "ipv4" for r in rows))
|
||||
parsed_if = {r["interface"] for r in rows if r["interface"]}
|
||||
self.assertEqual(parsed_if, set(ifaces))
|
||||
self.assertEqual(len({(r["process_id"], r["vrf"]) for r in rows}), len(procs))
|
||||
blob = " ".join(str(v) for r in rows for v in r.values()).lower()
|
||||
self.assertNotIn("encrypted", blob)
|
||||
self.assertNotIn("message-digest-key", blob)
|
||||
|
||||
def test_ospf_v3_and_isis(self) -> None:
|
||||
v3_body = extract_mim_section(self.text, "ospfv3")
|
||||
v3_if = re.findall(r"(?im)^\s*interface\s+(\S+)\s*$", v3_body)
|
||||
v3 = normalize_config_ospf(
|
||||
raw_text=self.text, command="show running-config ospfv3"
|
||||
)
|
||||
self.assertEqual({r["interface"] for r in v3 if r["interface"]}, set(v3_if))
|
||||
|
||||
isis_body = extract_mim_section(self.text, "isis")
|
||||
isis_if = re.findall(r"(?im)^\s*interface\s+(\S+)\s*$", isis_body)
|
||||
rows = normalize_config_isis(
|
||||
raw_text=self.text, command="show running-config isis"
|
||||
)
|
||||
self.assertEqual({r["interface"] for r in rows}, set(isis_if))
|
||||
blob = " ".join(str(v) for r in rows for v in r.values()).lower()
|
||||
self.assertNotIn("encrypted", blob)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
326
tests/test_zte_extended_parsers.py
Normal file
326
tests/test_zte_extended_parsers.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
"""Synthetic (desensitized) tests for extended ZTE biz_state parsers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from netx_api.biz_state.collect_session import resolve_aux_command
|
||||
from netx_api.biz_state.command_match import expand_from_bindings, match_command
|
||||
from netx_api.biz_state.enrich import apply_enrich_joins
|
||||
from netx_api.biz_state.parsers.common.vrf_list import normalize_vrf_list
|
||||
from netx_api.biz_state.parsers.zte import (
|
||||
normalize_bgp_peer,
|
||||
normalize_bgp_route,
|
||||
normalize_ip_route,
|
||||
normalize_ipv6_route,
|
||||
normalize_l2vpn_pw,
|
||||
normalize_optical_brief,
|
||||
normalize_ospf_neighbor,
|
||||
normalize_vrrp,
|
||||
)
|
||||
from netx_api.biz_state.profiles import AuxCommand, get_profile, metric_field_map, reload_profiles
|
||||
from netx_api.ntc_parse import apply_rule
|
||||
|
||||
|
||||
_VRF_SAMPLE = """
|
||||
Name Default RD Protocols VRF ID
|
||||
CUST_A 100:1 ipv4 1
|
||||
CUST_B 200:2 ipv4,ipv6 2
|
||||
mng <not set> ipv4,ipv6 3
|
||||
"""
|
||||
|
||||
_BGP_V4_SUMMARY = """
|
||||
Neighbor Ver As MsgRcvd MsgSend Up/Down State/PfxRcd
|
||||
10.0.0.1 4 65001 100 200 1w0d 5
|
||||
10.0.0.2 4 65002 10 20 00:01:02 Connect
|
||||
"""
|
||||
|
||||
_BGP_V6_SUMMARY = """
|
||||
Neighbor Ver As MsgRcvd MsgSend Up/Down State/PfxRcd
|
||||
FC00:1::1
|
||||
4 65001 100 200 1w0d 0
|
||||
FC00:1::2
|
||||
4 65002 10 20 2d3h 3
|
||||
"""
|
||||
|
||||
_OSPF_SAMPLE = """
|
||||
OSPF Router with ID (1.1.1.1) (Process ID 1)
|
||||
|
||||
Neighbor ID Pri State DeadTime Address Interface
|
||||
2.2.2.2 1 FULL/-- 00:00:39 10.0.0.2 smartgroup1
|
||||
3.3.3.3 1 FULL/DR 00:00:30 10.0.0.3 gei-0/0/0/1
|
||||
|
||||
OSPF Router with ID (1.1.1.1) (Process ID 20)
|
||||
|
||||
Neighbor ID Pri State DeadTime Address Interface
|
||||
4.4.4.4 1 FULL/-- 00:00:40 10.1.0.2 smartgroup2
|
||||
"""
|
||||
|
||||
_VRRP_SAMPLE = """
|
||||
Interface vrID Pri Time A P L State Master addr VRouter addr
|
||||
vlan10 10 110 1000 P Master 10.0.0.2 10.0.0.1
|
||||
gei-0/0/0/1.100 100 90 1000 P Backup 10.0.1.2 10.0.1.1
|
||||
smartgroup1.5 5 110 1000 P Init 0.0.0.0 10.0.2.1
|
||||
"""
|
||||
|
||||
_OPTICAL_SAMPLE = """
|
||||
Interface Type Wavelength RxPower(dBm) TxPower(dBm) Status Intensity(Rx)
|
||||
gei-0/0/0/1 1G-10km-SFP 1310nm -6.0/[-20.0,-3.0] -5.9/[-9.0,-3.0] Normal Normal
|
||||
cgei-0/1/0/1 100G-LR4 1310nm -2.1/[-8.0,2.0] -1.5/[-4.0,2.0] Normal Normal
|
||||
"""
|
||||
|
||||
_BGP_ROUTE_IN = """
|
||||
Routes Learned From This Neighbor:
|
||||
Network Next Hop Metric LocPrf RtPrf Path
|
||||
* 10.1.0.0/24 10.0.0.1 20 65001 ?
|
||||
* 10.2.0.0/24 10.0.0.1 20 65001 65009 ?
|
||||
"""
|
||||
|
||||
_IP_ROUTE = """
|
||||
Dest Gw Interface Owner Pri Metric
|
||||
*> 0.0.0.0/0 10.0.0.1 smartgroup1 BGP 200 1
|
||||
*> 10.1.0.0/24 10.0.0.2 gei-0/0/0/1 connected 0 0
|
||||
"""
|
||||
|
||||
_IPV6_ROUTE = """
|
||||
Dest Protocol Pri Metric Flag
|
||||
Nexthop,Interface
|
||||
2001:db8::/32 B 200 0
|
||||
2001:db8:1::1,smartgroup1
|
||||
fc00:1::/64 C 0 0
|
||||
::,gei-0/0/0/1.10
|
||||
"""
|
||||
|
||||
_L2VPN_PW = """
|
||||
PWName PeerIP FEC PWType State Llabel Rlabel VPNOwner
|
||||
pw1 10.0.0.1 128 Ethernet H UP 100 200 L:VPN_A
|
||||
pw2 10.0.0.2 128 Ethernet S DOWN - - L:VPN_B
|
||||
pw3 10.0.0.3 128 Ethernet UP 101 201 W:100
|
||||
"""
|
||||
|
||||
|
||||
class ZteExtendedParserTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
reload_profiles()
|
||||
|
||||
def test_vrf_list_protocols(self) -> None:
|
||||
rows = normalize_vrf_list(
|
||||
raw_text=_VRF_SAMPLE,
|
||||
vendor="zte",
|
||||
device_type="zte_zxros",
|
||||
command="show ip vrf",
|
||||
)
|
||||
by = {r["vrf_name"]: r for r in rows}
|
||||
self.assertIn("CUST_A", by)
|
||||
self.assertEqual(by["CUST_A"]["protocols"], "ipv4")
|
||||
self.assertIn("ipv6", by["CUST_B"]["protocols"])
|
||||
self.assertEqual(by["CUST_B"]["vrf_id"], "2")
|
||||
|
||||
def test_bgp_peer_ipv4_and_ipv6_wrap(self) -> None:
|
||||
v4 = normalize_bgp_peer(
|
||||
raw_text=_BGP_V4_SUMMARY, command="show bgp vpnv4 unicast summary"
|
||||
)
|
||||
self.assertEqual(len(v4), 2)
|
||||
self.assertEqual(v4[0]["afi"], "vpnv4")
|
||||
self.assertEqual(v4[0]["state"], "Established")
|
||||
self.assertEqual(v4[0]["pfx_rcd"], "5")
|
||||
self.assertEqual(v4[1]["state"], "Connect")
|
||||
|
||||
v6 = normalize_bgp_peer(
|
||||
raw_text=_BGP_V6_SUMMARY, command="show bgp vpnv6 unicast summary"
|
||||
)
|
||||
self.assertEqual(len(v6), 2)
|
||||
self.assertTrue(all(r["afi"] == "vpnv6" for r in v6))
|
||||
self.assertEqual(v6[0]["neighbor"].upper(), "FC00:1::1")
|
||||
|
||||
vrf = normalize_bgp_peer(
|
||||
raw_text=_BGP_V4_SUMMARY,
|
||||
command="show bgp vpnv4 unicast vrf CUST_A summary",
|
||||
params={"vrf": "CUST_A"},
|
||||
)
|
||||
self.assertTrue(all(r["vrf"] == "CUST_A" for r in vrf))
|
||||
|
||||
def test_bgp_summary_fsm(self) -> None:
|
||||
rows = apply_rule(
|
||||
platform="zte_zxros",
|
||||
rule_key="zte_zxros_show_bgp_summary",
|
||||
text=_BGP_V4_SUMMARY,
|
||||
command="show bgp vpnv4 unicast summary",
|
||||
)
|
||||
self.assertGreaterEqual(len(rows), 2)
|
||||
|
||||
def test_ospf_vrrp_optical(self) -> None:
|
||||
ospf = normalize_ospf_neighbor(raw_text=_OSPF_SAMPLE, command="show ip ospf neighbor")
|
||||
self.assertEqual(len(ospf), 3)
|
||||
procs = {r["process_id"] for r in ospf}
|
||||
self.assertEqual(procs, {"1", "20"})
|
||||
|
||||
vrrp = normalize_vrrp(raw_text=_VRRP_SAMPLE, command="show vrrp ipv4 brief")
|
||||
self.assertEqual(len(vrrp), 3)
|
||||
self.assertTrue(all(r["af"] == "ipv4" for r in vrrp))
|
||||
states = {r["state"] for r in vrrp}
|
||||
self.assertIn("Master", states)
|
||||
|
||||
opt = normalize_optical_brief(raw_text=_OPTICAL_SAMPLE, command="show opticalinfo brief")
|
||||
self.assertEqual(len(opt), 2)
|
||||
self.assertEqual(opt[0]["status"], "Normal")
|
||||
|
||||
def test_bgp_route_and_aux_render(self) -> None:
|
||||
routes = normalize_bgp_route(
|
||||
raw_text=_BGP_ROUTE_IN,
|
||||
command="show bgp vpnv4 unicast vrf CUST_A neighbor in 10.0.0.1",
|
||||
params={"vrf": "CUST_A", "neighbor": "10.0.0.1", "direction": "in"},
|
||||
)
|
||||
self.assertEqual(len(routes), 2)
|
||||
self.assertEqual(routes[0]["network"], "10.1.0.0/24")
|
||||
self.assertEqual(routes[0]["direction"], "in")
|
||||
|
||||
peers = normalize_bgp_peer(
|
||||
raw_text=_BGP_V4_SUMMARY, command="show bgp vpnv4 unicast vrf CUST_A summary"
|
||||
)
|
||||
from netx_api.biz_state.enrich import EnrichJoin
|
||||
|
||||
apply_enrich_joins(
|
||||
routes,
|
||||
{"bgp_summary": peers},
|
||||
[EnrichJoin(from_aux="bgp_summary", on="neighbor", take=("as_num", "state", "pfx_rcd"))],
|
||||
)
|
||||
self.assertEqual(routes[0]["as_num"], "65001")
|
||||
self.assertEqual(routes[0]["state"], "Established")
|
||||
|
||||
ra = resolve_aux_command(
|
||||
AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_vrf_summary"),
|
||||
params={"vrf": "CUST_A"},
|
||||
)
|
||||
self.assertEqual(ra.command, "show bgp vpnv4 unicast vrf CUST_A summary")
|
||||
|
||||
def test_ip_ipv6_route_and_pw(self) -> None:
|
||||
ip = normalize_ip_route(
|
||||
raw_text=_IP_ROUTE,
|
||||
command="show ip forwarding route vrf CUST_A",
|
||||
params={"vrf": "CUST_A"},
|
||||
)
|
||||
self.assertEqual(len(ip), 2)
|
||||
self.assertEqual(ip[0]["dest"], "0.0.0.0/0")
|
||||
self.assertEqual(ip[0]["vrf"], "CUST_A")
|
||||
|
||||
v6 = normalize_ipv6_route(
|
||||
raw_text=_IPV6_ROUTE,
|
||||
command="show ipv6 forwarding route vrf CUST_B",
|
||||
params={"vrf": "CUST_B"},
|
||||
)
|
||||
self.assertGreaterEqual(len(v6), 1)
|
||||
self.assertTrue(any(r["dest"].startswith("2001:db8") for r in v6))
|
||||
|
||||
pw = normalize_l2vpn_pw(raw_text=_L2VPN_PW, command="show l2vpn forwardinfo")
|
||||
self.assertEqual(len(pw), 3)
|
||||
self.assertEqual(pw[0]["state"].upper(), "UP")
|
||||
self.assertEqual(pw[1]["state"].upper(), "DOWN")
|
||||
|
||||
def test_profiles_and_expand(self) -> None:
|
||||
for mid in (
|
||||
"ospf_neighbor",
|
||||
"vrrp",
|
||||
"optical_brief",
|
||||
"bgp_route",
|
||||
"ip_route",
|
||||
"ipv6_route",
|
||||
"l2vpn_pw",
|
||||
"l2vpn_mac",
|
||||
"evpn_mac",
|
||||
"interface_detail",
|
||||
):
|
||||
self.assertIn(mid, metric_field_map())
|
||||
|
||||
self.assertIsNotNone(get_profile("zte.bgp_ipv6_summary"))
|
||||
self.assertIsNotNone(get_profile("zte.bgp_vpnv4_vrf_summary"))
|
||||
self.assertIsNotNone(get_profile("zte.ospf_neighbor"))
|
||||
self.assertIsNotNone(get_profile("zte.interface_detail"))
|
||||
self.assertIsNotNone(get_profile("zte.bgp_vpnv6_neighbor_in"))
|
||||
self.assertIsNotNone(get_profile("zte.bgp_vpnv6_neighbor_out"))
|
||||
|
||||
p = get_profile("zte.bgp_vpnv4_vrf_neighbor_in")
|
||||
assert p is not None
|
||||
pairs = expand_from_bindings(
|
||||
profile=p,
|
||||
bindings=[{"vrf": "CUST_A", "neighbor": "10.0.0.1"}],
|
||||
)
|
||||
self.assertEqual(len(pairs), 1)
|
||||
cmd, params = pairs[0]
|
||||
self.assertIn("vrf CUST_A", cmd)
|
||||
self.assertIn("10.0.0.1", cmd)
|
||||
hit = match_command(vendor_key="zte", command=cmd)
|
||||
self.assertIsNotNone(hit)
|
||||
assert hit is not None
|
||||
self.assertEqual(hit.params.get("vrf"), "CUST_A")
|
||||
self.assertEqual(hit.params.get("neighbor"), "10.0.0.1")
|
||||
|
||||
def test_interface_detail_and_vpnv6_neighbor(self) -> None:
|
||||
from netx_api.biz_state.parsers.zte import normalize_interface_detail
|
||||
|
||||
sample = """
|
||||
gei-0/0/0/1 is up, ifindex: 100
|
||||
Description: uplink-a
|
||||
The port is optical
|
||||
Negotiation force
|
||||
BW 1 Gbit/s
|
||||
MTU 1600 bytes
|
||||
Rate period : 120 s
|
||||
Input : 100 bit/s 1 packet/s
|
||||
Output : 200 bit/s 2 packet/s
|
||||
Peak rate:
|
||||
Input : 0 bit/s peak time N/A
|
||||
Output : 300 bit/s peak time N/A
|
||||
Intf utilization: input 1% output 2%
|
||||
gei-0/0/0/2 is administratively down, ifindex: 101
|
||||
Description: spare
|
||||
BW 1 Gbit/s
|
||||
MTU 1500 bytes
|
||||
Rate period : 120 s
|
||||
Input : 0 bit/s 0 packet/s
|
||||
Output : 0 bit/s 0 packet/s
|
||||
Intf utilization: input 0% output 0%
|
||||
"""
|
||||
rows = normalize_interface_detail(
|
||||
raw_text=sample,
|
||||
vendor="zte",
|
||||
device_type="zte_zxros",
|
||||
command="show interface",
|
||||
)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0]["admin"], "up")
|
||||
self.assertEqual(rows[0]["input_bps"], "100")
|
||||
self.assertEqual(rows[0]["out_util"], "2")
|
||||
self.assertEqual(rows[1]["admin"], "admin-down")
|
||||
|
||||
cmd = (
|
||||
"show interface | include ifindex|BW|The port is|MTU|Negotiation|"
|
||||
"Description|Current|Rate|Peak|Input|Output|utilization"
|
||||
)
|
||||
hit = match_command(vendor_key="zte", command=cmd)
|
||||
self.assertIsNotNone(hit)
|
||||
assert hit is not None
|
||||
self.assertEqual(hit.profile.profile_id, "zte.interface_detail")
|
||||
|
||||
hit6 = match_command(
|
||||
vendor_key="zte",
|
||||
command="show bgp vpnv6 unicast neighbor in FC00:1::1 | one-line",
|
||||
)
|
||||
self.assertIsNotNone(hit6)
|
||||
assert hit6 is not None
|
||||
self.assertEqual(hit6.profile.profile_id, "zte.bgp_vpnv6_neighbor_in")
|
||||
self.assertEqual(hit6.params.get("neighbor"), "FC00:1::1")
|
||||
|
||||
routes = normalize_bgp_route(
|
||||
raw_text=_BGP_ROUTE_IN,
|
||||
command="show bgp vpnv6 unicast neighbor in FC00:1::1",
|
||||
params={"neighbor": "FC00:1::1", "direction": "in", "afi": "vpnv6"},
|
||||
)
|
||||
self.assertGreaterEqual(len(routes), 1)
|
||||
self.assertEqual(routes[0]["afi"], "vpnv6")
|
||||
self.assertEqual(routes[0]["direction"], "in")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue