Make bgp_route hand-only for divergent IN/OUT CLI layouts.

Skip TextFSM for neighbor route tables (status vs plain, From, RD+VRF, heavy wraps); parse via callback and cover with live RR/VRF fixtures.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-23 15:36:25 +08:00
parent ac53af4a5d
commit c18c9afa2e
6 changed files with 354 additions and 194 deletions

View file

@ -235,7 +235,8 @@ def _run_primary_parse_job(job: Any) -> tuple[bool, bool]:
any_fail = False any_fail = False
def _declared_total(raw: str) -> int: def _declared_total(raw: str) -> int:
m = re.search(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)", raw or "") text = (raw or "").replace("\u00a0", " ")
m = re.search(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)", text)
return int(m.group(1)) if m else 0 return int(m.group(1)) if m else 0
def _flush_item(item: SpooledCommand, *, records: list[dict[str, Any]] | None = None) -> None: def _flush_item(item: SpooledCommand, *, records: list[dict[str, Any]] | None = None) -> None:

View file

@ -29,3 +29,34 @@ def prefer_fsm(
if mapped: if mapped:
return mapped return mapped
return hand_fn(raw_text=raw_text, **kw) return hand_fn(raw_text=raw_text, **kw)
def prefer_hand(
fsm_tables: Mapping[str, list[dict[str, Any]]] | None,
keys: Sequence[str],
map_rows: MapRowsFn,
hand_fn: HandFn,
*,
raw_text: str,
**kw: Any,
) -> list[dict[str, Any]]:
"""Hand-first dual path: callback when non-empty; else first mapped FSM table.
Use when one TextFSM cannot cover divergent CLI layouts (e.g. BGP neighbor
in vs out, status vs plain, heavy wraps). Empty ``keys`` / ``RULE_KEYS=()``
makes this hand-only.
"""
hand = hand_fn(raw_text=raw_text, **kw)
if hand:
return hand
tables = fsm_tables or {}
for key in keys or ():
k = str(key or "").strip()
if not k:
continue
rows = tables.get(k) or []
if rows:
mapped = map_rows(rows)
if mapped:
return mapped
return hand

View file

@ -1,34 +1,38 @@
"""ZTE: show bgp … neighbor {in|out} [vrf] routes.""" """ZTE: show bgp … neighbor {in|out} [vrf] routes.
Hand-only parser. IN/OUT, status vs plain, From column, RD+VRF annotations and
heavy IPv6 wraps diverge too much for one TextFSM; dual-path policy is hand-first
(``RULE_KEYS=()`` skips FSM in ``run_parser``).
"""
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import re import re
from typing import Any, Mapping from typing import Any
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_local_as, _detect_vrf from .bgp_peer import _detect_bgp_afi, _detect_local_as, _detect_vrf
RULE_KEYS = ("zte_zxros_show_bgp_neighbor_routes",) # Declared empty → run_parser will not apply TextFSM for this metric.
RULE_KEYS: tuple[str, ...] = ()
# Status codes may be glued or spaced: "*", "* i", "*>i", "* s", "< *" # Status must stay glued: "* i" / "*i" / "*>i" — never leave "i" as NETWORK.
_FLAGS = r"(?P<flags>(?:[*<>isd]+(?:\s+[*<>isd]+)*)?)" _STATUS_LEAD_RE = re.compile(
# Single-line IPv4-style: * i 10.1.0.0/24 10.0.0.1 … path r"^\s*(?P<flags>\*(?:\s*[<>isd]+)?|[<>isd]+)\s+"
_ROUTE_ONE_LINE_RE = re.compile(
rf"^\s*{_FLAGS}\s+"
r"(?P<net>\S+)\s+"
r"(?P<nh>\S+)\s+"
r"(?P<rest>.*)$"
) )
# Network alone (often IPv6 wrap): * i 2407::1/128 # After optional status: network + next-hop + remainder
_NET_ONLY_RE = re.compile(rf"^\s*{_FLAGS}\s*(?P<net>\S+)\s*$") _NET_NH_RE = re.compile(
r"^\s*(?P<net>\S+)\s+(?P<nh>\S+)(?:\s+(?P<rest>.*))?$"
)
_NET_ONLY_RE = re.compile(r"^\s*(?P<net>\S+)\s*$")
_DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+") _DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+")
# Neighbor may be IPv4 or IPv6 (consume until EOL / pipe)
_NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)") _NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)")
_TOTAL_RE = re.compile(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)") _TOTAL_RE = re.compile(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)")
_RD_RE = re.compile(r"(?i)^Route\s+Distinguisher\s*:\s*(\S+)") # Route Distinguisher:2:111 (default for vrf css-srv6-mpls-1)
_RD_RE = re.compile(
r"(?i)^Route\s+Distinguisher\s*:\s*(?P<rd>\S+)"
r"(?:\s*\(\s*default\s+for\s+vrf\s+(?P<vrf>\S+)\s*\))?"
)
_HEADER_NETS = frozenset( _HEADER_NETS = frozenset(
{ {
"network", "network",
@ -46,6 +50,12 @@ _HEADER_NETS = frozenset(
"from", "from",
} }
) )
_PATH_ENDS = frozenset({"?", "i", "e", "incomplete"})
def _normalize_cli_text(raw_text: str) -> str:
"""NBSP / odd spaces from paste or terminals → regular space."""
return str(raw_text or "").replace("\u00a0", " ").replace("\u2007", " ")
def _detect_direction(command: str, params: dict[str, str] | None) -> str: def _detect_direction(command: str, params: dict[str, str] | None) -> str:
@ -78,11 +88,11 @@ def _looks_like_ip_or_prefix(tok: str) -> bool:
def _looks_like_prefix(net: str) -> bool: def _looks_like_prefix(net: str) -> bool:
return _looks_like_ip_or_prefix(net) return _looks_like_ip_or_prefix(net) and "/" in str(net or "")
def _declared_total(raw_text: str) -> int | None: def _declared_total(raw_text: str) -> int | None:
m = _TOTAL_RE.search(str(raw_text or "")) m = _TOTAL_RE.search(_normalize_cli_text(raw_text))
if not m: if not m:
return None return None
try: try:
@ -97,23 +107,39 @@ def _route_dedupe_key(*, rd: str, net: str, nh: str) -> str:
def _split_rest(rest: str, *, path_continuation: bool = False) -> tuple[str, str, str, str]: def _split_rest(rest: str, *, path_continuation: bool = False) -> tuple[str, str, str, str]:
"""Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank). """Parse trailing [From] Metric LocPrf Tag/RtPrf Path columns.
``path_continuation``: indented wrap line after next-hop (often ``20 65254 ?`` OUT tables include a From column (IP) between Next Hop and Metric.
or ``4761 ?``) — prefer path/tag over inventing a metric. ``path_continuation``: indented wrap after network/nh (often ``0 100 283610 ?``).
""" """
parts = str(rest or "").split() parts = [p for p in str(rest or "").split() if p]
if not parts: if not parts:
return "", "", "", "" return "", "", "", ""
if path_continuation and parts[-1] in ("?", "i", "e", "incomplete"):
# Drop leading From IP when present (OUT neighbor tables).
if _looks_like_ip_or_prefix(parts[0]) and "/" not in parts[0]:
parts = parts[1:]
if not parts:
return "", "", "", ""
if path_continuation and parts[-1] in _PATH_ENDS:
if len(parts) == 1: if len(parts) == 1:
return "", "", "", parts[0] return "", "", "", parts[0]
if len(parts) == 2 and parts[0].isdigit(): if len(parts) == 2 and parts[0].isdigit():
# ``4761 ?`` → path # ``100 ?`` → loc_prf + origin; ``4761 ?`` → AS-path + origin
n = int(parts[0])
if n <= 255:
return "", parts[0], "", parts[1]
return "", "", "", " ".join(parts) return "", "", "", " ".join(parts)
if len(parts) == 3 and parts[0].isdigit() and parts[1].isdigit():
# ``0 100 ?`` → metric + loc + path
return parts[0], parts[1], "", parts[2]
if len(parts) >= 4 and all(parts[i].isdigit() for i in range(3)):
# ``0 100 283610 ?`` → metric loc tag path
return parts[0], parts[1], parts[2], " ".join(parts[3:])
if len(parts) >= 3 and parts[0].isdigit(): if len(parts) >= 3 and parts[0].isdigit():
# ``20 65254 ?`` → rtprf + path
return "", "", parts[0], " ".join(parts[1:]) return "", "", parts[0], " ".join(parts[1:])
metric = loc = tag = "" metric = loc = tag = ""
nums: list[str] = [] nums: list[str] = []
path_parts: list[str] = [] path_parts: list[str] = []
@ -122,18 +148,33 @@ def _split_rest(rest: str, *, path_continuation: bool = False) -> tuple[str, str
nums.append(p) nums.append(p)
else: else:
path_parts.append(p) path_parts.append(p)
if len(nums) >= 1: # One number before origin → LocPrf (OUT often omits Metric).
if len(nums) == 1 and path_parts:
loc = nums[0]
elif len(nums) >= 1:
metric = nums[0] metric = nums[0]
if len(nums) >= 2: if len(nums) >= 2:
loc = nums[1] loc = nums[1]
if len(nums) >= 3: if len(nums) >= 3:
tag = nums[2] tag = nums[2]
path = " ".join(path_parts) path = " ".join(path_parts)
return metric, loc, tag, path return metric, loc, tag, path
def _rest_looks_complete(rest: str) -> bool:
"""True when remainder has a path origin — safe to emit without waiting for wrap."""
parts = str(rest or "").split()
if not parts:
return False
if parts[-1] in _PATH_ENDS:
return True
# From-only (single IP) → wait for metric/path wrap
if len(parts) == 1 and _looks_like_ip_or_prefix(parts[0]) and "/" not in parts[0]:
return False
return len(parts) >= 2
def _empty_if_total_zero(raw_text: str) -> bool: def _empty_if_total_zero(raw_text: str) -> bool:
"""True when device reports Total number of routes: 0."""
total = _declared_total(raw_text) total = _declared_total(raw_text)
return total == 0 return total == 0
@ -152,13 +193,14 @@ def _skip_noise_line(line: str) -> bool:
return True return True
if low.startswith("valid ") or low.startswith("invalid "): if low.startswith("valid ") or low.startswith("invalid "):
return True return True
# Banner / clock lines (e.g. "09:50:02 Indonesia Sat Sep 19 2026")
if re.match(r"^\d{1,2}:\d{2}:\d{2}\b", low): if re.match(r"^\d{1,2}:\d{2}:\d{2}\b", low):
return True return True
if low.endswith("#") or "#'" in low: if low.endswith("#") or "#'" in low:
return True return True
if re.search(r"\S+\s*#\s*$", line): if re.search(r"\S+\s*#\s*$", line):
return True return True
if low.startswith("show "):
return True
return False return False
@ -181,7 +223,6 @@ def _emit_route(
if not _looks_like_prefix(net): if not _looks_like_prefix(net):
return return
if nh and not _looks_like_ip_or_prefix(nh): if nh and not _looks_like_ip_or_prefix(nh):
# Path/metric-only continuation without a real next-hop — keep empty nh
if re.search(r"[A-Za-z]", nh): if re.search(r"[A-Za-z]", nh):
return return
nh = "" nh = ""
@ -212,57 +253,6 @@ def _emit_route(
) )
def _map_fsm_rows(
rows: list[dict[str, Any]],
*,
local_as: str,
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 _looks_like_prefix(net):
continue
nh = row_get(r, "NEXT_HOP", "next_hop")
if str(nh or "").strip().lower() in _HEADER_NETS:
continue
if nh and not _looks_like_ip_or_prefix(nh):
# Reject FSM false hits like NETWORK=20 NEXT_HOP=65254
continue
rd = row_get(r, "RD", "rd")
key = _route_dedupe_key(rd=rd, net=net, nh=nh or "")
if key in seen:
continue
seen.add(key)
path = row_get(r, "PATH", "path")
flags = row_get(r, "STATUS", "status_codes", "FLAGS")
out.append(
{
"local_as": local_as[:16],
"afi": afi[:32],
"vrf": vrf[:128],
"neighbor": neighbor[:128],
"direction": direction[:8],
"rd": (rd or "")[:64],
"network": net[:128],
"next_hop": nh[: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": re.sub(r"\s+", "", (flags or "").strip())[:16],
"as_num": "",
"state": "",
"pfx_rcd": "",
}
)
return out
def _hand_parse( def _hand_parse(
*, *,
raw_text: str, raw_text: str,
@ -273,100 +263,122 @@ def _hand_parse(
direction: str = "", direction: str = "",
**_kw: Any, **_kw: Any,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Parse neighbor in/out tables; join IPv6 network / next-hop / path wraps.""" """Parse neighbor in/out tables; join heavy IPv6 / From / metric wraps."""
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
seen: set[str] = set() seen: set[str] = set()
pending_net = "" pending_net = ""
pending_flags = "" pending_flags = ""
pending_nh = "" pending_nh = ""
pending_rest = ""
current_rd = "" current_rd = ""
current_vrf = vrf
def _flush_pending(*, rest: str = "", path_continuation: bool = False) -> None: def _flush_pending(*, rest: str = "", path_continuation: bool = False) -> None:
nonlocal pending_net, pending_flags, pending_nh nonlocal pending_net, pending_flags, pending_nh, pending_rest
if not pending_net: if not pending_net:
return return
merged = " ".join(x for x in (pending_rest, rest) if x).strip()
_emit_route( _emit_route(
out, out,
seen, seen,
local_as=local_as, local_as=local_as,
afi=afi, afi=afi,
vrf=vrf, vrf=current_vrf,
neighbor=neighbor, neighbor=neighbor,
direction=direction, direction=direction,
rd=current_rd, rd=current_rd,
net=pending_net, net=pending_net,
nh=pending_nh, nh=pending_nh,
rest=rest, rest=merged,
flags=pending_flags, flags=pending_flags,
path_continuation=path_continuation, path_continuation=path_continuation,
) )
pending_net = "" pending_net = ""
pending_flags = "" pending_flags = ""
pending_nh = "" pending_nh = ""
pending_rest = ""
for raw in str(raw_text or "").splitlines(): def _start_pending(*, net: str, nh: str, flags: str, rest: str) -> None:
nonlocal pending_net, pending_flags, pending_nh, pending_rest
_flush_pending()
pending_net = net
pending_flags = flags
pending_nh = nh
pending_rest = rest
for raw in _normalize_cli_text(raw_text).splitlines():
line = raw.rstrip() line = raw.rstrip()
rd_m = _RD_RE.match(line.strip()) rd_m = _RD_RE.match(line.strip())
if rd_m: if rd_m:
_flush_pending() _flush_pending()
current_rd = rd_m.group(1).strip() current_rd = (rd_m.group("rd") or "").strip()
vrf_from_rd = (rd_m.group("vrf") or "").strip()
if vrf_from_rd:
current_vrf = vrf_from_rd
continue continue
if _skip_noise_line(line): if _skip_noise_line(line):
continue continue
# Continuation: indented next-hop after network-only line. # Indented continuation while a route is open
# RR vpnv6 often puts NH + LocPrf/Path on the same indented line if pending_net and line[:1].isspace():
# ("24.11.0.8 100 0 ?") — take first token
# as NH or ECMP legs collapse under empty next_hop dedupe (~half count).
if pending_net and not pending_nh and line[:1].isspace():
tok = line.strip() tok = line.strip()
if not tok:
continue
parts = tok.split() parts = tok.split()
first = parts[0] if parts else "" first = parts[0] if parts else ""
if _looks_like_ip_or_prefix(first) and "/" not in first: if not pending_nh:
pending_nh = first if _looks_like_ip_or_prefix(first) and "/" not in first:
rest = " ".join(parts[1:]) pending_nh = first
if rest: more = " ".join(parts[1:])
_flush_pending(rest=rest, path_continuation=True) if more and _rest_looks_complete(more):
continue _flush_pending(rest=more, path_continuation=True)
# Metrics/path without explicit next-hop (rare) elif more:
if tok and not _looks_like_prefix(first): pending_rest = " ".join(x for x in (pending_rest, more) if x)
continue
if not _looks_like_prefix(first):
_flush_pending(rest=tok, path_continuation=True)
continue
else:
_flush_pending(rest=tok, path_continuation=True) _flush_pending(rest=tok, path_continuation=True)
continue continue
# Continuation: indented path/metric after network+nh # Peel optional status codes (* i / *i / > …)
if pending_net and pending_nh and line[:1].isspace(): flags = ""
tok = line.strip() body = line
if tok: st = _STATUS_LEAD_RE.match(line)
_flush_pending(rest=tok, path_continuation=True) if st:
continue flags = (st.group("flags") or "").strip()
body = line[st.end() :]
# Full one-liner (typical IPv4 / RR "* i prefix …") # Full / partial: network + next-hop (+ rest)
m = _ROUTE_ONE_LINE_RE.match(line) m = _NET_NH_RE.match(body)
if m and _looks_like_prefix(m.group("net")) and _looks_like_ip_or_prefix(m.group("nh")): if m and _looks_like_prefix(m.group("net")) and _looks_like_ip_or_prefix(m.group("nh")):
_flush_pending() rest = (m.group("rest") or "").strip()
_emit_route( if _rest_looks_complete(rest):
out, _flush_pending()
seen, _emit_route(
local_as=local_as, out,
afi=afi, seen,
vrf=vrf, local_as=local_as,
neighbor=neighbor, afi=afi,
direction=direction, vrf=current_vrf,
rd=current_rd, neighbor=neighbor,
net=m.group("net"), direction=direction,
nh=m.group("nh"), rd=current_rd,
rest=m.group("rest"), net=m.group("net"),
flags=m.group("flags") or "", nh=m.group("nh"),
) rest=rest,
flags=flags,
)
else:
# Empty rest or From-only → wait for metric/path wrap
_start_pending(net=m.group("net"), nh=m.group("nh"), flags=flags, rest=rest)
continue continue
# Network alone → wait for next-hop / path wraps (IPv6) # Network alone → wait for next-hop wrap
m_net = _NET_ONLY_RE.match(line) m_net = _NET_ONLY_RE.match(body)
if m_net and _looks_like_prefix(m_net.group("net")): if m_net and _looks_like_prefix(m_net.group("net")):
_flush_pending() _start_pending(net=m_net.group("net"), nh="", flags=flags, rest="")
pending_net = m_net.group("net")
pending_flags = m_net.group("flags") or ""
pending_nh = ""
continue continue
_flush_pending() _flush_pending()
@ -376,62 +388,22 @@ def _hand_parse(
def normalize_bgp_route( def normalize_bgp_route(
*, *,
raw_text: str, raw_text: str,
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
vendor: str = "",
device_type: str = "",
command: str = "", command: str = "",
params: dict[str, str] | None = None, params: dict[str, str] | None = None,
**_kw: Any,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
local_as = _detect_local_as(command, params) """Hand-only normalize (see module docstring)."""
afi = _detect_bgp_afi(command, params) raw_text = _normalize_cli_text(raw_text)
vrf = _detect_vrf(command, params)
neighbor = _detect_neighbor(command, params)
direction = _detect_direction(command, params)
if _empty_if_total_zero(raw_text): if _empty_if_total_zero(raw_text):
return [] return []
return _hand_parse(
tables = dict(fsm_tables or {}) raw_text=raw_text,
if not any(tables.get(k) for k in RULE_KEYS): local_as=_detect_local_as(command, params),
platform = resolve_cli_platform( afi=_detect_bgp_afi(command, params),
vendor=vendor, vrf=_detect_vrf(command, params),
device_type=device_type, neighbor=_detect_neighbor(command, params),
vendor_key=resolve_vendor_key(vendor, device_type), direction=_detect_direction(command, params),
) )
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,
local_as=local_as,
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,
local_as=local_as,
afi=afi,
vrf=vrf,
neighbor=neighbor,
direction=direction,
**kw,
)
rows = prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
# Prefer hand when FSM under-parses vs device-declared total (common on vpnv6 wraps).
declared = _declared_total(raw_text)
if declared is not None and declared > 0 and len(rows) < int(declared * 0.9):
hand_rows = _hand(raw_text=raw_text)
if len(hand_rows) > len(rows):
return hand_rows
return rows
normalize_bgp_route.RULE_KEYS = RULE_KEYS normalize_bgp_route.RULE_KEYS = RULE_KEYS

View file

@ -13,7 +13,8 @@ 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_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_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_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) # bgp neighbor in/out routes: hand-only (parsers/zte/bgp_route.py); template kept as archive
# 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_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_ipv6_forwarding_route.textfsm, .*, zte_zxros, sh[[ow]] ipv6 forw[[arding]] rou[[te]]
zte/zte_zxros_show_l2vpn_forwardinfo_detail.textfsm, .*, zte_zxros, sh[[ow]] l2vpn forw[[ardinfo]] det[[ail]] zte/zte_zxros_show_l2vpn_forwardinfo_detail.textfsm, .*, zte_zxros, sh[[ow]] l2vpn forw[[ardinfo]] det[[ail]]

View file

@ -1,7 +1,12 @@
# ARCHIVE — not registered in index. bgp_route uses hand-only normalize
# (IN/OUT / status / From / RD+VRF / wraps diverge too much for one template).
Value Filldown RD (\S+) Value Filldown RD (\S+)
Value STATUS ((?:\*[<>isd]*(?:\s+[isd]+)?|[<>isd]+)?) Value Filldown VRF (\S+)
Value STATUS (\*(?:\s*[<>isd]+)?|[<>isd]+)
Value NETWORK (\d{1,3}(?:\.\d{1,3}){3}/\d{1,2}|[0-9A-Fa-f:]+/\d{1,3}) Value NETWORK (\d{1,3}(?:\.\d{1,3}){3}/\d{1,2}|[0-9A-Fa-f:]+/\d{1,3})
Value NEXT_HOP (\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f:]+) # IPv4, IPv6, or IPv4-mapped (::FFFF:a.b.c.d)
Value NEXT_HOP (\d{1,3}(?:\.\d{1,3}){3}|::[Ff]{4}:\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f:]+)
Value FROM (\d{1,3}(?:\.\d{1,3}){3}|::[Ff]{4}:\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f:]+)
Value METRIC (\S*) Value METRIC (\S*)
Value LOC_PRF (\S*) Value LOC_PRF (\S*)
Value TAG (\S*) Value TAG (\S*)
@ -12,13 +17,12 @@ Start
^\s*\*?\s*Network\s+Next -> Routes ^\s*\*?\s*Network\s+Next -> Routes
^Network\s+Next -> Routes ^Network\s+Next -> Routes
^\s*Dest\s+Next -> Routes ^\s*Dest\s+Next -> Routes
# RD may appear before header on some snippets; keep Filldown ready ^Route\s+Distinguisher\s*:?\s*${RD}(?:\s*\(\s*default\s+for\s+vrf\s+${VRF}\s*\))?
^Route\s+Distinguisher\s*:?\s*${RD}
^\s*$$ ^\s*$$
^.* -> Start ^.* -> Start
Routes Routes
^Route\s+Distinguisher\s*:?\s*${RD} ^Route\s+Distinguisher\s*:?\s*${RD}(?:\s*\(\s*default\s+for\s+vrf\s+${VRF}\s*\))?
^Status\s+codes ^Status\s+codes
^Origin\s+codes ^Origin\s+codes
^Current\s+AS ^Current\s+AS
@ -30,11 +34,28 @@ Routes
^\s*\*?\s*Network\s+Next ^\s*\*?\s*Network\s+Next
^Network\s+Next ^Network\s+Next
^\s*Dest\s+Next ^\s*Dest\s+Next
# One-line IPv4 / RR "* i prefix nh …" # IN one-line with status: * i prefix nh metric loc tag path
^\s*${STATUS}\s+${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record ^\s*${STATUS}\s+${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
^\s*${STATUS}\s+${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${PATH}\s*$$ -> Record
^\s*${STATUS}\s+${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record ^\s*${STATUS}\s+${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record
# IPv6 wrap: network alone, then next-hop (+ optional metric/path) lines # OUT one-line without status: net nh [from] metric locprf [tag] path
^\s*${NETWORK}\s+${NEXT_HOP}\s+${FROM}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
^\s*${NETWORK}\s+${NEXT_HOP}\s+${FROM}\s+${METRIC}\s+${LOC_PRF}\s+${PATH}\s*$$ -> Record
^\s*${NETWORK}\s+${NEXT_HOP}\s+${FROM}\s+${LOC_PRF}\s+${PATH}\s*$$ -> Record
^\s*${NETWORK}\s+${NEXT_HOP}\s+${LOC_PRF}\s+${PATH}\s*$$ -> Record
# IN wrap: status+net+nh then indented metrics
^\s*${STATUS}\s+${NETWORK}\s+${NEXT_HOP}\s*$$
^\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
^\s+${METRIC}\s+${LOC_PRF}\s+${PATH}\s*$$ -> Record
^\s+${PATH}\s*$$ -> Record
# OUT wrap: net+nh+from then indented metrics (From may be long IPv6)
^\s*${NETWORK}\s+${NEXT_HOP}\s+${FROM}\s*$$
^\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
^\s+${METRIC}\s+${LOC_PRF}\s+${PATH}\s*$$ -> Record
^\s+${PATH}\s*$$ -> Record
# Classic wrap: network alone, then next-hop (+ optional metric/path)
^\s*${STATUS}\s+${NETWORK}\s*$$ ^\s*${STATUS}\s+${NETWORK}\s*$$
^\s*${NETWORK}\s*$$
^\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record ^\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
^\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${PATH}\s*$$ -> Record ^\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${PATH}\s*$$ -> Record
^\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record ^\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
import unittest import unittest
from pathlib import Path from pathlib import Path
@ -1235,5 +1236,138 @@ Route Distinguisher:10.0.0.1:100
self.assertTrue(all(r.get("status_codes") == "*i" for r in routes)) self.assertTrue(all(r.get("status_codes") == "*i" for r in routes))
self.assertTrue(all(r.get("rd") == "10.0.0.1:100" for r in routes)) self.assertTrue(all(r.get("rd") == "10.0.0.1:100" for r in routes))
def test_bgp_route_prod_snippets_rd_vrf_and_wraps(self) -> None:
"""Live RR snippets: OUT without status, RD+VRF, vpnv6 *i + NH wrap."""
from pathlib import Path
fixture = (
Path(__file__).resolve().parents[2]
/ "test"
/ "show-zte"
/ "Untitled-1.ini"
)
if not fixture.is_file():
fixture = (
Path(__file__).resolve().parents[3]
/ "test"
/ "show-zte"
/ "Untitled-1.ini"
)
raw_all = fixture.read_text(encoding="utf-8", errors="replace")
def _section(cmd_prefix: str) -> tuple[str, str]:
text = raw_all.replace("\u00a0", " ")
parts = re.split(r"(?=^show bgp )", text, flags=re.M)
for part in parts:
part = part.strip()
if not part.startswith("show bgp"):
continue
head = part.splitlines()[0].strip()
if head.startswith(cmd_prefix):
body = "\n".join(part.splitlines()[1:])
return head, body
self.fail(f"section not found: {cmd_prefix}")
# ipv4 out — sample truncated vs declared 34; parse all pasted routes
cmd, body = _section("show bgp ipv4 unicast neighbor out")
ipv4_out = normalize_bgp_route(
raw_text=body,
command=cmd,
vendor="ZTE",
device_type="zte_zxros",
params={
"neighbor": "24.11.0.8",
"direction": "out",
"afi": "ipv4",
"local_as": "24208",
},
)
self.assertEqual(len(ipv4_out), 21)
self.assertTrue(all(r["next_hop"] for r in ipv4_out))
# vpnv6 out — RD + VRF + ::FFFF NH + From wraps
cmd, body = _section("show bgp vpnv6 unicast neighbor out")
v6_out = normalize_bgp_route(
raw_text=body,
command=cmd,
vendor="ZTE",
device_type="zte_zxros",
params={
"neighbor": "24.11.0.8",
"direction": "out",
"afi": "vpnv6",
"local_as": "24208",
},
)
self.assertEqual(len(v6_out), 559)
self.assertTrue(all(r.get("rd") for r in v6_out))
self.assertTrue(all(r.get("vrf") for r in v6_out))
self.assertEqual(v6_out[0]["rd"], "2:111")
self.assertEqual(v6_out[0]["vrf"], "css-srv6-mpls-1")
self.assertTrue(v6_out[0]["next_hop"].upper().startswith("::FFFF:"))
self.assertGreaterEqual(
sum(1 for r in v6_out if r["network"] == "60::/64"), 2, "ECMP under RD"
)
# vpnv4 out — RD + VRF
cmd, body = _section("show bgp vpnv4 unicast neighbor out")
v4_out = normalize_bgp_route(
raw_text=body,
command=cmd,
vendor="ZTE",
device_type="zte_zxros",
params={
"neighbor": "24.11.0.8",
"direction": "out",
"afi": "vpnv4",
"local_as": "24208",
},
)
self.assertEqual(len(v4_out), 54)
self.assertTrue(all(r.get("rd") and r.get("vrf") for r in v4_out))
# vpnv6 in — * i + IPv4-mapped NH wrap onto metric line
cmd, body = _section("show bgp vpnv6 unicast neighbor in")
v6_in = normalize_bgp_route(
raw_text=body,
command=cmd,
vendor="ZTE",
device_type="zte_zxros",
params={
"neighbor": "24.11.0.8",
"direction": "in",
"afi": "vpnv6",
"local_as": "24208",
},
)
self.assertEqual(len(v6_in), 22)
self.assertTrue(all(r.get("rd") for r in v6_in))
self.assertTrue(all(r.get("status_codes") == "*i" for r in v6_in))
self.assertTrue(all(":" in r["network"] for r in v6_in))
self.assertTrue(all(r["next_hop"] for r in v6_in))
self.assertEqual(v6_in[0]["network"], "100:0:17::/64")
self.assertEqual(v6_in[0]["next_hop"].upper(), "::FFFF:24.11.0.5")
# vpnv4 in — RD fill-down + ECMP next-hops
cmd, body = _section("show bgp vpnv4 unicast neighbor in")
v4_in = normalize_bgp_route(
raw_text=body,
command=cmd,
vendor="ZTE",
device_type="zte_zxros",
params={
"neighbor": "24.11.0.8",
"direction": "in",
"afi": "vpnv4",
"local_as": "24208",
},
)
self.assertEqual(len(v4_in), 21)
self.assertTrue(all(r.get("rd") for r in v4_in))
multi = [r for r in v4_in if r["network"] == "1.0.0.1/32"]
self.assertEqual(len(multi), 3)
self.assertEqual(len({r["next_hop"] for r in multi}), 3)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()