mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 06:53:19 +08:00
Add ZTE BGP VRF summary aux FIB collect with optional all-VRF expand.
Wire config_vrf plus ipv4/ipv6 forwarding as aux for VPN VRF summaries, and allow unbound items to expand all AF-filtered VRFs at collect time. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
78f7032d7b
commit
3576e4a69c
9 changed files with 390 additions and 18 deletions
|
|
@ -35,7 +35,13 @@ from .collect_session import (
|
||||||
resolve_aux_command,
|
resolve_aux_command,
|
||||||
run_primary_with_bundle,
|
run_primary_with_bundle,
|
||||||
)
|
)
|
||||||
from .command_match import expand_from_bindings, match_command, normalize_command
|
from .command_match import (
|
||||||
|
EXPAND_ALL_COMMAND,
|
||||||
|
expand_bindings_from_discover_records,
|
||||||
|
expand_from_bindings,
|
||||||
|
match_command,
|
||||||
|
normalize_command,
|
||||||
|
)
|
||||||
from .parsers import get_parser
|
from .parsers import get_parser
|
||||||
from .profiles import get_profile
|
from .profiles import get_profile
|
||||||
|
|
||||||
|
|
@ -404,7 +410,69 @@ def _run_collect_lane(
|
||||||
batch_row = sdb.get(BizStateBatch, batch_id)
|
batch_row = sdb.get(BizStateBatch, batch_id)
|
||||||
if not batch_row:
|
if not batch_row:
|
||||||
return 0, 0, True, False
|
return 0, 0, True, False
|
||||||
|
|
||||||
|
# Resolve expand_all → concrete per-VRF commands via discover profile.
|
||||||
|
flat_work: list[WorkItem] = []
|
||||||
|
aux_persisted: set[tuple[str, str]] = set()
|
||||||
for concrete, params, profile_id, item_id, mode in work:
|
for concrete, params, profile_id, item_id, mode in work:
|
||||||
|
if mode != "expand_all":
|
||||||
|
flat_work.append((concrete, params, profile_id, item_id, mode))
|
||||||
|
continue
|
||||||
|
profile = get_profile(profile_id)
|
||||||
|
if profile is None or not profile.placeholders:
|
||||||
|
any_fail = True
|
||||||
|
_append_event(
|
||||||
|
sdb,
|
||||||
|
task_id=str(batch_row.task_id or ""),
|
||||||
|
message=f"expand_all missing profile {profile_id}",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
ph = profile.placeholders[0]
|
||||||
|
disc = get_profile(str(ph.discover_profile_id or "").strip())
|
||||||
|
if disc is None:
|
||||||
|
any_fail = True
|
||||||
|
_append_event(
|
||||||
|
sdb,
|
||||||
|
task_id=str(batch_row.task_id or ""),
|
||||||
|
message=f"expand_all discover profile missing for {profile_id}",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
disc_cmd = normalize_command(disc.command_template)
|
||||||
|
entry, _ = session.fetch_and_parse(
|
||||||
|
disc_cmd,
|
||||||
|
parser_id=disc.parser_id,
|
||||||
|
textfsm_command=disc.textfsm_command or disc_cmd,
|
||||||
|
params={},
|
||||||
|
)
|
||||||
|
if not entry.ok:
|
||||||
|
any_fail = True
|
||||||
|
_append_event(
|
||||||
|
sdb,
|
||||||
|
task_id=str(batch_row.task_id or ""),
|
||||||
|
message=f"expand_all discover failed: {entry.error}",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pairs = expand_bindings_from_discover_records(
|
||||||
|
profile=profile,
|
||||||
|
records=entry.records,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
any_fail = True
|
||||||
|
_append_event(
|
||||||
|
sdb,
|
||||||
|
task_id=str(batch_row.task_id or ""),
|
||||||
|
message=str(exc),
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for cmd, p in pairs:
|
||||||
|
flat_work.append((cmd, p, profile_id, item_id, "normal"))
|
||||||
|
|
||||||
|
for concrete, params, profile_id, item_id, mode in flat_work:
|
||||||
if holder.get("timed_out"):
|
if holder.get("timed_out"):
|
||||||
raise TimeoutError(f"{label}_aborted")
|
raise TimeoutError(f"{label}_aborted")
|
||||||
cmd_count += 1
|
cmd_count += 1
|
||||||
|
|
@ -530,6 +598,9 @@ def _run_collect_lane(
|
||||||
cmd_row_id=aux_row.id,
|
cmd_row_id=aux_row.id,
|
||||||
)
|
)
|
||||||
aux_results[ra.key] = entry
|
aux_results[ra.key] = entry
|
||||||
|
aux_mid = str(getattr(ra.profile, "metric_id", "") or "").strip()
|
||||||
|
if aux_mid:
|
||||||
|
aux_row.metric_id = aux_mid
|
||||||
if cache_hit:
|
if cache_hit:
|
||||||
aux_row.parse_status = "aux_cached"
|
aux_row.parse_status = "aux_cached"
|
||||||
aux_row.message = (
|
aux_row.message = (
|
||||||
|
|
@ -549,6 +620,26 @@ def _run_collect_lane(
|
||||||
aux_row.raw_text = entry.raw
|
aux_row.raw_text = entry.raw
|
||||||
aux_row.row_count = len(entry.records or [])
|
aux_row.row_count = len(entry.records or [])
|
||||||
entry.cmd_row_id = aux_row.id
|
entry.cmd_row_id = aux_row.id
|
||||||
|
# Persist aux metrics once per CLI (config_vrf / FIB shared across VRFs).
|
||||||
|
if (
|
||||||
|
entry.ok
|
||||||
|
and entry.records
|
||||||
|
and aux_mid in _GENERIC_METRICS
|
||||||
|
):
|
||||||
|
persist_key = (normalize_command(ra.command), aux_mid)
|
||||||
|
if persist_key not in aux_persisted:
|
||||||
|
n_aux = _persist_metric_rows(
|
||||||
|
sdb,
|
||||||
|
batch=batch_row,
|
||||||
|
cmd_row=aux_row,
|
||||||
|
metric_id=aux_mid,
|
||||||
|
records=entry.records,
|
||||||
|
)
|
||||||
|
aux_row.row_count = n_aux
|
||||||
|
total_rows += n_aux
|
||||||
|
aux_persisted.add(persist_key)
|
||||||
|
if n_aux:
|
||||||
|
_bump_batch_progress(batch_id, add_rows=n_aux)
|
||||||
sdb.add(aux_row)
|
sdb.add(aux_row)
|
||||||
sdb.commit()
|
sdb.commit()
|
||||||
|
|
||||||
|
|
@ -786,6 +877,10 @@ def _run_collect_session(
|
||||||
_append_event(db, task_id=task_id, message=str(exc), level="error")
|
_append_event(db, task_id=task_id, message=str(exc), level="error")
|
||||||
continue
|
continue
|
||||||
for concrete, params in pairs:
|
for concrete, params in pairs:
|
||||||
|
if concrete == EXPAND_ALL_COMMAND:
|
||||||
|
# Defer VRF list expansion until CollectSession is open.
|
||||||
|
work.append(("", dict(params or {}), profile.profile_id, item.id, "expand_all"))
|
||||||
|
continue
|
||||||
cmd = normalize_command(concrete)
|
cmd = normalize_command(concrete)
|
||||||
# Prefer match_command metric so remapped if_intf shares key with config_interface
|
# Prefer match_command metric so remapped if_intf shares key with config_interface
|
||||||
hit = match_command(vendor_key=vendor_key, command=cmd)
|
hit = match_command(vendor_key=vendor_key, command=cmd)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .profiles import ParseProfile, all_profiles, get_profile
|
from .profiles import ParseProfile, PlaceholderDef, all_profiles, get_profile
|
||||||
|
|
||||||
|
# Sentinel concrete command: collect expands all discover values at runtime.
|
||||||
|
EXPAND_ALL_COMMAND = "__expand_all__"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -85,13 +88,52 @@ def normalize_binding_dicts(
|
||||||
return converted
|
return converted
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_discover_placeholders(profile: ParseProfile) -> list[PlaceholderDef]:
|
||||||
|
return [
|
||||||
|
ph
|
||||||
|
for ph in (profile.placeholders or [])
|
||||||
|
if (not ph.required)
|
||||||
|
and ph.bind_mode == "discover_select"
|
||||||
|
and str(ph.discover_profile_id or "").strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def filter_discover_records(
|
||||||
|
records: list[dict[str, Any]] | None,
|
||||||
|
ph: PlaceholderDef,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Apply placeholder discover filter; return unique values for ``ph``."""
|
||||||
|
value_field = str(ph.discover_value_field or ph.name or "").strip() or "vrf_name"
|
||||||
|
filt_field = str(ph.discover_filter_field or "").strip()
|
||||||
|
filt_contains = str(ph.discover_filter_contains or "").strip().lower()
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for rec in records or []:
|
||||||
|
if not isinstance(rec, dict):
|
||||||
|
continue
|
||||||
|
if filt_field and filt_contains:
|
||||||
|
hay = str(rec.get(filt_field) or "").strip().lower()
|
||||||
|
if filt_contains not in hay:
|
||||||
|
continue
|
||||||
|
val = str(rec.get(value_field) or "").strip()
|
||||||
|
if not val or val in seen:
|
||||||
|
continue
|
||||||
|
seen.add(val)
|
||||||
|
out.append(val)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def expand_from_bindings(
|
def expand_from_bindings(
|
||||||
*,
|
*,
|
||||||
profile: ParseProfile,
|
profile: ParseProfile,
|
||||||
bindings: list[dict[str, str]] | None = None,
|
bindings: list[dict[str, str]] | None = None,
|
||||||
command_override: str = "",
|
command_override: str = "",
|
||||||
) -> list[tuple[str, dict[str, str]]]:
|
) -> list[tuple[str, dict[str, str]]]:
|
||||||
"""Return list of (concrete_command, params). Reject leftover placeholders."""
|
"""Return list of (concrete_command, params). Reject leftover placeholders.
|
||||||
|
|
||||||
|
When all placeholders are optional discover_select and bindings are empty,
|
||||||
|
returns a single ``(EXPAND_ALL_COMMAND, {})`` sentinel for collect-time expansion.
|
||||||
|
"""
|
||||||
override = normalize_command(command_override)
|
override = normalize_command(command_override)
|
||||||
if override:
|
if override:
|
||||||
if "<" in override and ">" in override:
|
if "<" in override and ">" in override:
|
||||||
|
|
@ -107,6 +149,9 @@ def expand_from_bindings(
|
||||||
|
|
||||||
binds = normalize_binding_dicts(bindings, placeholders=profile.placeholders)
|
binds = normalize_binding_dicts(bindings, placeholders=profile.placeholders)
|
||||||
if not binds:
|
if not binds:
|
||||||
|
optional = _optional_discover_placeholders(profile)
|
||||||
|
if optional and len(optional) == len(profile.placeholders):
|
||||||
|
return [(EXPAND_ALL_COMMAND, {"__expand_all__": "1"})]
|
||||||
raise ValueError(f"profile {profile.profile_id} requires parameter bindings")
|
raise ValueError(f"profile {profile.profile_id} requires parameter bindings")
|
||||||
|
|
||||||
out: list[tuple[str, dict[str, str]]] = []
|
out: list[tuple[str, dict[str, str]]] = []
|
||||||
|
|
@ -124,6 +169,25 @@ def expand_from_bindings(
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def expand_bindings_from_discover_records(
|
||||||
|
*,
|
||||||
|
profile: ParseProfile,
|
||||||
|
records: list[dict[str, Any]] | None,
|
||||||
|
) -> list[tuple[str, dict[str, str]]]:
|
||||||
|
"""Build concrete commands from discover/parser records (e.g. config_vrf)."""
|
||||||
|
optional = _optional_discover_placeholders(profile)
|
||||||
|
if not optional:
|
||||||
|
raise ValueError(f"profile {profile.profile_id} has no optional discover placeholders")
|
||||||
|
if len(optional) != 1 or len(profile.placeholders) != 1:
|
||||||
|
raise ValueError(f"expand-all only supports a single optional placeholder: {profile.profile_id}")
|
||||||
|
ph = optional[0]
|
||||||
|
values = filter_discover_records(records, ph)
|
||||||
|
if not values:
|
||||||
|
raise ValueError(f"no discover values for {profile.profile_id} ({ph.discover_profile_id})")
|
||||||
|
bindings = [{ph.name: v} for v in values]
|
||||||
|
return expand_from_bindings(profile=profile, bindings=bindings)
|
||||||
|
|
||||||
|
|
||||||
def preview_task_item(
|
def preview_task_item(
|
||||||
*,
|
*,
|
||||||
vendor_key: str,
|
vendor_key: str,
|
||||||
|
|
@ -173,6 +237,16 @@ def preview_task_item(
|
||||||
"message": str(exc),
|
"message": str(exc),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pairs and pairs[0][0] == EXPAND_ALL_COMMAND:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"kind": kind,
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"commands": [],
|
||||||
|
"parse": "expand_all",
|
||||||
|
"message": "no bindings: collect will expand all discover VRFs",
|
||||||
|
}
|
||||||
|
|
||||||
previews = []
|
previews = []
|
||||||
for concrete, params in pairs:
|
for concrete, params in pairs:
|
||||||
hit = match_command(vendor_key=vendor_key, command=concrete)
|
hit = match_command(vendor_key=vendor_key, command=concrete)
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,25 @@ def discover_params(
|
||||||
|
|
||||||
candidates = []
|
candidates = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
filt_field = ""
|
||||||
|
filt_contains = ""
|
||||||
|
if collect_profile_id:
|
||||||
|
collect = get_profile(collect_profile_id)
|
||||||
|
if collect:
|
||||||
|
for ph in collect.placeholders:
|
||||||
|
if placeholder and ph.name != placeholder:
|
||||||
|
continue
|
||||||
|
filt_field = str(ph.discover_filter_field or "").strip()
|
||||||
|
filt_contains = str(ph.discover_filter_contains or "").strip().lower()
|
||||||
|
break
|
||||||
for rec in records:
|
for rec in records:
|
||||||
val = str(rec.get(value_field) or "").strip()
|
val = str(rec.get(value_field) or "").strip()
|
||||||
if not val or val in seen:
|
if not val or val in seen:
|
||||||
continue
|
continue
|
||||||
|
if filt_field and filt_contains:
|
||||||
|
hay = str(rec.get(filt_field) or "").strip().lower()
|
||||||
|
if filt_contains not in hay:
|
||||||
|
continue
|
||||||
seen.add(val)
|
seen.add(val)
|
||||||
label = str(rec.get(label_field) or val).strip() or val
|
label = str(rec.get(label_field) or val).strip() or val
|
||||||
candidates.append(
|
candidates.append(
|
||||||
|
|
@ -159,7 +174,7 @@ def discover_params(
|
||||||
"value": val,
|
"value": val,
|
||||||
"label": label,
|
"label": label,
|
||||||
"rd": str(rec.get("rd") or ""),
|
"rd": str(rec.get("rd") or ""),
|
||||||
"protocols": str(rec.get("protocols") or ""),
|
"protocols": str(rec.get("protocols") or rec.get("address_families") or ""),
|
||||||
"extra": rec,
|
"extra": rec,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ class PlaceholderDef:
|
||||||
discover_profile_id: str = ""
|
discover_profile_id: str = ""
|
||||||
discover_value_field: str = ""
|
discover_value_field: str = ""
|
||||||
discover_label_field: str = ""
|
discover_label_field: str = ""
|
||||||
|
# When required=False and no bindings: collect expands all discover values.
|
||||||
|
discover_filter_field: str = ""
|
||||||
|
discover_filter_contains: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -272,18 +275,57 @@ _BGP_PEER_FIELDS: list[FieldDef] = [
|
||||||
FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"),
|
FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"),
|
||||||
FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"),
|
FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"),
|
||||||
FieldDef("up_down", length=32, role="meta", display_name="Up/Down"),
|
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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Neighbor / dual-placeholder profiles still require an explicit VRF bind.
|
||||||
_BGP_VRF_PLACEHOLDER = PlaceholderDef(
|
_BGP_VRF_PLACEHOLDER = PlaceholderDef(
|
||||||
name="vrf",
|
name="vrf",
|
||||||
schema_field="vrf",
|
schema_field="vrf",
|
||||||
required=True,
|
required=True,
|
||||||
bind_mode="discover_select",
|
bind_mode="discover_select",
|
||||||
discover_profile_id="zte.vrf_list",
|
discover_profile_id="zte.config_vrf",
|
||||||
discover_value_field="vrf_name",
|
discover_value_field="vrf_name",
|
||||||
discover_label_field="vrf_name",
|
discover_label_field="vrf_name",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Single-VRF collect: bind selected VRFs, or leave empty → all (from config_vrf).
|
||||||
|
_VRF_PLACEHOLDER_ALL = PlaceholderDef(
|
||||||
|
name="vrf",
|
||||||
|
schema_field="vrf",
|
||||||
|
required=False,
|
||||||
|
bind_mode="discover_select",
|
||||||
|
discover_profile_id="zte.config_vrf",
|
||||||
|
discover_value_field="vrf_name",
|
||||||
|
discover_label_field="vrf_name",
|
||||||
|
)
|
||||||
|
|
||||||
|
_VRF_PLACEHOLDER_IPV4 = PlaceholderDef(
|
||||||
|
name="vrf",
|
||||||
|
schema_field="vrf",
|
||||||
|
required=False,
|
||||||
|
bind_mode="discover_select",
|
||||||
|
discover_profile_id="zte.config_vrf",
|
||||||
|
discover_value_field="vrf_name",
|
||||||
|
discover_label_field="vrf_name",
|
||||||
|
discover_filter_field="address_families",
|
||||||
|
discover_filter_contains="ipv4",
|
||||||
|
)
|
||||||
|
|
||||||
|
_VRF_PLACEHOLDER_IPV6 = PlaceholderDef(
|
||||||
|
name="vrf",
|
||||||
|
schema_field="vrf",
|
||||||
|
required=False,
|
||||||
|
bind_mode="discover_select",
|
||||||
|
discover_profile_id="zte.config_vrf",
|
||||||
|
discover_value_field="vrf_name",
|
||||||
|
discover_label_field="vrf_name",
|
||||||
|
discover_filter_field="address_families",
|
||||||
|
discover_filter_contains="ipv6",
|
||||||
|
)
|
||||||
|
|
||||||
_BGP_NEIGHBOR_PLACEHOLDER = PlaceholderDef(
|
_BGP_NEIGHBOR_PLACEHOLDER = PlaceholderDef(
|
||||||
name="neighbor",
|
name="neighbor",
|
||||||
schema_field="neighbor",
|
schema_field="neighbor",
|
||||||
|
|
@ -351,6 +393,8 @@ _IP_ROUTE_FIELDS: list[FieldDef] = [
|
||||||
FieldDef("pri", length=16, role="meta", display_name="Pri"),
|
FieldDef("pri", length=16, role="meta", display_name="Pri"),
|
||||||
FieldDef("metric", length=32, role="meta", display_name="Metric"),
|
FieldDef("metric", length=32, role="meta", display_name="Metric"),
|
||||||
FieldDef("flags", length=16, role="meta", display_name="Flags"),
|
FieldDef("flags", length=16, role="meta", display_name="Flags"),
|
||||||
|
FieldDef("rd", length=64, role="meta", display_name="RD"),
|
||||||
|
FieldDef("address_families", length=64, role="meta", display_name="AF"),
|
||||||
]
|
]
|
||||||
|
|
||||||
_IPV6_ROUTE_FIELDS: list[FieldDef] = [
|
_IPV6_ROUTE_FIELDS: list[FieldDef] = [
|
||||||
|
|
@ -362,6 +406,8 @@ _IPV6_ROUTE_FIELDS: list[FieldDef] = [
|
||||||
FieldDef("pri", length=16, role="meta", display_name="Pri"),
|
FieldDef("pri", length=16, role="meta", display_name="Pri"),
|
||||||
FieldDef("metric", length=32, role="meta", display_name="Metric"),
|
FieldDef("metric", length=32, role="meta", display_name="Metric"),
|
||||||
FieldDef("flags", length=16, role="meta", display_name="Flags"),
|
FieldDef("flags", length=16, role="meta", display_name="Flags"),
|
||||||
|
FieldDef("rd", length=64, role="meta", display_name="RD"),
|
||||||
|
FieldDef("address_families", length=64, role="meta", display_name="AF"),
|
||||||
]
|
]
|
||||||
|
|
||||||
_L2VPN_PW_FIELDS: list[FieldDef] = [
|
_L2VPN_PW_FIELDS: list[FieldDef] = [
|
||||||
|
|
@ -707,13 +753,28 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show bgp vpnv4 unicast vrf <vrf> summary | one-line",
|
command_template="show bgp vpnv4 unicast vrf <vrf> summary | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv4 unicast summary",
|
textfsm_command="show bgp vpnv4 unicast summary",
|
||||||
description="Per-VRF BGP VPNv4 peer summary.",
|
description=(
|
||||||
placeholders=[_BGP_VRF_PLACEHOLDER],
|
"Per-VRF BGP VPNv4 peer summary. Bind VRFs or leave empty for all "
|
||||||
|
"(from config VRF intent); aux: IPv4 FIB + config_vrf."
|
||||||
|
),
|
||||||
|
placeholders=[_VRF_PLACEHOLDER_IPV4],
|
||||||
fields=list(_BGP_PEER_FIELDS),
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
tags=["bgp", "vpnv4", "vrf", "status"],
|
tags=["bgp", "vpnv4", "vrf", "status"],
|
||||||
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"),
|
||||||
|
AuxCommand(key="ip_route", profile_id="zte.ip_route_vrf"),
|
||||||
|
],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="config_vrf",
|
||||||
|
left_on="vrf",
|
||||||
|
right_on="vrf_name",
|
||||||
|
take=("rd", "address_families"),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
ParseProfile(
|
ParseProfile(
|
||||||
profile_id="zte.bgp_vpnv6_vrf_summary",
|
profile_id="zte.bgp_vpnv6_vrf_summary",
|
||||||
|
|
@ -724,13 +785,28 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show bgp vpnv6 unicast vrf <vrf> summary | one-line",
|
command_template="show bgp vpnv6 unicast vrf <vrf> summary | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv6 unicast summary",
|
textfsm_command="show bgp vpnv6 unicast summary",
|
||||||
description="Per-VRF BGP VPNv6 peer summary.",
|
description=(
|
||||||
placeholders=[_BGP_VRF_PLACEHOLDER],
|
"Per-VRF BGP VPNv6 peer summary. Bind VRFs or leave empty for all "
|
||||||
|
"(from config VRF intent); aux: IPv6 FIB + config_vrf."
|
||||||
|
),
|
||||||
|
placeholders=[_VRF_PLACEHOLDER_IPV6],
|
||||||
fields=list(_BGP_PEER_FIELDS),
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
tags=["bgp", "vpnv6", "vrf", "status"],
|
tags=["bgp", "vpnv6", "vrf", "status"],
|
||||||
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"),
|
||||||
|
AuxCommand(key="ipv6_route", profile_id="zte.ipv6_route_vrf"),
|
||||||
|
],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="config_vrf",
|
||||||
|
left_on="vrf",
|
||||||
|
right_on="vrf_name",
|
||||||
|
take=("rd", "address_families"),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
ParseProfile(
|
ParseProfile(
|
||||||
profile_id="zte.bgp_evpn_summary",
|
profile_id="zte.bgp_evpn_summary",
|
||||||
|
|
@ -1052,13 +1128,25 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show ip forwarding route vrf <vrf> | one-line",
|
command_template="show ip forwarding route vrf <vrf> | one-line",
|
||||||
match=r"(?i)^\s*show\s+ip\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+ip\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show ip forwarding route",
|
textfsm_command="show ip forwarding route",
|
||||||
description="IPv4 FIB for one VRF.",
|
description=(
|
||||||
placeholders=[_BGP_VRF_PLACEHOLDER],
|
"IPv4 FIB per VRF. Bind VRFs or leave empty for all ipv4 VRFs "
|
||||||
|
"(config VRF intent aux)."
|
||||||
|
),
|
||||||
|
placeholders=[_VRF_PLACEHOLDER_IPV4],
|
||||||
fields=list(_IP_ROUTE_FIELDS),
|
fields=list(_IP_ROUTE_FIELDS),
|
||||||
tags=["route", "ipv4", "vrf"],
|
tags=["route", "ipv4", "vrf"],
|
||||||
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=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="config_vrf",
|
||||||
|
left_on="vrf",
|
||||||
|
right_on="vrf_name",
|
||||||
|
take=("rd", "address_families"),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
ParseProfile(
|
ParseProfile(
|
||||||
profile_id="zte.ip_route",
|
profile_id="zte.ip_route",
|
||||||
|
|
@ -1085,13 +1173,25 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show ipv6 forwarding route vrf <vrf> | one-line",
|
command_template="show ipv6 forwarding route vrf <vrf> | one-line",
|
||||||
match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show ipv6 forwarding route",
|
textfsm_command="show ipv6 forwarding route",
|
||||||
description="IPv6 FIB for one VRF.",
|
description=(
|
||||||
placeholders=[_BGP_VRF_PLACEHOLDER],
|
"IPv6 FIB per VRF. Bind VRFs or leave empty for all ipv6 VRFs "
|
||||||
|
"(config VRF intent aux)."
|
||||||
|
),
|
||||||
|
placeholders=[_VRF_PLACEHOLDER_IPV6],
|
||||||
fields=list(_IPV6_ROUTE_FIELDS),
|
fields=list(_IPV6_ROUTE_FIELDS),
|
||||||
tags=["route", "ipv6", "vrf"],
|
tags=["route", "ipv6", "vrf"],
|
||||||
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=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="config_vrf",
|
||||||
|
left_on="vrf",
|
||||||
|
right_on="vrf_name",
|
||||||
|
take=("rd", "address_families"),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
ParseProfile(
|
ParseProfile(
|
||||||
profile_id="zte.ipv6_route",
|
profile_id="zte.ipv6_route",
|
||||||
|
|
@ -1416,6 +1516,8 @@ def profile_to_public_dict(p: ParseProfile, *, overrides: dict[str, Any] | None
|
||||||
"discover_profile_id": ph.discover_profile_id,
|
"discover_profile_id": ph.discover_profile_id,
|
||||||
"discover_value_field": ph.discover_value_field,
|
"discover_value_field": ph.discover_value_field,
|
||||||
"discover_label_field": ph.discover_label_field,
|
"discover_label_field": ph.discover_label_field,
|
||||||
|
"discover_filter_field": ph.discover_filter_field,
|
||||||
|
"discover_filter_contains": ph.discover_filter_contains,
|
||||||
}
|
}
|
||||||
for ph in p.placeholders
|
for ph in p.placeholders
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,9 @@ def _assert_bindings_ready(db: Session, task_id: str) -> None:
|
||||||
profile = get_profile(it.source_profile_id)
|
profile = get_profile(it.source_profile_id)
|
||||||
if not profile or not profile.placeholders:
|
if not profile or not profile.placeholders:
|
||||||
continue
|
continue
|
||||||
|
# Optional discover placeholders: empty bindings → expand all at collect.
|
||||||
|
if all(not ph.required for ph in profile.placeholders):
|
||||||
|
continue
|
||||||
binds = (
|
binds = (
|
||||||
db.query(BizStateTaskItemBinding)
|
db.query(BizStateTaskItemBinding)
|
||||||
.filter(BizStateTaskItemBinding.item_id == it.id)
|
.filter(BizStateTaskItemBinding.item_id == it.id)
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,53 @@ class ZteExtendedParserTests(unittest.TestCase):
|
||||||
self.assertEqual(hit.params.get("vrf"), "CUST_A")
|
self.assertEqual(hit.params.get("vrf"), "CUST_A")
|
||||||
self.assertEqual(hit.params.get("neighbor"), "10.0.0.1")
|
self.assertEqual(hit.params.get("neighbor"), "10.0.0.1")
|
||||||
|
|
||||||
|
# BGP VRF summary / Forwarding VRF: optional bind + config_vrf aux
|
||||||
|
from netx_api.biz_state.command_match import (
|
||||||
|
EXPAND_ALL_COMMAND,
|
||||||
|
expand_bindings_from_discover_records,
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
):
|
||||||
|
prof = get_profile(pid)
|
||||||
|
assert prof is not None
|
||||||
|
self.assertTrue(prof.placeholders)
|
||||||
|
self.assertFalse(prof.placeholders[0].required)
|
||||||
|
self.assertEqual(prof.placeholders[0].discover_profile_id, "zte.config_vrf")
|
||||||
|
self.assertTrue(any(a.profile_id == "zte.config_vrf" for a in prof.aux_commands))
|
||||||
|
|
||||||
|
v4 = get_profile("zte.bgp_vpnv4_vrf_summary")
|
||||||
|
assert v4 is not None
|
||||||
|
self.assertTrue(any(a.key == "ip_route" for a in v4.aux_commands))
|
||||||
|
sentinel = expand_from_bindings(profile=v4, bindings=[])
|
||||||
|
self.assertEqual(sentinel[0][0], EXPAND_ALL_COMMAND)
|
||||||
|
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_ip = resolve_aux_command(
|
||||||
|
next(a for a in v4.aux_commands if a.key == "ip_route"),
|
||||||
|
params={"vrf": "CUST_A"},
|
||||||
|
)
|
||||||
|
self.assertEqual(ra_ip.command, "show ip forwarding route vrf CUST_A | one-line")
|
||||||
|
|
||||||
|
v6 = get_profile("zte.bgp_vpnv6_vrf_summary")
|
||||||
|
assert v6 is not None
|
||||||
|
self.assertTrue(any(a.key == "ipv6_route" for a in v6.aux_commands))
|
||||||
|
|
||||||
|
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"})
|
||||||
|
|
||||||
def test_interface_detail_and_vpnv6_neighbor(self) -> None:
|
def test_interface_detail_and_vpnv6_neighbor(self) -> None:
|
||||||
from netx_api.biz_state.parsers.zte import normalize_interface_detail
|
from netx_api.biz_state.parsers.zte import normalize_interface_detail
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ const en = {
|
||||||
cancel: "Close",
|
cancel: "Close",
|
||||||
bindTitle: "Select VRF bindings",
|
bindTitle: "Select VRF bindings",
|
||||||
unbound: "Not bound",
|
unbound: "Not bound",
|
||||||
|
allVrfsDefault: "All VRFs (default)",
|
||||||
batches: "Batches",
|
batches: "Batches",
|
||||||
viewBatch: "Open",
|
viewBatch: "Open",
|
||||||
export: "Export",
|
export: "Export",
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ const zh = {
|
||||||
cancel: "关闭",
|
cancel: "关闭",
|
||||||
bindTitle: "选择 VRF 绑定",
|
bindTitle: "选择 VRF 绑定",
|
||||||
unbound: "未关联",
|
unbound: "未关联",
|
||||||
|
allVrfsDefault: "默认全部 VRF",
|
||||||
batches: "采集批次",
|
batches: "采集批次",
|
||||||
viewBatch: "查看",
|
viewBatch: "查看",
|
||||||
export: "导出",
|
export: "导出",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,12 @@ type TaskRow = {
|
||||||
interval_sec?: number;
|
interval_sec?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Placeholder = { name: string };
|
type Placeholder = {
|
||||||
|
name: string;
|
||||||
|
required?: boolean;
|
||||||
|
bind_mode?: string;
|
||||||
|
discover_profile_id?: string;
|
||||||
|
};
|
||||||
type Profile = {
|
type Profile = {
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -786,17 +791,20 @@ export function BizStatePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBindings = async () => {
|
const saveBindings = async (values?: string[]) => {
|
||||||
if (!taskId || !bindItemId) return;
|
if (!taskId || !bindItemId) return;
|
||||||
const item = (detail?.items || []).find((it: any) => it.id === bindItemId);
|
const item = (detail?.items || []).find((it: any) => it.id === bindItemId);
|
||||||
const prof = profiles.find((p) => p.profile_id === item?.source_profile_id);
|
const prof = profiles.find((p) => p.profile_id === item?.source_profile_id);
|
||||||
const phName = (prof?.placeholders || [])[0]?.name || "vrf";
|
const phName = (prof?.placeholders || [])[0]?.name || "vrf";
|
||||||
|
const bindOptional = (prof?.placeholders || []).every((ph) => ph.required === false);
|
||||||
|
const picked = values !== undefined ? values : selectedVrfs;
|
||||||
|
if (!picked.length && !bindOptional) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await bizStateSetBindings(
|
await bizStateSetBindings(
|
||||||
taskId,
|
taskId,
|
||||||
bindItemId,
|
bindItemId,
|
||||||
selectedVrfs.map((v) => ({ placeholder: phName, value: v })),
|
picked.map((v) => ({ placeholder: phName, value: v })),
|
||||||
);
|
);
|
||||||
showOk(t("bizState.bindingsSaved"));
|
showOk(t("bizState.bindingsSaved"));
|
||||||
await loadTask(taskId);
|
await loadTask(taskId);
|
||||||
|
|
@ -1322,6 +1330,9 @@ export function BizStatePage() {
|
||||||
const enabled = Boolean(it?.enabled);
|
const enabled = Boolean(it?.enabled);
|
||||||
const binds = it?.bindings || [];
|
const binds = it?.bindings || [];
|
||||||
const needsBind = (prof.placeholders || []).length > 0;
|
const needsBind = (prof.placeholders || []).length > 0;
|
||||||
|
const bindOptional = (prof.placeholders || []).every(
|
||||||
|
(ph) => ph.required === false,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<tr key={prof.profile_id}>
|
<tr key={prof.profile_id}>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -1343,6 +1354,8 @@ export function BizStatePage() {
|
||||||
{needsBind
|
{needsBind
|
||||||
? binds.length
|
? binds.length
|
||||||
? binds.map((b: any) => b.value).join(", ")
|
? binds.map((b: any) => b.value).join(", ")
|
||||||
|
: bindOptional
|
||||||
|
? t("bizState.allVrfsDefault")
|
||||||
: t("bizState.unbound")
|
: t("bizState.unbound")
|
||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -1394,11 +1407,32 @@ export function BizStatePage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
isDisabled={busy || !selectedVrfs.length}
|
isDisabled={
|
||||||
|
busy ||
|
||||||
|
(!selectedVrfs.length &&
|
||||||
|
!(
|
||||||
|
profiles
|
||||||
|
.find(
|
||||||
|
(p) =>
|
||||||
|
p.profile_id ===
|
||||||
|
(detail?.items || []).find((it: any) => it.id === bindItemId)
|
||||||
|
?.source_profile_id,
|
||||||
|
)
|
||||||
|
?.placeholders || []
|
||||||
|
).every((ph) => ph.required === false))
|
||||||
|
}
|
||||||
onPress={() => void saveBindings()}
|
onPress={() => void saveBindings()}
|
||||||
>
|
>
|
||||||
{t("bizState.saveBindings")}
|
{t("bizState.saveBindings")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
isDisabled={busy}
|
||||||
|
onPress={() => void saveBindings([])}
|
||||||
|
>
|
||||||
|
{t("bizState.allVrfsDefault")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue