diff --git a/netx_api/biz_state/parsers/zte/bgp_peer.py b/netx_api/biz_state/parsers/zte/bgp_peer.py index 2a7d6b9..b14b911 100644 --- a/netx_api/biz_state/parsers/zte/bgp_peer.py +++ b/netx_api/biz_state/parsers/zte/bgp_peer.py @@ -17,7 +17,11 @@ _PEER_LINE_RE = re.compile( r"(?P\d+)\s+(?P\d+)\s+(?P\S+)\s+(?P\S+)\s*$", re.I, ) -_NEI_ONLY_RE = re.compile(r"^(?P[0-9A-Fa-f:]+)\s*$", re.I) +# Wrapped neighbor may be IPv4 or IPv6 on its own line +_NEI_ONLY_RE = re.compile( + r"^(?P\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f:]+)\s*$", + re.I, +) _CONT_RE = re.compile( r"^\s+(?P\d+)\s+(?P\S+)\s+" r"(?P\d+)\s+(?P\d+)\s+(?P\S+)\s+(?P\S+)\s*$", @@ -107,6 +111,12 @@ def _map_fsm_rows( pending = "" if not nei or not ver: continue + if "#" in nei or not re.match( + r"^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f]*:[0-9A-Fa-f:]+)$", + nei, + re.I, + ): + continue if nei in seen: continue seen.add(nei) diff --git a/netx_api/biz_state/parsers/zte/bgp_route.py b/netx_api/biz_state/parsers/zte/bgp_route.py index 6f4e64a..9d911f8 100644 --- a/netx_api/biz_state/parsers/zte/bgp_route.py +++ b/netx_api/biz_state/parsers/zte/bgp_route.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ipaddress import re from typing import Any, Mapping @@ -12,13 +13,19 @@ from .bgp_peer import _detect_bgp_afi, _detect_vrf RULE_KEYS = ("zte_zxros_show_bgp_neighbor_routes",) -_ROUTE_RE = re.compile( +# Single-line IPv4-style: * 10.1.0.0/24 10.0.0.1 … path +_ROUTE_ONE_LINE_RE = re.compile( r"^\s*(?P[*<>isd]*)\s*" - r"(?P\d{1,3}(?:\.\d{1,3}){3}/\d+|[0-9A-Fa-f:]+(?:/\d+)?)\s+" + r"(?P\S+)\s+" r"(?P\S+)\s+" r"(?P.*)$" ) +# Network alone (often IPv6 wrap): * 2407::1/128 or bare prefix for "out" +_NET_ONLY_RE = re.compile( + r"^\s*(?P[*<>isd]*)\s*(?P\S+)\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+)") _TOTAL_RE = re.compile(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)") _HEADER_NETS = frozenset( @@ -54,23 +61,43 @@ def _detect_neighbor(command: str, params: dict[str, str] | None) -> str: return m.group(1).strip() if m else "" -def _looks_like_prefix(net: str) -> bool: - tok = str(net or "").strip() - if not tok or tok.lower() in _HEADER_NETS: +def _looks_like_ip_or_prefix(tok: str) -> bool: + """True for IPv4/IPv6 address or prefix; rejects times like 09:50:02.""" + s = str(tok or "").strip() + if not s or s.lower() in _HEADER_NETS: return False - if re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}/\d{1,2}", tok): + try: + if "/" in s: + ipaddress.ip_network(s, strict=False) + else: + ipaddress.ip_address(s) return True - # IPv6 prefix / bare address - if ":" in tok and re.search(r"[0-9A-Fa-f]:", tok): - return True - return False + except ValueError: + return False -def _split_rest(rest: str) -> tuple[str, str, str, str]: - """Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank).""" +def _looks_like_prefix(net: str) -> bool: + return _looks_like_ip_or_prefix(net) + + +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). + + ``path_continuation``: indented wrap line after next-hop (often ``20 65254 ?`` + or ``4761 ?``) — prefer path/tag over inventing a metric. + """ parts = str(rest or "").split() if not parts: return "", "", "", "" + if path_continuation and parts[-1] in ("?", "i", "e", "incomplete"): + if len(parts) == 1: + return "", "", "", parts[0] + if len(parts) == 2 and parts[0].isdigit(): + # ``4761 ?`` → path + return "", "", "", " ".join(parts) + if len(parts) >= 3 and parts[0].isdigit(): + # ``20 65254 ?`` → rtprf + path + return "", "", parts[0], " ".join(parts[1:]) metric = loc = tag = "" nums: list[str] = [] path_parts: list[str] = [] @@ -95,6 +122,74 @@ def _empty_if_total_zero(raw_text: str) -> bool: return bool(m and int(m.group(1)) == 0) +def _skip_noise_line(line: str) -> bool: + low = line.strip().lower() + if not low: + return True + if low.startswith(("network", "dest ", "destination")): + return True + if "next hop" in low or low.startswith("status") or low.startswith("origin"): + return True + if low.startswith("routes ") or low.startswith("current as"): + return True + if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "): + return True + if low.startswith("route distinguisher") or low.startswith("valid ") or low.startswith( + "invalid " + ): + 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): + return True + if low.endswith("#") or "#'" in low: + return True + if re.search(r"\S+\s*#\s*$", line): + return True + return False + + +def _emit_route( + out: list[dict[str, Any]], + seen: set[str], + *, + afi: str, + vrf: str, + neighbor: str, + direction: str, + net: str, + nh: str, + rest: str, + flags: str, + path_continuation: bool = False, +) -> None: + if not _looks_like_prefix(net) or net in seen: + return + 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): + return + seen.add(net) + metric, loc, tag, path = _split_rest(rest, path_continuation=path_continuation) + out.append( + { + "afi": afi[:32], + "vrf": vrf[:128], + "neighbor": neighbor[:128], + "direction": direction[:8], + "network": net[:128], + "next_hop": (nh or "")[:128], + "metric": metric[:32], + "loc_prf": loc[:32], + "tag": tag[:32], + "path": path[:256], + "status_codes": (flags or "").strip()[:16], + "as_num": "", + "state": "", + "pfx_rcd": "", + } + ) + + def _map_fsm_rows( rows: list[dict[str, Any]], *, @@ -112,6 +207,9 @@ def _map_fsm_rows( 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 seen.add(net) path = row_get(r, "PATH", "path") out.append( @@ -144,49 +242,85 @@ def _hand_parse( direction: str = "", **_kw: Any, ) -> list[dict[str, Any]]: + """Parse neighbor in/out tables; join IPv6 network / next-hop / path wraps.""" out: list[dict[str, Any]] = [] seen: set[str] = set() + pending_net = "" + pending_flags = "" + pending_nh = "" + + def _flush_pending(*, rest: str = "", path_continuation: bool = False) -> None: + nonlocal pending_net, pending_flags, pending_nh + if not pending_net: + return + _emit_route( + out, + seen, + afi=afi, + vrf=vrf, + neighbor=neighbor, + direction=direction, + net=pending_net, + nh=pending_nh, + rest=rest, + flags=pending_flags, + path_continuation=path_continuation, + ) + pending_net = "" + pending_flags = "" + pending_nh = "" + for raw in str(raw_text or "").splitlines(): line = raw.rstrip() - if not line.strip(): + if _skip_noise_line(line): continue - low = line.strip().lower() - if low.startswith(("network", "dest ", "destination")): + + # Continuation: indented next-hop after network-only line + if pending_net and not pending_nh and line[:1].isspace(): + tok = line.strip() + if _looks_like_ip_or_prefix(tok) and "/" not in tok: + pending_nh = tok + continue + # Metrics/path without explicit next-hop (rare) + if tok and not _looks_like_prefix(tok.split()[0] if tok.split() else ""): + _flush_pending(rest=tok, path_continuation=True) + continue + + # Continuation: indented path/metric after network+nh + if pending_net and pending_nh and line[:1].isspace(): + tok = line.strip() + if tok: + _flush_pending(rest=tok, path_continuation=True) + continue + + # Full one-liner (typical IPv4) + m = _ROUTE_ONE_LINE_RE.match(line) + if m and _looks_like_prefix(m.group("net")) and _looks_like_ip_or_prefix(m.group("nh")): + _flush_pending() + _emit_route( + out, + seen, + afi=afi, + vrf=vrf, + neighbor=neighbor, + direction=direction, + net=m.group("net"), + nh=m.group("nh"), + rest=m.group("rest"), + flags=m.group("flags") or "", + ) continue - if "next hop" in low or low.startswith("status") or low.startswith("origin"): + + # Network alone → wait for next-hop / path wraps (IPv6) + m_net = _NET_ONLY_RE.match(line) + if m_net and _looks_like_prefix(m_net.group("net")): + _flush_pending() + pending_net = m_net.group("net") + pending_flags = m_net.group("flags") or "" + pending_nh = "" continue - if low.startswith("routes ") or low.startswith("current as"): - continue - if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "): - continue - if low.startswith("route distinguisher") or low.startswith("valid ") or low.startswith("invalid "): - continue - m = _ROUTE_RE.match(line) - if not m: - continue - net = m.group("net") - if not _looks_like_prefix(net) or 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": "", - } - ) + + _flush_pending() return out diff --git a/netx_api/biz_state/parsers/zte/config_bgp_peer.py b/netx_api/biz_state/parsers/zte/config_bgp_peer.py index 1d15bee..a6f575b 100644 --- a/netx_api/biz_state/parsers/zte/config_bgp_peer.py +++ b/netx_api/biz_state/parsers/zte/config_bgp_peer.py @@ -4,6 +4,11 @@ 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. +For **global** (non-VRF) address-families, a peer-group ``activate`` is also +expanded into one row per global member ``neighbor peer-group `` +so discover/bind sees the real Neighbor IPs (direct activates ∪ group +members). VRF address-families do **not** expand from global membership. + 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. @@ -189,14 +194,39 @@ def normalize_config_bgp_peer( out: list[dict[str, Any]] = [] seen: set[tuple[str, str, str, str]] = set() + + def _append(row: dict[str, Any]) -> None: + key = (row["afi"], row["vrf"], row["neighbor"], row["peer_group"]) + if key in seen: + return + seen.add(key) + out.append(row) + 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: + _append(row) + + # Global AF: peer-group activate → expand to member Neighbor IPs. + # VRF AF: do not expand from global peer-group membership. + if vrf_s: continue - seen.add(key) - out.append(row) + pg = str(row.get("peer_group") or "").strip() + if row.get("neighbor") or not pg: + continue + for member, minfo in meta.items(): + if not _is_ip_neighbor(member): + continue + if str(minfo.get("peer_group") or "").strip() != pg: + continue + m_info = _merge_info( + member, afi=afi_s, vrf=vrf_s, meta=meta, af_rm=af_rm + ) + m_row = _row( + afi=afi_s, vrf=vrf_s, token=member, act=act, info=m_info + ) + m_row["peer_group"] = pg[:64] + _append(m_row) # Global peers with remote-as but no AF activate for token, info in meta.items(): @@ -205,11 +235,7 @@ def normalize_config_bgp_peer( 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) + _append(row) return out diff --git a/netx_api/cli_templates/zte/zte_zxros_show_bgp_neighbor_routes.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_bgp_neighbor_routes.textfsm index da6af80..1bff0f0 100644 --- a/netx_api/cli_templates/zte/zte_zxros_show_bgp_neighbor_routes.textfsm +++ b/netx_api/cli_templates/zte/zte_zxros_show_bgp_neighbor_routes.textfsm @@ -1,5 +1,5 @@ -Value NETWORK (\S+) -Value NEXT_HOP (\S+) +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:]+) Value METRIC (\S*) Value LOC_PRF (\S*) Value TAG (\S*) @@ -26,8 +26,13 @@ Routes ^\s*\*?\s*Network\s+Next ^Network\s+Next ^\s*Dest\s+Next + # One-line IPv4-style ^\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 + # IPv6 wrap: network / next-hop / metrics+path on separate lines + ^\s*\*?\s*>?\s*${NETWORK}\s*$$ + ^\s+${NEXT_HOP}\s*$$ + ^\s+${PATH}\s*$$ -> Record ^\s*$$ ^\S+\s*#\s*$$ -> End ^.*#\s*$$ -> End diff --git a/netx_api/cli_templates/zte/zte_zxros_show_bgp_summary.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_bgp_summary.textfsm index f399229..1e1bf3e 100644 --- a/netx_api/cli_templates/zte/zte_zxros_show_bgp_summary.textfsm +++ b/netx_api/cli_templates/zte/zte_zxros_show_bgp_summary.textfsm @@ -1,4 +1,4 @@ -Value NEIGHBOR (\S+) +Value NEIGHBOR ([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9A-Fa-f]*:[0-9A-Fa-f:]+) Value VER (\d+) Value ASN (\S+) Value MSG_RCVD (\d+) @@ -12,7 +12,11 @@ Start ^.* -> Start Peers + # Single-line IPv4 (or short IPv6) peer ^${NEIGHBOR}\s+${VER}\s+${ASN}\s+${MSG_RCVD}\s+${MSG_SEND}\s+${UP_DOWN}\s+${STATE_PFX}\s*$$ -> Record + # IPv6 / long neighbor alone; stats on the next indented line + ^${NEIGHBOR}\s*$$ + ^\s+${VER}\s+${ASN}\s+${MSG_RCVD}\s+${MSG_SEND}\s+${UP_DOWN}\s+${STATE_PFX}\s*$$ -> Record ^All\s+ ^BGP\s+ ^Local\s+ diff --git a/tests/test_zte_config_intent.py b/tests/test_zte_config_intent.py index a24acf5..320b025 100644 --- a/tests/test_zte_config_intent.py +++ b/tests/test_zte_config_intent.py @@ -68,6 +68,8 @@ router bgp 65000 neighbor 10.0.0.1 password cipher SKIPME neighbor 10.0.0.1 route-map RM_IN in neighbor 10.0.0.1 peer-group CORE_RR + neighbor 10.0.0.9 peer-group CORE_RR + neighbor 10.0.0.9 remote-as 65019 neighbor CORE_RR peer-group neighbor CORE_RR remote-as 65009 neighbor FC00:1::1 remote-as 65002 @@ -297,7 +299,14 @@ class ZteConfigIntentTests(unittest.TestCase): # peer-group name is not an IP → neighbor empty, peer_group set self.assertIn(("vpnv4", "", "", "CORE_RR"), by) self.assertEqual(by[("vpnv4", "", "", "CORE_RR")]["remote_as"], "65009") + # Global AF expands peer-group members (10.0.0.9 only via group activate) + self.assertIn(("vpnv4", "", "10.0.0.9", "CORE_RR"), by) + self.assertEqual(by[("vpnv4", "", "10.0.0.9", "CORE_RR")]["remote_as"], "65019") + self.assertEqual(by[("vpnv4", "", "10.0.0.9", "CORE_RR")]["activate"], "enable") self.assertEqual(by[("ipv4", "CUST_A", "10.0.0.2", "")]["remote_as"], "65003") + # VRF must not pick up global peer-group members + self.assertNotIn(("ipv4", "CUST_A", "10.0.0.9", "CORE_RR"), by) + self.assertNotIn(("ipv4", "CUST_A", "10.0.0.1", "CORE_RR"), by) self.assertIn(("l2vpn-evpn", "", "10.0.0.1", "CORE_RR"), by) # global IPv6 neighbor without AF activate self.assertIn(("global", "", "FC00:1::1", ""), by) diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index eedcc3f..24ca2d9 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -255,6 +255,50 @@ class ZteExtendedParserTests(unittest.TestCase): ) self.assertTrue(all(r["vrf"] == "CUST_A" for r in vrf)) + # Real wrapped vpnv6 summary + IPv6 neighbor in/out routes + sample_dir = Path(__file__).resolve().parents[2] / "test" / "show-zte" + v6_sum = (sample_dir / "show-bgp-vpnv6").read_text(encoding="utf-8", errors="replace") + v6_real = normalize_bgp_peer( + raw_text=v6_sum, command="show bgp vpnv6 unicast summary | one-line" + ) + self.assertEqual(len(v6_real), 2) + self.assertTrue(all(":" in r["neighbor"] for r in v6_real)) + self.assertTrue(all(r["afi"] == "vpnv6" for r in v6_real)) + + v6_in = (sample_dir / "show-bgp-vpnv6-neighbor-router-in-vrf").read_text( + encoding="utf-8", errors="replace" + ) + routes_in = normalize_bgp_route( + raw_text=v6_in, + command="show bgp vpnv6 unicast vrf vpn.giims neighbor in 2407::45:0:0:5:1", + params={ + "vrf": "vpn.giims", + "neighbor": "2407::45:0:0:5:1", + "direction": "in", + "afi": "vpnv6", + }, + ) + self.assertEqual(len(routes_in), 9) + self.assertTrue(all(r["network"].startswith("2407::") for r in routes_in)) + self.assertTrue(all(r["next_hop"].startswith("2407::") for r in routes_in)) + self.assertFalse(any(r["network"].startswith("09:") for r in routes_in)) + + v6_out = (sample_dir / "show-bgp-vpnv6-neighbor-router-out-vrf").read_text( + encoding="utf-8", errors="replace" + ) + routes_out = normalize_bgp_route( + raw_text=v6_out, + command="show bgp vpnv6 unicast vrf vpn.giims neighbor out 2407::45:0:0:5:1", + params={ + "vrf": "vpn.giims", + "neighbor": "2407::45:0:0:5:1", + "direction": "out", + }, + ) + self.assertEqual(len(routes_out), 1) + self.assertEqual(routes_out[0]["network"], "2407::45:0:0:5:0/127") + self.assertIn("4761", routes_out[0]["path"]) + def test_bgp_summary_fsm(self) -> None: rows = apply_rule( platform="zte_zxros", @@ -716,6 +760,7 @@ $ peer_recs = [ {"afi": "vpnv4", "vrf": "", "neighbor": "10.0.0.1", "remote_as": "65001"}, {"afi": "vpnv4", "vrf": "", "neighbor": "", "peer_group": "CORE_RR", "remote_as": "65009"}, + {"afi": "vpnv4", "vrf": "", "neighbor": "10.0.0.9", "peer_group": "CORE_RR", "remote_as": "65019"}, {"afi": "vpnv6", "vrf": "", "neighbor": "FC00::1", "remote_as": "65002"}, {"afi": "ipv4", "vrf": "CUST_A", "neighbor": "10.0.0.2", "remote_as": "65003"}, {"afi": "ipv4", "vrf": "", "neighbor": "10.0.0.9", "remote_as": "65004"}, @@ -723,7 +768,7 @@ $ ] self.assertEqual( filter_discover_records(peer_recs, glob_v4.placeholders[0]), - ["10.0.0.1"], + ["10.0.0.1", "10.0.0.9"], ) self.assertEqual( filter_discover_records(peer_recs, glob_v6.placeholders[0]),