From f274d8ea7278b9e58a4875741755ea76aa01bae4 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 22 Sep 2026 14:17:12 +0800 Subject: [PATCH] Derive discover-backed aux automatically and fix BGP enrich join keys. Overwrite explicit aux from discover_select at load time; join VRF CE on vrf,neighbor and global neighbors on afi,neighbor to avoid cross-AF collisions. Co-authored-by: Cursor --- netx_api/biz_state/enrich.py | 38 +++++- netx_api/biz_state/profiles.py | 192 ++++++++++++++++++++++------- tests/test_biz_state_enrich.py | 72 +++++++++++ tests/test_biz_state_profiles.py | 94 +++++++++++++- tests/test_zte_extended_parsers.py | 74 ++++++++++- 5 files changed, 420 insertions(+), 50 deletions(-) create mode 100644 tests/test_biz_state_enrich.py diff --git a/netx_api/biz_state/enrich.py b/netx_api/biz_state/enrich.py index 569a4ab..d33a77f 100644 --- a/netx_api/biz_state/enrich.py +++ b/netx_api/biz_state/enrich.py @@ -13,6 +13,15 @@ class EnrichJoin: Example (ARP ← if_intf):: EnrichJoin(from_aux="if_intf", on="interface", take=("vrf",)) + + Composite keys (VRF CE peer ← config_bgp_peer):: + + EnrichJoin( + from_aux="config_bgp_peer", + left_on="vrf,neighbor", + right_on="vrf,neighbor", + take=("remote_as", "activate"), + ) """ from_aux: str @@ -23,12 +32,29 @@ class EnrichJoin: fill_missing: bool = True # on miss, set take fields to "" -def _sides(join: EnrichJoin) -> tuple[str, str]: +def _field_list(spec: str) -> list[str]: + return [p.strip() for p in str(spec or "").split(",") if p.strip()] + + +def _sides(join: EnrichJoin) -> tuple[list[str], list[str]]: left = str(join.left_on or join.on or "").strip() right = str(join.right_on or join.on or "").strip() - if not left or not right: + lf = _field_list(left) + rf = _field_list(right) + if not lf or not rf: raise ValueError(f"EnrichJoin {join.from_aux!r} needs on= or left_on/right_on") - return left, right + if len(lf) != len(rf): + raise ValueError( + f"EnrichJoin {join.from_aux!r} left/right field count mismatch: {lf} vs {rf}" + ) + return lf, rf + + +def _row_key(row: Mapping[str, Any], fields: list[str]) -> str: + parts = [str(row.get(f) or "").strip() for f in fields] + if not any(parts): + return "" + return "\0".join(parts) def apply_enrich_joins( @@ -41,7 +67,7 @@ def apply_enrich_joins( return records aux_map = aux_records or {} for join in joins: - left, right = _sides(join) + left_fields, right_fields = _sides(join) take = [str(t).strip() for t in (join.take or ()) if str(t).strip()] if not take: continue @@ -49,13 +75,13 @@ def apply_enrich_joins( for row in aux_map.get(str(join.from_aux or "").strip()) or []: if not isinstance(row, dict): continue - key = str(row.get(right) or "").strip() + key = _row_key(row, right_fields) if key and key not in index: index[key] = row for rec in records: if not isinstance(rec, dict): continue - hit = index.get(str(rec.get(left) or "").strip()) + hit = index.get(_row_key(rec, left_fields)) for field in take: if hit is not None: rec[field] = str(hit.get(field) or "").strip() diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index 818e73e..38fa4ce 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -44,7 +44,13 @@ class PlaceholderDef: @dataclass(frozen=True) class AuxCommand: - """Secondary collect: only key + profile_id (rest from that profile).""" + """Secondary collect: only key + profile_id (rest from that profile). + + When the primary profile uses ``discover_select`` placeholders, aux is + **framework-derived** from those ``discover_profile_id`` values at load + time (explicit ``aux_commands`` for that case are overwritten). Profiles + without discover (e.g. ARP → config_interface) keep explicit aux. + """ key: str profile_id: str @@ -820,9 +826,6 @@ def _zte_status_profiles() -> list[ParseProfile]: sort_order=370, enabled=True, kind="collect", - aux_commands=[ - AuxCommand(key="config_vrf", profile_id="zte.config_vrf"), - ], enrich_joins=[ EnrichJoin( from_aux="config_vrf", @@ -851,9 +854,6 @@ def _zte_status_profiles() -> list[ParseProfile]: sort_order=375, enabled=True, kind="collect", - aux_commands=[ - AuxCommand(key="config_vrf", profile_id="zte.config_vrf"), - ], enrich_joins=[ EnrichJoin( from_aux="config_vrf", @@ -970,19 +970,22 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv4 unicast neighbor in | one-line", match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast neighbor in", - description="Routes learned from VPNv4 neighbor; summary aux for peer state.", + description=( + "Routes learned from VPNv4 neighbor; discover neighbor from BGP peer intent; " + "aux: show running-config bgp." + ), placeholders=[_BGP_NEIGHBOR_VPNV4], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "route"], sort_order=420, enabled=True, kind="collect", - aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary")], enrich_joins=[ EnrichJoin( - from_aux="bgp_summary", - on="neighbor", - take=("as_num", "state", "pfx_rcd"), + from_aux="config_bgp_peer", + left_on="afi,neighbor", + right_on="afi,neighbor", + take=("remote_as", "activate", "route_map_in", "route_map_out"), ), ], ), @@ -995,7 +998,10 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv4 unicast neighbor out | one-line", match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast neighbor out", - description="Routes advertised to VPNv4 neighbor (large; bind neighbor).", + description=( + "Routes advertised to VPNv4 neighbor; discover from BGP peer intent; " + "aux: show running-config bgp." + ), placeholders=[_BGP_NEIGHBOR_VPNV4], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "route"], @@ -1003,12 +1009,12 @@ def _zte_status_profiles() -> list[ParseProfile]: enabled=True, kind="collect", collect_lane="heavy", - aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary")], enrich_joins=[ EnrichJoin( - from_aux="bgp_summary", - on="neighbor", - take=("as_num", "state", "pfx_rcd"), + from_aux="config_bgp_peer", + left_on="afi,neighbor", + right_on="afi,neighbor", + take=("remote_as", "activate", "route_map_in", "route_map_out"), ), ], ), @@ -1021,19 +1027,22 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv6 unicast neighbor in | one-line", match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast neighbor in", - description="Routes learned from VPNv6 neighbor; summary aux for peer state.", + description=( + "Routes learned from VPNv6 neighbor; discover from BGP peer intent; " + "aux: show running-config bgp." + ), placeholders=[_BGP_NEIGHBOR_VPNV6], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "route"], sort_order=426, enabled=True, kind="collect", - aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_summary")], enrich_joins=[ EnrichJoin( - from_aux="bgp_summary", - on="neighbor", - take=("as_num", "state", "pfx_rcd"), + from_aux="config_bgp_peer", + left_on="afi,neighbor", + right_on="afi,neighbor", + take=("remote_as", "activate", "route_map_in", "route_map_out"), ), ], ), @@ -1046,7 +1055,10 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv6 unicast neighbor out | one-line", match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast neighbor out", - description="Routes advertised to VPNv6 neighbor (large; bind neighbor).", + description=( + "Routes advertised to VPNv6 neighbor; discover from BGP peer intent; " + "aux: show running-config bgp." + ), placeholders=[_BGP_NEIGHBOR_VPNV6], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "route"], @@ -1054,12 +1066,12 @@ def _zte_status_profiles() -> list[ParseProfile]: enabled=True, kind="collect", collect_lane="heavy", - aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_summary")], enrich_joins=[ EnrichJoin( - from_aux="bgp_summary", - on="neighbor", - take=("as_num", "state", "pfx_rcd"), + from_aux="config_bgp_peer", + left_on="afi,neighbor", + right_on="afi,neighbor", + take=("remote_as", "activate", "route_map_in", "route_map_out"), ), ], ), @@ -1085,12 +1097,11 @@ def _zte_status_profiles() -> list[ParseProfile]: sort_order=430, enabled=True, kind="collect", - aux_commands=[AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer")], enrich_joins=[ EnrichJoin( from_aux="config_bgp_peer", - left_on="neighbor", - right_on="neighbor", + left_on="vrf,neighbor", + right_on="vrf,neighbor", take=("remote_as", "activate", "route_map_in", "route_map_out"), ), ], @@ -1118,12 +1129,11 @@ def _zte_status_profiles() -> list[ParseProfile]: enabled=True, kind="collect", collect_lane="heavy", - aux_commands=[AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer")], enrich_joins=[ EnrichJoin( from_aux="config_bgp_peer", - left_on="neighbor", - right_on="neighbor", + left_on="vrf,neighbor", + right_on="vrf,neighbor", take=("remote_as", "activate", "route_map_in", "route_map_out"), ), ], @@ -1150,12 +1160,11 @@ def _zte_status_profiles() -> list[ParseProfile]: sort_order=440, enabled=True, kind="collect", - aux_commands=[AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer")], enrich_joins=[ EnrichJoin( from_aux="config_bgp_peer", - left_on="neighbor", - right_on="neighbor", + left_on="vrf,neighbor", + right_on="vrf,neighbor", take=("remote_as", "activate", "route_map_in", "route_map_out"), ), ], @@ -1183,12 +1192,11 @@ def _zte_status_profiles() -> list[ParseProfile]: enabled=True, kind="collect", collect_lane="heavy", - aux_commands=[AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer")], enrich_joins=[ EnrichJoin( from_aux="config_bgp_peer", - left_on="neighbor", - right_on="neighbor", + left_on="vrf,neighbor", + right_on="vrf,neighbor", take=("remote_as", "activate", "route_map_in", "route_map_out"), ), ], @@ -1210,7 +1218,6 @@ def _zte_status_profiles() -> list[ParseProfile]: sort_order=450, enabled=True, kind="collect", - aux_commands=[AuxCommand(key="config_vrf", profile_id="zte.config_vrf")], enrich_joins=[ EnrichJoin( from_aux="config_vrf", @@ -1252,7 +1259,6 @@ def _zte_status_profiles() -> list[ParseProfile]: sort_order=460, enabled=True, kind="collect", - aux_commands=[AuxCommand(key="config_vrf", profile_id="zte.config_vrf")], enrich_joins=[ EnrichJoin( from_aux="config_vrf", @@ -1515,10 +1521,112 @@ def _apply_collect_lanes(profiles: list[ParseProfile]) -> list[ParseProfile]: return profiles +_AUX_PLACEHOLDER_RE = re.compile(r"<([^>]+)>") + + +def _discover_profile_ids(p: ParseProfile) -> list[str]: + """Ordered unique discover_profile_id from discover_select placeholders.""" + out: list[str] = [] + seen: set[str] = set() + for ph in p.placeholders or []: + if str(ph.bind_mode or "").strip() != "discover_select": + continue + dpi = str(ph.discover_profile_id or "").strip() + if not dpi or dpi in seen: + continue + seen.add(dpi) + out.append(dpi) + return out + + +def _aux_key_for_profile_id(profile_id: str) -> str: + pid = str(profile_id or "").strip() + return pid.rsplit(".", 1)[-1] if pid else "aux" + + +def _normalize_discover_aux(p: ParseProfile) -> None: + """If profile discovers params, aux_commands MUST be those discover profiles. + + Prevents drift (e.g. discover=config_bgp_peer but aux=bgp summary). + Explicit aux is still used when there is no discover_select (ARP pattern). + """ + disc = _discover_profile_ids(p) + if not disc: + return + p.aux_commands = [ + AuxCommand(key=_aux_key_for_profile_id(dpi), profile_id=dpi) for dpi in disc + ] + + +def _validate_profiles(profiles: list[ParseProfile]) -> None: + by_id = {p.profile_id: p for p in profiles} + errors: list[str] = [] + for p in profiles: + if not p.enabled: + continue + primary_ph = {ph.name for ph in (p.placeholders or [])} + aux_keys = {a.key for a in (p.aux_commands or [])} + disc = _discover_profile_ids(p) + if disc: + aux_pids = [a.profile_id for a in (p.aux_commands or [])] + if aux_pids != disc: + errors.append( + f"{p.profile_id}: aux_commands {aux_pids} must equal " + f"discover_profile_ids {disc} (framework normalize failed)" + ) + for a in p.aux_commands or []: + ap = by_id.get(a.profile_id) + if ap is None: + errors.append( + f"{p.profile_id}: aux {a.key!r} profile not found: {a.profile_id}" + ) + continue + tmpl = str(ap.command_template or "") + extra = set(_AUX_PLACEHOLDER_RE.findall(tmpl)) - primary_ph + if extra: + errors.append( + f"{p.profile_id}: aux {a.key!r} ({a.profile_id}) has " + f"placeholders {sorted(extra)} not in primary {sorted(primary_ph)}" + ) + for j in p.enrich_joins or []: + fk = str(j.from_aux or "").strip() + if fk and fk not in aux_keys: + errors.append( + f"{p.profile_id}: enrich_joins from_aux={fk!r} not in " + f"aux keys {sorted(aux_keys)}" + ) + for ph in p.placeholders or []: + if str(ph.bind_mode or "").strip() != "discover_select": + continue + dpi = str(ph.discover_profile_id or "").strip() + if not dpi: + errors.append( + f"{p.profile_id}: placeholder {ph.name!r} discover_select " + f"missing discover_profile_id" + ) + elif dpi not in by_id: + errors.append( + f"{p.profile_id}: placeholder {ph.name!r} discover_profile_id " + f"not found: {dpi}" + ) + if errors: + raise RuntimeError( + "biz_state profile validation failed:\n- " + "\n- ".join(errors) + ) + + +def _finalize_profiles(profiles: list[ParseProfile]) -> list[ParseProfile]: + """Normalize discover→aux, validate, apply collect lanes.""" + for p in profiles: + _normalize_discover_aux(p) + _validate_profiles(profiles) + return _apply_collect_lanes(profiles) + + def all_profiles() -> list[ParseProfile]: global _PROFILES if _PROFILES is None: - _PROFILES = _apply_collect_lanes( + _PROFILES = _finalize_profiles( _lldp_profiles() + _vrf_profiles() + _zte_status_profiles() ) return list(_PROFILES) diff --git a/tests/test_biz_state_enrich.py b/tests/test_biz_state_enrich.py new file mode 100644 index 0000000..4f9cefb --- /dev/null +++ b/tests/test_biz_state_enrich.py @@ -0,0 +1,72 @@ +"""Unit tests for EnrichJoin single- and composite-key joins.""" + +from __future__ import annotations + +import unittest + +from netx_api.biz_state.enrich import EnrichJoin, apply_enrich_joins + + +class EnrichJoinTests(unittest.TestCase): + def test_single_key(self) -> None: + rows = [{"neighbor": "10.0.0.1", "network": "1.1.1.0/24"}] + apply_enrich_joins( + rows, + { + "config_bgp_peer": [ + {"neighbor": "10.0.0.1", "remote_as": "65001", "activate": "enable"}, + ] + }, + [EnrichJoin(from_aux="config_bgp_peer", on="neighbor", take=("remote_as", "activate"))], + ) + self.assertEqual(rows[0]["remote_as"], "65001") + + def test_composite_vrf_neighbor(self) -> None: + rows = [ + {"vrf": "A", "neighbor": "10.0.0.1", "network": "1.0.0.0/24"}, + {"vrf": "B", "neighbor": "10.0.0.1", "network": "2.0.0.0/24"}, + ] + apply_enrich_joins( + rows, + { + "config_bgp_peer": [ + {"vrf": "A", "neighbor": "10.0.0.1", "remote_as": "1"}, + {"vrf": "B", "neighbor": "10.0.0.1", "remote_as": "2"}, + ] + }, + [ + EnrichJoin( + from_aux="config_bgp_peer", + left_on="vrf,neighbor", + right_on="vrf,neighbor", + take=("remote_as",), + ) + ], + ) + self.assertEqual(rows[0]["remote_as"], "1") + self.assertEqual(rows[1]["remote_as"], "2") + + def test_afi_neighbor_avoids_wrong_af(self) -> None: + rows = [{"afi": "vpnv4", "neighbor": "10.0.0.1"}] + apply_enrich_joins( + rows, + { + "config_bgp_peer": [ + {"afi": "ipv4", "neighbor": "10.0.0.1", "route_map_in": "RM_V4"}, + {"afi": "vpnv4", "neighbor": "10.0.0.1", "route_map_in": "RM_VPN"}, + ] + }, + [ + EnrichJoin( + from_aux="config_bgp_peer", + left_on="afi,neighbor", + right_on="afi,neighbor", + take=("route_map_in",), + ) + ], + ) + self.assertEqual(rows[0]["route_map_in"], "RM_VPN") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_biz_state_profiles.py b/tests/test_biz_state_profiles.py index 96c9345..19c600c 100644 --- a/tests/test_biz_state_profiles.py +++ b/tests/test_biz_state_profiles.py @@ -6,12 +6,18 @@ import unittest from netx_api.biz_state.command_match import expand_from_bindings, match_command, preview_task_item from netx_api.biz_state.profiles import ( + AuxCommand, + ParseProfile, all_profiles, get_profile, profile_to_public_dict, profiles_for_vendor, + reload_profiles, + _finalize_profiles, + _normalize_discover_aux, ) from netx_api.biz_state.parsers import normalize_lldp_neighbors +from netx_api.biz_state.profiles import PlaceholderDef from netx_api.lldp_shared import parse_neighbor_output import re @@ -64,8 +70,13 @@ class BizStateProfileTests(unittest.TestCase): hits = parse_neighbor_output("", vendor="zte", device_type="zte_zxros") self.assertEqual(hits, []) - def test_vrf_neighbor_discover_and_aux_both_config_bgp(self) -> None: + def test_bgp_neighbor_discover_and_aux_both_config_bgp(self) -> None: + reload_profiles() for pid in ( + "zte.bgp_vpnv4_neighbor_in", + "zte.bgp_vpnv4_neighbor_out", + "zte.bgp_vpnv6_neighbor_in", + "zte.bgp_vpnv6_neighbor_out", "zte.bgp_vpnv4_vrf_neighbor_in", "zte.bgp_vpnv4_vrf_neighbor_out", "zte.bgp_vpnv6_vrf_neighbor_in", @@ -81,6 +92,87 @@ class BizStateProfileTests(unittest.TestCase): self.assertEqual(len(aux), 1) self.assertIn("running-config bgp", aux[0]["command_template"]) + def test_discover_backed_profiles_omit_explicit_aux(self) -> None: + """Source declarations should rely on framework derive (no redundant aux).""" + import inspect + from netx_api.biz_state import profiles as profiles_mod + + src = inspect.getsource(profiles_mod._zte_status_profiles) + # ARP may keep explicit aux; discover-backed must not hardcode config_bgp_peer aux + self.assertNotIn( + 'AuxCommand(key="config_bgp_peer"', + src, + "discover-backed profiles must not list config_bgp_peer aux explicitly", + ) + reload_profiles() + p = get_profile("zte.bgp_vpnv4_neighbor_in") + assert p is not None + self.assertEqual([a.profile_id for a in p.aux_commands], ["zte.config_bgp_peer"]) + + def test_normalize_discover_aux_overwrites_wrong_aux(self) -> None: + """Framework replaces explicit aux when discover_select is present.""" + fake = ParseProfile( + profile_id="zte.test_neighbor_routes", + vendor_key="zte", + metric_id="bgp_route", + parser_id="bgp_route", + title="test", + command_template="show bgp neighbor in | one-line", + match=r".*", + placeholders=[ + PlaceholderDef( + name="neighbor", + schema_field="neighbor", + bind_mode="discover_select", + discover_profile_id="zte.config_bgp_peer", + discover_value_field="neighbor", + ) + ], + # Intentionally wrong — summary instead of config + aux_commands=[ + AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary"), + ], + ) + _normalize_discover_aux(fake) + self.assertEqual( + [(a.key, a.profile_id) for a in fake.aux_commands], + [("config_bgp_peer", "zte.config_bgp_peer")], + ) + + def test_arp_keeps_explicit_aux_without_discover(self) -> None: + reload_profiles() + arp = get_profile("zte.arp") + assert arp is not None + self.assertTrue(any(a.profile_id == "zte.config_interface" for a in arp.aux_commands)) + self.assertFalse( + any(str(ph.bind_mode or "") == "discover_select" for ph in (arp.placeholders or [])) + ) + + def test_finalize_rejects_aux_placeholder_outside_primary(self) -> None: + target = ParseProfile( + profile_id="zte.fake_vrf_summary", + vendor_key="zte", + metric_id="bgp_peer", + parser_id="bgp_peer", + title="fake", + command_template="show bgp vpnv4 unicast vrf summary | one-line", + match=r".*", + ) + bad = ParseProfile( + profile_id="zte.bad_aux", + vendor_key="zte", + metric_id="x", + parser_id="x", + title="bad", + command_template="show foo", + match=r".*", + placeholders=[], + aux_commands=[AuxCommand(key="vrf_sum", profile_id="zte.fake_vrf_summary")], + ) + with self.assertRaises(RuntimeError) as ctx: + _finalize_profiles([bad, target]) + self.assertIn("placeholders", str(ctx.exception)) + def test_aux_templates_placeholders_subset_of_primary(self) -> None: """Aux CLI must not introduce placeholders outside primary params. diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index 1788fb4..eedcc3f 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -396,13 +396,85 @@ $ [ EnrichJoin( from_aux="config_bgp_peer", - on="neighbor", + left_on="vrf,neighbor", + right_on="vrf,neighbor", take=("remote_as", "activate", "route_map_in", "route_map_out"), ) ], ) self.assertEqual(routes[0]["remote_as"], "65001") self.assertEqual(routes[0]["activate"], "enable") + self.assertEqual(routes[0]["route_map_in"], "RM_IN") + + # Same neighbor in two VRFs must not collide + routes_b = [ + {"vrf": "CUST_A", "neighbor": "10.0.0.1", "afi": "vpnv4"}, + {"vrf": "CUST_B", "neighbor": "10.0.0.1", "afi": "vpnv4"}, + ] + intent2 = normalize_config_bgp_peer( + raw_text=""" +! + neighbor 10.0.0.1 remote-as 65001 + address-family ipv4 vrf CUST_A + neighbor 10.0.0.1 activate + neighbor 10.0.0.1 route-map RM_A in + $ + address-family ipv4 vrf CUST_B + neighbor 10.0.0.1 activate + neighbor 10.0.0.1 route-map RM_B in + $ +$ +""", + command="show running-config bgp", + ) + apply_enrich_joins( + routes_b, + {"config_bgp_peer": intent2}, + [ + EnrichJoin( + from_aux="config_bgp_peer", + left_on="vrf,neighbor", + right_on="vrf,neighbor", + take=("route_map_in",), + ) + ], + ) + self.assertEqual(routes_b[0]["route_map_in"], "RM_A") + self.assertEqual(routes_b[1]["route_map_in"], "RM_B") + + # Global VPNv4 must not pick ipv4-unicast AF row for same neighbor + global_routes = [ + {"afi": "vpnv4", "vrf": "", "neighbor": "10.0.0.1", "network": "1.0.0.0/24"}, + ] + intent3 = normalize_config_bgp_peer( + raw_text=""" +! + neighbor 10.0.0.1 remote-as 65001 + address-family ipv4 + neighbor 10.0.0.1 activate + neighbor 10.0.0.1 route-map RM_IPV4 in + $ + address-family vpnv4 + neighbor 10.0.0.1 activate + neighbor 10.0.0.1 route-map RM_VPNV4 in + $ +$ +""", + command="show running-config bgp", + ) + apply_enrich_joins( + global_routes, + {"config_bgp_peer": intent3}, + [ + EnrichJoin( + from_aux="config_bgp_peer", + left_on="afi,neighbor", + right_on="afi,neighbor", + take=("route_map_in",), + ) + ], + ) + self.assertEqual(global_routes[0]["route_map_in"], "RM_VPNV4") ra = resolve_aux_command( AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer"),