Harden ZTE biz-state parsers and default | one-line collect CLIs.

Append secondary IPs with M/S tags, skip ND6 clock banners, split optical power vs thresholds including QSFP lanes, broaden static-route egress ifnames, and add | one-line to ZTE collect templates except interface detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-20 14:49:41 +08:00
parent d900aca633
commit ac4f115ee8
13 changed files with 373 additions and 109 deletions

View file

@ -2,6 +2,10 @@
Duplicate ``interface`` blocks (common for tunnels) are merged: later values
override empty ones; ``admin`` always takes the last explicit setting.
IPv4/IPv6 addresses (including ``secondary``) are collected in order into
``ip_address`` / ``ipv6_address``; ``secondary_tag`` / ``ipv6_secondary_tag``
mark the first as ``M`` and later ones as ``S``.
"""
from __future__ import annotations
@ -15,12 +19,29 @@ 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)
_IP_RE = re.compile(
r"^\s*ip\s+address\s+(\S+)(?:\s+(\S+))?(?:\s+secondary)?\s*$",
re.I,
)
_IP6_RE = re.compile(r"^\s*ipv6\s+address\s+(\S+)(?:\s+secondary)?\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 _ms_tags(count: int) -> str:
if count <= 0:
return ""
return ",".join("M" if i == 0 else "S" for i in range(count))
def _merge_addr_list(prev: str, new: str, *, limit: int = 256) -> str:
existing = [x for x in (prev or "").split(",") if x]
for ip in (new or "").split(","):
if ip and ip not in existing:
existing.append(ip)
return ",".join(existing)[:limit]
def _row_from_cur(cur: dict[str, Any]) -> dict[str, Any]:
ips = cur.get("_ips") or []
ip6s = cur.get("_ip6s") or []
@ -30,7 +51,9 @@ def _row_from_cur(cur: dict[str, Any]) -> dict[str, Any]:
"description": str(cur.get("description") or "")[:256],
"admin": str(cur.get("admin") or "up")[:16],
"ip_address": ",".join(ips)[:256],
"secondary_tag": _ms_tags(len(ips))[:64],
"ipv6_address": ",".join(ip6s)[:256],
"ipv6_secondary_tag": _ms_tags(len(ip6s))[:64],
"mtu": str(cur.get("mtu") or "")[:16],
"_admin_set": bool(cur.get("_admin_set")),
}
@ -57,17 +80,15 @@ def _flush(
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]
merged = _merge_addr_list(prev["ip_address"], row["ip_address"])
prev["ip_address"] = merged
prev["secondary_tag"] = _ms_tags(len([x for x in merged.split(",") if x]))[:64]
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]
merged = _merge_addr_list(prev["ipv6_address"], row["ipv6_address"])
prev["ipv6_address"] = merged
prev["ipv6_secondary_tag"] = _ms_tags(
len([x for x in merged.split(",") if x])
)[:64]
return
by_iface[iface] = len(out)
out.append(row)
@ -132,6 +153,9 @@ def normalize_config_interface(
if m:
addr = m.group(1).strip()
mask = (m.group(2) or "").strip()
# Avoid treating the literal word "secondary" as a mask when only one token follows.
if mask.lower() == "secondary":
mask = ""
cur["_ips"].append(f"{addr}/{mask}" if mask else addr)
continue
m = _IP6_RE.match(line)

View file

@ -27,9 +27,18 @@ _V6_HEAD = re.compile(
)
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
# Broad ZTE if-name match: whitelist prefixes + generic if-like tokens.
_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*)$"
r"(?i)^(?:"
r"null\d*"
r"|(?:xxv|xl|x|c|v6|q|fe)?gei-\S+"
r"|bvi\S*|vlan\S*|smartgroup\S*|loopback\S*|mgmt_?\S*"
r"|pos\S*|(?:fast)?eth\S*"
r"|(?:te_|gre_|ip_|v6)?tunnel\S*"
r"|irb\S*|ve\S*|dialer\S*|serial\S*|ppp\S*|bundle\S*"
r"|fei-\S*|qi-\S*"
r"|[a-z][a-z0-9]*[-_][a-z0-9_./:-]+" # e.g. te_tunnel36, gre_tunnel1
r")$"
)
_KW = frozenset(
{
@ -52,8 +61,21 @@ def _is_ipv4(tok: str) -> bool:
return bool(_IPV4_RE.match(tok or ""))
def _is_ipv6_tok(tok: str) -> bool:
t = str(tok or "")
return ":" in t and not _is_iface(t)
def _is_iface(tok: str) -> bool:
return bool(_IFACE_RE.match(tok or ""))
t = str(tok or "").strip()
if not t or t.lower() in _KW:
return False
if _is_ipv4(t):
return False
# IPv6 next-hop (has colon) is not an interface name.
if ":" in t and re.match(r"(?i)^[0-9a-f:]+(/\d+)?$", t):
return False
return bool(_IFACE_RE.match(t))
def _take_kw(tokens: list[str], i: int, row: dict[str, str]) -> int:
@ -112,14 +134,19 @@ def _parse_v4_rest(rest: str) -> dict[str, str]:
_take_kw(tokens, 2, row)
return row
if _is_iface(tokens[0]):
# interface [next-hop]
row["interface"] = tokens[0]
i = 1
if i < len(tokens) and _is_ipv4(tokens[i]) and tokens[i].lower() not in _KW:
if i < len(tokens) and _is_ipv4(tokens[i]):
row["next_hop"] = tokens[i]
i += 1
elif _is_ipv4(tokens[0]):
# next-hop [interface]
row["next_hop"] = tokens[0]
i = 1
if i < len(tokens) and _is_iface(tokens[i]):
row["interface"] = tokens[i]
i += 1
_take_kw(tokens, i, row)
return row
@ -147,12 +174,15 @@ def _parse_v6_rest(rest: str) -> dict[str, str]:
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:
if i < len(tokens) and _is_ipv6_tok(tokens[i]):
row["next_hop"] = tokens[i]
i += 1
elif ":" in tokens[0]:
elif _is_ipv6_tok(tokens[0]):
row["next_hop"] = tokens[0]
i = 1
if i < len(tokens) and _is_iface(tokens[i]):
row["interface"] = tokens[i]
i += 1
_take_kw(tokens, i, row)
return row

View file

@ -10,10 +10,23 @@ _ND6_ROW_RE = re.compile(
r"(?P<iface>\S+)\s+(?P<type>\S+)\s*$",
re.I,
)
# CLI banner / wall-clock line (e.g. "14:06:53 Beijing Sun Sep 20 2026")
_CLOCK_LINE_RE = re.compile(r"^\d{1,2}:\d{2}:\d{2}\b")
_TIME_OF_DAY_RE = re.compile(r"^\d{1,2}:\d{2}:\d{2}$")
RULE_KEYS: tuple[str, ...] = ()
def _looks_like_ipv6(addr: str) -> bool:
a = str(addr or "").strip()
if not a or ":" not in a:
return False
# HH:MM:SS is not an IPv6 address
if _TIME_OF_DAY_RE.match(a):
return False
return bool(re.search(r"[0-9a-fA-F]", a))
def normalize_nd6_cache(
*,
raw_text: str,
@ -30,6 +43,8 @@ def normalize_nd6_cache(
line = raw.strip()
if not line:
continue
if _CLOCK_LINE_RE.match(line):
continue
low = line.lower()
if low.startswith("s-static") or low.startswith("total cache") or low.startswith("only current"):
continue
@ -39,6 +54,8 @@ def normalize_nd6_cache(
if not m:
continue
addr = m.group("addr")
if not _looks_like_ipv6(addr):
continue
iface = m.group("iface")
key = (addr, iface)
if key in seen:
@ -56,4 +73,5 @@ def normalize_nd6_cache(
)
return out
normalize_nd6_cache.RULE_KEYS = RULE_KEYS

View file

@ -1,4 +1,9 @@
"""ZTE: show opticalinfo brief."""
"""ZTE: show opticalinfo brief [| one-line].
Power cells look like ``-6.0/[-20.0,-3.0]`` (value / [low,high] threshold).
50G+ (QSFP) modules print one lane per line; continuation lines are merged
into comma-separated ``rx_power`` / ``tx_power`` (and matching thresholds).
"""
from __future__ import annotations
@ -11,57 +16,172 @@ 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+)",
# value/[low,high] or bare N/A|Unsupported|number (no threshold)
_CELL_RE = re.compile(
r"^(?P<val>N/A|Unsupported|-?\d+(?:\.\d+)?)(?:/(?P<th>\[[^\]]+\]))?$",
re.I,
)
_HDR_RE = re.compile(
r"^(?P<iface>\S+)\s+(?P<otype>offline)\s*$|"
r"^(?P<iface2>\S+)\s+(?P<otype2>\S+)\s+(?P<wave>\S+)\s+"
r"(?P<rx>\S+)\s+(?P<tx>\S+)\s+(?P<status>\S+)(?:\s+(?P<intensity>\S+))?\s*$",
re.I,
)
# Lane continuation (indented); require a status token so bare "7.5 7.7" summary is skipped.
_CONT_RE = re.compile(
r"^\s+(?P<rx>\S+)\s+(?P<tx>\S+)\s+(?P<status>\S+)(?:\s+(?P<intensity>\S+))?\s*$",
re.I,
)
def _parse_cell(raw: str) -> tuple[str, str] | None:
s = str(raw or "").strip()
if not s:
return None
m = _CELL_RE.match(s)
if not m:
return None
return (m.group("val"), m.group("th") or "")
def _join(parts: list[str], *, limit: int = 128) -> str:
return ",".join(p for p in parts if p != "")[:limit]
def _dedupe_join(parts: list[str], *, limit: int = 128) -> str:
"""Join thresholds; collapse when every lane shares the same bracket."""
vals = [p for p in parts if p]
if not vals:
return ""
if len(set(vals)) == 1:
return vals[0][:limit]
return _join(vals, limit=limit)
def _empty_row(iface: str, optic_type: str = "", wavelength: str = "") -> dict[str, Any]:
return {
"interface": iface[:128],
"optic_type": optic_type[:64],
"wavelength": wavelength[:32],
"rx_power": "",
"rx_threshold": "",
"tx_power": "",
"tx_threshold": "",
"status": "",
"_rx": [],
"_rx_th": [],
"_tx": [],
"_tx_th": [],
"_status": [],
}
def _append_lane(row: dict[str, Any], rx_raw: str, tx_raw: str, status: str = "") -> bool:
rx = _parse_cell(rx_raw)
tx = _parse_cell(tx_raw)
if rx is None or tx is None:
return False
# Skip bare numeric summary lines (no threshold on either side, no status).
if not rx[1] and not tx[1] and not status:
return False
row["_rx"].append(rx[0])
row["_rx_th"].append(rx[1])
row["_tx"].append(tx[0])
row["_tx_th"].append(tx[1])
if status:
row["_status"].append(status)
return True
def _finalize(row: dict[str, Any]) -> dict[str, Any]:
statuses = row.pop("_status", [])
rx = row.pop("_rx", [])
rx_th = row.pop("_rx_th", [])
tx = row.pop("_tx", [])
tx_th = row.pop("_tx_th", [])
row["rx_power"] = _join(rx)
row["rx_threshold"] = _dedupe_join(rx_th)
row["tx_power"] = _join(tx)
row["tx_threshold"] = _dedupe_join(tx_th)
if statuses:
# Prefer first Abnormal/Unknown if any, else first status.
bad = next((s for s in statuses if s.lower() not in ("normal",)), None)
row["status"] = (bad or statuses[0])[:32]
return row
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
"""Aggregate Filldown lane rows from TextFSM into one row per interface."""
by: dict[str, dict[str, Any]] = {}
order: list[str] = []
for r in rows:
iface = row_get(r, "INTERFACE", "interface")
if not iface or iface.lower() == "interface" or iface in seen:
if not iface or iface.lower() == "interface":
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],
}
rx_raw = row_get(r, "RX_POWER", "rx_power")
tx_raw = row_get(r, "TX_POWER", "tx_power")
otype = row_get(r, "OPTIC_TYPE", "optic_type")
wave = row_get(r, "WAVELENGTH", "wavelength")
# Offline lines: only INTERFACE is new; Filldown may still hold prior type/wave.
if (not rx_raw and not tx_raw) or otype.lower() == "offline":
if iface not in by:
by[iface] = _empty_row(iface, "offline")
by[iface]["status"] = "offline"
order.append(iface)
continue
if iface not in by:
by[iface] = _empty_row(iface, otype, wave)
order.append(iface)
else:
cur = by[iface]
if otype and cur["optic_type"] in ("", "offline"):
cur["optic_type"] = otype[:64]
if wave and not cur["wavelength"]:
cur["wavelength"] = wave[:32]
_append_lane(
by[iface],
rx_raw,
tx_raw,
row_get(r, "STATUS", "status"),
)
return out
return [_finalize(by[i]) for i in order]
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[str] = set()
cur: dict[str, Any] | None = None
def _flush() -> None:
nonlocal cur
if cur:
out.append(_finalize(cur))
cur = None
for raw in str(raw_text or "").splitlines():
line = raw.strip()
if not line or line.lower().startswith("interface"):
if not raw.strip():
continue
m = _OPT_RE.match(line)
low = raw.strip().lower()
if low.startswith("interface") and "type" in low:
continue
m = _HDR_RE.match(raw.rstrip())
if m:
_flush()
if m.group("otype") and m.group("otype").lower() == "offline":
cur = _empty_row(m.group("iface"), "offline")
cur["status"] = "offline"
_flush()
continue
iface = m.group("iface2") or ""
cur = _empty_row(iface, m.group("otype2") or "", m.group("wave") or "")
_append_lane(cur, m.group("rx") or "", m.group("tx") or "", m.group("status") or "")
continue
if cur is None:
continue
m = _CONT_RE.match(raw)
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],
}
)
_append_lane(cur, m.group("rx"), m.group("tx"), m.group("status") or "")
_flush()
return out