From c6a98e252690950067d4099fa5f5b85fa55b019e Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 27 Sep 2026 16:42:52 +0800 Subject: [PATCH] Fix ZTE BGP vpnv4/vpnv6 RD VRF filldown and map VRF-scoped AFI to ipv4/ipv6. Stop inheriting `(default for vrf)` onto RD-only blocks, and normalize CE vrf commands so `(afi,vrf)` sheet splits no longer collide with global VPNx tables. Co-authored-by: Cursor --- netx_api/biz_state/parsers/zte/bgp_peer.py | 25 ++++++++- netx_api/biz_state/parsers/zte/bgp_route.py | 10 ++-- tests/test_zte_extended_parsers.py | 61 ++++++++++++++++++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/netx_api/biz_state/parsers/zte/bgp_peer.py b/netx_api/biz_state/parsers/zte/bgp_peer.py index 84651d7..76f09ad 100644 --- a/netx_api/biz_state/parsers/zte/bgp_peer.py +++ b/netx_api/biz_state/parsers/zte/bgp_peer.py @@ -33,9 +33,32 @@ _LOCAL_AS_IN_CMD_RE = re.compile(r"(?i)(?:^|\s)as\s+(\S+)(?:\s*\||\s*$)") def _detect_bgp_afi(command: str, params: dict[str, str] | None) -> str: + """Detect BGP AFI for peer/route rows. + + VRF-scoped CLI (``… vrf …``) is the CE/ipv4|ipv6 VRF AF even when + ZTE spells the command as ``vpnv4|vpnv6 unicast vrf``. Map those to + ``ipv4``/``ipv6`` so split-by-(afi,vrf) does not collide with global + VPNv4/VPNv6 tables that may carry RD ``(default for vrf X)`` into ``vrf``. + """ + low = str(command or "").lower() + has_vrf = bool(_detect_vrf(command, params)) + if has_vrf: + # Prefer command tokens over stale params.afi=vpnv* + if "vpnv6" in low or "ipv6" in low: + return "ipv6" + if "vpnv4" in low or "ipv4" in low: + return "ipv4" + # Bare ``vrf`` with params.afi hint + if params and params.get("afi"): + af = str(params.get("afi") or "").strip().lower() + if af in ("vpnv6", "ipv6"): + return "ipv6" + if af in ("vpnv4", "ipv4"): + return "ipv4" + return "ipv4" + 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: diff --git a/netx_api/biz_state/parsers/zte/bgp_route.py b/netx_api/biz_state/parsers/zte/bgp_route.py index f3fc985..73c5878 100644 --- a/netx_api/biz_state/parsers/zte/bgp_route.py +++ b/netx_api/biz_state/parsers/zte/bgp_route.py @@ -269,7 +269,10 @@ def _hand_parse( pending_nh = "" pending_rest = "" current_rd = "" - current_vrf = vrf + # Command-scoped VRF (CE view); RD blocks may temporarily override via + # ``(default for vrf X)`` but must not fill-down onto RD-only segments. + cmd_vrf = vrf + current_vrf = cmd_vrf def _flush_pending(*, rest: str = "", path_continuation: bool = False) -> dict[str, Any] | None: nonlocal pending_net, pending_flags, pending_nh, pending_rest @@ -313,9 +316,8 @@ def _hand_parse( if row: yield row 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 + # Reset every RD header: annotation wins; else fall back to cmd VRF. + current_vrf = (rd_m.group("vrf") or "").strip() or cmd_vrf continue if _skip_noise_line(line): continue diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index 51e485e..91bbb30 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -267,6 +267,7 @@ class ZteExtendedParserTests(unittest.TestCase): ) self.assertTrue(all(r["vrf"] == "CUST_A" for r in vrf)) self.assertTrue(all(r["local_as"] == "65000" for r in vrf)) + self.assertTrue(all(r["afi"] == "ipv4" for r in vrf), "vrf-scoped vpnv4 → ipv4") # Real wrapped vpnv6 summary + IPv6 neighbor in/out routes sample_dir = Path(__file__).resolve().parents[2] / "test" / "show-zte" @@ -288,10 +289,12 @@ class ZteExtendedParserTests(unittest.TestCase): "vrf": "vpn.giims", "neighbor": "2407::45:0:0:5:1", "direction": "in", - "afi": "vpnv6", + "afi": "vpnv6", # stale params; vrf scope must win → ipv6 }, ) self.assertEqual(len(routes_in), 9) + self.assertTrue(all(r["afi"] == "ipv6" for r in routes_in)) + self.assertTrue(all(r["vrf"] == "vpn.giims" for r in routes_in)) 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)) @@ -309,6 +312,7 @@ class ZteExtendedParserTests(unittest.TestCase): }, ) self.assertEqual(len(routes_out), 1) + self.assertEqual(routes_out[0]["afi"], "ipv6") self.assertEqual(routes_out[0]["network"], "2407::45:0:0:5:0/127") self.assertIn("4761", routes_out[0]["path"]) @@ -431,6 +435,8 @@ class ZteExtendedParserTests(unittest.TestCase): self.assertEqual(len(routes), 2) self.assertEqual(routes[0]["network"], "10.1.0.0/24") self.assertEqual(routes[0]["direction"], "in") + self.assertEqual(routes[0]["afi"], "ipv4") + self.assertEqual(routes[0]["vrf"], "CUST_A") from netx_api.biz_state.enrich import EnrichJoin from netx_api.biz_state.parsers.zte.config_bgp_peer import normalize_config_bgp_peer @@ -1411,6 +1417,59 @@ Route Distinguisher:65525:30001 (default for vrf CUST_V6) self.assertEqual(len(multi), 3) self.assertEqual(len({r["next_hop"] for r in multi}), 3) + def test_bgp_route_rd_vrf_no_filldown_vpnv4_vpnv6(self) -> None: + """RD ``(default for vrf)`` must not inherit onto following RD-only blocks.""" + raw_v4 = """ +Routes Learned From This Neighbor: +Total number of routes: 3 + Network Next Hop Metric LocPrf RtPrf Path +Route Distinguisher:10.0.0.1:100 (default for vrf CUST_LOCAL) +* i 192.0.2.0/24 10.1.1.1 0 100 0 65001 i +Route Distinguisher:10.0.0.2:200 +* i 198.51.100.0/24 10.1.1.2 0 100 0 65002 i +Route Distinguisher:10.0.0.3:300 (default for vrf CUST_OTHER) +* i 203.0.113.0/24 10.1.1.3 0 100 0 65003 i +""" + v4 = normalize_bgp_route( + raw_text=raw_v4, + command="show bgp vpnv4 unicast neighbor in 10.0.0.1 | one-line", + params={"neighbor": "10.0.0.1", "direction": "in", "afi": "vpnv4"}, + ) + self.assertEqual(len(v4), 3) + self.assertTrue(all(r["afi"] == "vpnv4" for r in v4)) + by_rd = {r["rd"]: r for r in v4} + self.assertEqual(by_rd["10.0.0.1:100"]["vrf"], "CUST_LOCAL") + self.assertEqual(by_rd["10.0.0.2:200"]["vrf"], "") + self.assertEqual(by_rd["10.0.0.3:300"]["vrf"], "CUST_OTHER") + + raw_v6 = """ +Routes Advertised to This Neighbor: +Total number of routes: 2 + Dest Next Hop Metric LocPrf InTag Path +Route Distinguisher:65525:30001 (default for vrf CUST_V6) +* i 56:16:10::/64 ::FFFF:10.1.1.1 100 0 ? +Route Distinguisher:65525:30002 +* i 56:16:32::/64 ::FFFF:10.1.1.1 100 0 ? +""" + v6 = normalize_bgp_route( + raw_text=raw_v6, + command="show bgp vpnv6 unicast neighbor out 24.11.0.8 | one-line", + params={"neighbor": "24.11.0.8", "direction": "out", "afi": "vpnv6"}, + ) + self.assertEqual(len(v6), 2) + self.assertTrue(all(r["afi"] == "vpnv6" for r in v6)) + by_rd6 = {r["rd"]: r for r in v6} + self.assertEqual(by_rd6["65525:30001"]["vrf"], "CUST_V6") + self.assertEqual(by_rd6["65525:30002"]["vrf"], "") + + # VRF CE views stay ipv4/ipv6 + cmd vrf (distinct from global vpn*) + v4_ce = normalize_bgp_route( + raw_text=_BGP_ROUTE_IN, + command="show bgp vpnv4 unicast vrf CUST_A neighbor in 10.0.0.1", + params={"vrf": "CUST_A", "neighbor": "10.0.0.1", "direction": "in"}, + ) + self.assertTrue(all(r["afi"] == "ipv4" and r["vrf"] == "CUST_A" for r in v4_ce)) + if __name__ == "__main__": unittest.main()