mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 21:53:13 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
c14d4e81ff
commit
f274d8ea72
5 changed files with 420 additions and 50 deletions
|
|
@ -13,6 +13,15 @@ class EnrichJoin:
|
||||||
Example (ARP ← if_intf)::
|
Example (ARP ← if_intf)::
|
||||||
|
|
||||||
EnrichJoin(from_aux="if_intf", on="interface", take=("vrf",))
|
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
|
from_aux: str
|
||||||
|
|
@ -23,12 +32,29 @@ class EnrichJoin:
|
||||||
fill_missing: bool = True # on miss, set take fields to ""
|
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()
|
left = str(join.left_on or join.on or "").strip()
|
||||||
right = str(join.right_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")
|
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(
|
def apply_enrich_joins(
|
||||||
|
|
@ -41,7 +67,7 @@ def apply_enrich_joins(
|
||||||
return records
|
return records
|
||||||
aux_map = aux_records or {}
|
aux_map = aux_records or {}
|
||||||
for join in joins:
|
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()]
|
take = [str(t).strip() for t in (join.take or ()) if str(t).strip()]
|
||||||
if not take:
|
if not take:
|
||||||
continue
|
continue
|
||||||
|
|
@ -49,13 +75,13 @@ def apply_enrich_joins(
|
||||||
for row in aux_map.get(str(join.from_aux or "").strip()) or []:
|
for row in aux_map.get(str(join.from_aux or "").strip()) or []:
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
continue
|
continue
|
||||||
key = str(row.get(right) or "").strip()
|
key = _row_key(row, right_fields)
|
||||||
if key and key not in index:
|
if key and key not in index:
|
||||||
index[key] = row
|
index[key] = row
|
||||||
for rec in records:
|
for rec in records:
|
||||||
if not isinstance(rec, dict):
|
if not isinstance(rec, dict):
|
||||||
continue
|
continue
|
||||||
hit = index.get(str(rec.get(left) or "").strip())
|
hit = index.get(_row_key(rec, left_fields))
|
||||||
for field in take:
|
for field in take:
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
rec[field] = str(hit.get(field) or "").strip()
|
rec[field] = str(hit.get(field) or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,13 @@ class PlaceholderDef:
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AuxCommand:
|
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
|
key: str
|
||||||
profile_id: str
|
profile_id: str
|
||||||
|
|
@ -820,9 +826,6 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
sort_order=370,
|
sort_order=370,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
aux_commands=[
|
|
||||||
AuxCommand(key="config_vrf", profile_id="zte.config_vrf"),
|
|
||||||
],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_vrf",
|
from_aux="config_vrf",
|
||||||
|
|
@ -851,9 +854,6 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
sort_order=375,
|
sort_order=375,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
aux_commands=[
|
|
||||||
AuxCommand(key="config_vrf", profile_id="zte.config_vrf"),
|
|
||||||
],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_vrf",
|
from_aux="config_vrf",
|
||||||
|
|
@ -970,19 +970,22 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show bgp vpnv4 unicast neighbor in <neighbor> | one-line",
|
command_template="show bgp vpnv4 unicast neighbor in <neighbor> | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv4 unicast neighbor in",
|
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],
|
placeholders=[_BGP_NEIGHBOR_VPNV4],
|
||||||
fields=list(_BGP_ROUTE_FIELDS),
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
tags=["bgp", "vpnv4", "route"],
|
tags=["bgp", "vpnv4", "route"],
|
||||||
sort_order=420,
|
sort_order=420,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="bgp_summary",
|
from_aux="config_bgp_peer",
|
||||||
on="neighbor",
|
left_on="afi,neighbor",
|
||||||
take=("as_num", "state", "pfx_rcd"),
|
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 <neighbor> | one-line",
|
command_template="show bgp vpnv4 unicast neighbor out <neighbor> | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv4 unicast neighbor out",
|
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],
|
placeholders=[_BGP_NEIGHBOR_VPNV4],
|
||||||
fields=list(_BGP_ROUTE_FIELDS),
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
tags=["bgp", "vpnv4", "route"],
|
tags=["bgp", "vpnv4", "route"],
|
||||||
|
|
@ -1003,12 +1009,12 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
collect_lane="heavy",
|
collect_lane="heavy",
|
||||||
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="bgp_summary",
|
from_aux="config_bgp_peer",
|
||||||
on="neighbor",
|
left_on="afi,neighbor",
|
||||||
take=("as_num", "state", "pfx_rcd"),
|
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 <neighbor> | one-line",
|
command_template="show bgp vpnv6 unicast neighbor in <neighbor> | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv6 unicast neighbor in",
|
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],
|
placeholders=[_BGP_NEIGHBOR_VPNV6],
|
||||||
fields=list(_BGP_ROUTE_FIELDS),
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
tags=["bgp", "vpnv6", "route"],
|
tags=["bgp", "vpnv6", "route"],
|
||||||
sort_order=426,
|
sort_order=426,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_summary")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="bgp_summary",
|
from_aux="config_bgp_peer",
|
||||||
on="neighbor",
|
left_on="afi,neighbor",
|
||||||
take=("as_num", "state", "pfx_rcd"),
|
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 <neighbor> | one-line",
|
command_template="show bgp vpnv6 unicast neighbor out <neighbor> | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv6 unicast neighbor out",
|
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],
|
placeholders=[_BGP_NEIGHBOR_VPNV6],
|
||||||
fields=list(_BGP_ROUTE_FIELDS),
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
tags=["bgp", "vpnv6", "route"],
|
tags=["bgp", "vpnv6", "route"],
|
||||||
|
|
@ -1054,12 +1066,12 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
collect_lane="heavy",
|
collect_lane="heavy",
|
||||||
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_summary")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="bgp_summary",
|
from_aux="config_bgp_peer",
|
||||||
on="neighbor",
|
left_on="afi,neighbor",
|
||||||
take=("as_num", "state", "pfx_rcd"),
|
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,
|
sort_order=430,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
aux_commands=[AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_bgp_peer",
|
from_aux="config_bgp_peer",
|
||||||
left_on="neighbor",
|
left_on="vrf,neighbor",
|
||||||
right_on="neighbor",
|
right_on="vrf,neighbor",
|
||||||
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1118,12 +1129,11 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
collect_lane="heavy",
|
collect_lane="heavy",
|
||||||
aux_commands=[AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_bgp_peer",
|
from_aux="config_bgp_peer",
|
||||||
left_on="neighbor",
|
left_on="vrf,neighbor",
|
||||||
right_on="neighbor",
|
right_on="vrf,neighbor",
|
||||||
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1150,12 +1160,11 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
sort_order=440,
|
sort_order=440,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
aux_commands=[AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_bgp_peer",
|
from_aux="config_bgp_peer",
|
||||||
left_on="neighbor",
|
left_on="vrf,neighbor",
|
||||||
right_on="neighbor",
|
right_on="vrf,neighbor",
|
||||||
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1183,12 +1192,11 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
collect_lane="heavy",
|
collect_lane="heavy",
|
||||||
aux_commands=[AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_bgp_peer",
|
from_aux="config_bgp_peer",
|
||||||
left_on="neighbor",
|
left_on="vrf,neighbor",
|
||||||
right_on="neighbor",
|
right_on="vrf,neighbor",
|
||||||
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1210,7 +1218,6 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
sort_order=450,
|
sort_order=450,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
aux_commands=[AuxCommand(key="config_vrf", profile_id="zte.config_vrf")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_vrf",
|
from_aux="config_vrf",
|
||||||
|
|
@ -1252,7 +1259,6 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
sort_order=460,
|
sort_order=460,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
aux_commands=[AuxCommand(key="config_vrf", profile_id="zte.config_vrf")],
|
|
||||||
enrich_joins=[
|
enrich_joins=[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_vrf",
|
from_aux="config_vrf",
|
||||||
|
|
@ -1515,10 +1521,112 @@ def _apply_collect_lanes(profiles: list[ParseProfile]) -> list[ParseProfile]:
|
||||||
return profiles
|
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]:
|
def all_profiles() -> list[ParseProfile]:
|
||||||
global _PROFILES
|
global _PROFILES
|
||||||
if _PROFILES is None:
|
if _PROFILES is None:
|
||||||
_PROFILES = _apply_collect_lanes(
|
_PROFILES = _finalize_profiles(
|
||||||
_lldp_profiles() + _vrf_profiles() + _zte_status_profiles()
|
_lldp_profiles() + _vrf_profiles() + _zte_status_profiles()
|
||||||
)
|
)
|
||||||
return list(_PROFILES)
|
return list(_PROFILES)
|
||||||
|
|
|
||||||
72
tests/test_biz_state_enrich.py
Normal file
72
tests/test_biz_state_enrich.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -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.command_match import expand_from_bindings, match_command, preview_task_item
|
||||||
from netx_api.biz_state.profiles import (
|
from netx_api.biz_state.profiles import (
|
||||||
|
AuxCommand,
|
||||||
|
ParseProfile,
|
||||||
all_profiles,
|
all_profiles,
|
||||||
get_profile,
|
get_profile,
|
||||||
profile_to_public_dict,
|
profile_to_public_dict,
|
||||||
profiles_for_vendor,
|
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.parsers import normalize_lldp_neighbors
|
||||||
|
from netx_api.biz_state.profiles import PlaceholderDef
|
||||||
from netx_api.lldp_shared import parse_neighbor_output
|
from netx_api.lldp_shared import parse_neighbor_output
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
@ -64,8 +70,13 @@ class BizStateProfileTests(unittest.TestCase):
|
||||||
hits = parse_neighbor_output("", vendor="zte", device_type="zte_zxros")
|
hits = parse_neighbor_output("", vendor="zte", device_type="zte_zxros")
|
||||||
self.assertEqual(hits, [])
|
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 (
|
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_in",
|
||||||
"zte.bgp_vpnv4_vrf_neighbor_out",
|
"zte.bgp_vpnv4_vrf_neighbor_out",
|
||||||
"zte.bgp_vpnv6_vrf_neighbor_in",
|
"zte.bgp_vpnv6_vrf_neighbor_in",
|
||||||
|
|
@ -81,6 +92,87 @@ class BizStateProfileTests(unittest.TestCase):
|
||||||
self.assertEqual(len(aux), 1)
|
self.assertEqual(len(aux), 1)
|
||||||
self.assertIn("running-config bgp", aux[0]["command_template"])
|
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 <neighbor> | 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 <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:
|
def test_aux_templates_placeholders_subset_of_primary(self) -> None:
|
||||||
"""Aux CLI must not introduce placeholders outside primary params.
|
"""Aux CLI must not introduce placeholders outside primary params.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -396,13 +396,85 @@ $
|
||||||
[
|
[
|
||||||
EnrichJoin(
|
EnrichJoin(
|
||||||
from_aux="config_bgp_peer",
|
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"),
|
take=("remote_as", "activate", "route_map_in", "route_map_out"),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.assertEqual(routes[0]["remote_as"], "65001")
|
self.assertEqual(routes[0]["remote_as"], "65001")
|
||||||
self.assertEqual(routes[0]["activate"], "enable")
|
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="""
|
||||||
|
!<bgp>
|
||||||
|
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="""
|
||||||
|
!<bgp>
|
||||||
|
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(
|
ra = resolve_aux_command(
|
||||||
AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer"),
|
AuxCommand(key="config_bgp_peer", profile_id="zte.config_bgp_peer"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue