diff --git a/netx_api/biz_state/command_match.py b/netx_api/biz_state/command_match.py index 683f299..539b203 100644 --- a/netx_api/biz_state/command_match.py +++ b/netx_api/biz_state/command_match.py @@ -223,7 +223,13 @@ def expand_from_bindings( val = params.get(ph.name) or params.get(ph.schema_field) or "" if ph.required and not val: raise ValueError(f"missing placeholder {ph.name} for {profile.profile_id}") - rendered = rendered.replace(f"<{ph.name}>", val) + if not val: + # Optional empty: drop `` as `` (or bare `` ``) so + # legacy single-AS binds still render a valid show command. + rendered = rendered.replace(f" as <{ph.name}>", "") + rendered = rendered.replace(f"<{ph.name}>", "") + else: + rendered = rendered.replace(f"<{ph.name}>", val) concrete = normalize_command(rendered) if re.search(r"<[^>]+>", concrete): raise ValueError(f"unresolved placeholders in: {concrete}") diff --git a/netx_api/biz_state/parsers/zte/bgp_peer.py b/netx_api/biz_state/parsers/zte/bgp_peer.py index b14b911..84651d7 100644 --- a/netx_api/biz_state/parsers/zte/bgp_peer.py +++ b/netx_api/biz_state/parsers/zte/bgp_peer.py @@ -28,6 +28,8 @@ _CONT_RE = re.compile( re.I, ) _VRF_IN_CMD_RE = re.compile(r"(?i)\bvrf\s+(\S+)") +# ``as `` sits before ``| one-line`` (optional for legacy cmds). +_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: @@ -56,6 +58,13 @@ def _detect_vrf(command: str, params: dict[str, str] | None) -> str: return m.group(1).strip() if m else "" +def _detect_local_as(command: str, params: dict[str, str] | None) -> str: + if params and params.get("local_as"): + return str(params.get("local_as") or "").strip() + m = _LOCAL_AS_IN_CMD_RE.search(str(command or "")) + return m.group(1).strip() if m else "" + + def _state_and_pfx(state_raw: str) -> tuple[str, str]: raw = str(state_raw or "").strip() if raw.isdigit(): @@ -66,6 +75,7 @@ def _state_and_pfx(state_raw: str) -> tuple[str, str]: def _peer_row( *, + local_as: str, afi: str, vrf: str, nei: str, @@ -78,6 +88,7 @@ def _peer_row( ) -> dict[str, Any]: state, pfx = _state_and_pfx(state_raw) return { + "local_as": local_as[:16], "afi": afi[:32], "vrf": vrf[:128], "neighbor": nei[:128], @@ -94,6 +105,7 @@ def _peer_row( def _map_fsm_rows( rows: list[dict[str, Any]], *, + local_as: str, afi: str, vrf: str, ) -> list[dict[str, Any]]: @@ -123,6 +135,7 @@ def _map_fsm_rows( state_raw = row_get(r, "STATE_PFX", "STATE", "state", "pfx_rcd") out.append( _peer_row( + local_as=local_as, afi=afi, vrf=vrf, nei=nei, @@ -140,6 +153,7 @@ def _map_fsm_rows( def _hand_parse( *, raw_text: str, + local_as: str = "", afi: str = "unknown", vrf: str = "", **_kw: Any, @@ -165,6 +179,7 @@ def _hand_parse( pending = "" out.append( _peer_row( + local_as=local_as, afi=afi, vrf=vrf, nei=nei, @@ -190,6 +205,7 @@ def _hand_parse( seen.add(nei) out.append( _peer_row( + local_as=local_as, afi=afi, vrf=vrf, nei=nei, @@ -213,6 +229,7 @@ def normalize_bgp_peer( command: str = "", params: dict[str, str] | None = None, ) -> list[dict[str, Any]]: + local_as = _detect_local_as(command, params) afi = _detect_bgp_afi(command, params) vrf = _detect_vrf(command, params) tables = dict(fsm_tables or {}) @@ -229,10 +246,10 @@ def normalize_bgp_peer( ) def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - return _map_fsm_rows(rows, afi=afi, vrf=vrf) + return _map_fsm_rows(rows, local_as=local_as, afi=afi, vrf=vrf) def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]: - return _hand_parse(raw_text=raw_text, afi=afi, vrf=vrf, **kw) + return _hand_parse(raw_text=raw_text, local_as=local_as, afi=afi, vrf=vrf, **kw) return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text) diff --git a/netx_api/biz_state/parsers/zte/bgp_route.py b/netx_api/biz_state/parsers/zte/bgp_route.py index 9d911f8..175949f 100644 --- a/netx_api/biz_state/parsers/zte/bgp_route.py +++ b/netx_api/biz_state/parsers/zte/bgp_route.py @@ -9,7 +9,7 @@ from typing import Any, Mapping 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_vrf +from .bgp_peer import _detect_bgp_afi, _detect_local_as, _detect_vrf RULE_KEYS = ("zte_zxros_show_bgp_neighbor_routes",) @@ -152,6 +152,7 @@ def _emit_route( out: list[dict[str, Any]], seen: set[str], *, + local_as: str, afi: str, vrf: str, neighbor: str, @@ -172,6 +173,7 @@ def _emit_route( metric, loc, tag, path = _split_rest(rest, path_continuation=path_continuation) out.append( { + "local_as": local_as[:16], "afi": afi[:32], "vrf": vrf[:128], "neighbor": neighbor[:128], @@ -193,6 +195,7 @@ def _emit_route( def _map_fsm_rows( rows: list[dict[str, Any]], *, + local_as: str, afi: str, vrf: str, neighbor: str, @@ -214,6 +217,7 @@ def _map_fsm_rows( path = row_get(r, "PATH", "path") out.append( { + "local_as": local_as[:16], "afi": afi[:32], "vrf": vrf[:128], "neighbor": neighbor[:128], @@ -236,6 +240,7 @@ def _map_fsm_rows( def _hand_parse( *, raw_text: str, + local_as: str = "", afi: str = "unknown", vrf: str = "", neighbor: str = "", @@ -256,6 +261,7 @@ def _hand_parse( _emit_route( out, seen, + local_as=local_as, afi=afi, vrf=vrf, neighbor=neighbor, @@ -300,6 +306,7 @@ def _hand_parse( _emit_route( out, seen, + local_as=local_as, afi=afi, vrf=vrf, neighbor=neighbor, @@ -333,6 +340,7 @@ def normalize_bgp_route( command: str = "", params: dict[str, str] | None = None, ) -> list[dict[str, Any]]: + local_as = _detect_local_as(command, params) afi = _detect_bgp_afi(command, params) vrf = _detect_vrf(command, params) neighbor = _detect_neighbor(command, params) @@ -355,12 +363,18 @@ def normalize_bgp_route( def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: return _map_fsm_rows( - rows, afi=afi, vrf=vrf, neighbor=neighbor, direction=direction + 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, 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 a6f575b..5005860 100644 --- a/netx_api/biz_state/parsers/zte/config_bgp_peer.py +++ b/netx_api/biz_state/parsers/zte/config_bgp_peer.py @@ -1,9 +1,12 @@ """ZTE config intent: ``show running-config bgp`` / MIM ``!``. -Emits one row per ``(afi, vrf, neighbor|peer_group)`` activation under +Emits one row per ``(local_as, 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. +Supports **multiple** ``router bgp `` instances: each peer is scoped to +its enclosing local AS (meta / peer-group expand do not cross AS boundaries). + 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 @@ -23,6 +26,7 @@ from .config_common import extract_mim_section, is_secret_line RULE_KEYS: tuple[str, ...] = () +_ROUTER_BGP_RE = re.compile(r"^\s*router\s+bgp\s+(\S+)\s*$", re.I) _NEI_REMOTE_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+remote-as\s+(\S+)\s*$", re.I) _NEI_UPD_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+update-source\s+(\S+)\s*$", re.I) _NEI_PG_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+peer-group(?:\s+(\S+))?\s*$", re.I) @@ -32,9 +36,18 @@ _NEI_RM_RE = re.compile( _NEI_ACT_RE = re.compile( r"^\s*neighbor\s+(\S+)\s+activate(?:\s+(disable))?\s*$", re.I ) +_AF_V4_VRF_RE = re.compile(r"^\s*address-family\s+ipv4\s+vrf\s+(\S+)\s*$", re.I) +_AF_V6_VRF_RE = re.compile(r"^\s*address-family\s+ipv6\s+vrf\s+(\S+)\s*$", re.I) +_AF_L2VPN_RE = re.compile(r"^\s*address-family\s+l2vpn\s+(\S+)\s*$", re.I) +# Multi-word AFs first (e.g. ``ipv6 sr-policy``); then single token. +_AF_MULTI_RE = re.compile( + r"^\s*address-family\s+(ipv6\s+sr-policy|ipv4\s+sr-policy)\s*$", re.I +) +_AF_SINGLE_RE = re.compile(r"^\s*address-family\s+(\S+)\s*$", re.I) _IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") -_AfKey = tuple[str, str, str] # (afi, vrf, token) +_AfKey = tuple[str, str, str, str] # (local_as, afi, vrf, token) +_MetaKey = tuple[str, str] # (local_as, token) def _is_ip_neighbor(token: str) -> bool: @@ -68,14 +81,15 @@ def _split_neighbor_cols(token: str, info: Mapping[str, str]) -> tuple[str, str] def _merge_info( token: str, *, + local_as: str, afi: str, vrf: str, - meta: Mapping[str, dict[str, str]], + meta: Mapping[_MetaKey, dict[str, str]], af_rm: Mapping[_AfKey, dict[str, str]], ) -> dict[str, str]: """Global neighbor meta + AF-scoped route-maps (AF wins on conflict).""" - info = dict(meta.get(token) or {}) - scoped = af_rm.get((afi, vrf, token)) or {} + info = dict(meta.get((local_as, token)) or {}) + scoped = af_rm.get((local_as, afi, vrf, token)) or {} for key in ("route_map_in", "route_map_out"): if scoped.get(key): info[key] = scoped[key] @@ -84,6 +98,7 @@ def _merge_info( def _row( *, + local_as: str, afi: str, vrf: str, token: str, @@ -92,6 +107,7 @@ def _row( ) -> dict[str, Any]: neighbor, peer_group = _split_neighbor_cols(token, info) return { + "local_as": str(local_as or "")[:16], "afi": afi[:32], "vrf": vrf[:128], "neighbor": neighbor[:128], @@ -104,6 +120,12 @@ def _row( } +def _normalize_afi_token(raw: str) -> str: + """Collapse multi-word AF names to a stable token (``ipv6 sr-policy`` → ``ipv6-sr-policy``).""" + parts = [p for p in re.split(r"\s+", str(raw or "").strip().lower()) if p] + return "-".join(parts) + + def normalize_config_bgp_peer( *, raw_text: str, @@ -115,96 +137,133 @@ def normalize_config_bgp_peer( ) -> list[dict[str, Any]]: _ = (fsm_tables, vendor, device_type, command, params) body = extract_mim_section(raw_text, "bgp") - meta: dict[str, dict[str, str]] = {} + meta: dict[_MetaKey, dict[str, str]] = {} af_rm: dict[_AfKey, dict[str, str]] = {} + local_as = "" afi = "" vrf = "" - activations: list[tuple[str, str, str, str]] = [] - # (afi, vrf, token, activate) + activations: list[tuple[str, str, str, str, str]] = [] + # (local_as, afi, vrf, token, activate) + + def _meta(token: str) -> dict[str, str]: + key = (local_as, token) + slot = meta.get(key) + if slot is None: + slot = {} + meta[key] = slot + return slot for raw in body.splitlines(): line = raw.rstrip() if is_secret_line(line): continue + + m = _ROUTER_BGP_RE.match(line) + if m: + local_as = m.group(1).strip() + afi, vrf = "", "" + continue + + # End of ``router bgp`` block (column-0 ``$``). + if re.match(r"^\$\s*$", line): + afi, vrf = "", "" + local_as = "" + continue + + # End of address-family (exactly two-space indent ``$``). + if re.match(r"^ \$\s*$", line) and afi: + afi, vrf = "", "" + continue + + if not local_as: + continue + m = _NEI_REMOTE_RE.match(line) if m: nei = m.group(1).strip() - meta.setdefault(nei, {}) - meta[nei]["remote_as"] = m.group(2).strip() + _meta(nei)["remote_as"] = m.group(2).strip() continue m = _NEI_UPD_RE.match(line) if m: nei = m.group(1).strip() - meta.setdefault(nei, {}) - meta[nei]["update_source"] = m.group(2).strip() + _meta(nei)["update_source"] = m.group(2).strip() continue m = _NEI_PG_RE.match(line) if m: nei = m.group(1).strip() - meta.setdefault(nei, {}) + slot = _meta(nei) group = (m.group(2) or "").strip() if group: # neighbor peer-group - meta[nei]["peer_group"] = group + slot["peer_group"] = group else: # neighbor peer-group → peer-group definition - meta[nei]["is_group"] = "1" + slot["is_group"] = "1" continue m = _NEI_RM_RE.match(line) if m and not afi: nei = m.group(1).strip() - meta.setdefault(nei, {}) direction = m.group(3).lower() key = "route_map_in" if direction == "in" else "route_map_out" - meta[nei][key] = m.group(2).strip() + _meta(nei)[key] = m.group(2).strip() continue - m = re.match(r"^\s*address-family\s+ipv4\s+vrf\s+(\S+)\s*$", line, re.I) + m = _AF_V4_VRF_RE.match(line) if m: afi, vrf = "ipv4", m.group(1).strip() continue - m = re.match(r"^\s*address-family\s+ipv6\s+vrf\s+(\S+)\s*$", line, re.I) + m = _AF_V6_VRF_RE.match(line) if m: afi, vrf = "ipv6", m.group(1).strip() continue - m = re.match(r"^\s*address-family\s+l2vpn\s+(\S+)\s*$", line, re.I) + m = _AF_L2VPN_RE.match(line) if m: afi, vrf = f"l2vpn-{m.group(1).lower()}", "" continue - m = re.match(r"^\s*address-family\s+(\S+)\s*$", line, re.I) + m = _AF_MULTI_RE.match(line) + if m: + afi, vrf = _normalize_afi_token(m.group(1)), "" + continue + m = _AF_SINGLE_RE.match(line) if m: afi, vrf = m.group(1).lower(), "" continue - if re.match(r"^\s*\$\s*$", line) and afi: - if line.startswith(" ") and not line.startswith(" "): - afi, vrf = "", "" - continue m = _NEI_ACT_RE.match(line) if m and afi: nei = m.group(1).strip() act = "disable" if m.group(2) else "enable" - activations.append((afi, vrf, nei, act)) + activations.append((local_as, afi, vrf, nei, act)) continue m = _NEI_RM_RE.match(line) if m and afi: nei = m.group(1).strip() direction = m.group(3).lower() rk = "route_map_in" if direction == "in" else "route_map_out" - slot = af_rm.setdefault((afi, vrf, nei), {}) + slot = af_rm.setdefault((local_as, afi, vrf, nei), {}) slot[rk] = m.group(2).strip() out: list[dict[str, Any]] = [] - seen: set[tuple[str, str, str, str]] = set() + seen: set[tuple[str, str, str, str, str]] = set() def _append(row: dict[str, Any]) -> None: - key = (row["afi"], row["vrf"], row["neighbor"], row["peer_group"]) + key = ( + row["local_as"], + 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) + for las, afi_s, vrf_s, token, act in activations: + info = _merge_info( + token, local_as=las, afi=afi_s, vrf=vrf_s, meta=meta, af_rm=af_rm + ) + row = _row( + local_as=las, afi=afi_s, vrf=vrf_s, token=token, act=act, info=info + ) _append(row) # Global AF: peer-group activate → expand to member Neighbor IPs. @@ -214,27 +273,45 @@ def normalize_config_bgp_peer( pg = str(row.get("peer_group") or "").strip() if row.get("neighbor") or not pg: continue - for member, minfo in meta.items(): + for (m_as, member), minfo in meta.items(): + if m_as != las: + continue 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 + member, local_as=las, afi=afi_s, vrf=vrf_s, meta=meta, af_rm=af_rm ) + # AF route-maps on the peer-group name apply to expanded members + # unless the member already has an AF-specific map. + pg_info = _merge_info( + pg, local_as=las, afi=afi_s, vrf=vrf_s, meta=meta, af_rm=af_rm + ) + for key in ("route_map_in", "route_map_out"): + if pg_info.get(key) and not m_info.get(key): + m_info[key] = pg_info[key] m_row = _row( - afi=afi_s, vrf=vrf_s, token=member, act=act, info=m_info + local_as=las, + 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(): + # Peers with remote-as but no AF activate (scoped per local AS) + activated_tokens = {(las, n) for las, _, _, n, _ in activations} + for (las, token), info in meta.items(): if not info.get("remote_as"): continue - if any(n == token for _, _, n, _ in activations): + if (las, token) in activated_tokens: continue - row = _row(afi="global", vrf="", token=token, act="", info=info) + row = _row( + local_as=las, afi="global", vrf="", token=token, act="", info=info + ) _append(row) return out diff --git a/netx_api/biz_state/parsers/zte/evpn_mac.py b/netx_api/biz_state/parsers/zte/evpn_mac.py index 08cecaa..9567296 100644 --- a/netx_api/biz_state/parsers/zte/evpn_mac.py +++ b/netx_api/biz_state/parsers/zte/evpn_mac.py @@ -8,6 +8,7 @@ from typing import Any, Mapping 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_local_as RULE_KEYS = ("zte_zxros_show_bgp_evpn_mac",) @@ -17,7 +18,11 @@ _ROUTE_RE = re.compile( ) -def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: +def _map_fsm_rows( + rows: list[dict[str, Any]], + *, + local_as: str, +) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[str] = set() for r in rows: @@ -27,6 +32,7 @@ def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: seen.add(net) out.append( { + "local_as": local_as[:16], "network": net[:256], "next_hop": row_get(r, "NEXT_HOP", "next_hop")[:128], "metric": row_get(r, "METRIC", "metric")[:32], @@ -39,7 +45,7 @@ def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: return out -def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]: +def _hand_parse(*, raw_text: str, local_as: str = "", **_kw: Any) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[str] = set() for raw in str(raw_text or "").splitlines(): @@ -79,6 +85,7 @@ def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]: path_parts.append(p) out.append( { + "local_as": local_as[:16], "network": net[:256], "next_hop": m.group("nh")[:128], "metric": metric[:32], @@ -100,7 +107,7 @@ def normalize_evpn_mac( command: str = "", params: dict[str, str] | None = None, ) -> list[dict[str, Any]]: - _ = params + local_as = _detect_local_as(command, params) tables = dict(fsm_tables or {}) if not any(tables.get(k) for k in RULE_KEYS): platform = resolve_cli_platform( @@ -113,7 +120,14 @@ def normalize_evpn_mac( tables = apply_rules( platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd ) - return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text) + + def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return _map_fsm_rows(rows, local_as=local_as) + + def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]: + return _hand_parse(raw_text=raw_text, local_as=local_as, **kw) + + return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text) normalize_evpn_mac.RULE_KEYS = RULE_KEYS diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index 85d0519..62cb43e 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -274,6 +274,7 @@ _ND6_FIELDS: list[FieldDef] = [ ] _BGP_PEER_FIELDS: list[FieldDef] = [ + FieldDef("local_as", length=16, indexed=True, is_key=True, display_name="Local AS", from_command_param=True), FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI", from_command_param=True), FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True), FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor"), @@ -285,12 +286,11 @@ _BGP_PEER_FIELDS: list[FieldDef] = [ FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"), FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"), FieldDef("up_down", length=32, role="meta", display_name="Up/Down"), - # From config_vrf enrich (VRF summary aux). FieldDef("rd", length=64, role="meta", display_name="RD"), FieldDef("address_families", length=64, role="meta", display_name="AF"), ] -# BGP VRF summary still binds VRF from config VRF intent. +# BGP VRF summary still binds VRF from config VRF intent (ip/ipv6 route profiles). _VRF_PLACEHOLDER_IPV4 = PlaceholderDef( name="vrf", schema_field="vrf", @@ -315,11 +315,24 @@ _VRF_PLACEHOLDER_IPV6 = PlaceholderDef( discover_filter_contains="ipv6", ) +# Local AS from ``router bgp `` (config_bgp_peer). Optional → expand all processes. +_BGP_LOCAL_AS = PlaceholderDef( + name="local_as", + schema_field="local_as", + required=False, + bind_mode="discover_select", + discover_profile_id="zte.config_bgp_peer", + discover_value_field="local_as", + discover_label_field="local_as", + discover_require_nonempty="local_as", +) + # 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. _BGP_NEIGHBOR_VPNV4 = PlaceholderDef( name="neighbor", schema_field="neighbor", - required=True, + required=False, bind_mode="discover_select", discover_profile_id="zte.config_bgp_peer", discover_value_field="neighbor", @@ -327,12 +340,13 @@ _BGP_NEIGHBOR_VPNV4 = PlaceholderDef( discover_filter_field="afi", discover_filter_contains="vpnv4", discover_require_empty="vrf", + discover_require_nonempty="neighbor", ) _BGP_NEIGHBOR_VPNV6 = PlaceholderDef( name="neighbor", schema_field="neighbor", - required=True, + required=False, bind_mode="discover_select", discover_profile_id="zte.config_bgp_peer", discover_value_field="neighbor", @@ -340,12 +354,13 @@ _BGP_NEIGHBOR_VPNV6 = PlaceholderDef( discover_filter_field="afi", discover_filter_contains="vpnv6", discover_require_empty="vrf", + discover_require_nonempty="neighbor", ) _BGP_NEIGHBOR_IPV4 = PlaceholderDef( name="neighbor", schema_field="neighbor", - required=True, + required=False, bind_mode="discover_select", discover_profile_id="zte.config_bgp_peer", discover_value_field="neighbor", @@ -353,12 +368,13 @@ _BGP_NEIGHBOR_IPV4 = PlaceholderDef( discover_filter_field="afi", discover_filter_contains="ipv4", discover_require_empty="vrf", + discover_require_nonempty="neighbor", ) _BGP_NEIGHBOR_IPV6 = PlaceholderDef( name="neighbor", schema_field="neighbor", - required=True, + required=False, bind_mode="discover_select", discover_profile_id="zte.config_bgp_peer", discover_value_field="neighbor", @@ -366,13 +382,14 @@ _BGP_NEIGHBOR_IPV6 = PlaceholderDef( discover_filter_field="afi", discover_filter_contains="ipv6", discover_require_empty="vrf", + discover_require_nonempty="neighbor", ) # Per-VRF CE peers live under address-family ipv4/ipv6 vrf . _BGP_VRF_PEER_IPV4 = PlaceholderDef( name="vrf", schema_field="vrf", - required=True, + required=False, bind_mode="discover_select", discover_profile_id="zte.config_bgp_peer", discover_value_field="vrf", @@ -385,7 +402,7 @@ _BGP_VRF_PEER_IPV4 = PlaceholderDef( _BGP_NEIGHBOR_IPV4_VRF = PlaceholderDef( name="neighbor", schema_field="neighbor", - required=True, + required=False, bind_mode="discover_select", discover_profile_id="zte.config_bgp_peer", discover_value_field="neighbor", @@ -398,7 +415,7 @@ _BGP_NEIGHBOR_IPV4_VRF = PlaceholderDef( _BGP_VRF_PEER_IPV6 = PlaceholderDef( name="vrf", schema_field="vrf", - required=True, + required=False, bind_mode="discover_select", discover_profile_id="zte.config_bgp_peer", discover_value_field="vrf", @@ -411,7 +428,7 @@ _BGP_VRF_PEER_IPV6 = PlaceholderDef( _BGP_NEIGHBOR_IPV6_VRF = PlaceholderDef( name="neighbor", schema_field="neighbor", - required=True, + required=False, bind_mode="discover_select", discover_profile_id="zte.config_bgp_peer", discover_value_field="neighbor", @@ -421,6 +438,49 @@ _BGP_NEIGHBOR_IPV6_VRF = PlaceholderDef( discover_require_nonempty="vrf", ) +# VRF BGP summary: unique (local_as, vrf) from peer intent (not bare config_vrf). +_BGP_VRF_SUMMARY_IPV4 = PlaceholderDef( + name="vrf", + schema_field="vrf", + required=False, + bind_mode="discover_select", + discover_profile_id="zte.config_bgp_peer", + discover_value_field="vrf", + discover_label_field="vrf", + discover_filter_field="afi", + discover_filter_contains="ipv4", + discover_require_nonempty="vrf", +) + +_BGP_VRF_SUMMARY_IPV6 = PlaceholderDef( + name="vrf", + schema_field="vrf", + required=False, + bind_mode="discover_select", + discover_profile_id="zte.config_bgp_peer", + discover_value_field="vrf", + discover_label_field="vrf", + discover_filter_field="afi", + discover_filter_contains="ipv6", + discover_require_nonempty="vrf", +) + +_BGP_ENRICH_PEER = [ + EnrichJoin( + from_aux="config_bgp_peer", + left_on="local_as,afi,neighbor", + right_on="local_as,afi,neighbor", + take=("remote_as", "activate", "route_map_in", "route_map_out"), + ), +] +_BGP_ENRICH_PEER_VRF = [ + EnrichJoin( + from_aux="config_bgp_peer", + left_on="local_as,afi,vrf,neighbor", + right_on="local_as,afi,vrf,neighbor", + take=("remote_as", "activate", "route_map_in", "route_map_out"), + ), +] _OSPF_FIELDS: list[FieldDef] = [ FieldDef("process_id", length=32, indexed=True, is_key=True, display_name="Process ID"), FieldDef("neighbor_id", length=64, indexed=True, is_key=True, display_name="Neighbor ID"), @@ -456,6 +516,7 @@ _OPTICAL_FIELDS: list[FieldDef] = [ ] _BGP_ROUTE_FIELDS: list[FieldDef] = [ + FieldDef("local_as", length=16, indexed=True, is_key=True, display_name="Local AS", from_command_param=True), FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI", from_command_param=True), FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True), FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor", from_command_param=True), @@ -554,6 +615,7 @@ _L2VPN_MAC_FIELDS: list[FieldDef] = [ ] _EVPN_MAC_FIELDS: list[FieldDef] = [ + FieldDef("local_as", length=16, indexed=True, is_key=True, display_name="Local AS", from_command_param=True), FieldDef("network", length=256, indexed=True, is_key=True, display_name="Network"), FieldDef("next_hop", length=128, role="state", display_name="NextHop"), FieldDef("path", length=256, role="state", display_name="Path"), @@ -585,6 +647,7 @@ _CONFIG_IFACE_FIELDS: list[FieldDef] = [ ] _CONFIG_BGP_PEER_FIELDS: list[FieldDef] = [ + FieldDef("local_as", length=16, indexed=True, is_key=True, display_name="Local AS"), FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI"), FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"), FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor"), @@ -779,10 +842,11 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_peer", parser_id="bgp_peer", title="BGP VPNv4 Summary", - command_template="show bgp vpnv4 unicast summary | one-line", - match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp vpnv4 unicast summary as | one-line", + match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+summary(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast summary", description="BGP VPNv4 peer summary (afi=vpnv4).", + placeholders=[_BGP_LOCAL_AS], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "vpnv4", "status"], sort_order=340, @@ -795,10 +859,11 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_peer", parser_id="bgp_peer", title="BGP IPv4 Summary", - command_template="show bgp ipv4 unicast summary | one-line", - match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp ipv4 unicast summary as | one-line", + match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+summary(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp ipv4 unicast summary", description="BGP IPv4 unicast peer summary (afi=ipv4).", + placeholders=[_BGP_LOCAL_AS], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "ipv4", "status"], sort_order=350, @@ -811,10 +876,11 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_peer", parser_id="bgp_peer", title="BGP VPNv6 Summary", - command_template="show bgp vpnv6 unicast summary | one-line", - match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp vpnv6 unicast summary as | one-line", + match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+summary(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast summary", description="BGP VPNv6 peer summary (afi=vpnv6).", + placeholders=[_BGP_LOCAL_AS], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "vpnv6", "status"], sort_order=360, @@ -827,10 +893,11 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_peer", parser_id="bgp_peer", title="BGP IPv6 Summary", - command_template="show bgp ipv6 unicast summary | one-line", - match=r"(?i)^\s*show\s+bgp\s+ipv6\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp ipv6 unicast summary as | one-line", + match=r"(?i)^\s*show\s+bgp\s+ipv6\s+unicast\s+summary(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp ipv6 unicast summary", description="BGP IPv6 unicast peer summary (afi=ipv6).", + placeholders=[_BGP_LOCAL_AS], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "ipv6", "status"], sort_order=365, @@ -843,27 +910,19 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_peer", parser_id="bgp_peer", title="BGP VPNv4 VRF Summary", - command_template="show bgp vpnv4 unicast vrf summary | one-line", - match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P\S+)\s+summary(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp vpnv4 unicast vrf summary as | one-line", + match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P\S+)\s+summary(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast summary", description=( - "Per-VRF BGP VPNv4 peer summary. Bind one or more VRFs; " - "aux: config_vrf (RD/AF enrich). IPv4 FIB is a separate monitor item." + "Per-VRF BGP VPNv4 peer summary. Bind (local_as, vrf) from BGP peer intent; " + "IPv4 FIB is a separate monitor item." ), - placeholders=[_VRF_PLACEHOLDER_IPV4], + placeholders=[_BGP_VRF_SUMMARY_IPV4, _BGP_LOCAL_AS], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "vpnv4", "vrf", "status"], sort_order=370, enabled=True, kind="collect", - enrich_joins=[ - EnrichJoin( - from_aux="config_vrf", - left_on="vrf", - right_on="vrf_name", - take=("rd", "address_families"), - ), - ], ), ParseProfile( profile_id="zte.bgp_vpnv6_vrf_summary", @@ -871,27 +930,19 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_peer", parser_id="bgp_peer", title="BGP VPNv6 VRF Summary", - command_template="show bgp vpnv6 unicast vrf summary | one-line", - match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P\S+)\s+summary(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp vpnv6 unicast vrf summary as | one-line", + match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P\S+)\s+summary(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast summary", description=( - "Per-VRF BGP VPNv6 peer summary. Bind one or more VRFs; " - "aux: config_vrf (RD/AF enrich). IPv6 FIB is a separate monitor item." + "Per-VRF BGP VPNv6 peer summary. Bind (local_as, vrf) from BGP peer intent; " + "IPv6 FIB is a separate monitor item." ), - placeholders=[_VRF_PLACEHOLDER_IPV6], + placeholders=[_BGP_VRF_SUMMARY_IPV6, _BGP_LOCAL_AS], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "vpnv6", "vrf", "status"], sort_order=375, enabled=True, kind="collect", - enrich_joins=[ - EnrichJoin( - from_aux="config_vrf", - left_on="vrf", - right_on="vrf_name", - take=("rd", "address_families"), - ), - ], ), ParseProfile( profile_id="zte.bgp_evpn_summary", @@ -899,10 +950,11 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_peer", parser_id="bgp_peer", title="BGP L2VPN EVPN Summary", - command_template="show bgp l2vpn evpn summary | one-line", - match=r"(?i)^\s*show\s+bgp\s+l2vpn\s+evpn\s+summary(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp l2vpn evpn summary as | one-line", + match=r"(?i)^\s*show\s+bgp\s+l2vpn\s+evpn\s+summary(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp l2vpn evpn summary", description="BGP L2VPN EVPN peer summary (afi=evpn).", + placeholders=[_BGP_LOCAL_AS], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "evpn", "status"], sort_order=380, @@ -915,10 +967,11 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_peer", parser_id="bgp_peer", title="BGP L2VPN VPLS Summary", - command_template="show bgp l2vpn vpls summary | one-line", - match=r"(?i)^\s*show\s+bgp\s+l2vpn\s+vpls\s+summary(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp l2vpn vpls summary as | one-line", + match=r"(?i)^\s*show\s+bgp\s+l2vpn\s+vpls\s+summary(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp l2vpn vpls summary", description="BGP L2VPN VPLS peer summary (afi=vpls).", + placeholders=[_BGP_LOCAL_AS], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "vpls", "status"], sort_order=385, @@ -997,27 +1050,20 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP VPNv4 Neighbor In", - 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*$", + command_template="show bgp vpnv4 unicast neighbor in as | one-line", + match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast neighbor in", description=( "Routes learned from VPNv4 neighbor; discover neighbor from BGP peer intent; " "aux: show running-config bgp." ), - placeholders=[_BGP_NEIGHBOR_VPNV4], + placeholders=[_BGP_NEIGHBOR_VPNV4, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "route"], sort_order=420, enabled=True, kind="collect", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="afi,neighbor", - right_on="afi,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER), ), ParseProfile( profile_id="zte.bgp_vpnv4_neighbor_out", @@ -1025,28 +1071,21 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP VPNv4 Neighbor Out", - 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*$", + command_template="show bgp vpnv4 unicast neighbor out as | one-line", + match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast neighbor out", description=( "Routes advertised to VPNv4 neighbor; discover from BGP peer intent; " "aux: show running-config bgp." ), - placeholders=[_BGP_NEIGHBOR_VPNV4], + placeholders=[_BGP_NEIGHBOR_VPNV4, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "route"], sort_order=425, enabled=True, kind="collect", collect_lane="heavy", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="afi,neighbor", - right_on="afi,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER), ), ParseProfile( profile_id="zte.bgp_vpnv6_neighbor_in", @@ -1054,27 +1093,20 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP VPNv6 Neighbor In", - 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*$", + command_template="show bgp vpnv6 unicast neighbor in as | one-line", + match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast neighbor in", description=( "Routes learned from VPNv6 neighbor; discover from BGP peer intent; " "aux: show running-config bgp." ), - placeholders=[_BGP_NEIGHBOR_VPNV6], + placeholders=[_BGP_NEIGHBOR_VPNV6, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "route"], sort_order=426, enabled=True, kind="collect", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="afi,neighbor", - right_on="afi,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER), ), ParseProfile( profile_id="zte.bgp_vpnv6_neighbor_out", @@ -1082,28 +1114,21 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP VPNv6 Neighbor Out", - 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*$", + command_template="show bgp vpnv6 unicast neighbor out as | one-line", + match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast neighbor out", description=( "Routes advertised to VPNv6 neighbor; discover from BGP peer intent; " "aux: show running-config bgp." ), - placeholders=[_BGP_NEIGHBOR_VPNV6], + placeholders=[_BGP_NEIGHBOR_VPNV6, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "route"], sort_order=427, enabled=True, kind="collect", collect_lane="heavy", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="afi,neighbor", - right_on="afi,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER), ), ParseProfile( profile_id="zte.bgp_ipv4_neighbor_in", @@ -1111,27 +1136,20 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP IPv4 Neighbor In", - command_template="show bgp ipv4 unicast neighbor in | one-line", - match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp ipv4 unicast neighbor in as | one-line", + 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." ), - placeholders=[_BGP_NEIGHBOR_IPV4], + placeholders=[_BGP_NEIGHBOR_IPV4, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "ipv4", "route"], sort_order=428, enabled=True, kind="collect", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="afi,neighbor", - right_on="afi,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER), ), ParseProfile( profile_id="zte.bgp_ipv4_neighbor_out", @@ -1139,28 +1157,21 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP IPv4 Neighbor Out", - command_template="show bgp ipv4 unicast neighbor out | one-line", - match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp ipv4 unicast neighbor out as | one-line", + 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; " "aux: show running-config bgp." ), - placeholders=[_BGP_NEIGHBOR_IPV4], + placeholders=[_BGP_NEIGHBOR_IPV4, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "ipv4", "route"], sort_order=429, enabled=True, kind="collect", collect_lane="heavy", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="afi,neighbor", - right_on="afi,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER), ), ParseProfile( profile_id="zte.bgp_ipv6_neighbor_in", @@ -1168,27 +1179,20 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP IPv6 Neighbor In", - command_template="show bgp ipv6 unicast neighbor in | one-line", - match=r"(?i)^\s*show\s+bgp\s+ipv6\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp ipv6 unicast neighbor in as | one-line", + 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." ), - placeholders=[_BGP_NEIGHBOR_IPV6], + placeholders=[_BGP_NEIGHBOR_IPV6, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "ipv6", "route"], sort_order=430, enabled=True, kind="collect", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="afi,neighbor", - right_on="afi,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER), ), ParseProfile( profile_id="zte.bgp_ipv6_neighbor_out", @@ -1196,28 +1200,21 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP IPv6 Neighbor Out", - command_template="show bgp ipv6 unicast neighbor out | one-line", - match=r"(?i)^\s*show\s+bgp\s+ipv6\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp ipv6 unicast neighbor out as | one-line", + 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; " "aux: show running-config bgp." ), - placeholders=[_BGP_NEIGHBOR_IPV6], + placeholders=[_BGP_NEIGHBOR_IPV6, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "ipv6", "route"], sort_order=431, enabled=True, kind="collect", collect_lane="heavy", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="afi,neighbor", - right_on="afi,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER), ), ParseProfile( profile_id="zte.bgp_vpnv4_vrf_neighbor_in", @@ -1225,30 +1222,24 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP VPNv4 VRF Neighbor In", - command_template="show bgp vpnv4 unicast vrf neighbor in | one-line", + command_template="show bgp vpnv4 unicast vrf neighbor in as | one-line", match=( r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P\S+)\s+" - r"neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" + r"neighbor\s+(?Pin)\s+(?P\S+)" + r"(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv4 unicast neighbor in", description=( - "Per-VRF CE peer routes; discover (vrf,neighbor) from BGP peer intent; " - "aux: show running-config bgp (no placeholders)." + "Per-VRF CE peer routes; discover (local_as,vrf,neighbor) from BGP peer intent; " + "aux: show running-config bgp." ), - placeholders=[_BGP_VRF_PEER_IPV4, _BGP_NEIGHBOR_IPV4_VRF], + placeholders=[_BGP_VRF_PEER_IPV4, _BGP_NEIGHBOR_IPV4_VRF, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "vrf", "route"], sort_order=440, enabled=True, kind="collect", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="vrf,neighbor", - right_on="vrf,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER_VRF), ), ParseProfile( profile_id="zte.bgp_vpnv4_vrf_neighbor_out", @@ -1256,31 +1247,25 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP VPNv4 VRF Neighbor Out", - command_template="show bgp vpnv4 unicast vrf neighbor out | one-line", + command_template="show bgp vpnv4 unicast vrf neighbor out as | one-line", match=( r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P\S+)\s+" - r"neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" + r"neighbor\s+(?Pout)\s+(?P\S+)" + r"(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv4 unicast neighbor out", description=( - "Per-VRF CE advertised routes; discover pairs from BGP peer intent; " + "Per-VRF CE advertised routes; discover (local_as,vrf,neighbor) from BGP peer intent; " "aux: show running-config bgp." ), - placeholders=[_BGP_VRF_PEER_IPV4, _BGP_NEIGHBOR_IPV4_VRF], + placeholders=[_BGP_VRF_PEER_IPV4, _BGP_NEIGHBOR_IPV4_VRF, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "vrf", "route"], sort_order=445, enabled=True, kind="collect", collect_lane="heavy", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="vrf,neighbor", - right_on="vrf,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER_VRF), ), ParseProfile( profile_id="zte.bgp_vpnv6_vrf_neighbor_in", @@ -1288,30 +1273,24 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP VPNv6 VRF Neighbor In", - command_template="show bgp vpnv6 unicast vrf neighbor in | one-line", + command_template="show bgp vpnv6 unicast vrf neighbor in as | one-line", match=( r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P\S+)\s+" - r"neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" + r"neighbor\s+(?Pin)\s+(?P\S+)" + r"(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv6 unicast neighbor in", description=( - "Per-VRF IPv6 CE peer routes; discover pairs from BGP peer intent; " + "Per-VRF IPv6 CE peer routes; discover (local_as,vrf,neighbor) from BGP peer intent; " "aux: show running-config bgp." ), - placeholders=[_BGP_VRF_PEER_IPV6, _BGP_NEIGHBOR_IPV6_VRF], + placeholders=[_BGP_VRF_PEER_IPV6, _BGP_NEIGHBOR_IPV6_VRF, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "vrf", "route"], sort_order=450, enabled=True, kind="collect", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="vrf,neighbor", - right_on="vrf,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER_VRF), ), ParseProfile( profile_id="zte.bgp_vpnv6_vrf_neighbor_out", @@ -1319,31 +1298,25 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="bgp_route", parser_id="bgp_route", title="BGP VPNv6 VRF Neighbor Out", - command_template="show bgp vpnv6 unicast vrf neighbor out | one-line", + command_template="show bgp vpnv6 unicast vrf neighbor out as | one-line", match=( r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P\S+)\s+" - r"neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" + r"neighbor\s+(?Pout)\s+(?P\S+)" + r"(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv6 unicast neighbor out", description=( - "Per-VRF IPv6 CE advertised routes; discover pairs from BGP peer intent; " + "Per-VRF IPv6 CE advertised routes; discover (local_as,vrf,neighbor) from BGP peer intent; " "aux: show running-config bgp." ), - placeholders=[_BGP_VRF_PEER_IPV6, _BGP_NEIGHBOR_IPV6_VRF], + placeholders=[_BGP_VRF_PEER_IPV6, _BGP_NEIGHBOR_IPV6_VRF, _BGP_LOCAL_AS], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "vrf", "route"], sort_order=455, enabled=True, kind="collect", collect_lane="heavy", - enrich_joins=[ - EnrichJoin( - from_aux="config_bgp_peer", - left_on="vrf,neighbor", - right_on="vrf,neighbor", - take=("remote_as", "activate", "route_map_in", "route_map_out"), - ), - ], + enrich_joins=list(_BGP_ENRICH_PEER_VRF), ), # --- Phase 4 forwarding / L2 --- ParseProfile( @@ -1482,10 +1455,11 @@ def _zte_status_profiles() -> list[ParseProfile]: metric_id="evpn_mac", parser_id="evpn_mac", title="BGP EVPN MAC", - command_template="show bgp evpn mac | one-line", - match=r"(?i)^\s*show\s+bgp\s+evpn\s+mac(?:\s*\|\s*one-line)?\s*$", + command_template="show bgp evpn mac as | one-line", + match=r"(?i)^\s*show\s+bgp\s+evpn\s+mac(?:\s+as\s+(?P\S+))?(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp evpn mac", description="BGP EVPN MAC NLRI (large; disabled by default).", + placeholders=[_BGP_LOCAL_AS], fields=list(_EVPN_MAC_FIELDS), tags=["bgp", "evpn", "mac"], sort_order=490, @@ -1537,7 +1511,7 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show running-config bgp | one-line", match=r"(?i)^\s*show\s+running-config\s+bgp(?:\s*\|\s*one-line)?\s*$", textfsm_command="show running-config bgp", - description="BGP neighbor AF activate / remote-as intent (passwords skipped).", + description="BGP neighbor AF activate / remote-as / local-as intent (multi router-bgp; passwords skipped).", fields=list(_CONFIG_BGP_PEER_FIELDS), tags=["config", "bgp", "intent"], sort_order=520, diff --git a/tests/test_biz_state_discover_cache.py b/tests/test_biz_state_discover_cache.py index c096a41..6cfe07f 100644 --- a/tests/test_biz_state_discover_cache.py +++ b/tests/test_biz_state_discover_cache.py @@ -25,8 +25,20 @@ class DiscoverCacheTests(unittest.TestCase): self.assertEqual(v6.placeholders[0].discover_profile_id, "zte.config_bgp_peer") records = [ - {"afi": "vpnv4", "vrf": "", "neighbor": "10.0.0.1", "remote_as": "65001"}, - {"afi": "vpnv6", "vrf": "", "neighbor": "FC00::1", "remote_as": "65002"}, + { + "local_as": "64900", + "afi": "vpnv4", + "vrf": "", + "neighbor": "10.0.0.1", + "remote_as": "65001", + }, + { + "local_as": "64900", + "afi": "vpnv6", + "vrf": "", + "neighbor": "FC00::1", + "remote_as": "65002", + }, ] db = MagicMock() @@ -53,7 +65,12 @@ class DiscoverCacheTests(unittest.TestCase): ) self.assertTrue(first["ok"]) self.assertFalse(first.get("cache_hit")) - self.assertEqual([c["value"] for c in first["candidates"]], ["10.0.0.1"]) + self.assertTrue(first.get("pair_mode")) + self.assertEqual(len(first["candidates"]), 1) + self.assertEqual( + first["candidates"][0]["bindings"], + {"neighbor": "10.0.0.1", "local_as": "64900"}, + ) self.assertEqual(open_conn.call_count, 1) self.assertEqual(send_cmd.call_count, 1) self.assertEqual(run_parser.call_count, 1) @@ -67,7 +84,11 @@ class DiscoverCacheTests(unittest.TestCase): ) self.assertTrue(second["ok"]) self.assertTrue(second.get("cache_hit")) - self.assertEqual([c["value"] for c in second["candidates"]], ["FC00::1"]) + self.assertEqual(len(second["candidates"]), 1) + self.assertEqual( + second["candidates"][0]["bindings"], + {"neighbor": "FC00::1", "local_as": "64900"}, + ) # Same discover profile → no second SSH/parse self.assertEqual(open_conn.call_count, 1) self.assertEqual(send_cmd.call_count, 1) diff --git a/tests/test_biz_state_export_commands.py b/tests/test_biz_state_export_commands.py index f8aaaf7..f4115b9 100644 --- a/tests/test_biz_state_export_commands.py +++ b/tests/test_biz_state_export_commands.py @@ -82,8 +82,8 @@ class BizStateExportCommandsTests(unittest.TestCase): cmds = plan["commands"] self.assertTrue(any("lldp" in c.lower() for c in cmds)) self.assertTrue(any("CUST_A" in c and "summary" in c for c in cmds)) - # Default export includes discover-backed aux (config_vrf), not FIB - self.assertTrue(any("running-config vrf" in c for c in cmds)) + # Default export includes discover-backed aux (config_bgp_peer) + self.assertTrue(any("running-config bgp" in c for c in cmds)) self.assertFalse(any("forwarding route" in c for c in cmds)) # Disabled item excluded by default self.assertFalse(any("isis" in c.lower() for c in cmds)) @@ -91,14 +91,14 @@ class BizStateExportCommandsTests(unittest.TestCase): primary_only = plan_task_collect_commands( self.db, self.task.id, include_aux=False ) - self.assertFalse(any("running-config vrf" in c for c in primary_only["commands"])) + self.assertFalse(any("running-config bgp" in c for c in primary_only["commands"])) text = export_task_commands_text(self.db, self.task.id) self.assertIn("task_id=t_export", text) self.assertIn("show lldp neighbor brief", text) self.assertIn("CUST_A", text) self.assertIn("# aux:", text) - self.assertIn("running-config vrf", text) + self.assertIn("running-config bgp", text) self.assertIn("# ---- flat unique commands ----", text) def test_shared_aux_repeated_per_item_deduped_in_flat(self) -> None: @@ -157,7 +157,7 @@ class BizStateExportCommandsTests(unittest.TestCase): flat = text.split("# ---- flat unique commands ----", 1)[-1] self.assertEqual(flat.count(aux_cli), 1) - def test_unbound_required_placeholder_noted(self) -> None: + def test_unbound_optional_placeholder_expand_all(self) -> None: item = BizStateTaskItem( id="i_exp", task_id=self.task.id, @@ -173,24 +173,25 @@ class BizStateExportCommandsTests(unittest.TestCase): plan = plan_task_collect_commands(self.db, self.task.id) sec = plan["items"][0] notes = " ".join(sec.get("notes") or []) - self.assertIn("requires parameter bindings", notes) - # Primary is template-only; aux (config_vrf) still counts as executable + self.assertIn("expand_all", notes) + # Primary is template-only; aux (config_bgp_peer) still counts as executable self.assertEqual(plan["command_count"], 1) - self.assertTrue(any("running-config vrf" in c for c in plan["commands"])) + self.assertTrue(any("running-config bgp" in c for c in plan["commands"])) tmpl_cmds = [c for c in sec.get("commands") or [] if c.get("role") == "template"] self.assertEqual(len(tmpl_cmds), 1) self.assertIn("", tmpl_cmds[0]["command"]) + self.assertIn("", tmpl_cmds[0]["command"]) aux_cmds = [c for c in sec.get("commands") or [] if c.get("role") == "aux"] self.assertEqual(len(aux_cmds), 1) text = export_task_commands_text(self.db, self.task.id) - self.assertIn("# show bgp vpnv4 unicast vrf summary", text) + self.assertIn("# show bgp vpnv4 unicast vrf summary as ", text) self.assertIn("# aux:", text) - self.assertIn("running-config vrf", text) + self.assertIn("running-config bgp", text) # Template must not appear in the flat executable list flat = text.split("# ---- flat unique commands ----", 1)[-1] self.assertNotIn("", flat) - self.assertIn("running-config vrf", flat) + self.assertIn("running-config bgp", flat) if __name__ == "__main__": diff --git a/tests/test_zte_config_intent.py b/tests/test_zte_config_intent.py index d288a7d..f4a7a60 100644 --- a/tests/test_zte_config_intent.py +++ b/tests/test_zte_config_intent.py @@ -92,6 +92,74 @@ $ ! """ +# Multi local-AS (trimmed from MER1 show running-config bgp) +_CFG_BGP_MULTI_AS = """ +! +bgp as-notation plain +router bgp 64900 + neighbor 2c0f:f7c0:0:500::50:2 remote-as 64900 + neighbor 2c0f:f7c0:0:500::50:2 update-source loopback130 + neighbor 172.16.0.131 remote-as 64900 + neighbor 172.16.0.131 update-source loopback130 + neighbor 10.229.147.93 remote-as 64900 + neighbor 10.229.147.93 update-source loopback130 + address-family link-state + neighbor 10.229.147.93 activate + neighbor 10.229.147.93 route-reflector-client + $ + address-family vpnv4 + neighbor 172.16.0.131 activate + neighbor 172.16.0.131 route-reflector-client + $ + address-family ipv6 + neighbor 2c0f:f7c0:0:500::50:2 activate + $ + address-family ipv6 sr-policy + neighbor 10.229.147.93 activate + $ + address-family vpnv6 + neighbor 2c0f:f7c0:0:500::50:2 activate + $ +$ +router bgp 64600 + neighbor 444::2 remote-as 64600 + neighbor 444::2 activate disable + neighbor 444::2 update-source loopback400 + address-family l2vpn evpn + neighbor 444::2 activate + neighbor 444::2 announce-sid + $ +$ +router bgp 64580 + neighbor 22:22:22::22 remote-as 64580 + neighbor 22:22:22::22 activate + neighbor 22:22:22::22 update-source loopback0 +$ +router bgp 100 + neighbor MAR_GROUP_V6_1 peer-group + neighbor MAR_GROUP_V6_1 remote-as 100 + neighbor MAR_GROUP_V6_1 activate disable + neighbor MAR_GROUP_V6_1 update-source loopback1001 + neighbor 2408:8121:8400:1:1000::4:0 remote-as 100 + neighbor 2408:8121:8400:1:1000::4:0 peer-group MAR_GROUP_V6_1 + neighbor 2408:8121:8400:1:1000::4:0 activate disable + neighbor 100.0.0.2 remote-as 100 + neighbor 100.0.0.2 activate + address-family l2vpn evpn + neighbor MAR_GROUP_V6_1 activate + neighbor MAR_GROUP_V6_1 route-map TO_MAR_EVPN_SRV6_GROUP_1 out + $ + address-family vpnv4 + neighbor 100.0.0.2 activate + $ + address-family ipv6 vrf SRv6 + neighbor 2000::1 remote-as 100 + neighbor 2000::1 activate + $ +$ +! +""" + _CFG_L2VPN = """ ! vpws VPN_A @@ -289,36 +357,116 @@ class ZteConfigIntentTests(unittest.TestCase): self.assertNotIn("SKIPME", blob) self.assertNotIn("password", blob.lower()) - by = {(r["afi"], r["vrf"], r["neighbor"], r["peer_group"]): r for r in rows} - self.assertIn(("vpnv4", "", "10.0.0.1", "CORE_RR"), by) - self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["remote_as"], "65001") - self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["activate"], "enable") - self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["update_source"], "loopback1") - self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["route_map_in"], "RM_IN") - self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["route_map_out"], "RM_OUT") + by = { + (r["local_as"], r["afi"], r["vrf"], r["neighbor"], r["peer_group"]): r + for r in rows + } + self.assertIn(("65000", "vpnv4", "", "10.0.0.1", "CORE_RR"), by) + self.assertEqual(by[("65000", "vpnv4", "", "10.0.0.1", "CORE_RR")]["remote_as"], "65001") + self.assertEqual(by[("65000", "vpnv4", "", "10.0.0.1", "CORE_RR")]["activate"], "enable") + self.assertEqual(by[("65000", "vpnv4", "", "10.0.0.1", "CORE_RR")]["update_source"], "loopback1") + self.assertEqual(by[("65000", "vpnv4", "", "10.0.0.1", "CORE_RR")]["route_map_in"], "RM_IN") + self.assertEqual(by[("65000", "vpnv4", "", "10.0.0.1", "CORE_RR")]["route_map_out"], "RM_OUT") # AF-scoped RM must not bleed into other address-families - self.assertEqual(by[("l2vpn-evpn", "", "10.0.0.1", "CORE_RR")]["route_map_in"], "RM_IN") - self.assertEqual(by[("l2vpn-evpn", "", "10.0.0.1", "CORE_RR")]["route_map_out"], "") + self.assertEqual(by[("65000", "l2vpn-evpn", "", "10.0.0.1", "CORE_RR")]["route_map_in"], "RM_IN") + self.assertEqual(by[("65000", "l2vpn-evpn", "", "10.0.0.1", "CORE_RR")]["route_map_out"], "") # 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") + self.assertIn(("65000", "vpnv4", "", "", "CORE_RR"), by) + self.assertEqual(by[("65000", "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") + self.assertIn(("65000", "vpnv4", "", "10.0.0.9", "CORE_RR"), by) + self.assertEqual(by[("65000", "vpnv4", "", "10.0.0.9", "CORE_RR")]["remote_as"], "65019") + self.assertEqual(by[("65000", "vpnv4", "", "10.0.0.9", "CORE_RR")]["activate"], "enable") + self.assertEqual(by[("65000", "ipv4", "CUST_A", "10.0.0.2", "")]["remote_as"], "65003") # Global ipv4 AF: same peer-group expand as vpnv4 (not VRF) - self.assertIn(("ipv4", "", "", "CORE_RR"), by) - self.assertIn(("ipv4", "", "10.0.0.1", "CORE_RR"), by) - self.assertIn(("ipv4", "", "10.0.0.9", "CORE_RR"), by) + self.assertIn(("65000", "ipv4", "", "", "CORE_RR"), by) + self.assertIn(("65000", "ipv4", "", "10.0.0.1", "CORE_RR"), by) + self.assertIn(("65000", "ipv4", "", "10.0.0.9", "CORE_RR"), by) # 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) + self.assertNotIn(("65000", "ipv4", "CUST_A", "10.0.0.9", "CORE_RR"), by) + self.assertNotIn(("65000", "ipv4", "CUST_A", "10.0.0.1", "CORE_RR"), by) + self.assertIn(("65000", "l2vpn-evpn", "", "10.0.0.1", "CORE_RR"), by) # global IPv6 neighbor without AF activate - self.assertIn(("global", "", "FC00:1::1", ""), by) + self.assertIn(("65000", "global", "", "FC00:1::1", ""), by) # no name left in neighbor column self.assertTrue(all((not r["neighbor"]) or _is_ip_neighbor(r["neighbor"]) for r in rows)) + self.assertTrue(all(r["local_as"] == "65000" for r in rows)) + + def test_config_bgp_peer_multi_local_as(self) -> None: + rows = normalize_config_bgp_peer( + raw_text=_CFG_BGP_MULTI_AS, command="show running-config bgp" + ) + local_ases = {r["local_as"] for r in rows} + self.assertEqual(local_ases, {"64900", "64600", "64580", "100"}) + + by = { + (r["local_as"], r["afi"], r["vrf"], r["neighbor"], r["peer_group"]): r + for r in rows + } + # AS 64900 AF activates + self.assertEqual( + by[("64900", "vpnv4", "", "172.16.0.131", "")]["remote_as"], "64900" + ) + self.assertEqual( + by[("64900", "vpnv4", "", "172.16.0.131", "")]["update_source"], + "loopback130", + ) + self.assertEqual( + by[("64900", "ipv6", "", "2c0f:f7c0:0:500::50:2", "")]["activate"], "enable" + ) + self.assertEqual( + by[("64900", "vpnv6", "", "2c0f:f7c0:0:500::50:2", "")]["activate"], "enable" + ) + self.assertIn(("64900", "link-state", "", "10.229.147.93", ""), by) + # Multi-word AF must not collapse into plain ipv6 + self.assertIn(("64900", "ipv6-sr-policy", "", "10.229.147.93", ""), by) + self.assertNotEqual( + by[("64900", "ipv6-sr-policy", "", "10.229.147.93", "")]["afi"], + "ipv6", + ) + # VRF CE under AS 100 (not 64900) + self.assertEqual( + by[("100", "ipv6", "SRv6", "2000::1", "")]["remote_as"], "100" + ) + self.assertNotIn(("64900", "ipv6", "SRv6", "2000::1", ""), by) + # AS 64600 EVPN peer (not mixed into 64900) + self.assertEqual( + by[("64600", "l2vpn-evpn", "", "444::2", "")]["remote_as"], "64600" + ) + self.assertEqual( + by[("64600", "l2vpn-evpn", "", "444::2", "")]["update_source"], + "loopback400", + ) + self.assertNotIn(("64900", "l2vpn-evpn", "", "444::2", ""), by) + # AS 64580: top-level activate only → global row + 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", + ) + self.assertEqual( + by[("100", "vpnv4", "", "100.0.0.2", "")]["remote_as"], "100" + ) + # 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"), + by, + ) + self.assertTrue( + all((not r["neighbor"]) or _is_ip_neighbor(r["neighbor"]) for r in rows) + ) def test_config_l2vpn_pw(self) -> None: rows = normalize_config_l2vpn_pw( diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index 3294605..5b0d2ef 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -233,27 +233,33 @@ class ZteExtendedParserTests(unittest.TestCase): def test_bgp_peer_ipv4_and_ipv6_wrap(self) -> None: v4 = normalize_bgp_peer( - raw_text=_BGP_V4_SUMMARY, command="show bgp vpnv4 unicast summary" + raw_text=_BGP_V4_SUMMARY, + command="show bgp vpnv4 unicast summary as 64900 | one-line", + params={"local_as": "64900"}, ) self.assertEqual(len(v4), 2) self.assertEqual(v4[0]["afi"], "vpnv4") + self.assertEqual(v4[0]["local_as"], "64900") self.assertEqual(v4[0]["state"], "Established") self.assertEqual(v4[0]["pfx_rcd"], "5") self.assertEqual(v4[1]["state"], "Connect") v6 = normalize_bgp_peer( - raw_text=_BGP_V6_SUMMARY, command="show bgp vpnv6 unicast summary" + raw_text=_BGP_V6_SUMMARY, + command="show bgp vpnv6 unicast summary as 64900", ) self.assertEqual(len(v6), 2) self.assertTrue(all(r["afi"] == "vpnv6" for r in v6)) + self.assertTrue(all(r["local_as"] == "64900" for r in v6)) self.assertEqual(v6[0]["neighbor"].upper(), "FC00:1::1") vrf = normalize_bgp_peer( raw_text=_BGP_V4_SUMMARY, - command="show bgp vpnv4 unicast vrf CUST_A summary", - params={"vrf": "CUST_A"}, + command="show bgp vpnv4 unicast vrf CUST_A summary as 65000", + params={"vrf": "CUST_A", "local_as": "65000"}, ) self.assertTrue(all(r["vrf"] == "CUST_A" for r in vrf)) + self.assertTrue(all(r["local_as"] == "65000" for r in vrf)) # Real wrapped vpnv6 summary + IPv6 neighbor in/out routes sample_dir = Path(__file__).resolve().parents[2] / "test" / "show-zte" @@ -425,12 +431,14 @@ class ZteExtendedParserTests(unittest.TestCase): intent = normalize_config_bgp_peer( raw_text=""" ! +router bgp 65000 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_IN in $ $ +! """, command="show running-config bgp", ) @@ -458,6 +466,7 @@ $ intent2 = normalize_config_bgp_peer( raw_text=""" ! +router bgp 65000 neighbor 10.0.0.1 remote-as 65001 address-family ipv4 vrf CUST_A neighbor 10.0.0.1 activate @@ -468,6 +477,7 @@ $ neighbor 10.0.0.1 route-map RM_B in $ $ +! """, command="show running-config bgp", ) @@ -493,6 +503,7 @@ $ intent3 = normalize_config_bgp_peer( raw_text=""" ! +router bgp 65000 neighbor 10.0.0.1 remote-as 65001 address-family ipv4 neighbor 10.0.0.1 activate @@ -503,6 +514,7 @@ $ neighbor 10.0.0.1 route-map RM_VPNV4 in $ $ +! """, command="show running-config bgp", ) @@ -688,28 +700,32 @@ $ self.assertEqual(p.aux_commands[0].profile_id, "zte.config_bgp_peer") pairs = expand_from_bindings( profile=p, - bindings=[{"vrf": "CUST_A", "neighbor": "10.0.0.1"}], + bindings=[{"vrf": "CUST_A", "neighbor": "10.0.0.1", "local_as": "64900"}], ) self.assertEqual(len(pairs), 1) cmd, params = pairs[0] self.assertIn("vrf CUST_A", cmd) self.assertIn("10.0.0.1", cmd) + self.assertIn("as 64900", cmd) hit = match_command(vendor_key="zte", command=cmd) self.assertIsNotNone(hit) assert hit is not None self.assertEqual(hit.params.get("vrf"), "CUST_A") self.assertEqual(hit.params.get("neighbor"), "10.0.0.1") + self.assertEqual(hit.params.get("local_as"), "64900") - # BGP VRF summary / Forwarding VRF: required bind + config_vrf aux - from netx_api.biz_state.command_match import expand_bindings_from_discover_records + # Forwarding VRF: required bind + config_vrf aux + # BGP VRF summary: optional (local_as, vrf) from config_bgp_peer + from netx_api.biz_state.command_match import ( + EXPAND_ALL_COMMAND, + expand_bindings_from_discover_records, + filter_discover_records, + normalize_binding_dicts, + shared_discover_placeholders, + ) from netx_api.biz_state.collect_session import resolve_aux_command - for pid in ( - "zte.bgp_vpnv4_vrf_summary", - "zte.bgp_vpnv6_vrf_summary", - "zte.ip_route_vrf", - "zte.ipv6_route_vrf", - ): + for pid in ("zte.ip_route_vrf", "zte.ipv6_route_vrf"): prof = get_profile(pid) assert prof is not None self.assertTrue(prof.placeholders) @@ -719,43 +735,76 @@ $ v4 = get_profile("zte.bgp_vpnv4_vrf_summary") assert v4 is not None - self.assertEqual([a.key for a in v4.aux_commands], ["config_vrf"]) - with self.assertRaises(ValueError): - expand_from_bindings(profile=v4, bindings=[]) - bound = expand_from_bindings(profile=v4, bindings=[{"vrf": "CUST_A"}]) - self.assertEqual(bound[0][0], "show bgp vpnv4 unicast vrf CUST_A summary | one-line") - ra_cfg = resolve_aux_command( - next(a for a in v4.aux_commands if a.key == "config_vrf"), - params={"vrf": "CUST_A"}, + self.assertEqual([a.key for a in v4.aux_commands], ["config_bgp_peer"]) + self.assertFalse(any(ph.required for ph in v4.placeholders)) + expand_all = expand_from_bindings(profile=v4, bindings=[]) + self.assertEqual(expand_all[0][0], EXPAND_ALL_COMMAND) + bound = expand_from_bindings( + profile=v4, bindings=[{"vrf": "CUST_A", "local_as": "64900"}] ) - self.assertEqual(ra_cfg.command, "show running-config vrf | one-line") + self.assertEqual( + bound[0][0], + "show bgp vpnv4 unicast vrf CUST_A summary as 64900 | one-line", + ) + # Optional local_as omitted → strip `` as `` + bound_no_as = expand_from_bindings(profile=v4, bindings=[{"vrf": "CUST_A"}]) + self.assertEqual( + bound_no_as[0][0], + "show bgp vpnv4 unicast vrf CUST_A summary | one-line", + ) + ra_cfg = resolve_aux_command( + next(a for a in v4.aux_commands if a.key == "config_bgp_peer"), + params={"vrf": "CUST_A", "local_as": "64900"}, + ) + self.assertEqual(ra_cfg.command, "show running-config bgp | one-line") v6 = get_profile("zte.bgp_vpnv6_vrf_summary") assert v6 is not None - self.assertEqual([a.key for a in v6.aux_commands], ["config_vrf"]) + self.assertEqual([a.key for a in v6.aux_commands], ["config_bgp_peer"]) - records = [ + peer_vrf_recs = [ + {"local_as": "64900", "afi": "ipv4", "vrf": "CUST_A", "neighbor": "10.0.0.2"}, + {"local_as": "64900", "afi": "ipv4", "vrf": "CUST_B", "neighbor": "10.0.0.3"}, + {"local_as": "64900", "afi": "ipv6", "vrf": "CUST_A", "neighbor": "FC00::2"}, + {"local_as": "64900", "afi": "ipv6", "vrf": "CUST_C", "neighbor": "FC00::3"}, + {"local_as": "65000", "afi": "ipv4", "vrf": "CUST_A", "neighbor": "10.0.0.9"}, + ] + ipv4_pairs = expand_bindings_from_discover_records( + profile=v4, records=peer_vrf_recs + ) + self.assertEqual( + {(p[1]["local_as"], p[1]["vrf"]) for p in ipv4_pairs}, + {("64900", "CUST_A"), ("64900", "CUST_B"), ("65000", "CUST_A")}, + ) + ipv6_pairs = expand_bindings_from_discover_records( + profile=v6, records=peer_vrf_recs + ) + self.assertEqual( + {(p[1]["local_as"], p[1]["vrf"]) for p in ipv6_pairs}, + {("64900", "CUST_A"), ("64900", "CUST_C")}, + ) + + # FIB VRF still expands from config_vrf records + ip_vrf = get_profile("zte.ip_route_vrf") + assert ip_vrf is not None + vrf_records = [ {"vrf_name": "CUST_A", "address_families": "ipv4,ipv6", "rd": "100:1"}, {"vrf_name": "CUST_B", "address_families": "ipv4", "rd": "100:2"}, {"vrf_name": "CUST_C", "address_families": "ipv6", "rd": "100:3"}, ] - ipv4_pairs = expand_bindings_from_discover_records(profile=v4, records=records) - self.assertEqual({p[1]["vrf"] for p in ipv4_pairs}, {"CUST_A", "CUST_B"}) - ipv6_pairs = expand_bindings_from_discover_records(profile=v6, records=records) - self.assertEqual({p[1]["vrf"] for p in ipv6_pairs}, {"CUST_A", "CUST_C"}) + fib_pairs = expand_bindings_from_discover_records( + profile=ip_vrf, records=vrf_records + ) + self.assertEqual({p[1]["vrf"] for p in fib_pairs}, {"CUST_A", "CUST_B"}) # BGP neighbor in/out: discover from config_bgp_peer with AF filters - from netx_api.biz_state.command_match import ( - filter_discover_records, - normalize_binding_dicts, - shared_discover_placeholders, - ) - glob_v4 = get_profile("zte.bgp_vpnv4_neighbor_in") assert glob_v4 is not None self.assertEqual(glob_v4.placeholders[0].discover_profile_id, "zte.config_bgp_peer") self.assertEqual(glob_v4.placeholders[0].discover_filter_contains, "vpnv4") self.assertEqual(glob_v4.placeholders[0].bind_mode, "discover_select") + self.assertTrue(any(ph.name == "local_as" for ph in glob_v4.placeholders)) + self.assertIn("as ", glob_v4.command_template) glob_v6 = get_profile("zte.bgp_vpnv6_neighbor_in") assert glob_v6 is not None @@ -767,13 +816,57 @@ $ self.assertEqual(glob_ipv4.placeholders[0].discover_require_empty, "vrf") 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.8", "remote_as": "65004"}, - {"afi": "ipv6", "vrf": "CUST_B", "neighbor": "FC00::2", "remote_as": "65005"}, + { + "local_as": "64900", + "afi": "vpnv4", + "vrf": "", + "neighbor": "10.0.0.1", + "remote_as": "65001", + }, + { + "local_as": "64900", + "afi": "vpnv4", + "vrf": "", + "neighbor": "", + "peer_group": "CORE_RR", + "remote_as": "65009", + }, + { + "local_as": "64900", + "afi": "vpnv4", + "vrf": "", + "neighbor": "10.0.0.9", + "peer_group": "CORE_RR", + "remote_as": "65019", + }, + { + "local_as": "64900", + "afi": "vpnv6", + "vrf": "", + "neighbor": "FC00::1", + "remote_as": "65002", + }, + { + "local_as": "64900", + "afi": "ipv4", + "vrf": "CUST_A", + "neighbor": "10.0.0.2", + "remote_as": "65003", + }, + { + "local_as": "64900", + "afi": "ipv4", + "vrf": "", + "neighbor": "10.0.0.8", + "remote_as": "65004", + }, + { + "local_as": "64900", + "afi": "ipv6", + "vrf": "CUST_B", + "neighbor": "FC00::2", + "remote_as": "65005", + }, ] self.assertEqual( filter_discover_records(peer_recs, glob_v4.placeholders[0]), @@ -799,33 +892,40 @@ $ ) shared = shared_discover_placeholders(vrf_nei) - self.assertEqual(len(shared), 2) + self.assertEqual(len(shared), 3) self.assertTrue(all(ph.discover_profile_id == "zte.config_bgp_peer" for ph in shared)) pair_cmds = expand_bindings_from_discover_records(profile=vrf_nei, records=peer_recs) self.assertEqual(len(pair_cmds), 1) - self.assertEqual(pair_cmds[0][1], {"vrf": "CUST_A", "neighbor": "10.0.0.2"}) + self.assertEqual( + pair_cmds[0][1], + {"vrf": "CUST_A", "neighbor": "10.0.0.2", "local_as": "64900"}, + ) self.assertIn("vrf CUST_A", pair_cmds[0][0]) self.assertIn("10.0.0.2", pair_cmds[0][0]) + self.assertIn("as 64900", pair_cmds[0][0]) # Interleaved placeholder/value rows zip into combined bindings zipped = normalize_binding_dicts( [ {"placeholder": "vrf", "value": "CUST_A"}, {"placeholder": "neighbor", "value": "10.0.0.2"}, + {"placeholder": "local_as", "value": "64900"}, {"placeholder": "vrf", "value": "CUST_B"}, {"placeholder": "neighbor", "value": "10.0.0.3"}, + {"placeholder": "local_as", "value": "64900"}, ], placeholders=vrf_nei.placeholders, ) self.assertEqual( zipped, [ - {"vrf": "CUST_A", "neighbor": "10.0.0.2"}, - {"vrf": "CUST_B", "neighbor": "10.0.0.3"}, + {"vrf": "CUST_A", "neighbor": "10.0.0.2", "local_as": "64900"}, + {"vrf": "CUST_B", "neighbor": "10.0.0.3", "local_as": "64900"}, ], ) expanded = expand_from_bindings(profile=vrf_nei, bindings=zipped) self.assertEqual(len(expanded), 2) + self.assertTrue(all("as 64900" in c for c, _ in expanded)) def test_interface_detail_and_vpnv6_neighbor(self) -> None: from netx_api.biz_state.parsers.zte import normalize_interface_detail