diff --git a/netx_api/biz_state/command_match.py b/netx_api/biz_state/command_match.py index 539b203..27790af 100644 --- a/netx_api/biz_state/command_match.py +++ b/netx_api/biz_state/command_match.py @@ -132,18 +132,60 @@ def _optional_discover_placeholders(profile: ParseProfile) -> list[PlaceholderDe return [ph for ph in _discover_placeholders(profile) if not ph.required] +def _neighbor_ip_family(addr: str) -> str: + """Return ``ipv4`` / ``ipv6`` / ```` for a neighbor literal.""" + s = str(addr or "").strip() + if not s: + return "" + if re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}", s): + return "ipv4" + if ":" not in s: + return "" + try: + import ipaddress + + ipaddress.ip_address(s.split("%", 1)[0]) + return "ipv6" + except ValueError: + return "" + + def _record_passes_discover_filter(rec: dict[str, Any], ph: PlaceholderDef) -> bool: filt_field = str(ph.discover_filter_field or "").strip() filt_contains = str(ph.discover_filter_contains or "").strip().lower() + hay = "" if filt_field and filt_contains: hay = str(rec.get(filt_field) or "").strip().lower() # ``afi`` must be exact (``ipv4`` must not match ``vpnv4``). # CSV fields like ``address_families`` still use substring/token contains. if filt_field == "afi": - if hay != filt_contains: + # Comma-separated exact OR (e.g. ``ipv4,global``). + allowed = {x.strip() for x in filt_contains.split(",") if x.strip()} + if hay not in allowed: return False elif filt_contains not in hay: return False + equals_raw = str(getattr(ph, "discover_equals", "") or "").strip() + if equals_raw: + for part in equals_raw.split(","): + part = part.strip() + if not part or "=" not in part: + continue + field, expected = part.split("=", 1) + field = field.strip() + expected = expected.strip().lower() + if not field: + continue + actual = str(rec.get(field) or "").strip().lower() + if actual != expected: + return False + # Top-level/global activate: split by neighbor IP family (legacy global≈AF). + gfam = str(getattr(ph, "discover_global_ip_family", "") or "").strip().lower() + if gfam and (hay == "global" or str(rec.get("afi") or "").strip().lower() == "global"): + if str(rec.get("activate") or "").strip().lower() != "enable": + return False + if _neighbor_ip_family(str(rec.get("neighbor") or "")) != gfam: + return False require = str(ph.discover_require_nonempty or "").strip() if require and not str(rec.get(require) or "").strip(): return False 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 5005860..1a7069b 100644 --- a/netx_api/biz_state/parsers/zte/config_bgp_peer.py +++ b/netx_api/biz_state/parsers/zte/config_bgp_peer.py @@ -233,6 +233,14 @@ def normalize_config_bgp_peer( act = "disable" if m.group(2) else "enable" activations.append((local_as, afi, vrf, nei, act)) continue + # Top-level activate (outside address-family): older ZTE builds treat + # global context as IPv4 unicast — record as afi=global. + m = _NEI_ACT_RE.match(line) + if m and not afi: + nei = m.group(1).strip() + act = "disable" if m.group(2) else "enable" + activations.append((local_as, "global", "", nei, act)) + continue m = _NEI_RM_RE.match(line) if m and afi: nei = m.group(1).strip() @@ -266,8 +274,10 @@ def normalize_config_bgp_peer( ) _append(row) - # Global AF: peer-group activate → expand to member Neighbor IPs. - # VRF AF: do not expand from global peer-group membership. + # Global AF: peer-group activate → expand to member Neighbor IPs + # configured at global (``neighbor peer-group ``). Discover + # for vpnv4/vpnv6/ipv6 then only filters this AF and still sees those + # global members. VRF AF: do not expand from global peer-group membership. if vrf_s: continue pg = str(row.get("peer_group") or "").strip() diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index 62cb43e..aa2bb96 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -38,6 +38,11 @@ class PlaceholderDef: # When required=False and no bindings: collect may expand all discover values. discover_filter_field: str = "" discover_filter_contains: str = "" + # Extra exact matches: ``activate=enable`` or ``activate=enable,foo=bar``. + discover_equals: str = "" + # When afi=global is allowed: only keep activate=enable neighbors whose IP + # matches this family (``ipv4`` / ``ipv6``). AF rows are unchanged. + discover_global_ip_family: str = "" # Skip rows where this field is empty (e.g. CE peers require non-empty vrf). discover_require_nonempty: str = "" # Skip rows where this field is non-empty (e.g. global AF peers require empty vrf). @@ -329,6 +334,12 @@ _BGP_LOCAL_AS = PlaceholderDef( # BGP neighbor in/out: discover peers from Config BGP Peer Intent, filtered by AF. # required=False with local_as → unbound collect expands all (local_as, neighbor) pairs. +# +# vpnv4 / vpnv6: filter that AF only. Peer-group activate under the AF is expanded +# into member Neighbor IPs (membership configured at global). +# +# ipv4 / ipv6: own AF rows + top-level/global activate=enable, split by neighbor +# address family (IPv4 literals → ipv4 discover, IPv6 literals → ipv6 discover). _BGP_NEIGHBOR_VPNV4 = PlaceholderDef( name="neighbor", schema_field="neighbor", @@ -366,7 +377,9 @@ _BGP_NEIGHBOR_IPV4 = PlaceholderDef( discover_value_field="neighbor", discover_label_field="neighbor", discover_filter_field="afi", - discover_filter_contains="ipv4", + discover_filter_contains="ipv4,global", + discover_equals="activate=enable", + discover_global_ip_family="ipv4", discover_require_empty="vrf", discover_require_nonempty="neighbor", ) @@ -380,7 +393,8 @@ _BGP_NEIGHBOR_IPV6 = PlaceholderDef( discover_value_field="neighbor", discover_label_field="neighbor", discover_filter_field="afi", - discover_filter_contains="ipv6", + discover_filter_contains="ipv6,global", + discover_global_ip_family="ipv6", discover_require_empty="vrf", discover_require_nonempty="neighbor", ) @@ -1140,8 +1154,9 @@ def _zte_status_profiles() -> list[ParseProfile]: match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp ipv4 unicast neighbor in", description=( - "Routes learned from IPv4 unicast neighbor; discover from BGP peer intent " - "(direct + peer-group members); aux: show running-config bgp." + "Routes learned from IPv4 unicast neighbor; discover ipv4 AF " + "activate plus top-level/global activate for IPv4 neighbors; " + "aux: show running-config bgp." ), placeholders=[_BGP_NEIGHBOR_IPV4, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), @@ -1161,7 +1176,8 @@ def _zte_status_profiles() -> list[ParseProfile]: match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp ipv4 unicast neighbor out", description=( - "Routes advertised to IPv4 unicast neighbor; discover from BGP peer intent; " + "Routes advertised to IPv4 unicast neighbor; discover ipv4 AF " + "activate plus top-level/global activate for IPv4 neighbors; " "aux: show running-config bgp." ), placeholders=[_BGP_NEIGHBOR_IPV4, _BGP_LOCAL_AS], @@ -1183,8 +1199,9 @@ def _zte_status_profiles() -> list[ParseProfile]: match=r"(?i)^\s*show\s+bgp\s+ipv6\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp ipv6 unicast neighbor in", description=( - "Routes learned from IPv6 unicast neighbor; discover from BGP peer intent " - "(direct + peer-group members); aux: show running-config bgp." + "Routes learned from IPv6 unicast neighbor; discover ipv6 AF " + "activate (incl. peer-group members) plus top-level/global " + "activate for IPv6 neighbors; aux: show running-config bgp." ), placeholders=[_BGP_NEIGHBOR_IPV6, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), @@ -1204,7 +1221,8 @@ def _zte_status_profiles() -> list[ParseProfile]: match=r"(?i)^\s*show\s+bgp\s+ipv6\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp ipv6 unicast neighbor out", description=( - "Routes advertised to IPv6 unicast neighbor; discover from BGP peer intent; " + "Routes advertised to IPv6 unicast neighbor; discover ipv6 AF " + "activate plus top-level/global activate for IPv6 neighbors; " "aux: show running-config bgp." ), placeholders=[_BGP_NEIGHBOR_IPV6, _BGP_LOCAL_AS], @@ -1824,6 +1842,8 @@ def profile_to_public_dict(p: ParseProfile, *, overrides: dict[str, Any] | None "discover_label_field": ph.discover_label_field, "discover_filter_field": ph.discover_filter_field, "discover_filter_contains": ph.discover_filter_contains, + "discover_equals": ph.discover_equals, + "discover_global_ip_family": ph.discover_global_ip_family, "discover_require_nonempty": ph.discover_require_nonempty, "discover_require_empty": ph.discover_require_empty, } diff --git a/tests/test_zte_config_intent.py b/tests/test_zte_config_intent.py index f4a7a60..872e43a 100644 --- a/tests/test_zte_config_intent.py +++ b/tests/test_zte_config_intent.py @@ -438,27 +438,29 @@ class ZteConfigIntentTests(unittest.TestCase): "loopback400", ) self.assertNotIn(("64900", "l2vpn-evpn", "", "444::2", ""), by) - # AS 64580: top-level activate only → global row + # AS 64580: top-level activate only → global row with activate=enable self.assertIn(("64580", "global", "", "22:22:22::22", ""), by) self.assertEqual( by[("64580", "global", "", "22:22:22::22", "")]["update_source"], "loopback0", ) - # AS 100: peer-group expand on EVPN - self.assertIn(("100", "l2vpn-evpn", "", "", "MAR_GROUP_V6_1"), by) - self.assertIn( - ("100", "l2vpn-evpn", "", "2408:8121:8400:1:1000::4:0", "MAR_GROUP_V6_1"), - by, - ) self.assertEqual( - by[ - ("100", "l2vpn-evpn", "", "2408:8121:8400:1:1000::4:0", "MAR_GROUP_V6_1") - ]["route_map_out"], - "TO_MAR_EVPN_SRV6_GROUP_1", + by[("64580", "global", "", "22:22:22::22", "")]["activate"], "enable" + ) + # AS 100: top-level activate → global (legacy ≈ ipv4) plus vpnv4 AF row + self.assertEqual( + by[("100", "global", "", "100.0.0.2", "")]["activate"], "enable" ) self.assertEqual( by[("100", "vpnv4", "", "100.0.0.2", "")]["remote_as"], "100" ) + # Top-level activate disable must not look like enable + self.assertEqual( + by[("100", "global", "", "2408:8121:8400:1:1000::4:0", "MAR_GROUP_V6_1")][ + "activate" + ], + "disable", + ) # Peer-group members must not leak across local AS self.assertNotIn( ("64900", "l2vpn-evpn", "", "2408:8121:8400:1:1000::4:0", "MAR_GROUP_V6_1"), diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index 5b0d2ef..1e85d6e 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -812,8 +812,10 @@ $ glob_ipv4 = get_profile("zte.bgp_ipv4_neighbor_in") assert glob_ipv4 is not None - self.assertEqual(glob_ipv4.placeholders[0].discover_filter_contains, "ipv4") + self.assertEqual(glob_ipv4.placeholders[0].discover_filter_contains, "ipv4,global") self.assertEqual(glob_ipv4.placeholders[0].discover_require_empty, "vrf") + self.assertEqual(glob_ipv4.placeholders[0].discover_equals, "activate=enable") + self.assertEqual(glob_ipv4.placeholders[0].discover_global_ip_family, "ipv4") peer_recs = [ { @@ -822,6 +824,7 @@ $ "vrf": "", "neighbor": "10.0.0.1", "remote_as": "65001", + "activate": "enable", }, { "local_as": "64900", @@ -830,6 +833,7 @@ $ "neighbor": "", "peer_group": "CORE_RR", "remote_as": "65009", + "activate": "enable", }, { "local_as": "64900", @@ -838,6 +842,7 @@ $ "neighbor": "10.0.0.9", "peer_group": "CORE_RR", "remote_as": "65019", + "activate": "enable", }, { "local_as": "64900", @@ -845,6 +850,7 @@ $ "vrf": "", "neighbor": "FC00::1", "remote_as": "65002", + "activate": "enable", }, { "local_as": "64900", @@ -852,6 +858,7 @@ $ "vrf": "CUST_A", "neighbor": "10.0.0.2", "remote_as": "65003", + "activate": "enable", }, { "local_as": "64900", @@ -859,6 +866,39 @@ $ "vrf": "", "neighbor": "10.0.0.8", "remote_as": "65004", + "activate": "enable", + }, + { + "local_as": "64900", + "afi": "global", + "vrf": "", + "neighbor": "10.0.0.7", + "remote_as": "65007", + "activate": "enable", + }, + { + "local_as": "64900", + "afi": "global", + "vrf": "", + "neighbor": "FC00::7", + "remote_as": "65017", + "activate": "enable", + }, + { + "local_as": "64900", + "afi": "ipv4", + "vrf": "", + "neighbor": "10.0.0.88", + "remote_as": "65088", + "activate": "disable", + }, + { + "local_as": "64900", + "afi": "ipv4", + "vrf": "", + "neighbor": "10.0.0.99", + "remote_as": "65099", + "activate": "", }, { "local_as": "64900", @@ -866,20 +906,48 @@ $ "vrf": "CUST_B", "neighbor": "FC00::2", "remote_as": "65005", + "activate": "enable", + }, + { + "local_as": "64900", + "afi": "ipv6", + "vrf": "", + "neighbor": "FC00::8", + "remote_as": "65006", + "activate": "enable", + }, + { + "local_as": "64900", + "afi": "ipv6", + "vrf": "", + "neighbor": "172.16.0.8", + "remote_as": "65018", + "activate": "enable", }, ] self.assertEqual( filter_discover_records(peer_recs, glob_v4.placeholders[0]), ["10.0.0.1", "10.0.0.9"], ) + # vpnv4: direct AF neighbor + peer-group member (membership at global, + # already expanded onto afi=vpnv4 by config_bgp_peer). self.assertEqual( filter_discover_records(peer_recs, glob_v6.placeholders[0]), ["FC00::1"], ) - # Global ipv4: only empty-vrf peers (not VRF CE) + # IPv4: ipv4 AF + global activate only for IPv4 literals self.assertEqual( filter_discover_records(peer_recs, glob_ipv4.placeholders[0]), - ["10.0.0.8"], + ["10.0.0.8", "10.0.0.7"], + ) + glob_ipv6 = get_profile("zte.bgp_ipv6_neighbor_in") + assert glob_ipv6 is not None + self.assertEqual(glob_ipv6.placeholders[0].discover_filter_contains, "ipv6,global") + self.assertEqual(glob_ipv6.placeholders[0].discover_global_ip_family, "ipv6") + # IPv6 AF (any IP under AF) + global activate only for IPv6 literals + self.assertEqual( + set(filter_discover_records(peer_recs, glob_ipv6.placeholders[0])), + {"FC00::8", "172.16.0.8", "FC00::7"}, ) # afi filter is exact: ipv4 must not match vpnv4 vrf_nei = get_profile("zte.bgp_vpnv4_vrf_neighbor_in")