mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
596 lines
20 KiB
Python
596 lines
20 KiB
Python
"""Collect runner: expand task items → CLI → match → parse → batch rows."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from ..cli_creds import cli_creds_skip_reason
|
|
from ..cli_resolve import resolve_cli_target
|
|
from ..cli_timeout import run_cli_with_timeout
|
|
from ..config import settings
|
|
from ..db import SessionLocal
|
|
from ..lldp_shared import resolve_vendor_key
|
|
from ..models import (
|
|
BizStateBatch,
|
|
BizStateBatchCommand,
|
|
BizStateEvent,
|
|
BizStateLldpNeighbor,
|
|
BizStateMetricRow,
|
|
BizStateTask,
|
|
BizStateTaskItem,
|
|
BizStateTaskItemBinding,
|
|
BizStateVrfRouteSummary,
|
|
)
|
|
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
|
|
from .profiles import get_profile
|
|
|
|
_log = logging.getLogger("netx.biz_state.runner")
|
|
|
|
|
|
def _utcnow() -> datetime:
|
|
return datetime.utcnow()
|
|
|
|
|
|
def _format_error(exc: BaseException) -> str:
|
|
return f"{type(exc).__name__}: {exc}"[:1020]
|
|
|
|
|
|
def _append_event(db, *, task_id: str, message: str, level: str = "error") -> None:
|
|
msg = str(message or "").strip()
|
|
if not msg or not task_id:
|
|
return
|
|
db.add(
|
|
BizStateEvent(
|
|
id=uuid4().hex,
|
|
task_id=task_id,
|
|
level=str(level or "error")[:16],
|
|
message=msg[:4000],
|
|
created_at=_utcnow(),
|
|
)
|
|
)
|
|
|
|
|
|
def _bindings_for_item(db, item_id: str) -> list[dict[str, str]]:
|
|
rows = (
|
|
db.query(BizStateTaskItemBinding)
|
|
.filter(BizStateTaskItemBinding.item_id == item_id)
|
|
.all()
|
|
)
|
|
# Keep one row per binding value (same placeholder may appear many times).
|
|
return [
|
|
{"placeholder": str(r.placeholder or "").strip(), "value": str(r.value or "").strip()}
|
|
for r in rows
|
|
if str(r.placeholder or "").strip() and str(r.value or "").strip()
|
|
]
|
|
|
|
|
|
def _persist_lldp_rows(
|
|
db,
|
|
*,
|
|
batch: BizStateBatch,
|
|
cmd_row: BizStateBatchCommand,
|
|
records: list[dict[str, Any]],
|
|
) -> int:
|
|
n = 0
|
|
seen: set[tuple[str, str, str]] = set()
|
|
for rec in records:
|
|
local_if = str(rec.get("local_if") or "").strip()[:128]
|
|
remote_sys = str(rec.get("remote_sys") or "").strip()[:256]
|
|
remote_if = str(rec.get("remote_if") or "").strip()[:128]
|
|
if not local_if and not remote_sys and not remote_if:
|
|
continue
|
|
key = (local_if, remote_sys, remote_if)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
db.add(
|
|
BizStateLldpNeighbor(
|
|
id=uuid4().hex,
|
|
batch_id=batch.id,
|
|
batch_command_id=cmd_row.id,
|
|
task_id=batch.task_id,
|
|
ne_id=batch.ne_id,
|
|
local_if=local_if,
|
|
remote_sys=remote_sys,
|
|
remote_if=remote_if,
|
|
remote_ip=str(rec.get("remote_ip") or "")[:128],
|
|
protocol=str(rec.get("protocol") or "lldp")[:32],
|
|
collected_at=_utcnow(),
|
|
)
|
|
)
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def _persist_vrf_route_summary(
|
|
db,
|
|
*,
|
|
batch: BizStateBatch,
|
|
cmd_row: BizStateBatchCommand,
|
|
records: list[dict[str, Any]],
|
|
) -> int:
|
|
n = 0
|
|
seen: set[tuple[str, str]] = set()
|
|
for rec in records:
|
|
vrf = str(rec.get("vrf") or "").strip()[:128]
|
|
source = str(rec.get("source") or "").strip()[:64]
|
|
if not vrf and not source:
|
|
continue
|
|
key = (vrf, source)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
try:
|
|
networks = int(rec.get("networks") or 0)
|
|
except (TypeError, ValueError):
|
|
networks = 0
|
|
db.add(
|
|
BizStateVrfRouteSummary(
|
|
id=uuid4().hex,
|
|
batch_id=batch.id,
|
|
batch_command_id=cmd_row.id,
|
|
task_id=batch.task_id,
|
|
ne_id=batch.ne_id,
|
|
vrf=vrf,
|
|
source=source,
|
|
networks=networks,
|
|
collected_at=_utcnow(),
|
|
)
|
|
)
|
|
n += 1
|
|
return n
|
|
|
|
|
|
_GENERIC_METRICS = {
|
|
"isis_adjacency",
|
|
"interface_brief",
|
|
"arp",
|
|
"nd6_cache",
|
|
"bgp_peer",
|
|
}
|
|
_METRIC_CHUNK = 2000
|
|
|
|
|
|
def _persist_metric_rows(
|
|
db,
|
|
*,
|
|
batch: BizStateBatch,
|
|
cmd_row: BizStateBatchCommand,
|
|
metric_id: str,
|
|
records: list[dict[str, Any]],
|
|
) -> int:
|
|
"""Bulk-insert generic metric rows (JSON payload per row)."""
|
|
mid = str(metric_id or "").strip()
|
|
if not mid or not records:
|
|
return 0
|
|
buf: list[dict[str, Any]] = []
|
|
n = 0
|
|
for i, rec in enumerate(records):
|
|
if not isinstance(rec, dict) or not rec:
|
|
continue
|
|
buf.append(
|
|
{
|
|
"id": uuid4().hex,
|
|
"batch_id": batch.id,
|
|
"batch_command_id": cmd_row.id,
|
|
"task_id": batch.task_id,
|
|
"ne_id": batch.ne_id,
|
|
"metric_id": mid,
|
|
"seq": i,
|
|
"data_json": dict(rec),
|
|
"collected_at": _utcnow(),
|
|
}
|
|
)
|
|
n += 1
|
|
if len(buf) >= _METRIC_CHUNK:
|
|
db.bulk_insert_mappings(BizStateMetricRow, buf)
|
|
buf.clear()
|
|
if buf:
|
|
db.bulk_insert_mappings(BizStateMetricRow, buf)
|
|
return n
|
|
|
|
|
|
def _finish_task(task_id: str, *, error: str = "") -> None:
|
|
db = SessionLocal()
|
|
try:
|
|
task = db.get(BizStateTask, task_id)
|
|
if not task:
|
|
return
|
|
task.collect_running = False
|
|
task.last_collect_ended_at = _utcnow()
|
|
task.last_error = str(error or "")[:1020]
|
|
task.updated_at = _utcnow()
|
|
if error:
|
|
_append_event(db, task_id=task_id, message=error, level="error")
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def dispatch_collect(task_id: str) -> None:
|
|
"""Claim and run one collect round."""
|
|
db = SessionLocal()
|
|
batch_id = ""
|
|
try:
|
|
task = db.get(BizStateTask, task_id)
|
|
if not task:
|
|
return
|
|
if task.collect_running:
|
|
return
|
|
if str(task.status or "") not in ("running", "draft", "paused"):
|
|
return
|
|
|
|
items = (
|
|
db.query(BizStateTaskItem)
|
|
.filter(
|
|
BizStateTaskItem.task_id == task_id,
|
|
BizStateTaskItem.enabled.is_(True),
|
|
)
|
|
.order_by(BizStateTaskItem.sort_order.asc())
|
|
.all()
|
|
)
|
|
if not items:
|
|
task.last_error = "no enabled task items"
|
|
task.updated_at = _utcnow()
|
|
db.commit()
|
|
return
|
|
|
|
task.collect_running = True
|
|
task.last_collect_started_at = _utcnow()
|
|
task.last_error = ""
|
|
task.updated_at = _utcnow()
|
|
|
|
batch = BizStateBatch(
|
|
id=uuid4().hex,
|
|
task_id=task.id,
|
|
source=task.source,
|
|
ne_id=task.ne_id,
|
|
ne_name=task.ne_name,
|
|
vendor=task.vendor,
|
|
status="running",
|
|
started_at=_utcnow(),
|
|
)
|
|
db.add(batch)
|
|
db.commit()
|
|
batch_id = batch.id
|
|
vendor = str(task.vendor or "")
|
|
device_type = str(task.device_type or "")
|
|
source = str(task.source or "managed").strip().lower()
|
|
ne_id = str(task.ne_id or "").strip()
|
|
retention = int(task.retention_batches or 30)
|
|
finally:
|
|
db.close()
|
|
|
|
if not batch_id:
|
|
return
|
|
|
|
error = ""
|
|
try:
|
|
_run_collect_session(
|
|
task_id=task_id,
|
|
batch_id=batch_id,
|
|
source=source,
|
|
ne_id=ne_id,
|
|
vendor=vendor,
|
|
device_type=device_type,
|
|
retention=retention,
|
|
)
|
|
except Exception as exc:
|
|
_log.exception("biz_state collect failed task=%s", task_id)
|
|
error = _format_error(exc)
|
|
db = SessionLocal()
|
|
try:
|
|
batch = db.get(BizStateBatch, batch_id)
|
|
if batch:
|
|
batch.status = "failed"
|
|
batch.message = error
|
|
batch.ended_at = _utcnow()
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
finally:
|
|
_finish_task(task_id, error=error)
|
|
|
|
|
|
def _run_collect_session(
|
|
*,
|
|
task_id: str,
|
|
batch_id: str,
|
|
source: str,
|
|
ne_id: str,
|
|
vendor: str,
|
|
device_type: str,
|
|
retention: int,
|
|
) -> None:
|
|
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
|
|
cap = int(settings.ne_collect_run_timeout_cap_sec or 600)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
task = db.get(BizStateTask, task_id)
|
|
batch = db.get(BizStateBatch, batch_id)
|
|
if not task or not batch:
|
|
return
|
|
|
|
try:
|
|
if source == "managed":
|
|
creds, info = resolve_cli_target(db, managed_ne_id=ne_id)
|
|
elif source == "ume":
|
|
creds, info = resolve_cli_target(db, ume_ne_id=ne_id)
|
|
else:
|
|
raise RuntimeError("invalid_source")
|
|
except HTTPException as exc:
|
|
raise RuntimeError(str(exc.detail or "resolve_failed")) from exc
|
|
|
|
skip = cli_creds_skip_reason(creds, interactive=False)
|
|
if skip:
|
|
raise RuntimeError(skip)
|
|
|
|
vendor_eff = str(info.get("vendor") or vendor or "")
|
|
device_type_eff = str(info.get("device_type") or device_type or "")
|
|
if vendor_eff and vendor_eff != task.vendor:
|
|
task.vendor = vendor_eff
|
|
if device_type_eff and device_type_eff != task.device_type:
|
|
task.device_type = device_type_eff
|
|
db.commit()
|
|
|
|
vendor_key = resolve_vendor_key(vendor_eff, device_type_eff)
|
|
|
|
items = (
|
|
db.query(BizStateTaskItem)
|
|
.filter(
|
|
BizStateTaskItem.task_id == task_id,
|
|
BizStateTaskItem.enabled.is_(True),
|
|
)
|
|
.order_by(BizStateTaskItem.sort_order.asc())
|
|
.all()
|
|
)
|
|
|
|
# Build work list before opening session
|
|
work: list[tuple[str, dict[str, str], str, str, str]] = []
|
|
# concrete, params, profile_id, item_id, mode
|
|
for item in items:
|
|
if item.kind == "custom_raw":
|
|
cmd = normalize_command(item.command_override)
|
|
if cmd:
|
|
work.append((cmd, {}, "", item.id, "custom"))
|
|
continue
|
|
profile = get_profile(item.source_profile_id)
|
|
if profile is None:
|
|
_append_event(
|
|
db,
|
|
task_id=task_id,
|
|
message=f"unknown profile {item.source_profile_id}",
|
|
level="error",
|
|
)
|
|
continue
|
|
binds = _bindings_for_item(db, item.id)
|
|
try:
|
|
pairs = expand_from_bindings(
|
|
profile=profile,
|
|
bindings=binds,
|
|
command_override=item.command_override,
|
|
)
|
|
except ValueError as exc:
|
|
_append_event(db, task_id=task_id, message=str(exc), level="error")
|
|
continue
|
|
for concrete, params in pairs:
|
|
work.append((concrete, params, profile.profile_id, item.id, "normal"))
|
|
|
|
if not work:
|
|
batch.status = "failed"
|
|
batch.message = "no commands to run"
|
|
batch.ended_at = _utcnow()
|
|
db.commit()
|
|
raise RuntimeError("no commands to run")
|
|
|
|
budget = min(cap, per_cmd * max(1, len(work)) + 90)
|
|
holder: dict[str, Any] = {}
|
|
|
|
def _session() -> tuple[int, int, bool, bool]:
|
|
from ..ne_netmiko import drain_read_channel
|
|
|
|
conn = open_netmiko_connection(creds, session_timeout=budget)
|
|
holder["conn"] = conn
|
|
total_rows = 0
|
|
cmd_count = 0
|
|
any_fail = False
|
|
any_ok = False
|
|
try:
|
|
try:
|
|
disable_target_paging(
|
|
conn,
|
|
vendor=str(creds.get("vendor") or vendor_eff or ""),
|
|
device_type=str(creds.get("device_type") or device_type_eff or ""),
|
|
)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
drain_read_channel(conn)
|
|
except Exception:
|
|
pass
|
|
|
|
sdb = SessionLocal()
|
|
try:
|
|
batch_row = sdb.get(BizStateBatch, batch_id)
|
|
if not batch_row:
|
|
return 0, 0, True, True
|
|
for concrete, params, profile_id, item_id, mode in work:
|
|
if holder.get("timed_out"):
|
|
raise TimeoutError("biz_state_aborted")
|
|
cmd_count += 1
|
|
cmd_row = BizStateBatchCommand(
|
|
id=uuid4().hex,
|
|
batch_id=batch_id,
|
|
task_item_id=item_id,
|
|
profile_id=profile_id,
|
|
raw_command=concrete[:512],
|
|
params_json=dict(params or {}),
|
|
created_at=_utcnow(),
|
|
)
|
|
try:
|
|
raw = send_show_command(conn, concrete, read_timeout=per_cmd)
|
|
cmd_row.raw_text = str(raw or "")
|
|
except Exception as exc:
|
|
any_fail = True
|
|
cmd_row.parse_status = "failed"
|
|
cmd_row.message = _format_error(exc)
|
|
sdb.add(cmd_row)
|
|
sdb.commit()
|
|
continue
|
|
|
|
if mode == "custom":
|
|
cmd_row.parse_status = "skipped_custom"
|
|
cmd_row.message = "custom_raw"
|
|
sdb.add(cmd_row)
|
|
sdb.commit()
|
|
any_ok = True
|
|
continue
|
|
|
|
hit = match_command(vendor_key=vendor_key, command=concrete)
|
|
if not hit:
|
|
any_fail = True
|
|
cmd_row.parse_status = "unmatched"
|
|
cmd_row.message = "no profile matched concrete command"
|
|
sdb.add(cmd_row)
|
|
sdb.commit()
|
|
continue
|
|
|
|
cmd_row.profile_id = hit.profile.profile_id
|
|
cmd_row.parser_id = hit.profile.parser_id
|
|
cmd_row.metric_id = hit.profile.metric_id
|
|
merged = {**params, **hit.params}
|
|
cmd_row.params_json = merged
|
|
|
|
parser = get_parser(hit.profile.parser_id)
|
|
if not parser:
|
|
any_fail = True
|
|
cmd_row.parse_status = "failed"
|
|
cmd_row.message = f"unknown parser {hit.profile.parser_id}"
|
|
sdb.add(cmd_row)
|
|
sdb.commit()
|
|
continue
|
|
|
|
try:
|
|
records = parser(
|
|
raw_text=cmd_row.raw_text,
|
|
vendor=vendor_eff,
|
|
device_type=device_type_eff,
|
|
command=hit.profile.textfsm_command or concrete,
|
|
params=merged,
|
|
)
|
|
except Exception as exc:
|
|
any_fail = True
|
|
cmd_row.parse_status = "failed"
|
|
cmd_row.message = f"parse: {_format_error(exc)}"
|
|
sdb.add(cmd_row)
|
|
sdb.commit()
|
|
continue
|
|
|
|
n = 0
|
|
if hit.profile.metric_id == "lldp_neighbor":
|
|
n = _persist_lldp_rows(
|
|
sdb, batch=batch_row, cmd_row=cmd_row, records=records
|
|
)
|
|
elif hit.profile.metric_id == "vrf_route_summary":
|
|
n = _persist_vrf_route_summary(
|
|
sdb, batch=batch_row, cmd_row=cmd_row, records=records
|
|
)
|
|
elif hit.profile.metric_id in _GENERIC_METRICS:
|
|
n = _persist_metric_rows(
|
|
sdb,
|
|
batch=batch_row,
|
|
cmd_row=cmd_row,
|
|
metric_id=hit.profile.metric_id,
|
|
records=records,
|
|
)
|
|
cmd_row.row_count = n
|
|
cmd_row.parse_status = "ok"
|
|
total_rows += n
|
|
any_ok = True
|
|
sdb.add(cmd_row)
|
|
sdb.commit()
|
|
finally:
|
|
sdb.close()
|
|
return total_rows, cmd_count, any_fail, any_ok
|
|
finally:
|
|
holder.pop("conn", None)
|
|
close_netmiko_connection(conn)
|
|
|
|
try:
|
|
total_rows, cmd_count, any_fail, any_ok = run_cli_with_timeout(
|
|
_session,
|
|
timeout_sec=budget,
|
|
conn_holder=holder,
|
|
label="biz_state",
|
|
acquire_budget=True,
|
|
)
|
|
except TimeoutError as exc:
|
|
raise RuntimeError(str(exc)[:1020]) from exc
|
|
|
|
batch = db.get(BizStateBatch, batch_id)
|
|
if batch:
|
|
batch.command_count = cmd_count
|
|
batch.row_count = total_rows
|
|
batch.ended_at = _utcnow()
|
|
if any_fail and any_ok:
|
|
batch.status = "partial"
|
|
elif any_fail and not any_ok:
|
|
batch.status = "failed"
|
|
batch.message = "all commands failed"
|
|
else:
|
|
batch.status = "success"
|
|
db.commit()
|
|
if batch.status in ("success", "partial"):
|
|
try:
|
|
from .compare_service import try_auto_compare_for_task
|
|
|
|
try_auto_compare_for_task(db, task_id, batch_id)
|
|
except Exception:
|
|
_log.exception("biz_state auto compare hook failed task=%s", task_id)
|
|
|
|
_purge_old_batches(db, task_id=task_id, keep=retention)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _purge_old_batches(db, *, task_id: str, keep: int) -> None:
|
|
keep_n = max(1, int(keep or 30))
|
|
rows = (
|
|
db.query(BizStateBatch)
|
|
.filter(BizStateBatch.task_id == task_id)
|
|
.order_by(BizStateBatch.started_at.desc())
|
|
.all()
|
|
)
|
|
drop = rows[keep_n:]
|
|
for b in drop:
|
|
bid = b.id
|
|
db.query(BizStateLldpNeighbor).filter(BizStateLldpNeighbor.batch_id == bid).delete()
|
|
db.query(BizStateVrfRouteSummary).filter(BizStateVrfRouteSummary.batch_id == bid).delete()
|
|
db.query(BizStateMetricRow).filter(BizStateMetricRow.batch_id == bid).delete()
|
|
db.query(BizStateBatchCommand).filter(BizStateBatchCommand.batch_id == bid).delete()
|
|
db.delete(b)
|
|
if drop:
|
|
db.commit()
|
|
|
|
|
|
def trigger_collect_now(task_id: str) -> dict[str, Any]:
|
|
db = SessionLocal()
|
|
try:
|
|
task = db.get(BizStateTask, task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="task not found")
|
|
if task.collect_running:
|
|
raise HTTPException(status_code=409, detail="collect already running")
|
|
finally:
|
|
db.close()
|
|
dispatch_collect(task_id)
|
|
return {"ok": True, "task_id": task_id}
|