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:
oliver 2026-09-19 21:11:31 +08:00
parent a65c9fc420
commit 285c19e5a3
67 changed files with 9510 additions and 723 deletions

View file

@ -796,11 +796,21 @@ def _key_in_port_map(
iface_fields: list[str],
port_map: dict[str, str],
) -> bool:
"""True when every interface segment of the key is an old-side map entry."""
"""True when every interface segment of the key is covered by the map.
Covered = exact map key, or parent (before last ``.``) is a map key.
"""
vals = _iface_values(key_str, key_fields=key_fields, iface_fields=iface_fields)
if not vals:
return False
return all(v in port_map for v in vals)
for v in vals:
if v in port_map:
continue
parent = v.rsplit(".", 1)[0] if "." in v else ""
if parent and parent in port_map:
continue
return False
return True
def _port_identity(key_fields: list[str], iface_fields: list[str]) -> bool:
@ -832,24 +842,28 @@ def _remap_key_str(
port_map: dict[str, str],
) -> str:
"""Map old-side key_str → new-side using iface segments (not whole-string only)."""
from ..biz_state.iface_normalize import resolve_mapped_iface
if not key_str:
return key_str
if not port_map:
return key_str
if key_str in port_map and (not key_fields or len(key_fields) == 1):
return port_map[key_str]
if (not key_fields or len(key_fields) == 1) and (
key_str in port_map or ("." in key_str and key_str.rsplit(".", 1)[0] in port_map)
):
return resolve_mapped_iface(key_str, port_map)
parts = str(key_str).split("|")
if key_fields and len(parts) == len(key_fields):
iface_set = {str(f) for f in iface_fields}
out: list[str] = []
for i, f in enumerate(key_fields):
v = parts[i]
if f in iface_set and v in port_map:
out.append(port_map[v])
if f in iface_set:
out.append(resolve_mapped_iface(v, port_map))
else:
out.append(v)
return "|".join(out)
return port_map.get(key_str, key_str)
return resolve_mapped_iface(key_str, port_map)
def _reverse_remap_key_str(
@ -860,24 +874,28 @@ def _reverse_remap_key_str(
rev_map: dict[str, str],
) -> str:
"""Map new-side key_str → old-side canon identity."""
from ..biz_state.iface_normalize import resolve_mapped_iface
if not key_str:
return key_str
if not rev_map:
return key_str
if key_str in rev_map and (not key_fields or len(key_fields) == 1):
return rev_map[key_str]
if (not key_fields or len(key_fields) == 1) and (
key_str in rev_map or ("." in key_str and key_str.rsplit(".", 1)[0] in rev_map)
):
return resolve_mapped_iface(key_str, rev_map)
parts = str(key_str).split("|")
if key_fields and len(parts) == len(key_fields):
iface_set = {str(f) for f in iface_fields}
out: list[str] = []
for i, f in enumerate(key_fields):
v = parts[i]
if f in iface_set and v in rev_map:
out.append(rev_map[v])
if f in iface_set:
out.append(resolve_mapped_iface(v, rev_map))
else:
out.append(v)
return "|".join(out)
return rev_map.get(key_str, key_str)
return resolve_mapped_iface(key_str, rev_map)
def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
@ -938,8 +956,14 @@ def evaluate_metric_dual(
sheet_override: dict[str, Any] | None = None,
out_of_expect: str = "strict",
sheet_id: str = "",
iface_normalize_rules: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge."""
from ..biz_state.iface_normalize import (
apply_iface_normalize_rows,
normalize_iface_rules,
)
sid = str(sheet_id or "").strip() or str(metric_id or "").strip()
expect_keys = expect_keys_for_metric(
expect, metric_id=metric_id, iface_fields=iface_fields, sheet_id=sid
@ -962,6 +986,21 @@ def evaluate_metric_dual(
success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else []
anomaly_patterns = list(ov.get("anomaly") or []) if isinstance(ov.get("anomaly"), list) else []
norm_rules = normalize_iface_rules(iface_normalize_rules)
old_baseline_rows = apply_iface_normalize_rows(
old_baseline_rows, iface_fields=iface_fields, rules=norm_rules
)
old_current_rows = apply_iface_normalize_rows(
old_current_rows, iface_fields=iface_fields, rules=norm_rules
)
new_current_rows = apply_iface_normalize_rows(
new_current_rows, iface_fields=iface_fields, rules=norm_rules
)
if new_baseline_rows is not None:
new_baseline_rows = apply_iface_normalize_rows(
new_baseline_rows, iface_fields=iface_fields, rules=norm_rules
)
old_cmp = compare_rows(
before_rows=old_baseline_rows,
after_rows=old_current_rows,
@ -970,6 +1009,7 @@ def evaluate_metric_dual(
compare_fields=compare_fields,
port_map=None,
field_rules=field_rules,
iface_normalize_rules=None, # already applied
)
old_idx = build_diff_index_from_compare(old_cmp)

View file

@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from ..models import BizCompareTemplate, BizMonitorTemplate
from ..timeutil import utcnow_naive
from ..biz_state import compare_service as cmp_svc
from ..biz_state.iface_normalize import default_zte_iface_normalize_rules
from .evaluate import PORT_METRIC_ID, PORT_STATUS_FIELDS
@ -18,13 +19,27 @@ def _utcnow():
return utcnow_naive()
def _out(row: BizMonitorTemplate, compare_name: str = "") -> dict[str, Any]:
def _out(row: BizMonitorTemplate, compare_name: str = "", *, db: Session | None = None) -> dict[str, Any]:
collect = [str(x).strip() for x in (row.collect_metric_ids_json or []) if str(x).strip()]
effective = list(collect)
if not effective and db is not None:
ct = db.get(BizCompareTemplate, row.compare_template_id) if row.compare_template_id else None
if ct:
seen: list[str] = []
for s in cmp_svc.template_metrics(ct):
mid = str(s.get("metric_id") or "").strip()
if mid and mid not in seen:
seen.append(mid)
effective = seen
if not effective:
effective = [PORT_METRIC_ID]
return {
"id": row.id,
"name": row.name,
"compare_template_id": row.compare_template_id or "",
"compare_template_name": compare_name,
"collect_metric_ids": list(row.collect_metric_ids_json or []),
"collect_metric_ids": collect,
"collect_metric_ids_effective": effective,
"defaults": dict(row.defaults_json or {}),
"sheet_overrides": list(row.sheet_overrides_json or []),
"note": row.note or "",
@ -52,6 +67,11 @@ def ensure_port_compare_template(db: Session) -> BizCompareTemplate:
"field_rules": [],
}
if row:
if not cmp_svc.template_iface_normalize(row):
cmp_svc._set_template_iface_normalize(row, default_zte_iface_normalize_rules()) # noqa: SLF001
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
return row
row = BizCompareTemplate(
id=uuid4().hex,
@ -61,6 +81,7 @@ def ensure_port_compare_template(db: Session) -> BizCompareTemplate:
updated_at=_utcnow(),
)
cmp_svc._apply_sheets_to_row(row, [sheet]) # noqa: SLF001 — shared normalizer
cmp_svc._set_template_iface_normalize(row, default_zte_iface_normalize_rules()) # noqa: SLF001
db.add(row)
db.commit()
db.refresh(row)
@ -262,68 +283,61 @@ def preset_override_for_metric(metric_id: str, *, sheet_id: str = "") -> dict[st
)
DEFAULT_MONITOR_TEMPLATE_NAME = "默认割接监控"
def ensure_default_monitor_templates(db: Session) -> None:
"""Seed once when monitor-template table is empty."""
"""Seed once when monitor-template table is empty — one template on the full status compare."""
if db.query(BizMonitorTemplate.id).limit(1).first():
return
cmp_svc.ensure_default_templates(db)
port_tpl = ensure_port_compare_template(db)
zte = (
db.query(BizCompareTemplate)
.filter(BizCompareTemplate.name == "ZTE status default")
.one_or_none()
)
seeds = [
if not zte:
return
zte_sheets = cmp_svc.template_metrics(zte)
zte_overrides = [
preset_override_for_metric(
str(s.get("metric_id") or ""),
sheet_id=str(s.get("sheet_id") or s.get("metric_id") or ""),
)
for s in zte_sheets
if s.get("metric_id")
]
db.add(
BizMonitorTemplate(
id=uuid4().hex,
name="端口割接监控",
compare_template_id=port_tpl.id,
collect_metric_ids_json=[PORT_METRIC_ID],
name=DEFAULT_MONITOR_TEMPLATE_NAME,
compare_template_id=zte.id,
collect_metric_ids_json=[],
defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"},
sheet_overrides_json=[preset_override_for_metric(PORT_METRIC_ID)],
note="Default: port status dual-verdict with up/down semantics",
sheet_overrides_json=zte_overrides,
note="Default: full status dual-verdict (port/ARP/BGP/…)",
created_at=_utcnow(),
updated_at=_utcnow(),
),
]
if zte:
zte_sheets = cmp_svc.template_metrics(zte)
zte_overrides = [
preset_override_for_metric(
str(s.get("metric_id") or ""),
sheet_id=str(s.get("sheet_id") or s.get("metric_id") or ""),
)
for s in zte_sheets
if s.get("metric_id")
]
seeds.append(
BizMonitorTemplate(
id=uuid4().hex,
name="ZTE 状态割接监控",
compare_template_id=zte.id,
collect_metric_ids_json=[],
defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"},
sheet_overrides_json=zte_overrides,
note="ZTE multi-sheet: port/ARP/BGP/… dual presets",
created_at=_utcnow(),
updated_at=_utcnow(),
)
)
for s in seeds:
db.add(s)
)
db.commit()
def default_port_monitor_template_id(db: Session) -> str:
"""Ensure seeds exist and return id of 「端口割接监控」 (or first template)."""
"""Ensure seeds exist and return the default monitor template id."""
ensure_default_monitor_templates(db)
row = (
db.query(BizMonitorTemplate)
.filter(BizMonitorTemplate.name == "端口割接监控")
.one_or_none()
)
if row:
return row.id
for name in (
DEFAULT_MONITOR_TEMPLATE_NAME,
"ZTE 状态割接监控",
"端口割接监控",
):
row = (
db.query(BizMonitorTemplate)
.filter(BizMonitorTemplate.name == name)
.one_or_none()
)
if row:
return row.id
first = db.query(BizMonitorTemplate).order_by(BizMonitorTemplate.created_at.asc()).first()
return first.id if first else ""
@ -339,7 +353,7 @@ def list_monitor_templates(db: Session) -> list[dict[str, Any]]:
ensure_default_monitor_templates(db)
names = _compare_name_map(db)
rows = db.query(BizMonitorTemplate).order_by(BizMonitorTemplate.name.asc()).all()
return [_out(r, names.get(r.compare_template_id or "", "")) for r in rows]
return [_out(r, names.get(r.compare_template_id or "", ""), db=db) for r in rows]
def get_monitor_template(db: Session, template_id: str) -> dict[str, Any]:
@ -347,7 +361,7 @@ def get_monitor_template(db: Session, template_id: str) -> dict[str, Any]:
if not row:
raise HTTPException(status_code=404, detail="monitor_template_not_found")
names = _compare_name_map(db)
return _out(row, names.get(row.compare_template_id or "", ""))
return _out(row, names.get(row.compare_template_id or "", ""), db=db)
def create_monitor_template(db: Session, body: dict[str, Any]) -> dict[str, Any]:
@ -381,7 +395,7 @@ def create_monitor_template(db: Session, body: dict[str, Any]) -> dict[str, Any]
db.commit()
db.refresh(row)
names = _compare_name_map(db)
return _out(row, names.get(row.compare_template_id or "", ""))
return _out(row, names.get(row.compare_template_id or "", ""), db=db)
def update_monitor_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]:
@ -417,7 +431,7 @@ def update_monitor_template(db: Session, template_id: str, body: dict[str, Any])
db.commit()
db.refresh(row)
names = _compare_name_map(db)
return _out(row, names.get(row.compare_template_id or "", ""))
return _out(row, names.get(row.compare_template_id or "", ""), db=db)
def delete_monitor_template(db: Session, template_id: str) -> None:

File diff suppressed because it is too large Load diff

View file

@ -17,22 +17,45 @@ router = APIRouter(prefix="/v1/biz-migration", tags=["biz-migration"])
class ProjectIn(BaseModel):
name: str
old_task_id: str
new_task_id: str
old_task_id: str = ""
new_task_id: str = ""
old_hf_task_id: str = ""
new_hf_task_id: str = ""
old_baseline_batch_id: str = ""
new_baseline_batch_id: str = ""
mapping_id: str = ""
mapping_name: str = ""
mapping_rows: list[dict[str, Any]] = Field(default_factory=list)
monitor_template_id: str = ""
status: str = "draft"
collect_metric_ids: list[str] = Field(default_factory=list)
metric_interval_sec: dict[str, int] = Field(default_factory=dict)
hf_interval_sec: int = 60
hf_start_at: str | None = None
hf_end_at: str | None = None
create_hf: bool = True
collect_now: bool = False
retention_days: int = 7
old_ne: dict[str, Any] | None = None
new_ne: dict[str, Any] | None = None
status: str = "active"
note: str = ""
class ProjectPatchIn(BaseModel):
name: str | None = None
old_task_id: str | None = None
new_task_id: str | None = None
old_hf_task_id: str | None = None
new_hf_task_id: str | None = None
old_baseline_batch_id: str | None = None
new_baseline_batch_id: str | None = None
mapping_id: str | None = None
monitor_template_id: str | None = None
collect_metric_ids: list[str] | None = None
metric_interval_sec: dict[str, int] | None = None
hf_interval_sec: int | None = None
hf_start_at: str | None = None
hf_end_at: str | None = None
status: str | None = None
note: str | None = None
@ -79,6 +102,19 @@ def api_list_projects(db: Session = Depends(get_db)):
return {"items": svc.list_projects(db)}
@router.get("/ne-portrait")
def api_ne_portrait(
source: str = "managed",
ne_id: str = "",
limit: int = 30,
db: Session = Depends(get_db),
):
"""Portrait biz_state task + recent batches for a NE (baseline picker)."""
if not str(ne_id or "").strip():
return {"source": source, "ne_id": "", "task": None, "batches": [], "hint": "ne_id_required"}
return svc.list_ne_portrait_options(db, source=source, ne_id=ne_id, limit=limit)
@router.post("/projects")
def api_create_project(body: ProjectIn, db: Session = Depends(get_db)):
return svc.create_project(db, body.model_dump())
@ -112,9 +148,14 @@ def api_baseline_expect(project_id: str, db: Session = Depends(get_db)):
class EnsureHighfreqIn(BaseModel):
interval_sec: int = 60
interval_sec: int | None = None
retention_days: int = 7
collect_now: bool = True
old_ne: dict[str, Any] | None = None
new_ne: dict[str, Any] | None = None
collect_metric_ids: list[str] | None = None
hf_start_at: str | None = None
hf_end_at: str | None = None
@router.post("/projects/{project_id}/ensure-port-highfreq")
@ -124,14 +165,28 @@ def api_ensure_port_highfreq(
body: EnsureHighfreqIn | None = None,
db: Session = Depends(get_db),
):
"""Create/bind HF biz_state tasks from monitor template collect_metric_ids."""
payload = body.model_dump() if body else {}
"""Create/bind HF biz_state tasks into hf slots (portrait slots untouched)."""
payload = body.model_dump(exclude_unset=True) if body else {}
# Persist schedule / collect override before ensure
patch: dict[str, Any] = {}
if "collect_metric_ids" in payload and payload["collect_metric_ids"] is not None:
patch["collect_metric_ids"] = payload["collect_metric_ids"]
if "hf_start_at" in payload:
patch["hf_start_at"] = payload.get("hf_start_at")
if "hf_end_at" in payload:
patch["hf_end_at"] = payload.get("hf_end_at")
if "interval_sec" in payload and payload["interval_sec"] is not None:
patch["hf_interval_sec"] = payload["interval_sec"]
if patch:
svc.patch_project(db, project_id, patch)
return svc.ensure_highfreq(
db,
project_id,
interval_sec=int(payload.get("interval_sec") or 60),
interval_sec=payload.get("interval_sec"),
retention_days=int(payload.get("retention_days") or 7),
collect_now=bool(payload.get("collect_now", True)),
old_ne=payload.get("old_ne"),
new_ne=payload.get("new_ne"),
)

View file

@ -158,10 +158,27 @@ def _persist_vrf_route_summary(
_GENERIC_METRICS = {
"isis_adjacency",
"interface_brief",
"interface_detail",
"arp",
"if_intf",
"nd6_cache",
"bgp_peer",
"ospf_neighbor",
"vrrp",
"optical_brief",
"bgp_route",
"ip_route",
"ipv6_route",
"l2vpn_pw",
"l2vpn_mac",
"evpn_mac",
"config_vrf",
"config_interface",
"config_bgp_peer",
"config_l2vpn_pw",
"config_static_route",
"config_ospf",
"config_isis",
}
_METRIC_CHUNK = 2000
@ -500,7 +517,7 @@ def _run_collect_session(
aux_results: dict[str, Any] = {}
for aux in list(hit.profile.aux_commands or []):
try:
ra = resolve_aux_command(aux)
ra = resolve_aux_command(aux, params=merged)
except ValueError as exc:
aux_row = BizStateBatchCommand(
id=uuid4().hex,
@ -534,6 +551,7 @@ def _run_collect_session(
ra.command,
parser_id=ra.parser_id,
textfsm_command=ra.textfsm_command,
params=merged,
cmd_row_id=aux_row.id,
)
aux_results[ra.key] = entry

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
import re
from .command_match import normalize_command
from .enrich import EnrichJoin, apply_enrich_joins
@ -34,8 +35,11 @@ class ResolvedAux:
profile: ParseProfile
def resolve_aux_command(aux: AuxCommand) -> ResolvedAux:
"""Resolve aux from ``profile_id`` (single source of truth)."""
def resolve_aux_command(
aux: AuxCommand,
params: dict[str, str] | None = None,
) -> ResolvedAux:
"""Resolve aux from ``profile_id``; render ``<placeholders>`` from primary params."""
key = str(aux.key or "").strip()
pid = str(aux.profile_id or "").strip()
if not key or not pid:
@ -43,10 +47,22 @@ def resolve_aux_command(aux: AuxCommand) -> ResolvedAux:
prof = get_profile(pid)
if not prof:
raise ValueError(f"aux profile not found: {pid}")
cmd = normalize_command(prof.command_template)
tmpl = str(prof.command_template or "")
rendered = tmpl
for name, val in dict(params or {}).items():
n = str(name or "").strip()
if not n:
continue
rendered = rendered.replace(f"<{n}>", str(val or "").strip())
cmd = normalize_command(rendered)
if not cmd:
raise ValueError(f"aux profile {pid} has empty command_template")
if re.search(r"<[^>]+>", cmd):
raise ValueError(f"aux profile {pid} unresolved placeholders: {cmd}")
textfsm = str(prof.textfsm_command or cmd).strip()
# Prefer concrete rendered command for FSM when aux had placeholders
if params and any(f"<{k}>" in tmpl for k in (params or {})):
textfsm = cmd
parser_id = str(prof.parser_id or "").strip()
meta = get_parser_meta(parser_id) if parser_id else None
rule_keys = tuple((meta or {}).get("rule_keys") or ())

View file

@ -5,6 +5,11 @@ from __future__ import annotations
from typing import Any, Mapping, Sequence
from .compare_rules import field_rule_map, values_equal, explain_diff
from .iface_normalize import (
apply_iface_normalize_rows,
normalize_iface_rules,
resolve_mapped_iface,
)
def apply_port_map(
@ -13,11 +18,12 @@ def apply_port_map(
iface_fields: list[str],
port_map: dict[str, str],
) -> dict[str, Any]:
"""Rewrite iface columns on a row (exact map or parent.subif inheritance)."""
out = dict(row)
for f in iface_fields:
val = str(out.get(f) or "").strip()
if val and val in port_map:
out[f] = port_map[val]
if val:
out[f] = resolve_mapped_iface(val, port_map)
return out
@ -32,7 +38,11 @@ def mapping_stats(
iface_fields: list[str],
port_map: dict[str, str],
) -> dict[str, Any]:
"""hit/miss/unused for port mapping validation."""
"""hit/miss/unused for port mapping validation.
``hit_before``: map key appears as a normalized before iface, or as the
parent of a before subinterface (so main-port-only maps still validate).
"""
before_ifaces: set[str] = set()
after_ifaces: set[str] = set()
for f in iface_fields:
@ -45,13 +55,25 @@ def mapping_stats(
if v:
after_ifaces.add(v)
before_bases: set[str] = set()
for v in before_ifaces:
before_bases.add(v)
if "." in v:
before_bases.add(v.rsplit(".", 1)[0])
after_bases: set[str] = set()
for v in after_ifaces:
after_bases.add(v)
if "." in v:
after_bases.add(v.rsplit(".", 1)[0])
mapped_before = set(port_map.keys())
mapped_after = set(port_map.values())
hit_before = sorted(mapped_before & before_ifaces)
miss_before = sorted(mapped_before - before_ifaces)
unused = sorted(mapped_before - before_ifaces) # same as miss for before presence
hit_after = sorted(mapped_after & after_ifaces)
miss_after = sorted(mapped_after - after_ifaces)
hit_before = sorted(mapped_before & before_bases)
miss_before = sorted(mapped_before - before_bases)
unused = list(miss_before)
hit_after = sorted(mapped_after & after_bases)
miss_after = sorted(mapped_after - after_bases)
return {
"before_iface_count": len(before_ifaces),
"after_iface_count": len(after_ifaces),
@ -74,13 +96,18 @@ def compare_rows(
compare_fields: list[str],
port_map: dict[str, str] | None = None,
field_rules: Sequence[Mapping[str, Any]] | None = None,
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
) -> dict[str, Any]:
"""Return summary + diffs list.
Diff kinds: added | removed | changed | unchanged
Empty ``port_map`` means ignore port/iface changes: matching keys exclude
``iface_fields`` (same-device batches compare without a rename map).
Pipeline: iface normalize (both sides) → port map (before) → match.
Empty ``port_map``: try to ignore port renames by dropping ``iface_fields``
from the match key (LLDP-style). If that would collapse distinct rows
(OSPF/VRRP/config where the same id appears on many interfaces), keep
iface columns so identity compare stays correct.
``field_rules`` drives normalize / numeric tolerance / per-field compare mode
(template-driven; no metric-specific branches here).
@ -89,24 +116,49 @@ def compare_rows(
raise ValueError("key_fields required")
pmap = dict(port_map or {})
rules = field_rule_map(field_rules)
iface_set = {str(f) for f in (iface_fields or []) if str(f).strip()}
# No map → ignore port renames: drop iface columns from the match key.
norm_rules = normalize_iface_rules(iface_normalize_rules)
iface_list = [str(f) for f in (iface_fields or []) if str(f).strip()]
iface_set = set(iface_list)
before_norm = apply_iface_normalize_rows(
before_rows, iface_fields=iface_list, rules=norm_rules
)
after_norm = apply_iface_normalize_rows(
after_rows, iface_fields=iface_list, rules=norm_rules
)
ignore_port_changes = False
# No map → optionally ignore port renames by dropping iface from match key.
if not pmap and iface_set:
match_keys = [k for k in key_fields if k not in iface_set]
if not match_keys:
candidate = [k for k in key_fields if k not in iface_set]
if not candidate:
match_keys = list(key_fields)
else:
before_c = [row_key(r, candidate) for r in before_norm]
after_c = [row_key(r, candidate) for r in after_norm]
if len(before_c) == len(set(before_c)) and len(after_c) == len(set(after_c)):
match_keys = candidate
ignore_port_changes = True
else:
# Remaining keys are not unique — iface is required for identity.
match_keys = list(key_fields)
else:
match_keys = list(key_fields)
before_mapped: list[dict[str, Any]] = [
apply_port_map(r, iface_fields=iface_fields, port_map=pmap) for r in before_rows
apply_port_map(r, iface_fields=iface_list, port_map=pmap) for r in before_norm
]
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
for r in after_rows:
after_index[row_key(r, match_keys)] = r
after_dup = 0
for r in after_norm:
k = row_key(r, match_keys)
if k in after_index:
after_dup += 1
after_index[k] = r
before_keys: set[tuple[str, ...]] = set()
before_dup = 0
diffs: list[dict[str, Any]] = []
added = removed = changed = unchanged = 0
@ -115,6 +167,8 @@ def compare_rows(
for orig, mapped in zip(before_rows, before_mapped):
k = row_key(mapped, match_keys)
if k in before_keys:
before_dup += 1
before_keys.add(k)
after = after_index.get(k)
if after is None:
@ -184,13 +238,13 @@ def compare_rows(
)
stats = mapping_stats(
before_rows=before_rows,
after_rows=after_rows,
iface_fields=iface_fields,
before_rows=before_norm,
after_rows=after_norm,
iface_fields=iface_list,
port_map=pmap,
)
if not pmap:
stats = {**stats, "ok": True, "ignore_port_changes": True}
stats = {**stats, "ok": True, "ignore_port_changes": ignore_port_changes}
return {
"summary": {
"before_count": len(before_rows),
@ -200,6 +254,8 @@ def compare_rows(
"changed": changed,
"unchanged": unchanged,
"match_key_fields": match_keys,
"duplicate_keys_before": before_dup,
"duplicate_keys_after": after_dup,
},
"diffs": diffs,
"mapping_stats": stats,

View file

@ -32,6 +32,11 @@ from .compare_rules import (
effective_compare_fields,
effective_display_fields,
)
from .iface_normalize import (
apply_iface_normalize_rows,
default_zte_iface_normalize_rules,
normalize_iface_rules,
)
from .profiles import metric_field_map
@ -397,6 +402,15 @@ def _default_sheet_for_metric(metric_id: str, *, compare_roles: tuple[str, ...]
# Context columns: show but not necessarily compare
ctx = [n for n in ("vrf", "entry_type", "age") if n not in keys and n not in compare]
extra["display_fields"] = list(keys) + list(compare) + ctx
elif metric_id in ("interface_detail", "optical_brief", "bgp_peer"):
# Counters (rates / optical power / pfx_rcd) stay visible but out of default compare.
counters = [f.name for f in fields if (not f.is_key) and f.role == "counter"]
meta = [
f.name
for f in fields
if (not f.is_key) and f.role == "meta" and f.name not in compare
]
extra["display_fields"] = list(keys) + list(compare) + counters + meta
return _sheet_def(
metric_id=metric_id,
key_fields=keys,
@ -449,6 +463,20 @@ def _bgp_afi_sheets() -> list[dict[str, Any]]:
("bgp_peer.ipv6", "BGP IPv6", "ipv6"),
("bgp_peer.vpnv4", "BGP VPNv4", "vpnv4"),
("bgp_peer.vpnv6", "BGP VPNv6", "vpnv6"),
("bgp_peer.evpn", "BGP EVPN", "evpn"),
("bgp_peer.vpls", "BGP VPLS", "vpls"),
),
op="eq",
)
def _vrrp_af_sheets() -> list[dict[str, Any]]:
return _sheets_split_by_field(
"vrrp",
"af",
(
("vrrp.ipv4", "VRRP IPv4", "ipv4"),
("vrrp.ipv6", "VRRP IPv6", "ipv6"),
),
op="eq",
)
@ -470,6 +498,7 @@ def _builtin_source_splits() -> dict[str, list[dict[str, Any]]]:
return {
"bgp_peer": _bgp_afi_sheets(),
"isis_adjacency": _isis_af_sheets(),
"vrrp": _vrrp_af_sheets(),
}
@ -477,9 +506,44 @@ def _default_zte_status_sheets() -> list[dict[str, Any]]:
return [
*_isis_af_sheets(),
_default_sheet_for_metric("interface_brief", compare_roles=("state",)),
_default_sheet_for_metric("interface_detail", compare_roles=("state",)),
_default_sheet_for_metric("arp", compare_roles=("state",)),
_default_sheet_for_metric("nd6_cache", compare_roles=("state",)),
_default_sheet_for_metric("ospf_neighbor", compare_roles=("state",)),
*_vrrp_af_sheets(),
_default_sheet_for_metric("optical_brief", compare_roles=("state",)),
*_bgp_afi_sheets(),
_default_sheet_for_metric("l2vpn_pw", compare_roles=("state",)),
_default_lldp_sheet(),
]
def _default_zte_config_sheets() -> list[dict[str, Any]]:
"""Config-intent metrics for cutover / intent-vs-intent compare."""
return [
_default_sheet_for_metric("config_vrf", compare_roles=("state",)),
_default_sheet_for_metric("config_interface", compare_roles=("state",)),
_default_sheet_for_metric("config_bgp_peer", compare_roles=("state",)),
_default_sheet_for_metric("config_l2vpn_pw", compare_roles=("state",)),
*_sheets_split_by_field(
"config_static_route",
"af",
(
("config_static_route.ipv4", "Static IPv4", "ipv4"),
("config_static_route.ipv6", "Static IPv6", "ipv6"),
),
compare_roles=("state",),
),
*_sheets_split_by_field(
"config_ospf",
"af",
(
("config_ospf.ipv4", "OSPF IPv4", "ipv4"),
("config_ospf.ipv6", "OSPF IPv6", "ipv6"),
),
compare_roles=("state",),
),
_default_sheet_for_metric("config_isis", compare_roles=("state",)),
]
@ -622,6 +686,17 @@ def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]:
]
def template_iface_normalize(t: BizCompareTemplate | None) -> list[dict[str, str]]:
"""Resolved iface type-alias rules for a compare template."""
if t is None:
return []
return normalize_iface_rules(getattr(t, "iface_normalize_json", None) or [])
def _set_template_iface_normalize(t: BizCompareTemplate, raw: Any) -> None:
t.iface_normalize_json = normalize_iface_rules(raw)
def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
sheets = template_metrics(t)
first = sheets[0] if sheets else None
@ -636,6 +711,7 @@ def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
"iface_fields": list((first or {}).get("iface_fields") or t.iface_fields or []),
"compare_fields": list((first or {}).get("compare_fields") or t.compare_fields or []),
"ignore_fields": [],
"iface_normalize_rules": template_iface_normalize(t),
"note": t.note,
"updated_at": t.updated_at.isoformat() + "Z" if t.updated_at else None,
}
@ -730,21 +806,37 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
name = "ZTE status default"
row = db.query(BizCompareTemplate).filter(BizCompareTemplate.name == name).one_or_none()
sheets = _default_zte_status_sheets()
# Volatile counters must not stay in compare_fields on upgraded installs.
_STRIP_COMPARE: dict[str, frozenset[str]] = {
"interface_detail": frozenset({"input_bps", "output_bps", "in_util", "out_util"}),
"optical_brief": frozenset({"rx_power", "tx_power"}),
"bgp_peer": frozenset({"pfx_rcd"}),
}
if row:
existing = template_metrics(row)
want = {s["metric_id"] for s in sheets}
have = {s["metric_id"] for s in existing}
changed = bool(want - have)
# Migrate ARP sheet: inject template row_filters if missing (replaces code filter)
upgraded: list[dict[str, Any]] = []
by_want = {s["metric_id"]: s for s in sheets}
for s in existing:
cur = dict(s)
if cur.get("metric_id") == "arp" and not cur.get("row_filters"):
mid = str(cur.get("metric_id") or "")
if mid == "arp" and not cur.get("row_filters"):
cur["row_filters"] = list(by_want.get("arp", {}).get("row_filters") or arp_dynamic_row_filters())
if not cur.get("field_rules") and by_want.get("arp", {}).get("field_rules"):
cur["field_rules"] = list(by_want["arp"]["field_rules"])
changed = True
strip = _STRIP_COMPARE.get(mid)
if strip:
old_cmp = list(cur.get("compare_fields") or [])
new_cmp = [f for f in old_cmp if f not in strip]
if new_cmp != old_cmp:
cur["compare_fields"] = new_cmp
want_disp = list((by_want.get(mid) or {}).get("display_fields") or [])
if want_disp:
cur["display_fields"] = want_disp
changed = True
upgraded.append(_normalize_sheet(cur) or cur)
if want - have:
for s in sheets:
@ -753,7 +845,12 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
changed = True
if changed:
_apply_sheets_to_row(row, upgraded if upgraded else sheets)
row.note = "Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP)"
row.note = "Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP/LLDP)"
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
if not template_iface_normalize(row):
_set_template_iface_normalize(row, default_zte_iface_normalize_rules())
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
@ -761,11 +858,51 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
row = BizCompareTemplate(
id=uuid4().hex,
name=name,
note="Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP)",
note="Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP/LLDP)",
created_at=_utcnow(),
updated_at=_utcnow(),
)
_apply_sheets_to_row(row, sheets)
_set_template_iface_normalize(row, default_zte_iface_normalize_rules())
db.add(row)
db.commit()
db.refresh(row)
return row
def ensure_default_zte_config_template(db: Session) -> BizCompareTemplate:
name = "ZTE config intent default"
row = db.query(BizCompareTemplate).filter(BizCompareTemplate.name == name).one_or_none()
sheets = _default_zte_config_sheets()
if row:
existing = template_metrics(row)
want = {s["metric_id"] for s in sheets}
have = {s["metric_id"] for s in existing}
if want - have:
upgraded = list(existing)
for s in sheets:
if s["metric_id"] not in have:
upgraded.append(s)
_apply_sheets_to_row(row, upgraded)
row.note = "Built-in ZTE config intent (VRF/IF/BGP/L2VPN PW)"
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
if not template_iface_normalize(row):
_set_template_iface_normalize(row, default_zte_iface_normalize_rules())
row.updated_at = _utcnow()
db.commit()
db.refresh(row)
return row
row = BizCompareTemplate(
id=uuid4().hex,
name=name,
note="Built-in ZTE config intent (VRF/IF/BGP/L2VPN PW)",
created_at=_utcnow(),
updated_at=_utcnow(),
)
_apply_sheets_to_row(row, sheets)
_set_template_iface_normalize(row, default_zte_iface_normalize_rules())
db.add(row)
db.commit()
db.refresh(row)
@ -773,16 +910,11 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
def ensure_default_templates(db: Session) -> None:
"""Seed built-ins only on first install (empty table).
"""Seed the single built-in compare template (most complete status sheet set).
Operators own templates after that — do not recreate deleted built-ins
or overwrite customized sheets on every list call.
Operators own additional templates after that — do not recreate deleted
siblings (port-only / LLDP / VRF / config) on every list call.
"""
if db.query(BizCompareTemplate.id).limit(1).first():
return
ensure_default_cutover_template(db)
ensure_default_lldp_template(db)
ensure_default_vrf_template(db)
ensure_default_zte_status_template(db)
@ -871,6 +1003,10 @@ def create_template(db: Session, body: dict[str, Any]) -> dict[str, Any]:
updated_at=_utcnow(),
)
_apply_sheets_to_row(t, sheets)
if "iface_normalize_rules" in body or "iface_normalize_json" in body:
_set_template_iface_normalize(
t, body.get("iface_normalize_rules", body.get("iface_normalize_json"))
)
db.add(t)
db.commit()
return _template_out(t)
@ -884,6 +1020,11 @@ def update_template(db: Session, template_id: str, body: dict[str, Any]) -> dict
t.name = str(body.get("name") or "")[:256]
if "note" in body:
t.note = str(body.get("note") or "")[:512]
if "iface_normalize_rules" in body or "iface_normalize_json" in body:
_set_template_iface_normalize(
t, body.get("iface_normalize_rules", body.get("iface_normalize_json"))
)
t.updated_at = _utcnow()
if any(
k in body
for k in (
@ -1105,12 +1246,22 @@ def validate_mapping(
pmap = _port_map_dict(db, mapping_id)
# Validate against first sheet that has iface fields (or first sheet)
target = next((s for s in sheets if s.get("iface_fields")), sheets[0])
before = _load_metric_rows(db, batch_id=before_batch_id, metric_id=target["metric_id"])
after = _load_metric_rows(db, batch_id=after_batch_id, metric_id=target["metric_id"])
iface_fields = list(target.get("iface_fields") or [])
norm = template_iface_normalize(tpl)
before = apply_iface_normalize_rows(
_load_metric_rows(db, batch_id=before_batch_id, metric_id=target["metric_id"]),
iface_fields=iface_fields,
rules=norm,
)
after = apply_iface_normalize_rows(
_load_metric_rows(db, batch_id=after_batch_id, metric_id=target["metric_id"]),
iface_fields=iface_fields,
rules=norm,
)
return mapping_stats(
before_rows=before,
after_rows=after,
iface_fields=list(target.get("iface_fields") or []),
iface_fields=iface_fields,
port_map=pmap,
)
@ -1232,6 +1383,7 @@ def _run_sheet(
before_batch_id: str,
after_batch_id: str,
port_map: dict[str, str],
iface_normalize_rules: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
key_fields = list(sheet.get("key_fields") or [])
iface_fields = list(sheet.get("iface_fields") or [])
@ -1261,6 +1413,7 @@ def _run_sheet(
compare_fields=compare_fields,
port_map=port_map,
field_rules=field_rules,
iface_normalize_rules=iface_normalize_rules,
)
summary = dict(result["summary"])
summary["before_raw_count"] = len(before_raw)
@ -1302,6 +1455,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
raise HTTPException(status_code=400, detail="template_has_no_metrics")
pmap = _port_map_dict(db, j.mapping_id)
norm_rules = template_iface_normalize(tpl)
sheet_results: list[dict[str, Any]] = []
agg = {
"before_count": 0,
@ -1319,6 +1473,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
before_batch_id=before_batch_id,
after_batch_id=after_batch_id,
port_map=pmap,
iface_normalize_rules=norm_rules,
)
sheet_results.append(one)
s = one["summary"]

View file

@ -0,0 +1,137 @@
"""Interface name normalize + port-map resolve (exact + parent.subif).
Normalize rules (template-owned): rewrite type aliases like ``GE`` → ``gei``.
Port map (job-owned): rewrite before → after, with subinterface suffix inheritance.
"""
from __future__ import annotations
from typing import Any, Mapping, Sequence
def normalize_iface_rules(raw: Any) -> list[dict[str, str]]:
"""Accept list of {from,to} or ``from,to`` lines; drop empties; longest ``from`` first."""
out: list[dict[str, str]] = []
if not raw:
return out
if isinstance(raw, str):
items: list[Any] = []
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "," in line:
a, b = line.split(",", 1)
items.append({"from": a.strip(), "to": b.strip()})
elif "\t" in line:
a, b = line.split("\t", 1)
items.append({"from": a.strip(), "to": b.strip()})
else:
parts = line.split()
if len(parts) >= 2:
items.append({"from": parts[0], "to": parts[1]})
raw = items
if not isinstance(raw, (list, tuple)):
return out
for item in raw:
if not isinstance(item, Mapping):
continue
fr = str(item.get("from") or item.get("src") or "").strip()
to = str(item.get("to") or item.get("dst") or "").strip()
if fr and to:
out.append({"from": fr, "to": to})
# Longest prefix first so XXVGE wins over XGE / GE.
out.sort(key=lambda r: len(r["from"]), reverse=True)
return out
def _prefix_ok(name: str, prefix: str) -> bool:
"""True when ``name`` starts with ``prefix`` (ci) and boundary is safe."""
if not name or not prefix:
return False
n = name
p = prefix
if len(n) < len(p):
return False
if n[: len(p)].lower() != p.lower():
return False
if len(n) == len(p):
return True
nxt = n[len(p)]
return nxt.isdigit() or nxt in "-/"
def normalize_iface_name(name: str, rules: Sequence[Mapping[str, str]] | None) -> str:
"""Apply first matching type-prefix rule; leave subif / rest intact."""
text = str(name or "").strip()
if not text or not rules:
return text
for rule in rules:
fr = str(rule.get("from") or "").strip()
to = str(rule.get("to") or "").strip()
if not fr or not to:
continue
if _prefix_ok(text, fr):
return to + text[len(fr) :]
return text
def apply_iface_normalize(
row: dict[str, Any],
*,
iface_fields: Sequence[str],
rules: Sequence[Mapping[str, str]] | None,
) -> dict[str, Any]:
if not rules or not iface_fields:
return dict(row)
out = dict(row)
for f in iface_fields:
key = str(f or "").strip()
if not key:
continue
val = str(out.get(key) or "").strip()
if val:
out[key] = normalize_iface_name(val, rules)
return out
def apply_iface_normalize_rows(
rows: Sequence[Mapping[str, Any]],
*,
iface_fields: Sequence[str],
rules: Sequence[Mapping[str, str]] | None,
) -> list[dict[str, Any]]:
return [
apply_iface_normalize(dict(r), iface_fields=iface_fields, rules=rules)
for r in rows
]
def resolve_mapped_iface(name: str, port_map: Mapping[str, str] | None) -> str:
"""Exact map hit, else parent + ``.suffix`` if parent mapped, else unchanged."""
text = str(name or "").strip()
if not text or not port_map:
return text
if text in port_map:
return str(port_map[text])
if "." not in text:
return text
parent, suffix = text.rsplit(".", 1)
if not parent or not suffix:
return text
if parent in port_map:
return f"{port_map[parent]}.{suffix}"
return text
def default_zte_iface_normalize_rules() -> list[dict[str, str]]:
"""Common ZTE brief aliases → canonical CLI names."""
return normalize_iface_rules(
[
{"from": "XXVGE", "to": "xxvgei"},
{"from": "XGE", "to": "xgei"},
{"from": "CGE", "to": "cgei"},
{"from": "GE", "to": "gei"},
{"from": "SG", "to": "smartgroup"},
]
)

View file

@ -2,7 +2,9 @@
LLDP already works via ``common.lldp_neighbors`` (profile ``parser_id=lldp_neighbors``).
Stub modules (not registered until implemented)::
Shared ``metric_id`` schemas live in ``profiles.py`` (ZTE is the reference
implementation). Stub modules keep normalize signatures aligned — do **not**
register in ``PARSERS`` until implemented::
interface_brief.py arp.py isis_adjacency.py nd6_cache.py bgp_peer.py

View file

@ -11,6 +11,14 @@ from .pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_ip_vrf",)
_VRF_LINE_RE = re.compile(
r"^\s*(?P<name>[A-Za-z0-9_./:-]+)\s+"
r"(?P<rd>\d+:\d+|<not set>|\S+:\S+)\s*"
r"(?:(?P<protocols>ipv4(?:,ipv6)?|ipv6(?:,ipv4)?|ipv4|ipv6)\s*)?"
r"(?P<vrf_id>\d+)?\s*$",
re.I,
)
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
@ -22,11 +30,16 @@ def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
if name in seen:
continue
seen.add(name)
protocols = row_get(r, "PROTOCOLS", "ADDRESS_FAMILY", "protocols")
# Legacy templates may put protocols in INTERFACES
if not protocols:
protocols = row_get(r, "INTERFACES", "interfaces")
out.append(
{
"vrf_name": name[:128],
"rd": row_get(r, "DEFAULT_RD", "RD", "rd")[:64],
"protocols": row_get(r, "PROTOCOLS", "ADDRESS_FAMILY", "INTERFACES", "protocols")[:64],
"rd": row_get(r, "RD", "DEFAULT_RD", "rd")[:64],
"protocols": protocols[:64],
"vrf_id": row_get(r, "VRF_ID", "vrf_id")[:32],
}
)
return out
@ -36,16 +49,23 @@ def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for line in str(raw_text or "").splitlines():
m = re.match(r"^\s*([A-Za-z0-9_./:-]+)\s+(\d+:\d+|<not set>|\S+:\S+)\s*", line)
m = _VRF_LINE_RE.match(line)
if not m:
continue
name = m.group(1)
name = m.group("name")
if name.lower() in ("name", "vrf", "vpn-instance", "total"):
continue
if name in seen:
continue
seen.add(name)
out.append({"vrf_name": name[:128], "rd": m.group(2)[:64], "protocols": ""})
out.append(
{
"vrf_name": name[:128],
"rd": (m.group("rd") or "")[:64],
"protocols": (m.group("protocols") or "")[:64],
"vrf_id": (m.group("vrf_id") or "")[:32],
}
)
return out

View file

@ -2,11 +2,16 @@
LLDP already works via ``common.lldp_neighbors`` (``display lldp neighbor``).
Stub modules (not registered until implemented)::
Shared ``metric_id`` / FieldDef schemas (cross-vendor compare) are defined in
``profiles.py`` for ZTE first. Stub modules below keep the same normalize
signatures for future Huawei implementations — do **not** register in
``PARSERS`` until TextFSM + prefer_fsm paths exist (avoids NotImplementedError
at collect time)::
interface_brief.py arp.py isis_adjacency.py nd6_cache.py bgp_peer.py
When ready, import and add to ``PARSERS``.
When ready, import and add to ``PARSERS``. Prefer same field names as ZTE
(``bgp_peer``, ``ospf_neighbor``, ``vrrp``, ``ip_route``, …).
"""
from __future__ import annotations

View file

@ -10,20 +10,54 @@ from typing import Any, Callable
from .arp import is_valid_arp_age, normalize_arp
from .bgp_peer import normalize_bgp_peer
from .bgp_route import normalize_bgp_route
from .config_bgp_peer import normalize_config_bgp_peer
from .config_interface import normalize_config_interface
from .config_isis import normalize_config_isis
from .config_l2vpn_pw import normalize_config_l2vpn_pw
from .config_ospf import normalize_config_ospf
from .config_static_route import normalize_config_static_route
from .config_vrf import normalize_config_vrf
from .evpn_mac import normalize_evpn_mac
from .if_intf import normalize_if_intf, parse_if_intf_vrf_map
from .interface_brief import normalize_interface_brief
from .interface_detail import normalize_interface_detail
from .ip_route import normalize_ip_route
from .ipv6_route import normalize_ipv6_route
from .isis_adjacency import normalize_isis_adjacency
from .l2vpn_mac import normalize_l2vpn_mac
from .l2vpn_pw import normalize_l2vpn_pw
from .nd6_cache import normalize_nd6_cache
from .optical_brief import normalize_optical_brief
from .ospf_neighbor import normalize_ospf_neighbor
from .vrrp import normalize_vrrp
NormalizeFn = Callable[..., list[dict[str, Any]]]
PARSERS: dict[str, NormalizeFn] = {
"isis_adjacency": normalize_isis_adjacency,
"interface_brief": normalize_interface_brief,
"interface_detail": normalize_interface_detail,
"arp": normalize_arp,
"if_intf": normalize_if_intf,
"nd6_cache": normalize_nd6_cache,
"bgp_peer": normalize_bgp_peer,
"ospf_neighbor": normalize_ospf_neighbor,
"vrrp": normalize_vrrp,
"optical_brief": normalize_optical_brief,
"bgp_route": normalize_bgp_route,
"ip_route": normalize_ip_route,
"ipv6_route": normalize_ipv6_route,
"l2vpn_pw": normalize_l2vpn_pw,
"l2vpn_mac": normalize_l2vpn_mac,
"evpn_mac": normalize_evpn_mac,
"config_vrf": normalize_config_vrf,
"config_interface": normalize_config_interface,
"config_bgp_peer": normalize_config_bgp_peer,
"config_l2vpn_pw": normalize_config_l2vpn_pw,
"config_static_route": normalize_config_static_route,
"config_ospf": normalize_config_ospf,
"config_isis": normalize_config_isis,
}
__all__ = [
@ -31,9 +65,26 @@ __all__ = [
"is_valid_arp_age",
"normalize_arp",
"normalize_bgp_peer",
"normalize_bgp_route",
"normalize_config_bgp_peer",
"normalize_config_interface",
"normalize_config_isis",
"normalize_config_l2vpn_pw",
"normalize_config_ospf",
"normalize_config_static_route",
"normalize_config_vrf",
"normalize_evpn_mac",
"normalize_if_intf",
"normalize_interface_brief",
"normalize_interface_detail",
"normalize_ip_route",
"normalize_ipv6_route",
"normalize_isis_adjacency",
"normalize_l2vpn_mac",
"normalize_l2vpn_pw",
"normalize_nd6_cache",
"normalize_optical_brief",
"normalize_ospf_neighbor",
"normalize_vrrp",
"parse_if_intf_vrf_map",
]

View file

@ -1,23 +1,39 @@
"""ZTE: show bgp {vpnv4|ipv4|vpnv6} unicast summary."""
"""ZTE: show bgp {afi} [unicast] [vrf <vrf>] summary (and l2vpn summaries)."""
from __future__ import annotations
import re
from typing import Any
from typing import Any, Mapping
_BGP_PEER_RE = re.compile(
r"^(?P<nei>\d{1,3}(?:\.\d{1,3}){3})\s+(?P<ver>\d+)\s+(?P<asn>\d+)\s+"
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_bgp_summary",)
_PEER_LINE_RE = re.compile(
r"^(?P<nei>\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f:]+)\s+"
r"(?P<ver>\d+)\s+(?P<asn>\S+)\s+"
r"(?P<rx>\d+)\s+(?P<tx>\d+)\s+(?P<up>\S+)\s+(?P<state>\S+)\s*$",
re.I,
)
RULE_KEYS: tuple[str, ...] = ()
_NEI_ONLY_RE = re.compile(r"^(?P<nei>[0-9A-Fa-f:]+)\s*$", re.I)
_CONT_RE = re.compile(
r"^\s+(?P<ver>\d+)\s+(?P<asn>\S+)\s+"
r"(?P<rx>\d+)\s+(?P<tx>\d+)\s+(?P<up>\S+)\s+(?P<state>\S+)\s*$",
re.I,
)
_VRF_IN_CMD_RE = re.compile(r"(?i)\bvrf\s+(\S+)")
def _detect_bgp_afi(command: str, params: dict[str, str] | None) -> str:
if params and params.get("afi"):
return str(params.get("afi") or "").strip().lower()
low = str(command or "").lower()
if "l2vpn" in low and "evpn" in low:
return "evpn"
if "l2vpn" in low and "vpls" in low:
return "vpls"
if "vpnv6" in low:
return "vpnv6"
if "vpnv4" in low:
@ -29,55 +45,186 @@ def _detect_bgp_afi(command: str, params: dict[str, str] | None) -> str:
return "unknown"
def _detect_vrf(command: str, params: dict[str, str] | None) -> str:
if params and params.get("vrf"):
return str(params.get("vrf") or "").strip()
m = _VRF_IN_CMD_RE.search(str(command or ""))
return m.group(1).strip() if m else ""
def _state_and_pfx(state_raw: str) -> tuple[str, str]:
raw = str(state_raw or "").strip()
if raw.isdigit():
return "Established", raw
# e.g. (NoNeg)
return raw, ""
def _peer_row(
*,
afi: str,
vrf: str,
nei: str,
ver: str,
asn: str,
rx: str,
tx: str,
up: str,
state_raw: str,
) -> dict[str, Any]:
state, pfx = _state_and_pfx(state_raw)
return {
"afi": afi[:32],
"vrf": vrf[:128],
"neighbor": nei[:128],
"ver": ver[:8],
"as_num": asn[:16],
"msg_rcvd": rx[:32],
"msg_send": tx[:32],
"up_down": up[:32],
"state": state[:64],
"pfx_rcd": pfx[:32],
}
def _map_fsm_rows(
rows: list[dict[str, Any]],
*,
afi: str,
vrf: str,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
pending = ""
for r in rows:
nei = row_get(r, "NEIGHBOR", "neighbor")
ver = row_get(r, "VER", "ver")
if nei and not ver:
pending = nei
continue
if not nei and pending and ver:
nei = pending
pending = ""
if not nei or not ver:
continue
if nei in seen:
continue
seen.add(nei)
state_raw = row_get(r, "STATE_PFX", "STATE", "state", "pfx_rcd")
out.append(
_peer_row(
afi=afi,
vrf=vrf,
nei=nei,
ver=ver,
asn=row_get(r, "ASN", "AS", "as_num"),
rx=row_get(r, "MSG_RCVD", "msg_rcvd"),
tx=row_get(r, "MSG_SEND", "msg_send"),
up=row_get(r, "UP_DOWN", "up_down"),
state_raw=state_raw,
)
)
return out
def _hand_parse(
*,
raw_text: str,
afi: str = "unknown",
vrf: str = "",
**_kw: Any,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
pending = ""
for raw in str(raw_text or "").splitlines():
line = raw.rstrip()
if not line.strip():
continue
low = line.strip().lower()
if low.startswith("bgp router") or low.startswith("local as") or low.startswith("all "):
continue
if low.startswith("neighbor") and "msg" in low:
continue
m = _PEER_LINE_RE.match(line.strip())
if m:
nei = m.group("nei")
if nei in seen:
continue
seen.add(nei)
pending = ""
out.append(
_peer_row(
afi=afi,
vrf=vrf,
nei=nei,
ver=m.group("ver"),
asn=m.group("asn"),
rx=m.group("rx"),
tx=m.group("tx"),
up=m.group("up"),
state_raw=m.group("state"),
)
)
continue
m_only = _NEI_ONLY_RE.match(line.strip())
if m_only:
pending = m_only.group("nei")
continue
m_cont = _CONT_RE.match(line)
if m_cont and pending:
nei = pending
pending = ""
if nei in seen:
continue
seen.add(nei)
out.append(
_peer_row(
afi=afi,
vrf=vrf,
nei=nei,
ver=m_cont.group("ver"),
asn=m_cont.group("asn"),
rx=m_cont.group("rx"),
tx=m_cont.group("tx"),
up=m_cont.group("up"),
state_raw=m_cont.group("state"),
)
)
return out
def normalize_bgp_peer(
*,
raw_text: str,
fsm_tables=None,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = (vendor, device_type, fsm_tables)
afi = _detect_bgp_afi(command, params)
out: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in str(raw_text or "").splitlines():
line = raw.strip()
if not line:
continue
low = line.lower()
if low.startswith("bgp router") or low.startswith("local as") or low.startswith("all "):
continue
if low.startswith("neighbor") and "msg" in low:
continue
m = _BGP_PEER_RE.match(line)
if not m:
continue
nei = m.group("nei")
if nei in seen:
continue
seen.add(nei)
state_raw = m.group("state")
if state_raw.isdigit():
state = "Established"
pfx = state_raw
else:
state = state_raw
pfx = ""
out.append(
{
"afi": afi[:32],
"neighbor": nei[:64],
"ver": m.group("ver")[:8],
"as_num": m.group("asn")[:16],
"msg_rcvd": m.group("rx")[:32],
"msg_send": m.group("tx")[:32],
"up_down": m.group("up")[:32],
"state": state[:64],
"pfx_rcd": pfx[:32],
}
vrf = _detect_vrf(command, params)
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
return out
cmd = str(command or "show bgp summary").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _map_fsm_rows(rows, afi=afi, vrf=vrf)
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
return _hand_parse(raw_text=raw_text, afi=afi, vrf=vrf, **kw)
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
normalize_bgp_peer.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,199 @@
"""ZTE: show bgp … neighbor {in|out} [vrf] routes."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
from .bgp_peer import _detect_bgp_afi, _detect_vrf
RULE_KEYS = ("zte_zxros_show_bgp_neighbor_routes",)
_ROUTE_RE = re.compile(
r"^\s*(?P<flags>[*<>isd]*)\s*"
r"(?P<net>\d{1,3}(?:\.\d{1,3}){3}/\d+|[0-9A-Fa-f:]+(?:/\d+)?)\s+"
r"(?P<nh>\S+)\s+"
r"(?P<rest>.*)$"
)
_DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+")
_NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)")
def _detect_direction(command: str, params: dict[str, str] | None) -> str:
if params and params.get("direction"):
return str(params.get("direction") or "").strip().lower()
m = _DIR_RE.search(str(command or ""))
return m.group(1).lower() if m else ""
def _detect_neighbor(command: str, params: dict[str, str] | None) -> str:
if params and params.get("neighbor"):
return str(params.get("neighbor") or "").strip()
m = _NEI_RE.search(str(command or ""))
return m.group(1).strip() if m else ""
def _split_rest(rest: str) -> tuple[str, str, str, str]:
"""Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank)."""
parts = str(rest or "").split()
if not parts:
return "", "", "", ""
# Last token(s) are AS path + origin; path ends with i|e|?
path = " ".join(parts)
metric = loc = tag = ""
# Heuristic: numeric-only leading fields are metric/loc/tag when present
nums: list[str] = []
path_parts: list[str] = []
for p in parts:
if not path_parts and re.fullmatch(r"\d+", p):
nums.append(p)
else:
path_parts.append(p)
if len(nums) >= 1:
metric = nums[0]
if len(nums) >= 2:
loc = nums[1]
if len(nums) >= 3:
tag = nums[2]
path = " ".join(path_parts)
return metric, loc, tag, path
def _map_fsm_rows(
rows: list[dict[str, Any]],
*,
afi: str,
vrf: str,
neighbor: str,
direction: str,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for r in rows:
net = row_get(r, "NETWORK", "network")
if not net or net.lower() == "network" or net in seen:
continue
seen.add(net)
path = row_get(r, "PATH", "path")
out.append(
{
"afi": afi[:32],
"vrf": vrf[:128],
"neighbor": neighbor[:128],
"direction": direction[:8],
"network": net[:128],
"next_hop": row_get(r, "NEXT_HOP", "next_hop")[:128],
"metric": row_get(r, "METRIC", "metric")[:32],
"loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32],
"tag": row_get(r, "TAG", "RT_PRF", "tag")[:32],
"path": path[:256],
"status_codes": "",
"as_num": "",
"state": "",
"pfx_rcd": "",
}
)
return out
def _hand_parse(
*,
raw_text: str,
afi: str = "unknown",
vrf: str = "",
neighbor: str = "",
direction: str = "",
**_kw: Any,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in str(raw_text or "").splitlines():
line = raw.rstrip()
if not line.strip():
continue
low = line.strip().lower()
if low.startswith("network") or "next hop" in low:
continue
if low.startswith("status") or low.startswith("origin") or low.startswith("routes "):
continue
if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "):
continue
if low.startswith("route distinguisher"):
continue
m = _ROUTE_RE.match(line)
if not m:
continue
net = m.group("net")
if net in seen:
continue
seen.add(net)
metric, loc, tag, path = _split_rest(m.group("rest"))
out.append(
{
"afi": afi[:32],
"vrf": vrf[:128],
"neighbor": neighbor[:128],
"direction": direction[:8],
"network": net[:128],
"next_hop": m.group("nh")[:128],
"metric": metric[:32],
"loc_prf": loc[:32],
"tag": tag[:32],
"path": path[:256],
"status_codes": (m.group("flags") or "").strip()[:16],
"as_num": "",
"state": "",
"pfx_rcd": "",
}
)
return out
def normalize_bgp_route(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
afi = _detect_bgp_afi(command, params)
vrf = _detect_vrf(command, params)
neighbor = _detect_neighbor(command, params)
direction = _detect_direction(command, params)
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show bgp neighbor routes").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _map_fsm_rows(
rows, afi=afi, vrf=vrf, neighbor=neighbor, direction=direction
)
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
return _hand_parse(
raw_text=raw_text,
afi=afi,
vrf=vrf,
neighbor=neighbor,
direction=direction,
**kw,
)
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
normalize_bgp_route.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,216 @@
"""ZTE config intent: ``show running-config bgp`` / MIM ``!<bgp>``.
Emits one row per ``(afi, vrf, neighbor|peer_group)`` activation under
address-family. IP literals go in ``neighbor``; non-IP names (peer-groups)
go in ``peer_group``. Never captures password / secret lines.
Route-maps under an address-family are scoped to that ``(afi, vrf)``;
global (top-level) route-maps apply to all AF rows for that neighbor and
are overlaid by AF-specific maps when both exist.
"""
from __future__ import annotations
import re
from typing import Any, Mapping
from .config_common import extract_mim_section, is_secret_line
RULE_KEYS: tuple[str, ...] = ()
_NEI_REMOTE_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+remote-as\s+(\S+)\s*$", re.I)
_NEI_UPD_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+update-source\s+(\S+)\s*$", re.I)
_NEI_PG_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+peer-group(?:\s+(\S+))?\s*$", re.I)
_NEI_RM_RE = re.compile(
r"^\s*neighbor\s+(\S+)\s+route-map\s+(\S+)\s+(in|out)\s*$", re.I
)
_NEI_ACT_RE = re.compile(
r"^\s*neighbor\s+(\S+)\s+activate(?:\s+(disable))?\s*$", re.I
)
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
_AfKey = tuple[str, str, str] # (afi, vrf, token)
def _is_ip_neighbor(token: str) -> bool:
"""True for IPv4/IPv6 literals; false for peer-group names (even with ':')."""
tok = str(token or "").strip()
if not tok:
return False
if _IPV4_RE.match(tok):
return True
# IPv6 must look like an address, not ``asn:name`` peer-group labels.
if ":" not in tok:
return False
try:
import ipaddress
ipaddress.ip_address(tok.split("%", 1)[0])
return True
except ValueError:
return False
def _split_neighbor_cols(token: str, info: Mapping[str, str]) -> tuple[str, str]:
"""Return ``(neighbor, peer_group)`` columns for a neighbor token."""
tok = str(token or "").strip()
if _is_ip_neighbor(tok):
return tok, str(info.get("peer_group") or "")
# Non-IP token is a peer-group name
return "", tok
def _merge_info(
token: str,
*,
afi: str,
vrf: str,
meta: Mapping[str, dict[str, str]],
af_rm: Mapping[_AfKey, dict[str, str]],
) -> dict[str, str]:
"""Global neighbor meta + AF-scoped route-maps (AF wins on conflict)."""
info = dict(meta.get(token) or {})
scoped = af_rm.get((afi, vrf, token)) or {}
for key in ("route_map_in", "route_map_out"):
if scoped.get(key):
info[key] = scoped[key]
return info
def _row(
*,
afi: str,
vrf: str,
token: str,
act: str,
info: Mapping[str, str],
) -> dict[str, Any]:
neighbor, peer_group = _split_neighbor_cols(token, info)
return {
"afi": afi[:32],
"vrf": vrf[:128],
"neighbor": neighbor[:128],
"peer_group": peer_group[:64],
"remote_as": str(info.get("remote_as") or "")[:16],
"activate": act[:16],
"update_source": str(info.get("update_source") or "")[:64],
"route_map_in": str(info.get("route_map_in") or "")[:128],
"route_map_out": str(info.get("route_map_out") or "")[:128],
}
def normalize_config_bgp_peer(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = (fsm_tables, vendor, device_type, command, params)
body = extract_mim_section(raw_text, "bgp")
meta: dict[str, dict[str, str]] = {}
af_rm: dict[_AfKey, dict[str, str]] = {}
afi = ""
vrf = ""
activations: list[tuple[str, str, str, str]] = []
# (afi, vrf, token, activate)
for raw in body.splitlines():
line = raw.rstrip()
if is_secret_line(line):
continue
m = _NEI_REMOTE_RE.match(line)
if m:
nei = m.group(1).strip()
meta.setdefault(nei, {})
meta[nei]["remote_as"] = m.group(2).strip()
continue
m = _NEI_UPD_RE.match(line)
if m:
nei = m.group(1).strip()
meta.setdefault(nei, {})
meta[nei]["update_source"] = m.group(2).strip()
continue
m = _NEI_PG_RE.match(line)
if m:
nei = m.group(1).strip()
meta.setdefault(nei, {})
group = (m.group(2) or "").strip()
if group:
# neighbor <ip> peer-group <name>
meta[nei]["peer_group"] = group
else:
# neighbor <name> peer-group → peer-group definition
meta[nei]["is_group"] = "1"
continue
m = _NEI_RM_RE.match(line)
if m and not afi:
nei = m.group(1).strip()
meta.setdefault(nei, {})
direction = m.group(3).lower()
key = "route_map_in" if direction == "in" else "route_map_out"
meta[nei][key] = m.group(2).strip()
continue
m = re.match(r"^\s*address-family\s+ipv4\s+vrf\s+(\S+)\s*$", line, re.I)
if m:
afi, vrf = "ipv4", m.group(1).strip()
continue
m = re.match(r"^\s*address-family\s+ipv6\s+vrf\s+(\S+)\s*$", line, re.I)
if m:
afi, vrf = "ipv6", m.group(1).strip()
continue
m = re.match(r"^\s*address-family\s+l2vpn\s+(\S+)\s*$", line, re.I)
if m:
afi, vrf = f"l2vpn-{m.group(1).lower()}", ""
continue
m = re.match(r"^\s*address-family\s+(\S+)\s*$", line, re.I)
if m:
afi, vrf = m.group(1).lower(), ""
continue
if re.match(r"^\s*\$\s*$", line) and afi:
if line.startswith(" ") and not line.startswith(" "):
afi, vrf = "", ""
continue
m = _NEI_ACT_RE.match(line)
if m and afi:
nei = m.group(1).strip()
act = "disable" if m.group(2) else "enable"
activations.append((afi, vrf, nei, act))
continue
m = _NEI_RM_RE.match(line)
if m and afi:
nei = m.group(1).strip()
direction = m.group(3).lower()
rk = "route_map_in" if direction == "in" else "route_map_out"
slot = af_rm.setdefault((afi, vrf, nei), {})
slot[rk] = m.group(2).strip()
out: list[dict[str, Any]] = []
seen: set[tuple[str, str, str, str]] = set()
for afi_s, vrf_s, token, act in activations:
info = _merge_info(token, afi=afi_s, vrf=vrf_s, meta=meta, af_rm=af_rm)
row = _row(afi=afi_s, vrf=vrf_s, token=token, act=act, info=info)
key = (row["afi"], row["vrf"], row["neighbor"], row["peer_group"])
if key in seen:
continue
seen.add(key)
out.append(row)
# Global peers with remote-as but no AF activate
for token, info in meta.items():
if not info.get("remote_as"):
continue
if any(n == token for _, _, n, _ in activations):
continue
row = _row(afi="global", vrf="", token=token, act="", info=info)
key = (row["afi"], row["vrf"], row["neighbor"], row["peer_group"])
if key in seen:
continue
seen.add(key)
out.append(row)
return out
normalize_config_bgp_peer.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,46 @@
"""Helpers for ZTE MIM running-config section parsing."""
from __future__ import annotations
import re
_MIM_OPEN = re.compile(r"^!<([^/>][^>]*)>\s*$")
_MIM_CLOSE = re.compile(r"^!</([^>]+)>\s*$")
def extract_mim_section(raw_text: str, section: str) -> str:
"""Return body of ``!<section>`` … ``!</section>`` if present; else original text."""
name = str(section or "").strip().lower()
if not name:
return str(raw_text or "")
text = str(raw_text or "")
lines = text.splitlines()
start = -1
for i, line in enumerate(lines):
m = _MIM_OPEN.match(line.strip())
if m and m.group(1).strip().lower() == name:
start = i + 1
break
if start < 0:
return text
out: list[str] = []
for line in lines[start:]:
m = _MIM_CLOSE.match(line.strip())
if m and m.group(1).strip().lower() == name:
break
out.append(line)
return "\n".join(out)
def is_secret_line(line: str) -> bool:
low = str(line or "").lower()
return any(
k in low
for k in (
"password",
"secret",
"encrypted",
"authentication-key",
"pre-shared-key",
)
)

View file

@ -0,0 +1,148 @@
"""ZTE config intent: ``show running-config if-intf`` / MIM ``!<if-intf>``.
Duplicate ``interface`` blocks (common for tunnels) are merged: later values
override empty ones; ``admin`` always takes the last explicit setting.
"""
from __future__ import annotations
import re
from typing import Any, Mapping
from .config_common import extract_mim_section, is_secret_line
RULE_KEYS: tuple[str, ...] = ()
_IFACE_RE = re.compile(r"^\s*interface\s+(\S+)\s*$", re.I)
_VRF_RE = re.compile(r"^\s*ip\s+vrf\s+forwarding\s+(\S+)\s*$", re.I)
_IP_RE = re.compile(r"^\s*ip\s+address\s+(\S+)(?:\s+(\S+))?\s*$", re.I)
_IP6_RE = re.compile(r"^\s*ipv6\s+address\s+(\S+)\s*$", re.I)
_DESC_RE = re.compile(r"^\s*description\s+(.*?)\s*$", re.I)
_MTU_RE = re.compile(r"^\s*mtu\s+(\d+)\s*$", re.I)
def _row_from_cur(cur: dict[str, Any]) -> dict[str, Any]:
ips = cur.get("_ips") or []
ip6s = cur.get("_ip6s") or []
return {
"interface": str(cur.get("interface") or "")[:128],
"vrf": str(cur.get("vrf") or "")[:128],
"description": str(cur.get("description") or "")[:256],
"admin": str(cur.get("admin") or "up")[:16],
"ip_address": ",".join(ips)[:256],
"ipv6_address": ",".join(ip6s)[:256],
"mtu": str(cur.get("mtu") or "")[:16],
"_admin_set": bool(cur.get("_admin_set")),
}
def _flush(
cur: dict[str, Any] | None,
out: list[dict[str, Any]],
by_iface: dict[str, int],
) -> None:
if not cur:
return
iface = str(cur.get("interface") or "")
if not iface:
return
row = _row_from_cur(cur)
admin_set = bool(row.pop("_admin_set", False))
if iface in by_iface:
prev = out[by_iface[iface]]
# Only override admin when this block explicitly set shutdown / no shutdown.
if admin_set:
prev["admin"] = row["admin"]
for key in ("vrf", "description", "mtu"):
if row[key]:
prev[key] = row[key]
if row["ip_address"]:
existing = [x for x in (prev["ip_address"] or "").split(",") if x]
for ip in row["ip_address"].split(","):
if ip and ip not in existing:
existing.append(ip)
prev["ip_address"] = ",".join(existing)[:256]
if row["ipv6_address"]:
existing = [x for x in (prev["ipv6_address"] or "").split(",") if x]
for ip in row["ipv6_address"].split(","):
if ip and ip not in existing:
existing.append(ip)
prev["ipv6_address"] = ",".join(existing)[:256]
return
by_iface[iface] = len(out)
out.append(row)
def normalize_config_interface(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = (fsm_tables, vendor, device_type, command, params)
body = extract_mim_section(raw_text, "if-intf")
out: list[dict[str, Any]] = []
by_iface: dict[str, int] = {}
cur: dict[str, Any] | None = None
for raw in body.splitlines():
line = raw.rstrip()
if is_secret_line(line):
continue
m = _IFACE_RE.match(line)
if m:
_flush(cur, out, by_iface)
cur = {
"interface": m.group(1).strip(),
"vrf": "",
"description": "",
"admin": "up",
"_admin_set": False,
"mtu": "",
"_ips": [],
"_ip6s": [],
}
continue
if not cur:
continue
if re.match(r"^\$\s*$", line):
_flush(cur, out, by_iface)
cur = None
continue
low = line.strip().lower()
if low == "shutdown":
cur["admin"] = "down"
cur["_admin_set"] = True
continue
if low == "no shutdown":
cur["admin"] = "up"
cur["_admin_set"] = True
continue
m = _VRF_RE.match(line)
if m:
cur["vrf"] = m.group(1).strip()
continue
m = _DESC_RE.match(line)
if m:
cur["description"] = (m.group(1) or "").strip().strip('"')[:256]
continue
m = _IP_RE.match(line)
if m:
addr = m.group(1).strip()
mask = (m.group(2) or "").strip()
cur["_ips"].append(f"{addr}/{mask}" if mask else addr)
continue
m = _IP6_RE.match(line)
if m:
cur["_ip6s"].append(m.group(1).strip())
continue
m = _MTU_RE.match(line)
if m:
cur["mtu"] = m.group(1)
_flush(cur, out, by_iface)
return out
normalize_config_interface.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,210 @@
"""ZTE config intent: IS-IS from ``!<isis>`` / ``show running-config isis``.
One row per ``(process, vrf, interface)``. Process-level attrs (area, system-id,
router-id, is-type) are copied onto each interface row.
Never captures hello-authentication / encrypted secrets.
"""
from __future__ import annotations
import re
from typing import Any, Mapping
from .config_common import extract_mim_section, is_secret_line
RULE_KEYS: tuple[str, ...] = ()
_PROC_RE = re.compile(
r"(?i)^\s*router\s+isis\s+(\S+)(?:\s+vrf\s+(\S+))?\s*$"
)
_AREA_RE = re.compile(r"(?i)^\s*area\s+(\S+)\s*$")
_SYS_RE = re.compile(r"(?i)^\s*system-id\s+(\S+)\s*$")
_RID_RE = re.compile(r"(?i)^\s*router-id\s+(\S+)\s*$")
_ISTYPE_RE = re.compile(r"(?i)^\s*is-type\s+(\S+)\s*$")
_IFACE_RE = re.compile(r"(?i)^\s*interface\s+(\S+)\s*$")
_CIRCUIT_RE = re.compile(r"(?i)^\s*circuit-type\s+(\S+)\s*$")
_NET_RE = re.compile(r"(?i)^\s*network\s+(\S+)\s*$")
_METRIC_RE = re.compile(r"(?i)^\s*metric\s+(\d+)\s*$")
_V6_METRIC_RE = re.compile(r"(?i)^\s*ipv6\s+metric\s+(\d+)\s*$")
_DOLLAR_RE = re.compile(r"^(\s*)\$\s*$")
def _indent(spaces: str) -> int:
return len(spaces.expandtabs(2))
def _flush(
proc: dict[str, Any] | None,
iface: dict[str, Any] | None,
out: list[dict[str, Any]],
seen: set[tuple[str, ...]],
) -> None:
if not proc or not iface:
return
key = (proc["process_id"], proc["vrf"], iface["interface"])
if key in seen:
return
seen.add(key)
out.append(
{
"process_id": str(proc["process_id"])[:32],
"vrf": str(proc.get("vrf") or "")[:128],
"area": str(proc.get("area") or "")[:64],
"system_id": str(proc.get("system_id") or "")[:64],
"router_id": str(proc.get("router_id") or "")[:64],
"is_type": str(proc.get("is_type") or "")[:32],
"interface": str(iface.get("interface") or "")[:128],
"circuit_type": str(iface.get("circuit_type") or "")[:32],
"network_type": str(iface.get("network_type") or "")[:32],
"ip_enable": str(iface.get("ip_enable") or "")[:8],
"ipv6_enable": str(iface.get("ipv6_enable") or "")[:8],
"metric": str(iface.get("metric") or "")[:16],
"ipv6_metric": str(iface.get("ipv6_metric") or "")[:16],
"passive": str(iface.get("passive") or "")[:8],
"bfd": str(iface.get("bfd") or "")[:16],
"ipv6_bfd": str(iface.get("ipv6_bfd") or "")[:16],
}
)
def normalize_config_isis(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = (fsm_tables, vendor, device_type, command, params)
body = extract_mim_section(raw_text, "isis")
out: list[dict[str, Any]] = []
seen: set[tuple[str, ...]] = set()
proc: dict[str, Any] | None = None
iface: dict[str, Any] | None = None
in_af = False
af_indent = 0
for raw in body.splitlines():
line = raw.rstrip()
if is_secret_line(line):
continue
m = _PROC_RE.match(line)
if m:
_flush(proc, iface, out, seen)
iface = None
in_af = False
af_indent = 0
proc = {
"process_id": m.group(1).strip(),
"vrf": (m.group(2) or "").strip(),
"area": "",
"system_id": "",
"router_id": "",
"is_type": "",
}
continue
if not proc:
continue
# Skip address-family subtree (nested $ must not close the process).
if in_af:
m = _DOLLAR_RE.match(line)
if m and _indent(m.group(1)) <= af_indent:
in_af = False
af_indent = 0
continue
if re.match(r"(?i)^\s*address-family\s+", line):
in_af = True
af_indent = len(line) - len(line.lstrip(" \t"))
continue
m = _DOLLAR_RE.match(line)
if m:
ind = _indent(m.group(1))
if iface is not None and ind >= 2:
_flush(proc, iface, out, seen)
iface = None
continue
_flush(proc, iface, out, seen)
iface = None
proc = None
continue
if iface is not None:
m = _CIRCUIT_RE.match(line)
if m:
iface["circuit_type"] = m.group(1).lower()
continue
m = _NET_RE.match(line)
if m:
iface["network_type"] = m.group(1).lower()
continue
m = _V6_METRIC_RE.match(line)
if m:
iface["ipv6_metric"] = m.group(1)
continue
m = _METRIC_RE.match(line)
if m:
iface["metric"] = m.group(1)
continue
low = line.strip().lower()
if low == "ip router isis" or low.startswith("ip router isis "):
iface["ip_enable"] = "yes"
continue
if low == "ipv6 router isis" or low.startswith("ipv6 router isis "):
iface["ipv6_enable"] = "yes"
continue
if low == "passive-mode" or low == "passive":
iface["passive"] = "yes"
continue
if low == "bfd-enable" or low.startswith("bfd-enable"):
iface["bfd"] = "enable"
continue
if low == "ipv6 bfd-enable" or low.startswith("ipv6 bfd-enable"):
iface["ipv6_bfd"] = "enable"
continue
continue
m = _IFACE_RE.match(line)
if m:
_flush(proc, iface, out, seen)
iface = {
"interface": m.group(1).strip(),
"circuit_type": "",
"network_type": "",
"ip_enable": "",
"ipv6_enable": "",
"metric": "",
"ipv6_metric": "",
"passive": "",
"bfd": "",
"ipv6_bfd": "",
}
continue
m = _AREA_RE.match(line)
if m:
proc["area"] = m.group(1).strip()
continue
m = _SYS_RE.match(line)
if m:
proc["system_id"] = m.group(1).strip()
continue
m = _RID_RE.match(line)
if m:
# prefer IPv4-looking router-id at process level
rid = m.group(1).strip()
if ":" not in rid or not proc.get("router_id"):
proc["router_id"] = rid
continue
m = _ISTYPE_RE.match(line)
if m:
proc["is_type"] = m.group(1).lower()
continue
_flush(proc, iface, out, seen)
return out
normalize_config_isis.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,140 @@
"""ZTE config intent: ``show running-config l2vpn`` / MIM ``!<l2vpn>``.
One row per ``pseudo-wire`` / ``backup-pw`` under ``vpws`` / ``vpls``.
"""
from __future__ import annotations
import re
from typing import Any, Mapping
from .config_common import extract_mim_section, is_secret_line
RULE_KEYS: tuple[str, ...] = ()
_VPN_RE = re.compile(r'^\s*(vpws|vpls)\s+("?)([^"\s]+)\2\s*$', re.I)
_DESC_RE = re.compile(r"^\s*description\s+(.*?)\s*$", re.I)
_AP_RE = re.compile(r"^\s*access-point\s+(\S+)\s*$", re.I)
_PW_RE = re.compile(r"^\s*pseudo-wire\s+(\S+)", re.I)
_BACKUP_PW_RE = re.compile(r"^\s*backup-pw\s+(\S+)", re.I)
_NEI_RE = re.compile(
r"^\s*neighbou?r\s+(\S+)\s+vcid\s+(\S+)\s*$",
re.I,
)
_ENC_RE = re.compile(r"^\s*encapsulation\s+(\S+)\s*$", re.I)
def _flush_pw(
cur_vpn: dict[str, Any] | None,
cur_pw: dict[str, Any] | None,
out: list[dict[str, Any]],
seen: set[tuple[str, str]],
) -> None:
if not cur_vpn or not cur_pw:
return
pw = str(cur_pw.get("pw_name") or "")
vpn = str(cur_vpn.get("vpn_name") or "")
key = (vpn, pw)
if not pw or not vpn or key in seen:
return
seen.add(key)
out.append(
{
"vpn_type": str(cur_vpn.get("vpn_type") or "")[:16],
"vpn_name": vpn[:128],
"pw_name": pw[:128],
"peer": str(cur_pw.get("peer") or "")[:64],
"vcid": str(cur_pw.get("vcid") or "")[:64],
"encapsulation": str(cur_pw.get("encapsulation") or "")[:32],
"access_point": str(cur_vpn.get("access_point") or "")[:128],
"description": str(cur_vpn.get("description") or "")[:256],
}
)
def _start_pw(
cur_vpn: dict[str, Any] | None,
cur_pw: dict[str, Any] | None,
out: list[dict[str, Any]],
seen: set[tuple[str, str]],
pw_name: str,
) -> dict[str, Any]:
_flush_pw(cur_vpn, cur_pw, out, seen)
return {"pw_name": pw_name.strip(), "peer": "", "vcid": "", "encapsulation": ""}
def normalize_config_l2vpn_pw(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = (fsm_tables, vendor, device_type, command, params)
body = extract_mim_section(raw_text, "l2vpn")
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
cur_vpn: dict[str, Any] | None = None
cur_pw: dict[str, Any] | None = None
for raw in body.splitlines():
line = raw.rstrip()
if is_secret_line(line):
continue
m = _VPN_RE.match(line)
if m:
_flush_pw(cur_vpn, cur_pw, out, seen)
cur_pw = None
cur_vpn = {
"vpn_type": m.group(1).lower(),
"vpn_name": m.group(3).strip(),
"description": "",
"access_point": "",
}
continue
if not cur_vpn:
continue
if re.match(r"^\$\s*$", line):
_flush_pw(cur_vpn, cur_pw, out, seen)
cur_pw = None
cur_vpn = None
continue
m = _DESC_RE.match(line)
if m and cur_pw is None:
cur_vpn["description"] = (m.group(1) or "").strip().strip('"')[:256]
continue
m = _AP_RE.match(line)
if m:
cur_vpn["access_point"] = m.group(1).strip()
continue
m = _PW_RE.match(line)
if m:
cur_pw = _start_pw(cur_vpn, cur_pw, out, seen, m.group(1))
continue
m = _BACKUP_PW_RE.match(line)
if m:
cur_pw = _start_pw(cur_vpn, cur_pw, out, seen, m.group(1))
continue
# indented $ closes nested block; flush PW once peer is known so
# backup-pw / redundancy neighbours do not overwrite the primary.
if re.match(r"^\s+\$\s*$", line):
if cur_pw and cur_pw.get("peer"):
_flush_pw(cur_vpn, cur_pw, out, seen)
cur_pw = None
continue
if not cur_pw:
continue
m = _NEI_RE.match(line)
if m:
cur_pw["peer"] = m.group(1).strip()
cur_pw["vcid"] = m.group(2).strip()
continue
m = _ENC_RE.match(line)
if m:
cur_pw["encapsulation"] = m.group(1).strip()
_flush_pw(cur_vpn, cur_pw, out, seen)
return out
normalize_config_l2vpn_pw.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,267 @@
"""ZTE config intent: OSPFv2/v3 from ``!<ospfv2>`` / ``!<ospfv3>``.
One row per ``(af, process, vrf, area, interface)``. Areas with no interfaces
still emit a row with empty ``interface``. Redistribute is process-scoped and
applied when the process block closes (it often appears after areas).
Never captures digest keys / encrypted auth material.
"""
from __future__ import annotations
import re
from typing import Any, Mapping
from .config_common import extract_mim_section, is_secret_line
RULE_KEYS: tuple[str, ...] = ()
_V2_RE = re.compile(r"(?i)^\s*router\s+ospf\s+(\S+)(?:\s+vrf\s+(\S+))?\s*$")
_V3_RE = re.compile(r"(?i)^\s*ipv6\s+router\s+ospf\s+(\S+)(?:\s+vrf\s+(\S+))?\s*$")
_RID_RE = re.compile(r"(?i)^\s*router-id\s+(\S+)\s*$")
_AREA_RE = re.compile(r"(?i)^\s*area\s+(\S+)\s*$")
_IFACE_RE = re.compile(r"(?i)^\s*interface\s+(\S+)\s*$")
_COST_RE = re.compile(r"(?i)^\s*cost\s+(\d+)\s*$")
_HELLO_RE = re.compile(r"(?i)^\s*hello-interval\s+(\d+)\s*$")
_DEAD_RE = re.compile(r"(?i)^\s*dead-interval\s+(\d+)\s*$")
_NET_RE = re.compile(r"(?i)^\s*network\s+(\S+)\s*$")
_REDIST_RE = re.compile(r"(?i)^\s*redistribute\s+(\S+)")
_DOLLAR_RE = re.compile(r"^(\s*)\$\s*$")
def _indent(spaces: str) -> int:
return len(spaces.expandtabs(2))
def _queue_iface(
proc: dict[str, Any],
area: dict[str, Any],
iface: dict[str, Any],
) -> None:
proc.setdefault("_pending", []).append(
{
"area": str(area.get("area") or ""),
"area_type": str(area.get("area_type") or "normal"),
"interface": str(iface.get("interface") or ""),
"network_type": str(iface.get("network_type") or ""),
"cost": str(iface.get("cost") or ""),
"hello_interval": str(iface.get("hello_interval") or ""),
"dead_interval": str(iface.get("dead_interval") or ""),
"bfd": str(iface.get("bfd") or ""),
}
)
def _emit_process(
proc: dict[str, Any] | None,
out: list[dict[str, Any]],
seen: set[tuple[str, ...]],
) -> None:
if not proc:
return
redist = ",".join(sorted(proc.get("_redistribute") or []))[:256]
for item in proc.get("_pending") or []:
key = (
proc["af"],
proc["process_id"],
proc["vrf"],
item["area"],
item["interface"],
)
if key in seen:
continue
seen.add(key)
out.append(
{
"af": proc["af"][:8],
"process_id": str(proc["process_id"])[:32],
"vrf": str(proc.get("vrf") or "")[:128],
"router_id": str(proc.get("router_id") or "")[:64],
"area": item["area"][:64],
"area_type": item["area_type"][:32],
"interface": item["interface"][:128],
"network_type": item["network_type"][:32],
"cost": item["cost"][:16],
"hello_interval": item["hello_interval"][:16],
"dead_interval": item["dead_interval"][:16],
"bfd": item["bfd"][:16],
"redistribute": redist,
}
)
def _close_area(proc: dict[str, Any] | None, area: dict[str, Any] | None) -> None:
if not proc or not area:
return
if int(area.get("_iface_count") or 0) == 0:
_queue_iface(
proc,
area,
{
"interface": "",
"network_type": "",
"cost": "",
"hello_interval": "",
"dead_interval": "",
"bfd": "",
},
)
def _parse_ospf_body(body: str, *, af: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, ...]] = set()
proc: dict[str, Any] | None = None
area: dict[str, Any] | None = None
iface: dict[str, Any] | None = None
head_re = _V3_RE if af == "ipv6" else _V2_RE
for raw in body.splitlines():
line = raw.rstrip()
if is_secret_line(line):
continue
m = head_re.match(line)
if m:
if iface and proc and area:
_queue_iface(proc, area, iface)
_close_area(proc, area)
_emit_process(proc, out, seen)
iface = None
area = None
proc = {
"af": af,
"process_id": m.group(1).strip(),
"vrf": (m.group(2) or "").strip(),
"router_id": "",
"_redistribute": set(),
"_pending": [],
}
continue
if not proc:
continue
m = _DOLLAR_RE.match(line)
if m:
ind = _indent(m.group(1))
if iface is not None and ind >= 4:
_queue_iface(proc, area, iface) # type: ignore[arg-type]
iface = None
elif area is not None and ind >= 2:
if iface and area:
_queue_iface(proc, area, iface)
iface = None
_close_area(proc, area)
area = None
else:
if iface and area:
_queue_iface(proc, area, iface)
iface = None
_close_area(proc, area)
area = None
_emit_process(proc, out, seen)
proc = None
continue
if iface is not None:
m = _COST_RE.match(line)
if m:
iface["cost"] = m.group(1)
continue
m = _HELLO_RE.match(line)
if m:
iface["hello_interval"] = m.group(1)
continue
m = _DEAD_RE.match(line)
if m:
iface["dead_interval"] = m.group(1)
continue
m = _NET_RE.match(line)
if m:
iface["network_type"] = m.group(1).lower()
continue
low = line.strip().lower()
if low == "bfd" or low.startswith("bfd interval") or low.startswith("bfd enable"):
iface["bfd"] = "enable"
continue
if low == "bfd disable":
iface["bfd"] = "disable"
continue
continue
m = _AREA_RE.match(line)
if m:
if iface and area:
_queue_iface(proc, area, iface)
iface = None
_close_area(proc, area)
area = {
"area": m.group(1).strip(),
"area_type": "normal",
"_iface_count": 0,
}
continue
if area is not None:
m = _IFACE_RE.match(line)
if m:
if iface:
_queue_iface(proc, area, iface)
iface = {
"interface": m.group(1).strip(),
"network_type": "",
"cost": "",
"hello_interval": "",
"dead_interval": "",
"bfd": "",
}
area["_iface_count"] = int(area.get("_iface_count") or 0) + 1
continue
low = line.strip().lower()
if low.startswith("stub"):
area["area_type"] = "stub"
continue
if low.startswith("nssa"):
area["area_type"] = "nssa"
continue
# process-level (also after areas closed)
if iface is None:
m = _RID_RE.match(line)
if m:
proc["router_id"] = m.group(1).strip()
continue
m = _REDIST_RE.match(line)
if m:
proc["_redistribute"].add(m.group(1).lower())
continue
if iface and proc and area:
_queue_iface(proc, area, iface)
_close_area(proc, area)
_emit_process(proc, out, seen)
return out
def normalize_config_ospf(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = (fsm_tables, vendor, device_type, params)
cmd = str(command or "").lower()
text = str(raw_text or "")
if "ospfv3" in cmd:
return _parse_ospf_body(extract_mim_section(text, "ospfv3"), af="ipv6")
if "ospfv2" in cmd or re.search(r"(?i)running-config\s+ospf(?:v2)?\b", cmd):
return _parse_ospf_body(extract_mim_section(text, "ospfv2"), af="ipv4")
if re.search(r"(?i)^!<ospfv3>\s*$", text, re.M) and not re.search(
r"(?i)^!<ospfv2>\s*$", text, re.M
):
return _parse_ospf_body(extract_mim_section(text, "ospfv3"), af="ipv6")
return _parse_ospf_body(extract_mim_section(text, "ospfv2"), af="ipv4")
normalize_config_ospf.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,283 @@
"""ZTE config intent: static routes from ``!<static>`` / ``!<ipv6-static-route>``.
Commands:
``show running-config static``
``show running-config ipv6-static-route``
"""
from __future__ import annotations
import re
from typing import Any, Mapping
from .config_common import extract_mim_section, is_secret_line
RULE_KEYS: tuple[str, ...] = ()
_V4_HEAD = re.compile(
r"(?i)^\s*ip\s+route(?:\s+vrf\s+(?P<vrf>\S+))?\s+"
r"(?P<prefix>\d{1,3}(?:\.\d{1,3}){3})\s+"
r"(?P<mask>\d{1,3}(?:\.\d{1,3}){3})\s+"
r"(?P<rest>.*)$"
)
_V6_HEAD = re.compile(
r"(?i)^\s*ipv6\s+route(?:\s+vrf\s+(?P<vrf>\S+))?\s+"
r"(?P<prefix>\S+)\s+"
r"(?P<rest>.*)$"
)
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
_IFACE_RE = re.compile(
r"(?i)^(null\d*|gei-\S+|xgei-\S+|cgei-\S+|bvi\S*|vlan\S*|smartgroup\S*|"
r"loopback\S*|mgmt\S*|pos\S*|eth\S*|tunnel\S*|irb\S*|ve\S*)$"
)
_KW = frozenset(
{
"name",
"track",
"tag",
"metric",
"distance",
"preference",
"nexthop-vrf",
"bfd",
"enable",
"disable",
"permanent",
}
)
def _is_ipv4(tok: str) -> bool:
return bool(_IPV4_RE.match(tok or ""))
def _is_iface(tok: str) -> bool:
return bool(_IFACE_RE.match(tok or ""))
def _take_kw(tokens: list[str], i: int, row: dict[str, str]) -> int:
"""Consume optional keyword/value pairs starting at ``i``; return new index."""
n = len(tokens)
while i < n:
low = tokens[i].lower()
if low in ("bfd",):
# bfd [enable|disable]
row["bfd"] = "enable"
i += 1
if i < n and tokens[i].lower() in ("enable", "disable"):
row["bfd"] = tokens[i].lower()
i += 1
continue
if low in ("enable", "disable", "permanent"):
if low == "permanent":
row["permanent"] = "yes"
i += 1
continue
if low in ("name", "track", "tag", "metric", "distance", "preference", "nexthop-vrf"):
if i + 1 >= n:
break
val = tokens[i + 1]
if low == "nexthop-vrf":
row["nexthop_vrf"] = val
elif low == "name":
row["route_name"] = val
else:
row[low] = val
i += 2
continue
break
return i
def _parse_v4_rest(rest: str) -> dict[str, str]:
tokens = (rest or "").split()
row: dict[str, str] = {
"next_hop": "",
"interface": "",
"nexthop_vrf": "",
"metric": "",
"track": "",
"tag": "",
"route_name": "",
"bfd": "",
"distance": "",
}
if not tokens:
return row
i = 0
# nexthop-vrf without explicit IP
if tokens[0].lower() == "nexthop-vrf" and len(tokens) >= 2:
row["nexthop_vrf"] = tokens[1]
_take_kw(tokens, 2, row)
return row
if _is_iface(tokens[0]):
row["interface"] = tokens[0]
i = 1
if i < len(tokens) and _is_ipv4(tokens[i]) and tokens[i].lower() not in _KW:
row["next_hop"] = tokens[i]
i += 1
elif _is_ipv4(tokens[0]):
row["next_hop"] = tokens[0]
i = 1
_take_kw(tokens, i, row)
return row
def _parse_v6_rest(rest: str) -> dict[str, str]:
tokens = (rest or "").split()
row: dict[str, str] = {
"next_hop": "",
"interface": "",
"nexthop_vrf": "",
"metric": "",
"track": "",
"tag": "",
"route_name": "",
"bfd": "",
"distance": "",
}
if not tokens:
return row
i = 0
if tokens[0].lower() == "nexthop-vrf" and len(tokens) >= 2:
row["nexthop_vrf"] = tokens[1]
_take_kw(tokens, 2, row)
return row
if _is_iface(tokens[0]):
row["interface"] = tokens[0]
i = 1
if i < len(tokens) and ":" in tokens[i] and tokens[i].lower() not in _KW:
row["next_hop"] = tokens[i]
i += 1
elif ":" in tokens[0]:
row["next_hop"] = tokens[0]
i = 1
_take_kw(tokens, i, row)
return row
def _row(
*,
af: str,
vrf: str,
prefix: str,
mask: str,
extra: dict[str, str],
) -> dict[str, Any]:
return {
"af": af[:8],
"vrf": (vrf or "")[:128],
"prefix": prefix[:128],
"mask": (mask or "")[:64],
"next_hop": (extra.get("next_hop") or "")[:64],
"interface": (extra.get("interface") or "")[:128],
"nexthop_vrf": (extra.get("nexthop_vrf") or "")[:128],
"metric": (extra.get("metric") or "")[:16],
"bfd": (extra.get("bfd") or "")[:16],
"track": (extra.get("track") or "")[:64],
"route_name": (extra.get("route_name") or "")[:128],
"tag": (extra.get("tag") or "")[:32],
"distance": (extra.get("distance") or extra.get("preference") or "")[:16],
}
def _parse_v4(body: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, ...]] = set()
for raw in body.splitlines():
line = raw.rstrip()
if is_secret_line(line):
continue
m = _V4_HEAD.match(line)
if not m:
continue
extra = _parse_v4_rest(m.group("rest") or "")
row = _row(
af="ipv4",
vrf=(m.group("vrf") or "").strip(),
prefix=m.group("prefix").strip(),
mask=m.group("mask").strip(),
extra=extra,
)
key = (
row["vrf"],
row["prefix"],
row["mask"],
row["next_hop"],
row["interface"],
row["nexthop_vrf"],
)
if key in seen:
continue
seen.add(key)
out.append(row)
return out
def _parse_v6(body: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, ...]] = set()
for raw in body.splitlines():
line = raw.rstrip()
if is_secret_line(line):
continue
m = _V6_HEAD.match(line)
if not m:
continue
extra = _parse_v6_rest(m.group("rest") or "")
row = _row(
af="ipv6",
vrf=(m.group("vrf") or "").strip(),
prefix=m.group("prefix").strip(),
mask="",
extra=extra,
)
key = (
row["vrf"],
row["prefix"],
row["next_hop"],
row["interface"],
row["nexthop_vrf"],
)
if key in seen:
continue
seen.add(key)
out.append(row)
return out
def _detect_af(command: str, raw_text: str) -> str:
cmd = str(command or "").lower()
if "ipv6-static" in cmd or "ipv6 static" in cmd:
return "ipv6"
if re.search(r"(?i)^\s*show\s+running-config\s+static\b", cmd):
return "ipv4"
# Full dump / ambiguous: prefer section presence
text = str(raw_text or "")
if re.search(r"(?i)^!<ipv6-static-route>\s*$", text, re.M) and not re.search(
r"(?i)^!<static>\s*$", text, re.M
):
return "ipv6"
return "ipv4"
def normalize_config_static_route(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = (fsm_tables, vendor, device_type, params)
af = _detect_af(command, raw_text)
if af == "ipv6":
body = extract_mim_section(raw_text, "ipv6-static-route")
return _parse_v6(body)
body = extract_mim_section(raw_text, "static")
return _parse_v4(body)
normalize_config_static_route.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,107 @@
"""ZTE config intent: ``show running-config vrf`` / MIM ``!<vrf>``."""
from __future__ import annotations
import re
from typing import Any, Mapping
from .config_common import extract_mim_section, is_secret_line
RULE_KEYS: tuple[str, ...] = ()
_VRF_RE = re.compile(r"^\s*ip\s+vrf\s+(\S+)\s*$", re.I)
_RD_RE = re.compile(r"^\s*rd\s+(\S+)\s*$", re.I)
_DESC_RE = re.compile(r'^\s*description\s+("?)(.*?)\1\s*$', re.I)
_AF_RE = re.compile(r"^\s*address-family\s+(ipv4|ipv6)\s*$", re.I)
_RT_RE = re.compile(r"^\s*route-target\s+(export|import)\s+(\S+)\s*$", re.I)
def _flush(cur: dict[str, Any] | None, out: list[dict[str, Any]], seen: set[str]) -> None:
if not cur:
return
name = str(cur.get("vrf_name") or "")
if not name or name in seen:
return
seen.add(name)
afs = sorted(cur.get("_afs") or [])
exports = sorted(cur.get("_rt_export") or [])
imports = sorted(cur.get("_rt_import") or [])
out.append(
{
"vrf_name": name[:128],
"rd": str(cur.get("rd") or "")[:64],
"description": str(cur.get("description") or "")[:256],
"address_families": ",".join(afs)[:64],
"rt_export": ",".join(exports)[:512],
"rt_import": ",".join(imports)[:512],
}
)
def normalize_config_vrf(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = (fsm_tables, vendor, device_type, command, params)
body = extract_mim_section(raw_text, "vrf")
out: list[dict[str, Any]] = []
seen: set[str] = set()
cur: dict[str, Any] | None = None
for raw in body.splitlines():
line = raw.rstrip()
if is_secret_line(line):
continue
m = _VRF_RE.match(line)
if m:
_flush(cur, out, seen)
cur = {
"vrf_name": m.group(1).strip(),
"rd": "",
"description": "",
"_afs": set(),
"_rt_export": set(),
"_rt_import": set(),
}
continue
if not cur:
continue
if line.strip() == "$" and not line.startswith(" "):
# top-level close for vrf block (indent 0 $)
if not line.startswith(" ") and not line.startswith("\t"):
_flush(cur, out, seen)
cur = None
continue
# ZTE uses bare `$` at column 0 to end blocks; nested `$` are indented
if re.match(r"^\$\s*$", line):
_flush(cur, out, seen)
cur = None
continue
m = _RD_RE.match(line)
if m:
cur["rd"] = m.group(1).strip()
continue
m = _DESC_RE.match(line)
if m:
cur["description"] = (m.group(2) or "").strip()[:256]
continue
m = _AF_RE.match(line)
if m:
cur["_afs"].add(m.group(1).lower())
continue
m = _RT_RE.match(line)
if m:
direction, value = m.group(1).lower(), m.group(2).strip()
if direction == "export":
cur["_rt_export"].add(value)
else:
cur["_rt_import"].add(value)
_flush(cur, out, seen)
return out
normalize_config_vrf.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,119 @@
"""ZTE: show bgp evpn mac."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_bgp_evpn_mac",)
_ROUTE_RE = re.compile(
r"^\s*(?P<flags>[*>isd]*)\s*"
r"(?P<net>\S+)\s+(?P<nh>\S+)\s+(?P<rest>.*)$"
)
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for r in rows:
net = row_get(r, "NETWORK", "network")
if not net or net.lower() == "network" or net in seen:
continue
seen.add(net)
out.append(
{
"network": net[:256],
"next_hop": row_get(r, "NEXT_HOP", "next_hop")[:128],
"metric": row_get(r, "METRIC", "metric")[:32],
"loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32],
"rt_prf": row_get(r, "RT_PRF", "rt_prf")[:32],
"path": row_get(r, "PATH", "path")[:256],
"status_codes": "",
}
)
return out
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in str(raw_text or "").splitlines():
line = raw.rstrip()
if not line.strip():
continue
low = line.strip().lower()
if low.startswith("network") or low.startswith("status") or low.startswith("origin"):
continue
if low.startswith("route distinguisher"):
continue
m = _ROUTE_RE.match(line)
if not m:
continue
net = m.group("net")
if "/" not in net and ":" not in net and not re.search(r"[0-9a-fA-F]{4}\.", net):
# skip non-NLRI-looking tokens
if not re.match(r"^\[", net):
continue
if net in seen:
continue
seen.add(net)
rest = m.group("rest").split()
metric = loc = rt = ""
path_parts: list[str] = []
for p in rest:
if not path_parts and re.fullmatch(r"\d+", p):
if not metric:
metric = p
elif not loc:
loc = p
elif not rt:
rt = p
else:
path_parts.append(p)
else:
path_parts.append(p)
out.append(
{
"network": net[:256],
"next_hop": m.group("nh")[:128],
"metric": metric[:32],
"loc_prf": loc[:32],
"rt_prf": rt[:32],
"path": " ".join(path_parts)[:256],
"status_codes": (m.group("flags") or "").strip()[:16],
}
)
return out
def normalize_evpn_mac(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = params
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show bgp evpn mac").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
normalize_evpn_mac.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,195 @@
"""ZTE: filtered ``show interface | include …`` (rate / util / BW)."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_interface",)
_IF_HDR_RE = re.compile(
r"^(?P<iface>\S+)\s+is\s+(?P<state>administratively\s+down|up|down)"
r"(?:,\s*ifindex:\s*(?P<ifindex>\d+))?\s*$",
re.I,
)
_DESC_RE = re.compile(r"^\s*Description:\s*(?P<desc>.*?)\s*$", re.I)
_BW_RE = re.compile(r"^\s*BW\s+(?P<bw>.+?)\s*$", re.I)
_MTU_RE = re.compile(r"^\s*MTU\s+(?P<mtu>\d+)\s+bytes", re.I)
_MEDIA_RE = re.compile(r"^\s*The\s+port\s+is\s+(?P<media>\S+)", re.I)
_NEG_RE = re.compile(r"^\s*Negotiation\s+(?P<neg>\S+)", re.I)
_PERIOD_RE = re.compile(r"^\s*Rate\s+period\s*:\s*(?P<p>\d+)", re.I)
_IN_RE = re.compile(r"^\s*Input\s*:\s*(?P<bps>[\d.]+)\s*bit/s", re.I)
_OUT_RE = re.compile(r"^\s*Output\s*:\s*(?P<bps>[\d.]+)\s*bit/s", re.I)
_UTIL_RE = re.compile(
r"^\s*Intf\s+utilization:\s*input\s*(?P<inu>[\d.]+)%\s+output\s*(?P<outu>[\d.]+)%",
re.I,
)
def _empty_row(iface: str) -> dict[str, Any]:
return {
"interface": iface[:128],
"admin": "",
"ifindex": "",
"description": "",
"port_media": "",
"negotiation": "",
"bw": "",
"mtu": "",
"rate_period": "",
"input_bps": "",
"output_bps": "",
"in_util": "",
"out_util": "",
}
def _normalize_admin(state: str) -> str:
s = str(state or "").strip().lower()
# Guard against FSM greedily eating ", ifindex:"
if "," in s:
s = s.split(",", 1)[0].strip()
if "administratively" in s:
return "admin-down"
if s == "up":
return "up"
if s == "down":
return "down"
return s[:32]
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for r in rows:
iface = row_get(r, "IFNAME", "interface")
in_util = row_get(r, "IN_UTIL", "in_util")
# Incomplete FSM rows (no util yet) are filldown noise — skip
if not iface or not in_util or iface in seen:
continue
seen.add(iface)
admin_raw = row_get(r, "ADMIN_STATE", "ADMIN_OPER", "admin")
out.append(
{
"interface": iface[:128],
"admin": _normalize_admin(admin_raw)[:32],
"ifindex": row_get(r, "IFINDEX", "ifindex")[:32],
"description": row_get(r, "DESCRIPTION", "description")[:256],
"port_media": row_get(r, "PORT_MEDIA", "port_media")[:32],
"negotiation": row_get(r, "NEGOTIATION", "negotiation")[:32],
"bw": row_get(r, "BW_RAW", "bw")[:64],
"mtu": row_get(r, "MTU", "mtu")[:16],
"rate_period": row_get(r, "RATE_PERIOD", "rate_period")[:16],
"input_bps": row_get(r, "INPUT_BPS", "input_bps")[:32],
"output_bps": row_get(r, "OUTPUT_BPS", "output_bps")[:32],
"in_util": in_util[:16],
"out_util": row_get(r, "OUT_UTIL", "out_util")[:16],
}
)
return out
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
cur: dict[str, Any] | None = None
in_rates = False
saw_input = False
def _flush() -> None:
nonlocal cur
if cur and cur.get("interface"):
out.append(cur)
cur = None
for raw in str(raw_text or "").splitlines():
line = raw.rstrip()
if not line.strip():
continue
m = _IF_HDR_RE.match(line.strip())
if m:
_flush()
cur = _empty_row(m.group("iface"))
cur["admin"] = _normalize_admin(m.group("state"))
cur["ifindex"] = (m.group("ifindex") or "")[:32]
in_rates = False
saw_input = False
continue
if not cur:
continue
m = _DESC_RE.match(line)
if m:
cur["description"] = (m.group("desc") or "").strip()[:256]
continue
m = _MEDIA_RE.match(line)
if m:
cur["port_media"] = m.group("media")[:32]
continue
m = _NEG_RE.match(line)
if m:
cur["negotiation"] = m.group("neg")[:32]
continue
m = _BW_RE.match(line)
if m:
cur["bw"] = m.group("bw").strip()[:64]
continue
m = _MTU_RE.match(line)
if m:
cur["mtu"] = m.group("mtu")[:16]
continue
m = _PERIOD_RE.match(line)
if m:
cur["rate_period"] = m.group("p")[:16]
in_rates = True
saw_input = False
continue
if in_rates and "Peak rate" in line:
continue
m = _IN_RE.match(line)
if m and in_rates and not saw_input:
cur["input_bps"] = m.group("bps")[:32]
saw_input = True
continue
m = _OUT_RE.match(line)
if m and in_rates and saw_input and not cur.get("output_bps"):
cur["output_bps"] = m.group("bps")[:32]
continue
m = _UTIL_RE.match(line)
if m:
cur["in_util"] = m.group("inu")[:16]
cur["out_util"] = m.group("outu")[:16]
in_rates = False
continue
_flush()
return out
def normalize_interface_detail(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = params
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show interface").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
normalize_interface_detail.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,107 @@
"""ZTE: show ip forwarding route [vrf <vrf>]."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
from .bgp_peer import _detect_vrf
RULE_KEYS = ("zte_zxros_show_ip_forwarding_route",)
_ROUTE_RE = re.compile(
r"^\s*(?P<flags>[*>R]*)\s*"
r"(?P<dest>\d{1,3}(?:\.\d{1,3}){3}/\d+)\s+"
r"(?P<gw>\S+)\s+(?P<iface>\S+)\s+(?P<owner>\S+)\s+"
r"(?P<pri>\S+)\s+(?P<metric>\S+)\s*$"
)
def _map_fsm_rows(rows: list[dict[str, Any]], *, vrf: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for r in rows:
dest = row_get(r, "DEST", "dest")
if not dest or dest.lower() == "dest" or dest in seen:
continue
seen.add(dest)
out.append(
{
"vrf": vrf[:128],
"dest": dest[:64],
"gateway": row_get(r, "GATEWAY", "gateway")[:64],
"interface": row_get(r, "INTERFACE", "interface")[:128],
"owner": row_get(r, "OWNER", "owner")[:64],
"pri": row_get(r, "PRI", "pri")[:16],
"metric": row_get(r, "METRIC", "metric")[:32],
"flags": row_get(r, "FLAGS", "flags")[:16],
}
)
return out
def _hand_parse(*, raw_text: str, vrf: str = "", **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in str(raw_text or "").splitlines():
line = raw.rstrip()
if not line.strip() or line.strip().lower().startswith("dest"):
continue
m = _ROUTE_RE.match(line)
if not m:
continue
dest = m.group("dest")
if dest in seen:
continue
seen.add(dest)
out.append(
{
"vrf": vrf[:128],
"dest": dest[:64],
"gateway": m.group("gw")[:64],
"interface": m.group("iface")[:128],
"owner": m.group("owner")[:64],
"pri": m.group("pri")[:16],
"metric": m.group("metric")[:32],
"flags": (m.group("flags") or "").strip()[:16],
}
)
return out
def normalize_ip_route(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
vrf = _detect_vrf(command, params)
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show ip forwarding route").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _map_fsm_rows(rows, vrf=vrf)
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
return _hand_parse(raw_text=raw_text, vrf=vrf, **kw)
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
normalize_ip_route.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,128 @@
"""ZTE: show ipv6 forwarding route [vrf <vrf>]."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
from .bgp_peer import _detect_vrf
RULE_KEYS = ("zte_zxros_show_ipv6_forwarding_route",)
_DEST_RE = re.compile(
r"^\s*(?P<dest>[0-9A-Fa-f:]+/\d+)\s+(?P<owner>\S+)\s+(?P<pri>\S+)\s+"
r"(?P<metric>\S+)(?:\s+(?P<flags>\S+))?\s*$"
)
_NH_RE = re.compile(r"^\s+(?P<gw>[0-9A-Fa-f:]+|\S+),(?P<iface>\S+)\s*$")
def _map_fsm_rows(rows: list[dict[str, Any]], *, vrf: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
cur: dict[str, Any] | None = None
for r in rows:
dest = row_get(r, "DEST", "dest")
gw = row_get(r, "GATEWAY", "gateway")
iface = row_get(r, "INTERFACE", "interface")
if dest:
cur = {
"vrf": vrf[:128],
"dest": dest[:128],
"gateway": "",
"interface": "",
"owner": row_get(r, "OWNER", "owner")[:64],
"pri": row_get(r, "PRI", "pri")[:16],
"metric": row_get(r, "METRIC", "metric")[:32],
"flags": row_get(r, "FLAGS", "flags")[:16],
}
if gw or iface:
cur["gateway"] = gw[:128]
cur["interface"] = iface[:128]
key = (dest, gw)
if key not in seen:
seen.add(key)
out.append(cur)
cur = None
continue
if cur and (gw or iface):
cur["gateway"] = gw[:128]
cur["interface"] = iface[:128]
key = (cur["dest"], gw)
if key not in seen:
seen.add(key)
out.append(dict(cur))
cur = None
return out
def _hand_parse(*, raw_text: str, vrf: str = "", **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
pending: dict[str, Any] | None = None
for raw in str(raw_text or "").splitlines():
line = raw.rstrip()
if not line.strip():
continue
m = _DEST_RE.match(line)
if m:
pending = {
"vrf": vrf[:128],
"dest": m.group("dest")[:128],
"gateway": "",
"interface": "",
"owner": m.group("owner")[:64],
"pri": m.group("pri")[:16],
"metric": m.group("metric")[:32],
"flags": (m.group("flags") or "")[:16],
}
continue
m2 = _NH_RE.match(line)
if m2 and pending:
gw = m2.group("gw")
key = (pending["dest"], gw)
if key not in seen:
seen.add(key)
row = dict(pending)
row["gateway"] = gw[:128]
row["interface"] = m2.group("iface")[:128]
out.append(row)
pending = None
return out
def normalize_ipv6_route(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
vrf = _detect_vrf(command, params)
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show ipv6 forwarding route").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _map_fsm_rows(rows, vrf=vrf)
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
return _hand_parse(raw_text=raw_text, vrf=vrf, **kw)
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
normalize_ipv6_route.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,95 @@
"""ZTE: show mac l2vpn."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_mac_l2vpn",)
_MAC_RE = re.compile(
r"^(?P<mac>[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4})\s+"
r"(?P<vpn>\S+)\s+(?P<vlan>\S+)\s+(?P<out>.+?)\s+(?P<attr>\S+)\s*$"
)
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for r in rows:
mac = row_get(r, "MAC", "mac")
vpn = row_get(r, "VPN", "vpn")
if not mac or mac.lower() == "mac":
continue
key = (mac, vpn)
if key in seen:
continue
seen.add(key)
out.append(
{
"mac": mac[:64],
"vpn": vpn[:128],
"vlan": row_get(r, "VLAN", "vlan")[:32],
"outgoing": row_get(r, "OUTGOING", "outgoing")[:256],
"attribute": row_get(r, "ATTRIBUTE", "attribute")[:64],
}
)
return out
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for raw in str(raw_text or "").splitlines():
line = raw.strip()
if not line or line.lower().startswith("mac "):
continue
m = _MAC_RE.match(line)
if not m:
continue
key = (m.group("mac"), m.group("vpn"))
if key in seen:
continue
seen.add(key)
out.append(
{
"mac": m.group("mac")[:64],
"vpn": m.group("vpn")[:128],
"vlan": m.group("vlan")[:32],
"outgoing": m.group("out").strip()[:256],
"attribute": m.group("attr")[:64],
}
)
return out
def normalize_l2vpn_mac(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = params
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show mac l2vpn").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
normalize_l2vpn_mac.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,106 @@
"""ZTE: show l2vpn forwardinfo (PW state)."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_l2vpn_forwardinfo",)
_PW_RE = re.compile(
r"^(?P<name>\S+)\s+(?P<peer>\S+)\s+(?P<fec>\S+)\s+(?P<pwt>\S+)\s+"
r"(?:(?P<mode>[A-Za-z$]+)\s+)?"
r"(?P<state>UP|DOWN)\s+(?P<ll>\S+)\s+(?P<rl>\S+)\s+(?P<owner>\S+)\s*$",
re.I,
)
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for r in rows:
name = row_get(r, "PW_NAME", "pw_name")
if not name or name.lower() == "pwname" or name in seen:
continue
seen.add(name)
state = row_get(r, "STATE", "state")
# FSM may glue mode into pw_type
pwt = row_get(r, "PW_TYPE", "pw_type")
out.append(
{
"pw_name": name[:128],
"peer": row_get(r, "PEER", "peer")[:64],
"fec": row_get(r, "FEC", "fec")[:32],
"pw_type": pwt[:64],
"state": state[:32],
"local_label": row_get(r, "LLABEL", "local_label")[:32],
"remote_label": row_get(r, "RLABEL", "remote_label")[:32],
"vpn_owner": row_get(r, "VPN_OWNER", "vpn_owner")[:256],
}
)
return out
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in str(raw_text or "").splitlines():
line = raw.strip()
if not line or line.lower().startswith("pwname"):
continue
m = _PW_RE.match(line)
if not m:
continue
name = m.group("name")
if name in seen:
continue
seen.add(name)
mode = (m.group("mode") or "").strip()
pwt = m.group("pwt")
if mode:
pwt = f"{pwt} {mode}"
out.append(
{
"pw_name": name[:128],
"peer": m.group("peer")[:64],
"fec": m.group("fec")[:32],
"pw_type": pwt[:64],
"state": m.group("state")[:32],
"local_label": m.group("ll")[:32],
"remote_label": m.group("rl")[:32],
"vpn_owner": m.group("owner")[:256],
}
)
return out
def normalize_l2vpn_pw(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = params
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show l2vpn forwardinfo").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
normalize_l2vpn_pw.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,93 @@
"""ZTE: show opticalinfo brief."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_opticalinfo_brief",)
_OPT_RE = re.compile(
r"^(?P<iface>\S+)\s+(?P<otype>\S+)\s+(?P<wave>\S+)\s+"
r"(?P<rx>\S+)\s+(?P<tx>\S+)\s+(?P<status>\S+)",
)
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for r in rows:
iface = row_get(r, "INTERFACE", "interface")
if not iface or iface.lower() == "interface" or iface in seen:
continue
seen.add(iface)
out.append(
{
"interface": iface[:128],
"optic_type": row_get(r, "OPTIC_TYPE", "optic_type")[:64],
"wavelength": row_get(r, "WAVELENGTH", "wavelength")[:32],
"rx_power": row_get(r, "RX_POWER", "rx_power")[:64],
"tx_power": row_get(r, "TX_POWER", "tx_power")[:64],
"status": row_get(r, "STATUS", "status")[:32],
}
)
return out
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in str(raw_text or "").splitlines():
line = raw.strip()
if not line or line.lower().startswith("interface"):
continue
m = _OPT_RE.match(line)
if not m:
continue
iface = m.group("iface")
if iface in seen:
continue
seen.add(iface)
out.append(
{
"interface": iface[:128],
"optic_type": m.group("otype")[:64],
"wavelength": m.group("wave")[:32],
"rx_power": m.group("rx")[:64],
"tx_power": m.group("tx")[:64],
"status": m.group("status")[:32],
}
)
return out
def normalize_optical_brief(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = params
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show opticalinfo brief").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
normalize_optical_brief.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,108 @@
"""ZTE: show ip ospf neighbor."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_ospf_neighbor",)
_PROC_RE = re.compile(r"Process\s+ID\s+(\d+)", re.I)
_NEI_RE = re.compile(
r"^(?P<nid>\d{1,3}(?:\.\d{1,3}){3})\s+(?P<pri>\S+)\s+(?P<state>\S+)\s+"
r"(?P<dead>\S+)\s+(?P<addr>\S+)\s+(?P<iface>\S+)\s*$"
)
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
for r in rows:
nid = row_get(r, "NEIGHBOR_ID", "neighbor_id")
iface = row_get(r, "INTERFACE", "interface")
proc = row_get(r, "PROCESS_ID", "process_id") or "0"
if not nid or not iface:
continue
key = (proc, nid, iface)
if key in seen:
continue
seen.add(key)
out.append(
{
"process_id": proc[:32],
"neighbor_id": nid[:64],
"pri": row_get(r, "PRI", "pri")[:16],
"state": row_get(r, "STATE", "state")[:64],
"dead_time": row_get(r, "DEAD_TIME", "dead_time")[:32],
"address": row_get(r, "ADDRESS", "address")[:64],
"interface": iface[:128],
}
)
return out
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
proc = "0"
for raw in str(raw_text or "").splitlines():
line = raw.strip()
if not line:
continue
pm = _PROC_RE.search(line)
if pm:
proc = pm.group(1)
continue
if line.lower().startswith("neighbor id"):
continue
m = _NEI_RE.match(line)
if not m:
continue
key = (proc, m.group("nid"), m.group("iface"))
if key in seen:
continue
seen.add(key)
out.append(
{
"process_id": proc[:32],
"neighbor_id": m.group("nid")[:64],
"pri": m.group("pri")[:16],
"state": m.group("state")[:64],
"dead_time": m.group("dead")[:32],
"address": m.group("addr")[:64],
"interface": m.group("iface")[:128],
}
)
return out
def normalize_ospf_neighbor(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
_ = params
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show ip ospf neighbor").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
normalize_ospf_neighbor.RULE_KEYS = RULE_KEYS

View file

@ -0,0 +1,122 @@
"""ZTE: show vrrp {ipv4|ipv6} brief."""
from __future__ import annotations
import re
from typing import Any, Mapping
from ....lldp_shared import resolve_vendor_key
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
from ..common.pipeline import prefer_fsm
RULE_KEYS = ("zte_zxros_show_vrrp_brief",)
_VRRP_RE = re.compile(
r"^(?P<iface>\S+)\s+(?P<vrid>\d+)\s+(?P<pri>\d+)\s+(?P<time>\d+)\s+"
r"(?:(?P<flags>[APL\s]+)\s+)?"
r"(?P<state>Master|Backup|Init)\s+"
r"(?P<master>\S+)\s+(?P<vrouter>\S+)\s*$",
re.I,
)
def _detect_af(command: str, params: dict[str, str] | None) -> str:
if params and params.get("af"):
return str(params.get("af") or "").strip().lower()
low = str(command or "").lower()
if "ipv6" in low:
return "ipv6"
return "ipv4"
def _map_fsm_rows(rows: list[dict[str, Any]], *, af: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
for r in rows:
iface = row_get(r, "INTERFACE", "interface")
vrid = row_get(r, "VR_ID", "vr_id")
if not iface or not vrid:
continue
key = (af, iface, vrid)
if key in seen:
continue
seen.add(key)
out.append(
{
"af": af[:16],
"interface": iface[:128],
"vr_id": vrid[:32],
"priority": row_get(r, "PRIORITY", "priority")[:16],
"time": row_get(r, "TIME", "time")[:16],
"flags": row_get(r, "FLAGS", "flags")[:16],
"state": row_get(r, "STATE", "state")[:32],
"master_addr": row_get(r, "MASTER_ADDR", "master_addr")[:128],
"vrouter_addr": row_get(r, "VROUTER_ADDR", "vrouter_addr")[:128],
}
)
return out
def _hand_parse(*, raw_text: str, af: str = "ipv4", **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
for raw in str(raw_text or "").splitlines():
line = raw.strip()
if not line or line.lower().startswith("interface"):
continue
m = _VRRP_RE.match(line)
if not m:
continue
key = (af, m.group("iface"), m.group("vrid"))
if key in seen:
continue
seen.add(key)
out.append(
{
"af": af[:16],
"interface": m.group("iface")[:128],
"vr_id": m.group("vrid")[:32],
"priority": m.group("pri")[:16],
"time": m.group("time")[:16],
"flags": (m.group("flags") or "").strip()[:16],
"state": m.group("state")[:32],
"master_addr": m.group("master")[:128],
"vrouter_addr": m.group("vrouter")[:128],
}
)
return out
def normalize_vrrp(
*,
raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "",
params: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
af = _detect_af(command, params)
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(
vendor=vendor,
device_type=device_type,
vendor_key=resolve_vendor_key(vendor, device_type),
)
cmd = str(command or "show vrrp ipv4 brief").strip()
if platform and cmd:
tables = apply_rules(
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
)
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return _map_fsm_rows(rows, af=af)
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
return _hand_parse(raw_text=raw_text, af=af, **kw)
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
normalize_vrrp.RULE_KEYS = RULE_KEYS

View file

@ -155,7 +155,11 @@ def _vrf_profiles() -> list[ParseProfile]:
r"(?i)^\s*display\s+ip\s+vpn-instance\s*$",
"display ip vpn-instance",
),
"zte": ("show ip vrf", r"(?i)^\s*show\s+ip\s+vrf\s*$", "show ip vrf"),
"zte": (
"show ip vrf | one-line",
r"(?i)^\s*show\s+ip\s+vrf(?:\s*\|\s*one-line)?\s*$",
"show ip vrf",
),
}
collect_cmds: dict[str, tuple[str, str]] = {
"cisco": (
@ -194,6 +198,7 @@ def _vrf_profiles() -> list[ParseProfile]:
FieldDef("vrf_name", length=128, indexed=True, is_key=True, display_name="VRF"),
FieldDef("rd", length=64, role="meta", display_name="RD"),
FieldDef("protocols", length=64, role="meta", display_name="协议"),
FieldDef("vrf_id", length=32, role="meta", display_name="VRF ID"),
],
tags=["vrf", "discover"],
sort_order=order,
@ -266,6 +271,23 @@ _IFACE_BRIEF_FIELDS: list[FieldDef] = [
FieldDef("description", length=256, role="meta", display_name="描述"),
]
_IFACE_DETAIL_FIELDS: list[FieldDef] = [
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
FieldDef("admin", length=32, role="state", display_name="Admin"),
# Rates churn between collects — display only, never default-compare.
FieldDef("input_bps", length=32, role="counter", display_name="Input bps"),
FieldDef("output_bps", length=32, role="counter", display_name="Output bps"),
FieldDef("in_util", length=16, role="counter", display_name="In Util%"),
FieldDef("out_util", length=16, role="counter", display_name="Out Util%"),
FieldDef("bw", length=64, role="meta", display_name="BW"),
FieldDef("mtu", length=16, role="meta", display_name="MTU"),
FieldDef("ifindex", length=32, role="meta", display_name="ifindex"),
FieldDef("description", length=256, role="meta", display_name="描述"),
FieldDef("port_media", length=32, role="meta", display_name="Media"),
FieldDef("negotiation", length=32, role="meta", display_name="Negotiation"),
FieldDef("rate_period", length=16, role="meta", display_name="Rate period"),
]
_ARP_FIELDS: list[FieldDef] = [
FieldDef("ip", length=64, indexed=True, is_key=True, display_name="IP"),
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
@ -294,16 +316,228 @@ _ND6_FIELDS: list[FieldDef] = [
_BGP_PEER_FIELDS: list[FieldDef] = [
FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI", from_command_param=True),
FieldDef("neighbor", length=64, indexed=True, is_key=True, display_name="Neighbor"),
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor"),
FieldDef("as_num", length=16, role="state", display_name="AS"),
FieldDef("state", length=64, role="state", display_name="State"),
FieldDef("pfx_rcd", length=32, role="state", display_name="PfxRcd"),
# Prefix count churns between collects — display only for cutover compare.
FieldDef("pfx_rcd", length=32, role="counter", display_name="PfxRcd"),
FieldDef("ver", length=8, role="meta", display_name="Ver"),
FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"),
FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"),
FieldDef("up_down", length=32, role="meta", display_name="Up/Down"),
]
_BGP_VRF_PLACEHOLDER = PlaceholderDef(
name="vrf",
schema_field="vrf",
required=True,
bind_mode="discover_select",
discover_profile_id="zte.vrf_list",
discover_value_field="vrf_name",
discover_label_field="vrf_name",
)
_BGP_NEIGHBOR_PLACEHOLDER = PlaceholderDef(
name="neighbor",
schema_field="neighbor",
required=True,
bind_mode="manual_text",
)
_OSPF_FIELDS: list[FieldDef] = [
FieldDef("process_id", length=32, indexed=True, is_key=True, display_name="Process ID"),
FieldDef("neighbor_id", length=64, indexed=True, is_key=True, display_name="Neighbor ID"),
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
FieldDef("state", length=64, role="state", display_name="State"),
FieldDef("address", length=64, role="state", display_name="Address"),
FieldDef("dead_time", length=32, role="meta", display_name="DeadTime"),
FieldDef("pri", length=16, role="meta", display_name="Pri"),
]
_VRRP_FIELDS: list[FieldDef] = [
FieldDef("af", length=16, indexed=True, is_key=True, display_name="AF", from_command_param=True),
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
FieldDef("vr_id", length=32, indexed=True, is_key=True, display_name="VR ID"),
FieldDef("state", length=32, role="state", display_name="State"),
FieldDef("priority", length=16, role="state", display_name="Priority"),
FieldDef("master_addr", length=128, role="state", display_name="Master"),
FieldDef("vrouter_addr", length=128, role="state", display_name="VRouter"),
FieldDef("time", length=16, role="meta", display_name="Time"),
FieldDef("flags", length=16, role="meta", display_name="Flags"),
]
_OPTICAL_FIELDS: list[FieldDef] = [
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
FieldDef("optic_type", length=64, role="meta", display_name="Type"),
FieldDef("wavelength", length=32, role="meta", display_name="Wavelength"),
# Optical power jitters — display only; compare status for cutover.
FieldDef("rx_power", length=64, role="counter", display_name="RxPower"),
FieldDef("tx_power", length=64, role="counter", display_name="TxPower"),
FieldDef("status", length=32, role="state", display_name="Status"),
]
_BGP_ROUTE_FIELDS: list[FieldDef] = [
FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI", from_command_param=True),
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor", from_command_param=True),
FieldDef("direction", length=8, indexed=True, is_key=True, display_name="Dir", from_command_param=True),
FieldDef("network", length=128, indexed=True, is_key=True, display_name="Network"),
FieldDef("next_hop", length=128, role="state", display_name="NextHop"),
FieldDef("metric", length=32, role="meta", display_name="Metric"),
FieldDef("loc_prf", length=32, role="meta", display_name="LocPrf"),
FieldDef("tag", length=32, role="meta", display_name="Tag"),
FieldDef("path", length=256, role="state", display_name="Path"),
FieldDef("status_codes", length=16, role="meta", display_name="Codes"),
FieldDef("as_num", length=16, role="state", display_name="AS"),
FieldDef("state", length=64, role="state", display_name="PeerState"),
FieldDef("pfx_rcd", length=32, role="meta", display_name="PfxRcd"),
]
_IP_ROUTE_FIELDS: list[FieldDef] = [
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
FieldDef("dest", length=64, indexed=True, is_key=True, display_name="Dest"),
FieldDef("gateway", length=64, indexed=True, is_key=True, display_name="Gateway"),
FieldDef("interface", length=128, is_key=True, is_interface=True, display_name="接口"),
FieldDef("owner", length=64, role="state", display_name="Owner"),
FieldDef("pri", length=16, role="meta", display_name="Pri"),
FieldDef("metric", length=32, role="meta", display_name="Metric"),
FieldDef("flags", length=16, role="meta", display_name="Flags"),
]
_IPV6_ROUTE_FIELDS: list[FieldDef] = [
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
FieldDef("dest", length=128, indexed=True, is_key=True, display_name="Dest"),
FieldDef("gateway", length=128, indexed=True, is_key=True, display_name="Gateway"),
FieldDef("interface", length=128, is_key=True, is_interface=True, display_name="接口"),
FieldDef("owner", length=64, role="state", display_name="Owner"),
FieldDef("pri", length=16, role="meta", display_name="Pri"),
FieldDef("metric", length=32, role="meta", display_name="Metric"),
FieldDef("flags", length=16, role="meta", display_name="Flags"),
]
_L2VPN_PW_FIELDS: list[FieldDef] = [
FieldDef("pw_name", length=128, indexed=True, is_key=True, display_name="PW"),
FieldDef("peer", length=64, indexed=True, is_key=True, display_name="Peer"),
FieldDef("state", length=32, role="state", display_name="State"),
FieldDef("fec", length=32, role="meta", display_name="FEC"),
FieldDef("pw_type", length=64, role="meta", display_name="PWType"),
FieldDef("local_label", length=32, role="meta", display_name="Llabel"),
FieldDef("remote_label", length=32, role="meta", display_name="Rlabel"),
FieldDef("vpn_owner", length=256, role="meta", display_name="VPNOwner"),
]
_L2VPN_MAC_FIELDS: list[FieldDef] = [
FieldDef("mac", length=64, indexed=True, is_key=True, display_name="MAC"),
FieldDef("vpn", length=128, indexed=True, is_key=True, display_name="VPN"),
FieldDef("vlan", length=32, role="meta", display_name="VLAN"),
FieldDef("outgoing", length=256, role="state", display_name="Outgoing"),
FieldDef("attribute", length=64, role="meta", display_name="Attribute"),
]
_EVPN_MAC_FIELDS: list[FieldDef] = [
FieldDef("network", length=256, indexed=True, is_key=True, display_name="Network"),
FieldDef("next_hop", length=128, role="state", display_name="NextHop"),
FieldDef("path", length=256, role="state", display_name="Path"),
FieldDef("metric", length=32, role="meta", display_name="Metric"),
FieldDef("loc_prf", length=32, role="meta", display_name="LocPrf"),
FieldDef("rt_prf", length=32, role="meta", display_name="RtPrf"),
FieldDef("status_codes", length=16, role="meta", display_name="Codes"),
]
_CONFIG_VRF_FIELDS: list[FieldDef] = [
FieldDef("vrf_name", length=128, indexed=True, is_key=True, display_name="VRF"),
FieldDef("rd", length=64, role="state", display_name="RD"),
FieldDef("address_families", length=64, role="state", display_name="AF"),
FieldDef("rt_export", length=512, role="state", display_name="RT Export"),
FieldDef("rt_import", length=512, role="state", display_name="RT Import"),
FieldDef("description", length=256, role="meta", display_name="描述"),
]
_CONFIG_IFACE_FIELDS: list[FieldDef] = [
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
FieldDef("admin", length=16, role="state", display_name="Admin"),
FieldDef("ip_address", length=256, role="state", display_name="IPv4"),
FieldDef("ipv6_address", length=256, role="state", display_name="IPv6"),
FieldDef("mtu", length=16, role="meta", display_name="MTU"),
FieldDef("description", length=256, role="meta", display_name="描述"),
]
_CONFIG_BGP_PEER_FIELDS: list[FieldDef] = [
FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI"),
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor"),
FieldDef("peer_group", length=64, indexed=True, is_key=True, display_name="Group"),
FieldDef("remote_as", length=16, role="state", display_name="Remote AS"),
FieldDef("activate", length=16, role="state", display_name="Activate"),
FieldDef("update_source", length=64, role="meta", display_name="Update-Source"),
FieldDef("route_map_in", length=128, role="meta", display_name="RM In"),
FieldDef("route_map_out", length=128, role="meta", display_name="RM Out"),
]
_CONFIG_L2VPN_PW_FIELDS: list[FieldDef] = [
FieldDef("vpn_type", length=16, indexed=True, is_key=True, display_name="Type"),
FieldDef("vpn_name", length=128, indexed=True, is_key=True, display_name="VPN"),
FieldDef("pw_name", length=128, indexed=True, is_key=True, display_name="PW"),
FieldDef("peer", length=64, role="state", display_name="Peer"),
FieldDef("vcid", length=64, role="state", display_name="VCID"),
FieldDef("encapsulation", length=32, role="meta", display_name="Encap"),
FieldDef("access_point", length=128, role="meta", display_name="Access"),
FieldDef("description", length=256, role="meta", display_name="描述"),
]
_CONFIG_STATIC_ROUTE_FIELDS: list[FieldDef] = [
FieldDef("af", length=8, indexed=True, is_key=True, display_name="AF"),
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
FieldDef("prefix", length=128, indexed=True, is_key=True, display_name="Prefix"),
FieldDef("mask", length=64, indexed=True, is_key=True, display_name="Mask"),
FieldDef("next_hop", length=64, indexed=True, is_key=True, display_name="Next-Hop"),
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
FieldDef("nexthop_vrf", length=128, role="state", display_name="Nexthop-VRF"),
FieldDef("metric", length=16, role="state", display_name="Metric"),
FieldDef("bfd", length=16, role="state", display_name="BFD"),
FieldDef("track", length=64, role="meta", display_name="Track"),
FieldDef("route_name", length=128, role="meta", display_name="Name"),
FieldDef("tag", length=32, role="meta", display_name="Tag"),
FieldDef("distance", length=16, role="meta", display_name="Distance"),
]
_CONFIG_OSPF_FIELDS: list[FieldDef] = [
FieldDef("af", length=8, indexed=True, is_key=True, display_name="AF"),
FieldDef("process_id", length=32, indexed=True, is_key=True, display_name="Process"),
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
FieldDef("area", length=64, indexed=True, is_key=True, display_name="Area"),
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
FieldDef("area_type", length=32, role="state", display_name="Area Type"),
FieldDef("network_type", length=32, role="state", display_name="Network"),
FieldDef("cost", length=16, role="state", display_name="Cost"),
FieldDef("hello_interval", length=16, role="state", display_name="Hello"),
FieldDef("dead_interval", length=16, role="state", display_name="Dead"),
FieldDef("bfd", length=16, role="state", display_name="BFD"),
FieldDef("router_id", length=64, role="meta", display_name="Router-ID"),
FieldDef("redistribute", length=256, role="meta", display_name="Redistribute"),
]
_CONFIG_ISIS_FIELDS: list[FieldDef] = [
FieldDef("process_id", length=32, indexed=True, is_key=True, display_name="Process"),
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
FieldDef("circuit_type", length=32, role="state", display_name="Circuit"),
FieldDef("network_type", length=32, role="state", display_name="Network"),
FieldDef("ip_enable", length=8, role="state", display_name="IPv4"),
FieldDef("ipv6_enable", length=8, role="state", display_name="IPv6"),
FieldDef("metric", length=16, role="state", display_name="Metric"),
FieldDef("ipv6_metric", length=16, role="state", display_name="IPv6 Metric"),
FieldDef("passive", length=8, role="state", display_name="Passive"),
FieldDef("bfd", length=16, role="state", display_name="BFD"),
FieldDef("ipv6_bfd", length=16, role="state", display_name="IPv6 BFD"),
FieldDef("area", length=64, role="meta", display_name="Area"),
FieldDef("system_id", length=64, role="meta", display_name="System-ID"),
FieldDef("router_id", length=64, role="meta", display_name="Router-ID"),
FieldDef("is_type", length=32, role="meta", display_name="IS-Type"),
]
def _zte_status_profiles() -> list[ParseProfile]:
"""ZTE ZXROS status snapshots from lab show commands."""
@ -340,6 +574,28 @@ def _zte_status_profiles() -> list[ParseProfile]:
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.interface_detail",
vendor_key="zte",
metric_id="interface_detail",
parser_id="interface_detail",
title="Interface Detail (filtered)",
command_template=(
"show interface | include ifindex|BW|The port is|MTU|Negotiation|"
"Description|Current|Rate|Peak|Input|Output|utilization"
),
match=(
r"(?i)^\s*show\s+interface\s*\|\s*include\s+"
r".*\bifindex\b.*$"
),
textfsm_command="show interface",
description="Filtered show interface: BW/rate/util/description.",
fields=list(_IFACE_DETAIL_FIELDS),
tags=["interface", "rate", "status"],
sort_order=315,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.arp",
vendor_key="zte",
@ -371,9 +627,12 @@ def _zte_status_profiles() -> list[ParseProfile]:
command_template="show running-config if-intf",
match=r"(?i)^\s*show\s+running-config\s+if-intf\s*$",
textfsm_command="show running-config if-intf",
description="Interface VRF from running-config if-intf (ip vrf forwarding).",
description=(
"Interface VRF map for ARP enrich (aux). "
"Free-form CLI match prefers zte.config_interface (longer regex)."
),
fields=list(_IF_INTF_FIELDS),
tags=["interface", "vrf", "config", "status"],
tags=["interface", "vrf", "config", "status", "aux"],
sort_order=325,
enabled=True,
kind="collect",
@ -401,7 +660,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
parser_id="bgp_peer",
title="BGP VPNv4 Summary",
command_template="show bgp vpnv4 unicast summary",
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+summary\s*$",
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv4 unicast summary",
description="BGP VPNv4 peer summary (afi=vpnv4).",
fields=list(_BGP_PEER_FIELDS),
@ -417,7 +676,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
parser_id="bgp_peer",
title="BGP IPv4 Summary",
command_template="show bgp ipv4 unicast summary",
match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+summary\s*$",
match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp ipv4 unicast summary",
description="BGP IPv4 unicast peer summary (afi=ipv4).",
fields=list(_BGP_PEER_FIELDS),
@ -433,7 +692,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
parser_id="bgp_peer",
title="BGP VPNv6 Summary",
command_template="show bgp vpnv6 unicast summary",
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+summary\s*$",
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv6 unicast summary",
description="BGP VPNv6 peer summary (afi=vpnv6).",
fields=list(_BGP_PEER_FIELDS),
@ -442,6 +701,626 @@ def _zte_status_profiles() -> list[ParseProfile]:
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.bgp_ipv6_summary",
vendor_key="zte",
metric_id="bgp_peer",
parser_id="bgp_peer",
title="BGP IPv6 Summary",
command_template="show bgp ipv6 unicast summary",
match=r"(?i)^\s*show\s+bgp\s+ipv6\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp ipv6 unicast summary",
description="BGP IPv6 unicast peer summary (afi=ipv6).",
fields=list(_BGP_PEER_FIELDS),
tags=["bgp", "ipv6", "status"],
sort_order=365,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.bgp_vpnv4_vrf_summary",
vendor_key="zte",
metric_id="bgp_peer",
parser_id="bgp_peer",
title="BGP VPNv4 VRF Summary",
command_template="show bgp vpnv4 unicast vrf <vrf> summary",
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv4 unicast summary",
description="Per-VRF BGP VPNv4 peer summary.",
placeholders=[_BGP_VRF_PLACEHOLDER],
fields=list(_BGP_PEER_FIELDS),
tags=["bgp", "vpnv4", "vrf", "status"],
sort_order=370,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.bgp_vpnv6_vrf_summary",
vendor_key="zte",
metric_id="bgp_peer",
parser_id="bgp_peer",
title="BGP VPNv6 VRF Summary",
command_template="show bgp vpnv6 unicast vrf <vrf> summary",
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv6 unicast summary",
description="Per-VRF BGP VPNv6 peer summary.",
placeholders=[_BGP_VRF_PLACEHOLDER],
fields=list(_BGP_PEER_FIELDS),
tags=["bgp", "vpnv6", "vrf", "status"],
sort_order=375,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.bgp_evpn_summary",
vendor_key="zte",
metric_id="bgp_peer",
parser_id="bgp_peer",
title="BGP L2VPN EVPN Summary",
command_template="show bgp l2vpn evpn summary",
match=r"(?i)^\s*show\s+bgp\s+l2vpn\s+evpn\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp l2vpn evpn summary",
description="BGP L2VPN EVPN peer summary (afi=evpn).",
fields=list(_BGP_PEER_FIELDS),
tags=["bgp", "evpn", "status"],
sort_order=380,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.bgp_vpls_summary",
vendor_key="zte",
metric_id="bgp_peer",
parser_id="bgp_peer",
title="BGP L2VPN VPLS Summary",
command_template="show bgp l2vpn vpls summary",
match=r"(?i)^\s*show\s+bgp\s+l2vpn\s+vpls\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp l2vpn vpls summary",
description="BGP L2VPN VPLS peer summary (afi=vpls).",
fields=list(_BGP_PEER_FIELDS),
tags=["bgp", "vpls", "status"],
sort_order=385,
enabled=True,
kind="collect",
),
# --- Phase 1 status ---
ParseProfile(
profile_id="zte.ospf_neighbor",
vendor_key="zte",
metric_id="ospf_neighbor",
parser_id="ospf_neighbor",
title="OSPF Neighbor",
command_template="show ip ospf neighbor | one-line",
match=r"(?i)^\s*show\s+ip\s+ospf\s+neighbor(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show ip ospf neighbor",
description="OSPF neighbor table (multi Process ID).",
fields=list(_OSPF_FIELDS),
tags=["ospf", "l3", "status"],
sort_order=390,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.vrrp_ipv4",
vendor_key="zte",
metric_id="vrrp",
parser_id="vrrp",
title="VRRP IPv4 Brief",
command_template="show vrrp ipv4 brief | one-line",
match=r"(?i)^\s*show\s+vrrp\s+ipv4\s+brief(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show vrrp ipv4 brief",
description="VRRP IPv4 brief (af=ipv4).",
fields=list(_VRRP_FIELDS),
tags=["vrrp", "l3", "status"],
sort_order=400,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.vrrp_ipv6",
vendor_key="zte",
metric_id="vrrp",
parser_id="vrrp",
title="VRRP IPv6 Brief",
command_template="show vrrp ipv6 brief | one-line",
match=r"(?i)^\s*show\s+vrrp\s+ipv6\s+brief(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show vrrp ipv6 brief",
description="VRRP IPv6 brief (af=ipv6).",
fields=list(_VRRP_FIELDS),
tags=["vrrp", "ipv6", "status"],
sort_order=405,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.optical_brief",
vendor_key="zte",
metric_id="optical_brief",
parser_id="optical_brief",
title="Optical Info Brief",
command_template="show opticalinfo brief | one-line",
match=r"(?i)^\s*show\s+opticalinfo\s+brief(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show opticalinfo brief",
description="Transceiver Rx/Tx power and status.",
fields=list(_OPTICAL_FIELDS),
tags=["optical", "interface", "status"],
sort_order=410,
enabled=True,
kind="collect",
),
# --- Phase 3 BGP neighbor routes ---
ParseProfile(
profile_id="zte.bgp_vpnv4_neighbor_in",
vendor_key="zte",
metric_id="bgp_route",
parser_id="bgp_route",
title="BGP VPNv4 Neighbor In",
command_template="show bgp vpnv4 unicast neighbor in <neighbor>",
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv4 unicast neighbor in",
description="Routes learned from VPNv4 neighbor; summary aux for peer state.",
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER],
fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv4", "route"],
sort_order=420,
enabled=True,
kind="collect",
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary")],
enrich_joins=[
EnrichJoin(
from_aux="bgp_summary",
on="neighbor",
take=("as_num", "state", "pfx_rcd"),
),
],
),
ParseProfile(
profile_id="zte.bgp_vpnv4_neighbor_out",
vendor_key="zte",
metric_id="bgp_route",
parser_id="bgp_route",
title="BGP VPNv4 Neighbor Out",
command_template="show bgp vpnv4 unicast neighbor out <neighbor>",
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv4 unicast neighbor out",
description="Routes advertised to VPNv4 neighbor (large; bind neighbor).",
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER],
fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv4", "route"],
sort_order=425,
enabled=False,
kind="collect",
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary")],
enrich_joins=[
EnrichJoin(
from_aux="bgp_summary",
on="neighbor",
take=("as_num", "state", "pfx_rcd"),
),
],
),
ParseProfile(
profile_id="zte.bgp_vpnv6_neighbor_in",
vendor_key="zte",
metric_id="bgp_route",
parser_id="bgp_route",
title="BGP VPNv6 Neighbor In",
command_template="show bgp vpnv6 unicast neighbor in <neighbor>",
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv6 unicast neighbor in",
description="Routes learned from VPNv6 neighbor; summary aux for peer state.",
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER],
fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv6", "route"],
sort_order=426,
enabled=True,
kind="collect",
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_summary")],
enrich_joins=[
EnrichJoin(
from_aux="bgp_summary",
on="neighbor",
take=("as_num", "state", "pfx_rcd"),
),
],
),
ParseProfile(
profile_id="zte.bgp_vpnv6_neighbor_out",
vendor_key="zte",
metric_id="bgp_route",
parser_id="bgp_route",
title="BGP VPNv6 Neighbor Out",
command_template="show bgp vpnv6 unicast neighbor out <neighbor>",
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv6 unicast neighbor out",
description="Routes advertised to VPNv6 neighbor (large; bind neighbor).",
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER],
fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv6", "route"],
sort_order=427,
enabled=False,
kind="collect",
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_summary")],
enrich_joins=[
EnrichJoin(
from_aux="bgp_summary",
on="neighbor",
take=("as_num", "state", "pfx_rcd"),
),
],
),
ParseProfile(
profile_id="zte.bgp_vpnv4_vrf_neighbor_in",
vendor_key="zte",
metric_id="bgp_route",
parser_id="bgp_route",
title="BGP VPNv4 VRF Neighbor In",
command_template="show bgp vpnv4 unicast vrf <vrf> neighbor in <neighbor>",
match=(
r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
r"neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
),
textfsm_command="show bgp vpnv4 unicast neighbor in",
description="Per-VRF routes learned from neighbor; VRF summary aux.",
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER],
fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv4", "vrf", "route"],
sort_order=430,
enabled=True,
kind="collect",
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_vrf_summary")],
enrich_joins=[
EnrichJoin(
from_aux="bgp_summary",
on="neighbor",
take=("as_num", "state", "pfx_rcd"),
),
],
),
ParseProfile(
profile_id="zte.bgp_vpnv4_vrf_neighbor_out",
vendor_key="zte",
metric_id="bgp_route",
parser_id="bgp_route",
title="BGP VPNv4 VRF Neighbor Out",
command_template="show bgp vpnv4 unicast vrf <vrf> neighbor out <neighbor>",
match=(
r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
r"neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
),
textfsm_command="show bgp vpnv4 unicast neighbor out",
description="Per-VRF routes advertised to neighbor (may be large).",
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER],
fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv4", "vrf", "route"],
sort_order=435,
enabled=False,
kind="collect",
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_vrf_summary")],
enrich_joins=[
EnrichJoin(
from_aux="bgp_summary",
on="neighbor",
take=("as_num", "state", "pfx_rcd"),
),
],
),
ParseProfile(
profile_id="zte.bgp_vpnv6_vrf_neighbor_in",
vendor_key="zte",
metric_id="bgp_route",
parser_id="bgp_route",
title="BGP VPNv6 VRF Neighbor In",
command_template="show bgp vpnv6 unicast vrf <vrf> neighbor in <neighbor>",
match=(
r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
r"neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
),
textfsm_command="show bgp vpnv6 unicast neighbor in",
description="Per-VRF VPNv6 routes learned from neighbor.",
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER],
fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv6", "vrf", "route"],
sort_order=440,
enabled=True,
kind="collect",
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_vrf_summary")],
enrich_joins=[
EnrichJoin(
from_aux="bgp_summary",
on="neighbor",
take=("as_num", "state", "pfx_rcd"),
),
],
),
ParseProfile(
profile_id="zte.bgp_vpnv6_vrf_neighbor_out",
vendor_key="zte",
metric_id="bgp_route",
parser_id="bgp_route",
title="BGP VPNv6 VRF Neighbor Out",
command_template="show bgp vpnv6 unicast vrf <vrf> neighbor out <neighbor>",
match=(
r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
r"neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
),
textfsm_command="show bgp vpnv6 unicast neighbor out",
description="Per-VRF VPNv6 routes advertised to neighbor.",
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER],
fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv6", "vrf", "route"],
sort_order=445,
enabled=False,
kind="collect",
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_vrf_summary")],
enrich_joins=[
EnrichJoin(
from_aux="bgp_summary",
on="neighbor",
take=("as_num", "state", "pfx_rcd"),
),
],
),
# --- Phase 4 forwarding / L2 ---
ParseProfile(
profile_id="zte.ip_route_vrf",
vendor_key="zte",
metric_id="ip_route",
parser_id="ip_route",
title="IPv4 Forwarding VRF",
command_template="show ip forwarding route vrf <vrf>",
match=r"(?i)^\s*show\s+ip\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show ip forwarding route",
description="IPv4 FIB for one VRF.",
placeholders=[_BGP_VRF_PLACEHOLDER],
fields=list(_IP_ROUTE_FIELDS),
tags=["route", "ipv4", "vrf"],
sort_order=450,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.ip_route",
vendor_key="zte",
metric_id="ip_route",
parser_id="ip_route",
title="IPv4 Forwarding Global",
command_template="show ip forwarding route",
match=r"(?i)^\s*show\s+ip\s+forwarding\s+route(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show ip forwarding route",
description="Global IPv4 FIB (large; disabled by default).",
fields=list(_IP_ROUTE_FIELDS),
tags=["route", "ipv4"],
sort_order=455,
enabled=False,
kind="collect",
),
ParseProfile(
profile_id="zte.ipv6_route_vrf",
vendor_key="zte",
metric_id="ipv6_route",
parser_id="ipv6_route",
title="IPv6 Forwarding VRF",
command_template="show ipv6 forwarding route vrf <vrf>",
match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show ipv6 forwarding route",
description="IPv6 FIB for one VRF.",
placeholders=[_BGP_VRF_PLACEHOLDER],
fields=list(_IPV6_ROUTE_FIELDS),
tags=["route", "ipv6", "vrf"],
sort_order=460,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.ipv6_route",
vendor_key="zte",
metric_id="ipv6_route",
parser_id="ipv6_route",
title="IPv6 Forwarding Global",
command_template="show ipv6 forwarding route",
match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show ipv6 forwarding route",
description="Global IPv6 FIB (large; disabled by default).",
fields=list(_IPV6_ROUTE_FIELDS),
tags=["route", "ipv6"],
sort_order=465,
enabled=False,
kind="collect",
),
ParseProfile(
profile_id="zte.l2vpn_pw",
vendor_key="zte",
metric_id="l2vpn_pw",
parser_id="l2vpn_pw",
title="L2VPN PW State",
command_template="show l2vpn forwardinfo | one-line",
match=r"(?i)^\s*show\s+l2vpn\s+forwardinfo(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show l2vpn forwardinfo",
description="L2VPN pseudowire forwardinfo state.",
fields=list(_L2VPN_PW_FIELDS),
tags=["l2vpn", "pw", "status"],
sort_order=470,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.l2vpn_mac",
vendor_key="zte",
metric_id="l2vpn_mac",
parser_id="l2vpn_mac",
title="L2VPN MAC Table",
command_template="show mac l2vpn | one-line",
match=r"(?i)^\s*show\s+mac\s+l2vpn(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show mac l2vpn",
description="L2VPN MAC table (large; disabled by default).",
fields=list(_L2VPN_MAC_FIELDS),
tags=["l2vpn", "mac"],
sort_order=480,
enabled=False,
kind="collect",
),
ParseProfile(
profile_id="zte.evpn_mac",
vendor_key="zte",
metric_id="evpn_mac",
parser_id="evpn_mac",
title="BGP EVPN MAC",
command_template="show bgp evpn mac | one-line",
match=r"(?i)^\s*show\s+bgp\s+evpn\s+mac(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp evpn mac",
description="BGP EVPN MAC NLRI (large; disabled by default).",
fields=list(_EVPN_MAC_FIELDS),
tags=["bgp", "evpn", "mac"],
sort_order=490,
enabled=False,
kind="collect",
),
# --- Config intent (P0) ---
ParseProfile(
profile_id="zte.config_vrf",
vendor_key="zte",
metric_id="config_vrf",
parser_id="config_vrf",
title="Config VRF Intent",
command_template="show running-config vrf",
match=r"(?i)^\s*show\s+running-config\s+vrf(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config vrf",
description="VRF RD/RT/AF intent from running-config vrf (no secrets).",
fields=list(_CONFIG_VRF_FIELDS),
tags=["config", "vrf", "intent"],
sort_order=500,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.config_interface",
vendor_key="zte",
metric_id="config_interface",
parser_id="config_interface",
title="Config Interface Intent",
command_template="show running-config if-intf",
match=r"(?i)^\s*show\s+running-config\s+if-intf(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config if-intf",
description="Interface VRF/IP/admin intent (richer than if_intf aux map).",
fields=list(_CONFIG_IFACE_FIELDS),
tags=["config", "interface", "intent"],
sort_order=510,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.config_bgp_peer",
vendor_key="zte",
metric_id="config_bgp_peer",
parser_id="config_bgp_peer",
title="Config BGP Peer Intent",
command_template="show running-config bgp",
match=r"(?i)^\s*show\s+running-config\s+bgp(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config bgp",
description="BGP neighbor AF activate / remote-as intent (passwords skipped).",
fields=list(_CONFIG_BGP_PEER_FIELDS),
tags=["config", "bgp", "intent"],
sort_order=520,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.config_l2vpn_pw",
vendor_key="zte",
metric_id="config_l2vpn_pw",
parser_id="config_l2vpn_pw",
title="Config L2VPN PW Intent",
command_template="show running-config l2vpn",
match=r"(?i)^\s*show\s+running-config\s+l2vpn(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config l2vpn",
description="VPWS/VPLS pseudo-wire peer/vcid intent.",
fields=list(_CONFIG_L2VPN_PW_FIELDS),
tags=["config", "l2vpn", "intent"],
sort_order=530,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.config_static_route",
vendor_key="zte",
metric_id="config_static_route",
parser_id="config_static_route",
title="Config Static Route (IPv4)",
command_template="show running-config static",
match=r"(?i)^\s*show\s+running-config\s+static(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config static",
description="IPv4 static route intent from running-config static.",
fields=list(_CONFIG_STATIC_ROUTE_FIELDS),
tags=["config", "static", "route", "intent"],
sort_order=540,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.config_static_route_v6",
vendor_key="zte",
metric_id="config_static_route",
parser_id="config_static_route",
title="Config Static Route (IPv6)",
command_template="show running-config ipv6-static-route",
match=r"(?i)^\s*show\s+running-config\s+ipv6-static-route(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config ipv6-static-route",
description="IPv6 static route intent from running-config ipv6-static-route.",
fields=list(_CONFIG_STATIC_ROUTE_FIELDS),
tags=["config", "static", "route", "ipv6", "intent"],
sort_order=541,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.config_ospf",
vendor_key="zte",
metric_id="config_ospf",
parser_id="config_ospf",
title="Config OSPF Intent (v2)",
command_template="show running-config ospfv2",
match=r"(?i)^\s*show\s+running-config\s+ospfv2(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config ospfv2",
description="OSPFv2 process/area/interface intent (auth secrets skipped).",
fields=list(_CONFIG_OSPF_FIELDS),
tags=["config", "ospf", "intent"],
sort_order=550,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.config_ospf_v3",
vendor_key="zte",
metric_id="config_ospf",
parser_id="config_ospf",
title="Config OSPF Intent (v3)",
command_template="show running-config ospfv3",
match=r"(?i)^\s*show\s+running-config\s+ospfv3(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config ospfv3",
description="OSPFv3 process/area/interface intent.",
fields=list(_CONFIG_OSPF_FIELDS),
tags=["config", "ospf", "ipv6", "intent"],
sort_order=551,
enabled=True,
kind="collect",
),
ParseProfile(
profile_id="zte.config_isis",
vendor_key="zte",
metric_id="config_isis",
parser_id="config_isis",
title="Config ISIS Intent",
command_template="show running-config isis",
match=r"(?i)^\s*show\s+running-config\s+isis(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show running-config isis",
description="IS-IS process/interface intent (auth secrets skipped).",
fields=list(_CONFIG_ISIS_FIELDS),
tags=["config", "isis", "intent"],
sort_order=560,
enabled=True,
kind="collect",
),
]

View file

@ -17,6 +17,7 @@ def apply_biz_state_schema(conn: Connection) -> None:
# Keep lightweight indexes that older DBs might miss.
for sql in (
"ALTER TABLE biz_compare_template ADD COLUMN IF NOT EXISTS metrics_json JSON DEFAULT '[]'",
"ALTER TABLE biz_compare_template ADD COLUMN IF NOT EXISTS iface_normalize_json JSON DEFAULT '[]'",
"CREATE INDEX IF NOT EXISTS ix_biz_state_task_status ON biz_state_task (status)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_task_id ON biz_state_batch (task_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
@ -63,6 +64,19 @@ def apply_biz_state_schema(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_compare ON biz_monitor_template (compare_template_id)",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS monitor_template_id VARCHAR(64) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_monitor_tpl ON biz_migration_project (monitor_template_id)",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS old_hf_task_id VARCHAR(64) DEFAULT ''",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS new_hf_task_id VARCHAR(64) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_old_hf ON biz_migration_project (old_hf_task_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_new_hf ON biz_migration_project (new_hf_task_id)",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS collect_metric_ids_json JSON DEFAULT '[]'",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS hf_interval_sec INTEGER DEFAULT 60",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS hf_start_at TIMESTAMP",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS hf_end_at TIMESTAMP",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS metric_interval_sec_json JSON DEFAULT '{}'",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS old_hf_bindings_json JSON DEFAULT '[]'",
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS new_hf_bindings_json JSON DEFAULT '[]'",
"ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_biz_state_task_purpose ON biz_state_task (purpose)",
):
try:
_run_sql(conn, sql)

View file

@ -156,6 +156,7 @@ def create_task(db: Session, body: dict[str, Any]) -> dict[str, Any]:
vendor=vendor,
device_type=device_type,
note=str(body.get("note") or "")[:256],
purpose=str(body.get("purpose") or "")[:32],
status=status,
interval_sec=max(60, int(body.get("interval_sec") or 3600)),
retention_days=max(1, min(3650, int(body.get("retention_days") or 30))),
@ -234,6 +235,8 @@ def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, An
raise HTTPException(status_code=404, detail="task_not_found")
if "note" in body:
task.note = str(body.get("note") or "")[:256]
if "purpose" in body and body["purpose"] is not None:
task.purpose = str(body.get("purpose") or "")[:32]
if "interval_sec" in body:
task.interval_sec = max(60, int(body.get("interval_sec") or 3600))
if "retention_days" in body:
@ -253,7 +256,11 @@ def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, An
st = str(body.get("status") or "").strip()
if st in ("draft", "running", "paused", "stopped"):
if st == "running":
_assert_bindings_ready(db, task.id)
# Cutover HF: NE-scoped catalogs; placeholder bindings optional / stamped later
purpose = str(getattr(task, "purpose", None) or "").strip()
note = str(getattr(task, "note", None) or "")
if purpose != "cutover_hf" and not note.startswith("割接高频"):
_assert_bindings_ready(db, task.id)
task.status = st
task.updated_at = _utcnow()
db.commit()
@ -350,6 +357,7 @@ def get_task(db: Session, task_id: str) -> dict[str, Any]:
"vendor": task.vendor,
"device_type": task.device_type,
"note": task.note,
"purpose": str(getattr(task, "purpose", None) or ""),
"status": task.status,
"interval_sec": task.interval_sec,
"retention_days": int(getattr(task, "retention_days", None) or 30),
@ -367,8 +375,24 @@ def get_task(db: Session, task_id: str) -> dict[str, Any]:
}
def list_tasks(db: Session) -> list[dict[str, Any]]:
rows = db.query(BizStateTask).order_by(BizStateTask.updated_at.desc()).all()
def list_tasks(db: Session, *, purpose: str | None = None) -> list[dict[str, Any]]:
from sqlalchemy import or_
q = db.query(BizStateTask)
purpose_f = str(purpose or "").strip()
if purpose_f:
if purpose_f == "portrait":
# Portrait = empty purpose or explicit portrait (exclude cutover_hf)
q = q.filter(
or_(
BizStateTask.purpose == "",
BizStateTask.purpose == "portrait",
BizStateTask.purpose.is_(None),
)
)
else:
q = q.filter(BizStateTask.purpose == purpose_f)
rows = q.order_by(BizStateTask.updated_at.desc()).all()
return [
{
"id": t.id,
@ -378,6 +402,7 @@ def list_tasks(db: Session) -> list[dict[str, Any]]:
"ne_ip": t.ne_ip,
"vendor": t.vendor,
"note": t.note,
"purpose": str(getattr(t, "purpose", None) or ""),
"status": t.status,
"interval_sec": t.interval_sec,
"collect_running": bool(t.collect_running),

View file

@ -44,6 +44,7 @@ class TaskCreateIn(BaseModel):
vendor: str = ""
device_type: str = ""
note: str = ""
purpose: str = ""
status: str = "draft"
interval_sec: int = 3600
retention_days: int = 30
@ -54,6 +55,7 @@ class TaskCreateIn(BaseModel):
class TaskPatchIn(BaseModel):
note: str | None = None
purpose: str | None = None
interval_sec: int | None = None
retention_days: int | None = None
daily_keep_enabled: bool | None = None
@ -151,8 +153,8 @@ def api_set_bindings(
@router.get("/tasks")
def api_list_tasks(db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": svc.list_tasks(db)}
def api_list_tasks(purpose: str = "", db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": svc.list_tasks(db, purpose=purpose or None)}
@router.post("/tasks")
@ -285,6 +287,8 @@ class TemplateIn(BaseModel):
note: str = ""
# Preferred: multi-metric sheets
metrics: list[TemplateMetricIn] | None = None
# Template-owned interface type aliases (GE→gei, …)
iface_normalize_rules: list[dict[str, str]] | None = None
# Legacy single-metric fields (still accepted)
metric_id: str = "lldp_neighbor"
key_fields: list[str] = Field(default_factory=list)
@ -300,6 +304,7 @@ class TemplatePatchIn(BaseModel):
name: str | None = None
note: str | None = None
metrics: list[TemplateMetricIn] | None = None
iface_normalize_rules: list[dict[str, str]] | None = None
metric_id: str | None = None
key_fields: list[str] | None = None
iface_fields: list[str] | None = None

View file

@ -77,12 +77,62 @@ def shutdown_biz_state_dispatch_pool(*, wait: bool = False) -> None:
_dispatch_pool = None
def _sync_cutover_hf_windows() -> None:
"""Pause/resume cutover_hf tasks according to migration project hf_start/hf_end."""
db = SessionLocal()
try:
from .biz_migration.service import (
PURPOSE_CUTOVER_HF,
_all_hf_task_ids,
_hf_window_status,
)
from .biz_state import service as biz_svc
from .models import BizMigrationProject
projects = db.query(BizMigrationProject).all()
for proj in projects:
want = _hf_window_status(proj)
tids = _all_hf_task_ids(proj, "old") + _all_hf_task_ids(proj, "new")
for tid in tids:
task = db.get(BizStateTask, tid)
if not task:
continue
purpose = str(getattr(task, "purpose", None) or "").strip()
if purpose and purpose != PURPOSE_CUTOVER_HF:
continue
if want == "paused" and task.status == "running":
try:
patch = {"status": "paused"}
if purpose != PURPOSE_CUTOVER_HF:
patch["purpose"] = PURPOSE_CUTOVER_HF
biz_svc.update_task(db, tid, patch)
except Exception:
_log.exception("pause hf window failed task=%s", tid)
elif want == "running" and task.status == "paused":
try:
patch = {"status": "running"}
if purpose != PURPOSE_CUTOVER_HF:
patch["purpose"] = PURPOSE_CUTOVER_HF
biz_svc.update_task(db, tid, patch)
except Exception:
_log.exception("resume hf window failed task=%s", tid)
except Exception:
_log.exception("cutover hf window sync failed")
finally:
db.close()
def try_dispatch_due_tasks() -> int:
import time as _time
global _last_tick_mono
_last_tick_mono = _time.monotonic()
try:
_sync_cutover_hf_windows()
except Exception:
_log.exception("hf window sync tick failed")
db = SessionLocal()
try:
tasks = (

View file

@ -9,6 +9,16 @@ zte/zte_zxros_show_interface.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] .+
zte/zte_zxros_show_ip_vrf.textfsm, .*, zte_zxros, sh[[ow]] ip vrf$
zte/zte_zxros_show_arp.textfsm, .*, zte_zxros, sh[[ow]] arp
zte/zte_zxros_show_running_config_if_intf.textfsm, .*, zte_zxros, sh[[ow]] run[[ning-config]] if-intf
zte/zte_zxros_show_bgp_summary.textfsm, .*, zte_zxros, sh[[ow]] bgp .+ sum[[mary]]
zte/zte_zxros_show_ospf_neighbor.textfsm, .*, zte_zxros, sh[[ow]] ip ospf nei[[ghbor]]
zte/zte_zxros_show_vrrp_brief.textfsm, .*, zte_zxros, sh[[ow]] vrrp .+ br[[ief]]
zte/zte_zxros_show_opticalinfo_brief.textfsm, .*, zte_zxros, sh[[ow]] opticalinfo br[[ief]]
zte/zte_zxros_show_bgp_neighbor_routes.textfsm, .*, zte_zxros, sh[[ow]] bgp .+ nei[[ghbor]] (in|out)
zte/zte_zxros_show_ip_forwarding_route.textfsm, .*, zte_zxros, sh[[ow]] ip forw[[arding]] rou[[te]]
zte/zte_zxros_show_ipv6_forwarding_route.textfsm, .*, zte_zxros, sh[[ow]] ipv6 forw[[arding]] rou[[te]]
zte/zte_zxros_show_l2vpn_forwardinfo.textfsm, .*, zte_zxros, sh[[ow]] l2vpn forw[[ardinfo]]
zte/zte_zxros_show_mac_l2vpn.textfsm, .*, zte_zxros, sh[[ow]] mac l2vpn
zte/zte_zxros_show_bgp_evpn_mac.textfsm, .*, zte_zxros, sh[[ow]] bgp evpn mac
# --- cisco ---
cisco/cisco_ios_show_interfaces.textfsm, .*, cisco_ios, sh[[ow]] int[[erfaces]] .+

View file

@ -0,0 +1,23 @@
Value NETWORK (\S+)
Value NEXT_HOP (\S+)
Value METRIC (\S*)
Value LOC_PRF (\S*)
Value RT_PRF (\S*)
Value PATH (.*)
Start
^\s*Network\s+Next\s+Hop -> Routes
^Status\s+codes
^Origin\s+codes
^Route\s+Distinguisher
^\s*$$
^.* -> Start
Routes
^Route\s+Distinguisher
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${RT_PRF}\s+${PATH}\s*$$ -> Record
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Routes

View file

@ -0,0 +1,29 @@
Value NETWORK (\S+)
Value NEXT_HOP (\S+)
Value METRIC (\S*)
Value LOC_PRF (\S*)
Value TAG (\S*)
Value PATH (.*)
Start
^Routes\s+(Learned|Sent) -> Routes
^\s*\*?\s*Network -> Routes
^Network\s+ -> Routes
^\s*$$
^.* -> Start
Routes
^Route\s+Distinguisher
^Status\s+codes
^Origin\s+codes
^Total\s+number
^\s+Valid
^\s+Invalid
^Local\s+router
^Remote\s+router
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Routes

View file

@ -0,0 +1,22 @@
Value NEIGHBOR (\S+)
Value VER (\d+)
Value ASN (\S+)
Value MSG_RCVD (\d+)
Value MSG_SEND (\d+)
Value UP_DOWN (\S+)
Value STATE_PFX (\S+)
Start
^Neighbor\s+Ver\s+As -> Peers
^\s*$$
^.* -> Start
Peers
^${NEIGHBOR}\s+${VER}\s+${ASN}\s+${MSG_RCVD}\s+${MSG_SEND}\s+${UP_DOWN}\s+${STATE_PFX}\s*$$ -> Record
^All\s+
^BGP\s+
^Local\s+
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Peers

View file

@ -1,7 +1,11 @@
Value Filldown IFNAME (\S+)
Value Filldown ADMIN_OPER (up|down)
Value Filldown ADMIN_STATE ((?:administratively\s+)?(?:up|down))
Value Filldown IFINDEX (\d+)
Value Filldown DESCRIPTION (.+?)
Value Filldown PORT_MEDIA (\S+)
Value Filldown NEGOTIATION (\S+)
Value Filldown BW_RAW (\d+(?:\.\d+)?\s*[kKmMgGtT]?\s*(?:G?bit|bit)/s(?:ec)?)
Value Filldown MTU (\d+)
Value Filldown RATE_PERIOD (\d+)
Value Filldown INPUT_BPS ([\d.]+)
Value Filldown OUTPUT_BPS ([\d.]+)
@ -9,9 +13,12 @@ Value IN_UTIL ([\d.]+)
Value OUT_UTIL ([\d.]+)
Start
^${IFNAME}\s+is\s+${ADMIN_OPER}\b
^${IFNAME}\s+is\s+${ADMIN_STATE}(?:,\s*ifindex:\s*${IFINDEX})?
^\s*Description:\s*${DESCRIPTION}\s*$$
^\s*The\s+port\s+is\s+${PORT_MEDIA}
^\s*Negotiation\s+${NEGOTIATION}
^\s*BW\s+${BW_RAW}
^\s*MTU\s+${MTU}\s+bytes
^\s*Rate\s+period\s*:\s*${RATE_PERIOD}\s*s -> Rates
^\s*$$
^.* -> Start
@ -20,11 +27,13 @@ Rates
^\s*Input\s*:\s*${INPUT_BPS}\s*bit/s
^\s*Output\s*:\s*${OUTPUT_BPS}\s*bit/s
^\s*Peak\s+rate: -> AfterPeak
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record Start
^${IFNAME}\s+is\s+${ADMIN_STATE}(?:,\s*ifindex:\s*${IFINDEX})? -> Clearall Start
^\s*$$
^.* -> Rates
AfterPeak
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record Start
^${IFNAME}\s+is\s+${ADMIN_STATE}(?:,\s*ifindex:\s*${IFINDEX})? -> Clearall Start
^\s*$$
^.* -> AfterPeak

View file

@ -0,0 +1,23 @@
Value FLAGS (\S*)
Value DEST (\S+)
Value GATEWAY (\S+)
Value INTERFACE (\S+)
Value OWNER (\S+)
Value PRI (\S+)
Value METRIC (\S+)
Start
^\s*Dest\s+Gw -> Entries
^Headers:
^Codes
^Status\s+codes
^\s*$$
^.* -> Start
Entries
^\s*${FLAGS}\s*${DEST}\s+${GATEWAY}\s+${INTERFACE}\s+${OWNER}\s+${PRI}\s+${METRIC}\s*$$ -> Record
^\s*${DEST}\s+${GATEWAY}\s+${INTERFACE}\s+${OWNER}\s+${PRI}\s+${METRIC}\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Entries

View file

@ -1,6 +1,7 @@
Value Required NAME (\S+)
Value RD (\S+)
Value INTERFACES (.*)
Value PROTOCOLS (\S+)
Value VRF_ID (\d+)
Start
^Name\s+Default\s+RD -> Records
@ -10,7 +11,8 @@ Start
^.* -> Start
Records
^${NAME}\s+${RD}\s+${INTERFACES}\s*$$ -> Record
^${NAME}\s+${RD}\s+${PROTOCOLS}\s+${VRF_ID}\s*$$ -> Record
^${NAME}\s+${RD}\s+${PROTOCOLS}\s*$$ -> Record
^${NAME}\s+${RD}\s*$$ -> Record
^${NAME}\s*$$ -> Record
^-+

View file

@ -0,0 +1,23 @@
Value DEST (\S+)
Value OWNER (\S+)
Value PRI (\S+)
Value METRIC (\S+)
Value FLAGS (\S*)
Value GATEWAY (\S+)
Value INTERFACE (\S+)
Start
^Headers:
^Codes
^Flag\s+codes
^\s*Dest -> Entries
^\s*$$
^.* -> Start
Entries
^\s*${DEST}\s+${OWNER}\s+${PRI}\s+${METRIC}\s+${FLAGS}\s*$$ -> Record
^\s+${GATEWAY},${INTERFACE}\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Entries

View file

@ -0,0 +1,23 @@
Value PW_NAME (\S+)
Value PEER (\S+)
Value FEC (\S+)
Value PW_TYPE (\S+)
Value STATE (\S+)
Value LLABEL (\S+)
Value RLABEL (\S+)
Value VPN_OWNER (\S+)
Start
^PWName\s+PeerIP -> Entries
^PW\s+count
^Headers:
^Codes
^\s*$$
^.* -> Start
Entries
^${PW_NAME}\s+${PEER}\s+${FEC}\s+${PW_TYPE}\s+${STATE}\s+${LLABEL}\s+${RLABEL}\s+${VPN_OWNER}\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Entries

View file

@ -0,0 +1,19 @@
Value MAC (\S+)
Value VPN (\S+)
Value VLAN (\S+)
Value OUTGOING (.*)
Value ATTRIBUTE (\S+)
Start
^MAC\s+VPN -> Entries
^Total\s+MAC
^Headers:
^\s*$$
^.* -> Start
Entries
^${MAC}\s+${VPN}\s+${VLAN}\s+${OUTGOING}\s+${ATTRIBUTE}\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Entries

View file

@ -0,0 +1,18 @@
Value INTERFACE (\S+)
Value OPTIC_TYPE (\S+)
Value WAVELENGTH (\S+)
Value RX_POWER (\S+)
Value TX_POWER (\S+)
Value STATUS (\S+)
Start
^Interface\s+Type -> Entries
^\s*$$
^.* -> Start
Entries
^${INTERFACE}\s+${OPTIC_TYPE}\s+${WAVELENGTH}\s+${RX_POWER}\s+${TX_POWER}\s+${STATUS}(?:\s+\S+)?\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Entries

View file

@ -0,0 +1,23 @@
Value Filldown PROCESS_ID (\d+)
Value Required NEIGHBOR_ID (\S+)
Value PRI (\S+)
Value STATE (\S+)
Value DEAD_TIME (\S+)
Value ADDRESS (\S+)
Value INTERFACE (\S+)
Start
^\s*OSPF\s+Router\s+with\s+ID\s+\S+\s+\(Process\s+ID\s+${PROCESS_ID}\) -> Neighbors
^Neighbor\s+ID -> Neighbors
^\s*$$
^.* -> Start
Neighbors
^\s*OSPF\s+Router\s+with\s+ID\s+\S+\s+\(Process\s+ID\s+${PROCESS_ID}\) -> Neighbors
^Neighbor\s+ID
^Codes:
^${NEIGHBOR_ID}\s+${PRI}\s+${STATE}\s+${DEAD_TIME}\s+${ADDRESS}\s+${INTERFACE}\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Neighbors

View file

@ -0,0 +1,21 @@
Value INTERFACE (\S+)
Value VR_ID (\S+)
Value PRIORITY (\S+)
Value TIME (\S+)
Value FLAGS (\S*)
Value STATE (\S+)
Value MASTER_ADDR (\S+)
Value VROUTER_ADDR (\S+)
Start
^Interface\s+vrID -> Entries
^\s*$$
^.* -> Start
Entries
^${INTERFACE}\s+${VR_ID}\s+${PRIORITY}\s+${TIME}\s+${FLAGS}\s+${STATE}\s+${MASTER_ADDR}\s+${VROUTER_ADDR}\s*$$ -> Record
^${INTERFACE}\s+${VR_ID}\s+${PRIORITY}\s+${TIME}\s+${STATE}\s+${MASTER_ADDR}\s+${VROUTER_ADDR}\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
^.* -> Entries

View file

@ -14,20 +14,40 @@ from ._types import JsonType as _JsonType
class BizMigrationProject(Base):
"""Cutover project linking old/new collect tasks + baselines."""
"""Cutover project linking old/new portrait + HF collect tasks + baselines.
``old_task_id`` / ``new_task_id`` = portrait (biz-monitor) tasks — baseline source.
``old_hf_task_id`` / ``new_hf_task_id`` = cutover high-freq tasks — current source.
"""
__tablename__ = "biz_migration_project"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
name: Mapped[str] = mapped_column(String(256), default="", index=True)
# Portrait / biz-monitor tasks (baseline batches come from these)
old_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
new_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
# Cutover high-freq tasks (current batches for evaluate)
old_hf_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
new_hf_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
old_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
new_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
# Reuse biz_port_mapping (before_if=old, after_if=new)
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
# BizMonitorTemplate — HOW (via compare template) + dual/status rules
monitor_template_id: Mapped[str] = mapped_column(String(64), default="", index=True)
# Project-level collect override (empty = use monitor template)
collect_metric_ids_json: Mapped[list] = mapped_column(_JsonType, default=list)
# Per-metric interval overrides: {"interface_brief": 60, "bgp_peer": 300}
metric_interval_sec_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
# HF bindings (may be multiple tasks when intervals differ):
# [{"task_id":"...","metric_ids":["interface_brief"],"interval_sec":60}, ...]
old_hf_bindings_json: Mapped[list] = mapped_column(_JsonType, default=list)
new_hf_bindings_json: Mapped[list] = mapped_column(_JsonType, default=list)
# HF schedule window (project-level)
hf_interval_sec: Mapped[int] = mapped_column(Integer, default=60)
hf_start_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
hf_end_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|active|done
note: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)

View file

@ -30,6 +30,8 @@ class BizStateTask(Base):
vendor: Mapped[str] = mapped_column(String(64), default="")
device_type: Mapped[str] = mapped_column(String(64), default="")
note: Mapped[str] = mapped_column(String(256), default="")
# "" | portrait | cutover_hf — filter cutover HF in biz-state list
purpose: Mapped[str] = mapped_column(String(32), default="", index=True)
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped
interval_sec: Mapped[int] = mapped_column(Integer, default=300)
# Keep snapshots for N calendar days (protected baselines/refs never auto-deleted)
@ -223,6 +225,8 @@ class BizCompareTemplate(Base):
compare_fields: Mapped[list] = mapped_column(_JsonType, default=list)
ignore_fields: Mapped[list] = mapped_column(_JsonType, default=list)
metrics_json: Mapped[list] = mapped_column(_JsonType, default=list)
# Template-owned type aliases: [{"from":"GE","to":"gei"}, ...]
iface_normalize_json: Mapped[list] = mapped_column(_JsonType, default=list)
note: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)