diff --git a/netx_api/biz_state/collect_runner.py b/netx_api/biz_state/collect_runner.py index cca562b..f27e136 100644 --- a/netx_api/biz_state/collect_runner.py +++ b/netx_api/biz_state/collect_runner.py @@ -29,7 +29,7 @@ from ..models import ( from ..ne_netmiko import disable_target_paging, send_show_command from ..ne_session_factory import close_netmiko_connection, open_netmiko_connection from .command_match import expand_from_bindings, match_command, normalize_command -from .parsers import get_parser, run_parser +from .parsers import get_parser, get_parser_meta, run_parser from .profiles import get_profile _log = logging.getLogger("netx.biz_state.runner") @@ -153,6 +153,7 @@ _GENERIC_METRICS = { "isis_adjacency", "interface_brief", "arp", + "if_intf", "nd6_cache", "bgp_peer", } @@ -419,10 +420,12 @@ def _run_collect_session( pass sdb = SessionLocal() + # Same-batch cache: concrete CLI -> raw + parse artifacts (aux reuse). + cmd_cache: dict[str, dict[str, Any]] = {} try: batch_row = sdb.get(BizStateBatch, batch_id) if not batch_row: - return 0, 0, True, True + return 0, 0, True, False for concrete, params, profile_id, item_id, mode in work: if holder.get("timed_out"): raise TimeoutError("biz_state_aborted") @@ -478,6 +481,151 @@ def _run_collect_session( sdb.commit() continue + primary_ck = normalize_command(concrete) + cmd_cache[primary_ck] = { + "raw": cmd_row.raw_text or "", + "fsm_tables": {}, + "records": None, + "ok": True, + "error": "", + "cmd_row_id": cmd_row.id, + } + + raws: dict[str, str] = {"primary": cmd_row.raw_text or ""} + aux_records: dict[str, list[dict[str, Any]]] = {} + fsm_extra: dict[str, list[dict[str, Any]]] = {} + command_rules: dict[str, list[str]] = { + "primary": list( + (get_parser_meta(hit.profile.parser_id) or {}).get("rule_keys") + or () + ) + } + + for aux in list(hit.profile.aux_commands or []): + aux_concrete = normalize_command(aux.command_template) + if not aux_concrete: + continue + aux_rules = list(aux.rule_keys or ()) + if not aux_rules and aux.parser_id: + aux_rules = list( + (get_parser_meta(aux.parser_id) or {}).get("rule_keys") or () + ) + command_rules[aux.key] = aux_rules + + cached = cmd_cache.get(aux_concrete) + aux_row = BizStateBatchCommand( + id=uuid4().hex, + batch_id=batch_id, + task_item_id=item_id, + profile_id=aux.profile_id or hit.profile.profile_id, + parser_id=aux.parser_id or "", + metric_id="", + raw_command=aux_concrete[:512], + params_json={}, + created_at=_utcnow(), + ) + cmd_count += 1 + if cached and cached.get("ok"): + raws[aux.key] = str(cached.get("raw") or "") + if cached.get("records") is not None: + aux_records[aux.key] = list(cached.get("records") or []) + fsm_extra.update(cached.get("fsm_tables") or {}) + aux_row.parse_status = "aux_cached" + aux_row.message = ( + f"aux_for={cmd_row.id};cache_hit;src={cached.get('cmd_row_id') or ''}" + )[:1020] + aux_row.raw_text = "" + aux_row.row_count = len(cached.get("records") or []) + sdb.add(aux_row) + sdb.commit() + continue + + try: + aux_raw = send_show_command( + conn, aux_concrete, read_timeout=per_cmd + ) + aux_row.raw_text = str(aux_raw or "") + except Exception as exc: + aux_row.parse_status = "aux_failed" + aux_row.message = ( + f"aux_for={cmd_row.id};{_format_error(exc)}" + )[:1020] + sdb.add(aux_row) + sdb.commit() + cmd_cache[aux_concrete] = { + "raw": "", + "fsm_tables": {}, + "records": [], + "ok": False, + "error": str(exc), + "cmd_row_id": aux_row.id, + } + raws[aux.key] = "" + continue + + aux_recs: list[dict[str, Any]] = [] + aux_fsm: dict[str, list[dict[str, Any]]] = {} + if aux.parser_id and get_parser(aux.parser_id): + try: + aux_recs, aux_fsm, _aux_keys = run_parser( + aux.parser_id, + raw_text=aux_row.raw_text or "", + vendor=vendor_eff, + device_type=device_type_eff, + command=aux.textfsm_command or aux_concrete, + textfsm_command=aux.textfsm_command or "", + params={}, + ) + except Exception as exc: + aux_row.parse_status = "aux_failed" + aux_row.message = ( + f"aux_for={cmd_row.id};parse:{_format_error(exc)}" + )[:1020] + sdb.add(aux_row) + sdb.commit() + cmd_cache[aux_concrete] = { + "raw": aux_row.raw_text or "", + "fsm_tables": {}, + "records": [], + "ok": False, + "error": str(exc), + "cmd_row_id": aux_row.id, + } + raws[aux.key] = aux_row.raw_text or "" + continue + elif aux_rules: + from ..ntc_parse import apply_rules, resolve_cli_platform + + plat = resolve_cli_platform( + vendor=vendor_eff, + device_type=device_type_eff, + vendor_key=vendor_key, + ) + aux_fsm = apply_rules( + platform=plat, + text=aux_row.raw_text or "", + rule_keys=aux_rules, + command=aux.textfsm_command or aux_concrete, + ) + + raws[aux.key] = aux_row.raw_text or "" + if aux_recs: + aux_records[aux.key] = aux_recs + fsm_extra.update(aux_fsm) + aux_row.parse_status = "aux" + aux_row.message = f"aux_for={cmd_row.id}"[:1020] + aux_row.row_count = len(aux_recs) + sdb.add(aux_row) + sdb.commit() + cmd_cache[aux_concrete] = { + "raw": aux_row.raw_text or "", + "fsm_tables": dict(aux_fsm), + "records": list(aux_recs), + "ok": True, + "error": "", + "cmd_row_id": aux_row.id, + } + try: records, fsm_tables, rule_keys = run_parser( hit.profile.parser_id, @@ -487,14 +635,26 @@ def _run_collect_session( command=hit.profile.textfsm_command or concrete, params=merged, textfsm_command=hit.profile.textfsm_command or "", + raws=raws, + command_rules=command_rules, + aux_records=aux_records, + fsm_tables_extra=fsm_extra, ) - # Debug hint only — avoid persisting large FSM JSON. + cmd_cache[primary_ck]["fsm_tables"] = dict(fsm_tables) + cmd_cache[primary_ck]["records"] = list(records) + hints = [] if rule_keys: nonempty = [k for k in rule_keys if fsm_tables.get(k)] - cmd_row.message = ( - f"fsm_keys={','.join(rule_keys)}" - + (f";hit={','.join(nonempty)}" if nonempty else ";hit=") - )[:1020] + hints.append( + f"fsm_keys={','.join(rule_keys)};hit={','.join(nonempty)}" + ) + if aux_records: + hints.append( + "aux=" + + ",".join(f"{k}:{len(v)}" for k, v in aux_records.items()) + ) + if hints: + cmd_row.message = ";".join(hints)[:1020] except Exception as exc: any_fail = True cmd_row.parse_status = "failed" diff --git a/netx_api/biz_state/parsers/__init__.py b/netx_api/biz_state/parsers/__init__.py index 8f05b2a..5ea217c 100644 --- a/netx_api/biz_state/parsers/__init__.py +++ b/netx_api/biz_state/parsers/__init__.py @@ -20,17 +20,18 @@ Adding a status metric 2. ``parsers//.py``: ``RULE_KEYS = ("",)`` + ``normalize(..., fsm_tables=...)`` (use ``common.pipeline.prefer_fsm``) 3. Register in the vendor ``PARSERS`` dict -4. ``profiles.py``: add ``ParseProfile`` (command match + ``FieldDef`` schema) +4. ``profiles.py``: add ``ParseProfile`` (command match + ``FieldDef`` schema); + optional ``aux_commands`` for multi-command collect items Collect runs TextFSM rules first → ``fsm_tables``, then calls ``normalize`` with -both ``raw_text`` and ``fsm_tables``. The parser decides: return mapped FSM rows, -post-process them, or ignore FSM and hand-parse ``raw_text``. +``raw_text`` / ``raws`` / ``fsm_tables`` / ``aux_records``. Prefer FSM; hand-parse +only when FSM has no rows. """ from __future__ import annotations import inspect -from typing import Any, Callable, Sequence +from typing import Any, Callable, Mapping, Sequence from ...lldp_shared import resolve_vendor_key from ...ntc_parse import apply_rules, resolve_cli_platform, rules_for_command @@ -47,8 +48,6 @@ from .zte import PARSERS as _ZTE_PARSERS NormalizeFn = Callable[..., list[dict[str, Any]]] -# Later vendor packages may override earlier ones for the same parser_id. -# Prefer moving shared logic to common/ when two vendors implement the same metric. _VENDOR_PARSERS: list[dict[str, NormalizeFn]] = [ _CISCO_PARSERS, _HUAWEI_PARSERS, @@ -56,7 +55,7 @@ _VENDOR_PARSERS: list[dict[str, NormalizeFn]] = [ _JUNIPER_PARSERS, _NOKIA_PARSERS, _ERICSSON_PARSERS, - _ZTE_PARSERS, # last so current ZTE status parsers win on overlaps + _ZTE_PARSERS, ] _REGISTRY: dict[str, NormalizeFn] = { @@ -77,7 +76,6 @@ def registered_parser_ids() -> list[str]: def _rule_keys_for_fn(fn: NormalizeFn) -> tuple[tuple[str, ...], bool]: - """Return ``(keys, declared)``. ``declared`` is True when RULE_KEYS is set on fn/module.""" keys_raw = None declared = False if hasattr(fn, "RULE_KEYS"): @@ -101,7 +99,6 @@ def _rule_keys_for_fn(fn: NormalizeFn) -> tuple[tuple[str, ...], bool]: def get_parser_meta(parser_id: str) -> dict[str, Any] | None: - """Return ``{fn, rule_keys, rule_keys_declared}`` for a registered parser, or None.""" fn = get_parser(parser_id) if not fn: return None @@ -116,7 +113,6 @@ def resolve_rule_keys( command: str = "", textfsm_command: str = "", ) -> list[str]: - """RULE_KEYS from parser when declared; else index stems for textfsm_command/command.""" meta = get_parser_meta(parser_id) if not meta: return [] @@ -138,8 +134,12 @@ def run_parser( command: str = "", params: dict[str, str] | None = None, textfsm_command: str = "", + raws: Mapping[str, str] | None = None, + command_rules: Mapping[str, Sequence[str]] | None = None, + aux_records: Mapping[str, list[dict[str, Any]]] | None = None, + fsm_tables_extra: Mapping[str, list[dict[str, Any]]] | None = None, ) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]], list[str]]: - """Apply TextFSM rules then normalize. + """Apply TextFSM per-command then normalize. Returns ``(records, fsm_tables, rule_keys_used)``. """ @@ -153,28 +153,83 @@ def run_parser( vendor_key=resolve_vendor_key(vendor, device_type), ) cmd_hint = str(textfsm_command or command or "").strip() - rule_keys = resolve_rule_keys( - parser_id=parser_id, - platform=platform, - command=command, - textfsm_command=textfsm_command, - ) - fsm_tables: dict[str, list[dict[str, Any]]] = {} - if rule_keys and str(raw_text or "").strip(): - fsm_tables = apply_rules( + raw_map: dict[str, str] = {"primary": str(raw_text or "")} + if raws: + for k, v in raws.items(): + key = str(k or "").strip() or "primary" + raw_map[key] = str(v or "") + + rules_map: dict[str, list[str]] = {} + if command_rules: + for k, seq in command_rules.items(): + rules_map[str(k)] = [str(x).strip() for x in (seq or ()) if str(x).strip()] + if "primary" not in rules_map: + rules_map["primary"] = resolve_rule_keys( + parser_id=parser_id, platform=platform, - text=raw_text, - rule_keys=rule_keys, - command=cmd_hint, + command=command, + textfsm_command=textfsm_command, ) - records = fn( - raw_text=raw_text, - fsm_tables=fsm_tables, - vendor=vendor, - device_type=device_type, - command=cmd_hint or command, - params=params or {}, - ) + + fsm_tables: dict[str, list[dict[str, Any]]] = {} + used_keys: list[str] = [] + for key, rules in rules_map.items(): + if not rules: + continue + text = raw_map.get(key) or (raw_map.get("primary") if key == "primary" else "") + if not str(text or "").strip(): + for rk in rules: + fsm_tables.setdefault(rk, []) + if rk not in used_keys: + used_keys.append(rk) + continue + wrap_cmd = cmd_hint if key == "primary" else key.replace("_", " ") + part = apply_rules( + platform=platform, + text=text, + rule_keys=rules, + command=wrap_cmd, + ) + fsm_tables.update(part) + for rk in rules: + if rk not in used_keys: + used_keys.append(rk) + + if fsm_tables_extra: + for k, rows in fsm_tables_extra.items(): + stem = str(k or "").strip() + if not stem: + continue + fsm_tables[stem] = list(rows or []) + if stem not in used_keys: + used_keys.append(stem) + + call_kw: dict[str, Any] = { + "raw_text": raw_map.get("primary") or "", + "fsm_tables": fsm_tables, + "raws": raw_map, + "aux_records": dict(aux_records or {}), + "vendor": vendor, + "device_type": device_type, + "command": cmd_hint or command, + "params": params or {}, + } + try: + sig = inspect.signature(fn) + if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()): + filtered = call_kw + else: + filtered = {k: v for k, v in call_kw.items() if k in sig.parameters} + except (TypeError, ValueError): + filtered = { + "raw_text": call_kw["raw_text"], + "fsm_tables": fsm_tables, + "vendor": vendor, + "device_type": device_type, + "command": cmd_hint or command, + "params": params or {}, + } + records = fn(**filtered) if not isinstance(records, list): records = [] - return records, fsm_tables, rule_keys + return records, fsm_tables, used_keys diff --git a/netx_api/biz_state/parsers/zte/__init__.py b/netx_api/biz_state/parsers/zte/__init__.py index 10f00da..5e31dd4 100644 --- a/netx_api/biz_state/parsers/zte/__init__.py +++ b/netx_api/biz_state/parsers/zte/__init__.py @@ -10,6 +10,7 @@ from typing import Any, Callable from .arp import is_valid_arp_age, normalize_arp from .bgp_peer import normalize_bgp_peer +from .if_intf import normalize_if_intf, parse_if_intf_vrf_map from .interface_brief import normalize_interface_brief from .isis_adjacency import normalize_isis_adjacency from .nd6_cache import normalize_nd6_cache @@ -20,6 +21,7 @@ PARSERS: dict[str, NormalizeFn] = { "isis_adjacency": normalize_isis_adjacency, "interface_brief": normalize_interface_brief, "arp": normalize_arp, + "if_intf": normalize_if_intf, "nd6_cache": normalize_nd6_cache, "bgp_peer": normalize_bgp_peer, } @@ -29,7 +31,9 @@ __all__ = [ "is_valid_arp_age", "normalize_arp", "normalize_bgp_peer", + "normalize_if_intf", "normalize_interface_brief", "normalize_isis_adjacency", "normalize_nd6_cache", + "parse_if_intf_vrf_map", ] diff --git a/netx_api/biz_state/parsers/zte/arp.py b/netx_api/biz_state/parsers/zte/arp.py index c347f6e..8132543 100644 --- a/netx_api/biz_state/parsers/zte/arp.py +++ b/netx_api/biz_state/parsers/zte/arp.py @@ -1,6 +1,7 @@ """ZTE: show arp [| one-line]. Pipeline: TextFSM rule ``zte_zxros_show_arp`` → map rows → hand fallback. +VRF filled from aux ``if_intf`` (show running-config if-intf), FSM-first. """ from __future__ import annotations @@ -11,6 +12,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 .if_intf import parse_if_intf_vrf_map RULE_KEYS = ("zte_zxros_show_arp",) @@ -32,6 +34,7 @@ def _row_from_fields( exter: str = "", inter: str = "", sub: str = "", + vrf: str = "", ) -> dict[str, Any] | None: ip = str(ip or "").strip() iface = str(iface or "").strip() @@ -48,6 +51,7 @@ def _row_from_fields( "inter_vlan": str(inter or "").strip()[:32], "sub_interface": str(sub or "").strip()[:128], "entry_type": "dynamic" if dynamic else "static", + "vrf": str(vrf or "").strip()[:128], } @@ -110,6 +114,40 @@ def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]: return out +def _vrf_map_from_aux( + *, + raws: Mapping[str, str] | None, + aux_records: Mapping[str, list[dict[str, Any]]] | None, + fsm_tables: Mapping[str, list[dict[str, Any]]] | None, +) -> dict[str, str]: + recs = list((aux_records or {}).get("if_intf") or []) + if recs: + return parse_if_intf_vrf_map(rows=recs) + fsm_rows = (fsm_tables or {}).get("zte_zxros_show_running_config_if_intf") or [] + if fsm_rows: + mapped = [ + { + "interface": row_get(r, "INTERFACE", "interface"), + "vrf": row_get(r, "VRF", "vrf"), + } + for r in fsm_rows + ] + return parse_if_intf_vrf_map(rows=mapped) + raw = str((raws or {}).get("if_intf") or "") + if raw.strip(): + return parse_if_intf_vrf_map(raw_text=raw) + return {} + + +def _enrich_vrf(rows: list[dict[str, Any]], vrf_map: dict[str, str]) -> list[dict[str, Any]]: + if not rows: + return rows + for r in rows: + iface = str(r.get("interface") or "").strip() + r["vrf"] = (vrf_map.get(iface) or "")[:128] + return rows + + def normalize_arp( *, raw_text: str, @@ -118,6 +156,8 @@ def normalize_arp( device_type: str = "", command: str = "", params: dict[str, str] | None = None, + raws: Mapping[str, str] | None = None, + aux_records: Mapping[str, list[dict[str, Any]]] | None = None, ) -> list[dict[str, Any]]: _ = params tables = dict(fsm_tables or {}) @@ -132,7 +172,7 @@ def normalize_arp( tables = apply_rules( platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd ) - return prefer_fsm( + rows = prefer_fsm( tables, RULE_KEYS, _map_fsm_rows, @@ -142,6 +182,8 @@ def normalize_arp( device_type=device_type, command=command, ) + vrf_map = _vrf_map_from_aux(raws=raws, aux_records=aux_records, fsm_tables=tables) + return _enrich_vrf(rows, vrf_map) normalize_arp.RULE_KEYS = RULE_KEYS diff --git a/netx_api/biz_state/parsers/zte/if_intf.py b/netx_api/biz_state/parsers/zte/if_intf.py new file mode 100644 index 0000000..9a57a49 --- /dev/null +++ b/netx_api/biz_state/parsers/zte/if_intf.py @@ -0,0 +1,113 @@ +"""ZTE: show running-config if-intf → interface / VRF map. + +Pipeline: TextFSM ``zte_zxros_show_running_config_if_intf`` first; hand fallback. +""" + +from __future__ import annotations + +import re +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 + +RULE_KEYS = ("zte_zxros_show_running_config_if_intf",) + +_IFACE_RE = re.compile(r"^\s*interface\s+(\S+)\s*$", re.I) +_VRF_RE = re.compile(r"^\s*ip\s+vrf\s+forwarding\s+(\S+)\s*$", re.I) + + +def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for r in rows: + iface = row_get(r, "INTERFACE", "interface") + vrf = row_get(r, "VRF", "vrf") + if not iface or not vrf or iface in seen: + continue + seen.add(iface) + out.append({"interface": iface[:128], "vrf": vrf[:128]}) + return out + + +def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + seen: set[str] = set() + cur = "" + cur_vrf = "" + for raw in str(raw_text or "").splitlines(): + line = raw.rstrip() + m_if = _IFACE_RE.match(line) + if m_if: + if cur and cur_vrf and cur not in seen: + seen.add(cur) + out.append({"interface": cur[:128], "vrf": cur_vrf[:128]}) + cur = m_if.group(1).strip() + cur_vrf = "" + continue + if line.strip() == "$": + if cur and cur_vrf and cur not in seen: + seen.add(cur) + out.append({"interface": cur[:128], "vrf": cur_vrf[:128]}) + cur = "" + cur_vrf = "" + continue + m_vrf = _VRF_RE.match(line) + if m_vrf and cur: + cur_vrf = m_vrf.group(1).strip() + if cur and cur_vrf and cur not in seen: + out.append({"interface": cur[:128], "vrf": cur_vrf[:128]}) + return out + + +def parse_if_intf_vrf_map(raw_text: str = "", *, rows: list[dict[str, Any]] | None = None) -> dict[str, str]: + """Build interface → vrf from metric rows or raw if-intf text.""" + if rows is None: + rows = _hand_parse(raw_text=raw_text) + out: dict[str, str] = {} + for r in rows or []: + iface = str((r or {}).get("interface") or "").strip() + vrf = str((r or {}).get("vrf") or "").strip() + if iface and vrf: + out[iface] = vrf + return out + + +def normalize_if_intf( + *, + raw_text: str, + fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None, + vendor: str = "", + device_type: str = "", + command: str = "", + params: dict[str, str] | None = None, + raws: Mapping[str, str] | None = None, + aux_records: Mapping[str, list[dict[str, Any]]] | None = None, +) -> list[dict[str, Any]]: + _ = (params, raws, aux_records) + tables = dict(fsm_tables or {}) + if not any(tables.get(k) for k in RULE_KEYS): + platform = resolve_cli_platform( + vendor=vendor, + device_type=device_type, + vendor_key=resolve_vendor_key(vendor, device_type), + ) + cmd = str(command or "show running-config if-intf").strip() + if platform: + 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, + vendor=vendor, + device_type=device_type, + command=command, + ) + + +normalize_if_intf.RULE_KEYS = RULE_KEYS diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index 050a93d..0ce2a96 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -34,6 +34,18 @@ class PlaceholderDef: discover_label_field: str = "" +@dataclass(frozen=True) +class AuxCommand: + """Secondary CLI bound to a collect profile (same session, optional cache).""" + + key: str + command_template: str + textfsm_command: str = "" + parser_id: str = "" + rule_keys: tuple[str, ...] = () + profile_id: str = "" + + @dataclass class ParseProfile: profile_id: str @@ -52,6 +64,7 @@ class ParseProfile: sort_order: int = 100 enabled: bool = True kind: str = "collect" # collect | discover + aux_commands: list[AuxCommand] = field(default_factory=list) _LLDP_FIELDS: list[FieldDef] = [ @@ -260,11 +273,17 @@ _ARP_FIELDS: list[FieldDef] = [ FieldDef("mac", length=64, role="state", display_name="MAC"), FieldDef("age", length=32, role="meta", display_name="Age"), FieldDef("entry_type", length=16, role="meta", display_name="类型"), + FieldDef("vrf", length=128, role="meta", display_name="VRF"), FieldDef("exter_vlan", length=32, role="meta", display_name="Exter VLAN"), FieldDef("inter_vlan", length=32, role="meta", display_name="Inter VLAN"), FieldDef("sub_interface", length=128, role="meta", display_name="Sub-IF"), ] +_IF_INTF_FIELDS: list[FieldDef] = [ + FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"), + FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"), +] + _ND6_FIELDS: list[FieldDef] = [ FieldDef("address", length=128, indexed=True, is_key=True, display_name="IPv6"), FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"), @@ -332,12 +351,38 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show arp | one-line", match=r"(?i)^\s*show\s+arp(?:\s*\|\s*one-line)?\s*$", textfsm_command="show arp", - description="ARP entries (IP/MAC/interface).", + description="ARP entries (IP/MAC/interface); VRF from if-intf aux.", fields=list(_ARP_FIELDS), tags=["arp", "l3", "status"], sort_order=320, enabled=True, kind="collect", + aux_commands=[ + AuxCommand( + key="if_intf", + command_template="show running-config if-intf", + textfsm_command="show running-config if-intf", + parser_id="if_intf", + rule_keys=("zte_zxros_show_running_config_if_intf",), + profile_id="zte.if_intf", + ) + ], + ), + ParseProfile( + profile_id="zte.if_intf", + vendor_key="zte", + metric_id="if_intf", + parser_id="if_intf", + title="IF VRF (if-intf)", + command_template="show running-config if-intf", + match=r"(?i)^\s*show\s+running-config\s+if-intf\s*$", + textfsm_command="show running-config if-intf", + description="Interface VRF from running-config if-intf (ip vrf forwarding).", + fields=list(_IF_INTF_FIELDS), + tags=["interface", "vrf", "config", "status"], + sort_order=325, + enabled=True, + kind="collect", ), ParseProfile( profile_id="zte.nd6_cache", @@ -498,4 +543,15 @@ def profile_to_public_dict(p: ParseProfile, *, overrides: dict[str, Any] | None "kind": p.kind, "match": p.match, "textfsm_command": p.textfsm_command or p.command_template, + "aux_commands": [ + { + "key": a.key, + "command_template": a.command_template, + "textfsm_command": a.textfsm_command or a.command_template, + "parser_id": a.parser_id, + "rule_keys": list(a.rule_keys), + "profile_id": a.profile_id, + } + for a in (p.aux_commands or []) + ], } diff --git a/netx_api/cli_templates/index b/netx_api/cli_templates/index index 387eea6..4d0c27a 100644 --- a/netx_api/cli_templates/index +++ b/netx_api/cli_templates/index @@ -8,6 +8,7 @@ zte/zte_zxros_show_interface_brief.textfsm, .*, zte_zxros, sh[[ow]] int[[erface] zte/zte_zxros_show_interface.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] .+ zte/zte_zxros_show_ip_vrf.textfsm, .*, zte_zxros, sh[[ow]] ip vrf$ zte/zte_zxros_show_arp.textfsm, .*, zte_zxros, sh[[ow]] arp +zte/zte_zxros_show_running_config_if_intf.textfsm, .*, zte_zxros, sh[[ow]] run[[ning-config]] if-intf # --- cisco --- cisco/cisco_ios_show_interfaces.textfsm, .*, cisco_ios, sh[[ow]] int[[erfaces]] .+ diff --git a/netx_api/cli_templates/zte/zte_zxros_show_running_config_if_intf.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_running_config_if_intf.textfsm new file mode 100644 index 0000000..557a2a6 --- /dev/null +++ b/netx_api/cli_templates/zte/zte_zxros_show_running_config_if_intf.textfsm @@ -0,0 +1,13 @@ +Value Filldown INTERFACE (\S+) +Value Required VRF (\S+) + +Start + ^interface\s+${INTERFACE} -> If + ^\s*$$ + ^.* -> Start + +If + ^\s*ip\s+vrf\s+forwarding\s+${VRF} -> Record + ^interface\s+${INTERFACE} -> If + ^\s*$$ + ^.* -> If diff --git a/tests/test_multi_command_arp.py b/tests/test_multi_command_arp.py new file mode 100644 index 0000000..e6a2031 --- /dev/null +++ b/tests/test_multi_command_arp.py @@ -0,0 +1,159 @@ +"""Tests for multi-command collect (ARP + if-intf) and FSM-first if-intf.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from netx_api.biz_state.parsers import get_parser_meta, run_parser +from netx_api.biz_state.parsers.zte.if_intf import normalize_if_intf, parse_if_intf_vrf_map +from netx_api.biz_state.profiles import get_profile, reload_profiles +from netx_api.ntc_parse import _all_index_entries, apply_rule + + +def _log() -> str: + p = Path(__file__).resolve().parents[2] / "test" / "log" + if not p.is_file(): + p = Path(__file__).resolve().parents[3] / "test" / "log" + return p.read_text(encoding="utf-8", errors="ignore") if p.is_file() else "" + + +IF_INTF_SAMPLE = """\ +! +interface cdgei-0/1/0/1 + no shutdown +$ +interface cdgei-0/1/0/1.1 + mtu 9600 + ip vrf forwarding IuB_UP-evpn + ip address 131.1.1.1 255.255.255.0 +$ +interface cdgei-0/1/1/2.1 + ip vrf forwarding 400G-1 + ip address 11.1.1.1 255.255.255.0 +$ +""" + +ARP_MATCHING = """\ +IP Hardware Exter Inter Sub +Address Age Address Interface VlanID VlanID Interface +-------------------------------------------------------------------------------- +131.1.1.2 03:22:07 0011.2233.4455 cdgei-0/1/0/1.1 N/A N/A cdgei-0/1/0/1.1 +11.1.1.2 H 00aa.bbcc.ddee cdgei-0/1/1/2.1 N/A N/A N/A +10.0.0.1 01:00:00 aabb.ccdd.eeff gei-0/0/0/1 N/A N/A N/A +""" + + +class IfIntfParserTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + _all_index_entries.cache_clear() + reload_profiles() + + def test_fsm_rule(self) -> None: + rows = apply_rule( + platform="zte_zxros", + rule_key="zte_zxros_show_running_config_if_intf", + text=IF_INTF_SAMPLE, + command="show running-config if-intf", + ) + self.assertEqual(len(rows), 2) + by_if = {r.get("interface"): r.get("vrf") for r in rows} + self.assertEqual(by_if["cdgei-0/1/0/1.1"], "IuB_UP-evpn") + self.assertEqual(by_if["cdgei-0/1/1/2.1"], "400G-1") + + def test_normalize_and_map(self) -> None: + rows = normalize_if_intf( + raw_text=IF_INTF_SAMPLE, + vendor="zte", + device_type="zte_zxros", + command="show running-config if-intf", + ) + self.assertEqual(len(rows), 2) + m = parse_if_intf_vrf_map(rows=rows) + self.assertEqual(m["cdgei-0/1/0/1.1"], "IuB_UP-evpn") + + def test_log_sample(self) -> None: + blob = _log() + if not blob: + self.skipTest("test/log missing") + text = blob[blob.find("!") :] if "!" in blob else "" + if not text.strip(): + self.skipTest("if-intf section missing") + rows = normalize_if_intf( + raw_text=text, vendor="zte", device_type="zte_zxros", command="show running-config if-intf" + ) + self.assertGreaterEqual(len(rows), 2) + + +class ArpMultiCommandTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + _all_index_entries.cache_clear() + reload_profiles() + + def test_profile_aux(self) -> None: + p = get_profile("zte.arp") + assert p is not None + self.assertEqual(len(p.aux_commands), 1) + self.assertEqual(p.aux_commands[0].key, "if_intf") + self.assertEqual(p.aux_commands[0].parser_id, "if_intf") + self.assertIsNotNone(get_profile("zte.if_intf")) + self.assertEqual(get_parser_meta("if_intf")["rule_keys"], ("zte_zxros_show_running_config_if_intf",)) + + def test_arp_enriches_vrf_via_aux_records(self) -> None: + if_recs, if_fsm, _ = run_parser( + "if_intf", + raw_text=IF_INTF_SAMPLE, + vendor="zte", + device_type="zte_zxros", + command="show running-config if-intf", + ) + records, tables, keys = run_parser( + "arp", + raw_text=ARP_MATCHING, + vendor="zte", + device_type="zte_zxros", + command="show arp | one-line", + textfsm_command="show arp", + raws={"primary": ARP_MATCHING, "if_intf": IF_INTF_SAMPLE}, + command_rules={ + "primary": ["zte_zxros_show_arp"], + "if_intf": ["zte_zxros_show_running_config_if_intf"], + }, + aux_records={"if_intf": if_recs}, + fsm_tables_extra=if_fsm, + ) + self.assertIn("zte_zxros_show_arp", keys) + by_ip = {r["ip"]: r for r in records} + self.assertEqual(by_ip["131.1.1.2"]["vrf"], "IuB_UP-evpn") + self.assertEqual(by_ip["11.1.1.2"]["vrf"], "400G-1") + self.assertEqual(by_ip["10.0.0.1"]["vrf"], "") + + +class CmdCacheLogicTests(unittest.TestCase): + """Unit-level: same concrete aux command reused from cache dict.""" + + def test_cache_hit_skips_second_collect(self) -> None: + calls: list[str] = [] + + def fake_send(_conn, cmd, read_timeout=0): + calls.append(cmd) + return f"RAW:{cmd}" + + cache: dict[str, dict] = {} + cmd = "show running-config if-intf" + # miss + raw = fake_send(None, cmd) + cache[cmd] = {"raw": raw, "ok": True, "records": [{"interface": "a", "vrf": "v"}], "fsm_tables": {}} + # hit + if cache.get(cmd, {}).get("ok"): + reused = cache[cmd]["raw"] + else: + reused = fake_send(None, cmd) + self.assertEqual(calls, [cmd]) + self.assertEqual(reused, "RAW:show running-config if-intf") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_zte_status_parsers.py b/tests/test_zte_status_parsers.py index fafe0c6..12ead32 100644 --- a/tests/test_zte_status_parsers.py +++ b/tests/test_zte_status_parsers.py @@ -51,9 +51,10 @@ class ZteStatusParserTests(unittest.TestCase): self.assertIn("zte.bgp_vpnv4_summary", zte) self.assertIn("zte.bgp_ipv4_summary", zte) self.assertIn("zte.bgp_vpnv6_summary", zte) - for mid in ("isis_adjacency", "interface_brief", "arp", "nd6_cache", "bgp_peer"): + for mid in ("isis_adjacency", "interface_brief", "arp", "nd6_cache", "bgp_peer", "if_intf"): self.assertIn(mid, metric_field_map()) self.assertIsNotNone(get_profile("zte.isis_adjacency")) + self.assertIsNotNone(get_profile("zte.if_intf")) def test_isis_adjacency(self) -> None: text = _section(