mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 09:10:50 +08:00
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>
267 lines
8.7 KiB
Python
267 lines
8.7 KiB
Python
"""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
|