netx/netx_api/biz_state/collect_session.py
oliver 285c19e5a3 Decouple cutover HF from portrait and tighten ops UI.
Portrait stays on biz-monitor; cutover create/ensure owns purpose=cutover_hf slots with project collect override, multi-interval bindings, window pause, and industrial detail UX. Also expand ZTE collect/config parsers and TextFSM for route/BGP/L2VPN/VRRP coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 21:11:31 +08:00

257 lines
8.1 KiB
Python

"""Per-batch CLI session helpers: aux resolution, command cache, parse bundle."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
import re
from .command_match import normalize_command
from .enrich import EnrichJoin, apply_enrich_joins
from .parsers import get_parser, get_parser_meta, run_parser
from .profiles import AuxCommand, ParseProfile, get_profile
SendFn = Callable[..., str]
@dataclass
class CachedCommand:
raw: str = ""
fsm_tables: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
records: list[dict[str, Any]] = field(default_factory=list)
ok: bool = True
error: str = ""
cmd_row_id: str = ""
@dataclass(frozen=True)
class ResolvedAux:
key: str
profile_id: str
command: str
textfsm_command: str
parser_id: str
rule_keys: tuple[str, ...]
profile: ParseProfile
def resolve_aux_command(
aux: AuxCommand,
params: dict[str, str] | None = None,
) -> ResolvedAux:
"""Resolve aux from ``profile_id``; render ``<placeholders>`` from primary params."""
key = str(aux.key or "").strip()
pid = str(aux.profile_id or "").strip()
if not key or not pid:
raise ValueError("AuxCommand requires key and profile_id")
prof = get_profile(pid)
if not prof:
raise ValueError(f"aux profile not found: {pid}")
tmpl = str(prof.command_template or "")
rendered = tmpl
for name, val in dict(params or {}).items():
n = str(name or "").strip()
if not n:
continue
rendered = rendered.replace(f"<{n}>", str(val or "").strip())
cmd = normalize_command(rendered)
if not cmd:
raise ValueError(f"aux profile {pid} has empty command_template")
if re.search(r"<[^>]+>", cmd):
raise ValueError(f"aux profile {pid} unresolved placeholders: {cmd}")
textfsm = str(prof.textfsm_command or cmd).strip()
# Prefer concrete rendered command for FSM when aux had placeholders
if params and any(f"<{k}>" in tmpl for k in (params or {})):
textfsm = cmd
parser_id = str(prof.parser_id or "").strip()
meta = get_parser_meta(parser_id) if parser_id else None
rule_keys = tuple((meta or {}).get("rule_keys") or ())
return ResolvedAux(
key=key,
profile_id=pid,
command=cmd,
textfsm_command=textfsm,
parser_id=parser_id,
rule_keys=rule_keys,
profile=prof,
)
@dataclass
class ParseBundle:
"""Inputs for primary ``run_parser`` after primary + aux collection."""
raws: dict[str, str] = field(default_factory=dict)
command_rules: dict[str, list[str]] = field(default_factory=dict)
aux_records: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
fsm_extra: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
class CollectSession:
"""SSH session-scoped command cache + aux fetch/parse."""
def __init__(
self,
conn: Any,
*,
vendor: str = "",
device_type: str = "",
vendor_key: str = "",
read_timeout: int = 120,
send_fn: SendFn | None = None,
) -> None:
self.conn = conn
self.vendor = vendor
self.device_type = device_type
self.vendor_key = vendor_key
self.read_timeout = int(read_timeout or 120)
self._send = send_fn
self.cache: dict[str, CachedCommand] = {}
def _send_show(self, command: str) -> str:
if self._send is None:
from ..ne_netmiko import send_show_command
return str(send_show_command(self.conn, command, read_timeout=self.read_timeout) or "")
return str(self._send(self.conn, command, read_timeout=self.read_timeout) or "")
def remember(
self,
command: str,
*,
raw: str = "",
fsm_tables: dict[str, list[dict[str, Any]]] | None = None,
records: list[dict[str, Any]] | None = None,
ok: bool = True,
error: str = "",
cmd_row_id: str = "",
) -> CachedCommand:
ck = normalize_command(command)
entry = CachedCommand(
raw=str(raw or ""),
fsm_tables=dict(fsm_tables or {}),
records=list(records or []),
ok=bool(ok),
error=str(error or ""),
cmd_row_id=str(cmd_row_id or ""),
)
self.cache[ck] = entry
return entry
def get_cached(self, command: str) -> CachedCommand | None:
ck = normalize_command(command)
hit = self.cache.get(ck)
if hit and hit.ok:
return hit
return None
def fetch_and_parse(
self,
command: str,
*,
parser_id: str = "",
textfsm_command: str = "",
params: dict[str, str] | None = None,
cmd_row_id: str = "",
) -> tuple[CachedCommand, bool]:
"""Return ``(entry, cache_hit)``. On miss: CLI + optional parser."""
cached = self.get_cached(command)
if cached is not None:
return cached, True
try:
raw = self._send_show(command)
except Exception as exc:
entry = self.remember(
command,
raw="",
ok=False,
error=f"{type(exc).__name__}: {exc}",
cmd_row_id=cmd_row_id,
)
return entry, False
records: list[dict[str, Any]] = []
fsm_tables: dict[str, list[dict[str, Any]]] = {}
if parser_id and get_parser(parser_id):
try:
records, fsm_tables, _keys = run_parser(
parser_id,
raw_text=raw,
vendor=self.vendor,
device_type=self.device_type,
command=textfsm_command or command,
textfsm_command=textfsm_command or "",
params=params or {},
)
except Exception as exc:
entry = self.remember(
command,
raw=raw,
ok=False,
error=f"parse: {type(exc).__name__}: {exc}",
cmd_row_id=cmd_row_id,
)
return entry, False
entry = self.remember(
command,
raw=raw,
fsm_tables=fsm_tables,
records=records,
ok=True,
cmd_row_id=cmd_row_id,
)
return entry, False
def primary_rule_keys(parser_id: str) -> list[str]:
meta = get_parser_meta(parser_id) or {}
return list(meta.get("rule_keys") or ())
def build_parse_bundle(
*,
primary_raw: str,
primary_parser_id: str,
aux_results: dict[str, CachedCommand],
resolved_aux: list[ResolvedAux],
) -> ParseBundle:
bundle = ParseBundle(
raws={"primary": str(primary_raw or "")},
command_rules={"primary": primary_rule_keys(primary_parser_id)},
)
for ra in resolved_aux:
entry = aux_results.get(ra.key) or CachedCommand(ok=False)
bundle.raws[ra.key] = entry.raw
bundle.command_rules[ra.key] = list(ra.rule_keys)
if entry.records:
bundle.aux_records[ra.key] = list(entry.records)
bundle.fsm_extra.update(entry.fsm_tables or {})
return bundle
def run_primary_with_bundle(
parser_id: str,
*,
bundle: ParseBundle,
vendor: str = "",
device_type: str = "",
command: str = "",
textfsm_command: str = "",
params: dict[str, str] | None = None,
enrich_joins: list[EnrichJoin] | None = None,
) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]], list[str]]:
records, fsm_tables, keys = run_parser(
parser_id,
raw_text=bundle.raws.get("primary") or "",
vendor=vendor,
device_type=device_type,
command=command,
textfsm_command=textfsm_command,
params=params,
raws=bundle.raws,
command_rules=bundle.command_rules,
aux_records=bundle.aux_records,
fsm_tables_extra=bundle.fsm_extra,
)
if enrich_joins:
apply_enrich_joins(records, bundle.aux_records, enrich_joins)
return records, fsm_tables, keys