netx/netx_api/biz_state/import_runner.py
oliver d701e4a35e Add offline biz_state log import with unified create-task entry.
Create tasks via pick-NE or import log; re-import appends batches like collects, without inventory NE or SSH.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 07:36:11 +08:00

662 lines
20 KiB
Python

"""Offline log import → same biz_state batch / parse / persist path (no SSH)."""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from ..db import SessionLocal
from ..lldp_shared import resolve_vendor_key
from ..models import BizStateBatch, BizStateTask
from ..timeutil import utcnow_naive
from .collect_runner import (
_finish_task,
_finalize_batch_status,
_format_error,
_run_primary_parse_job,
)
from .collect_session import resolve_aux_command
from .command_match import match_command, normalize_command
from .log_split import LogSegment, unpack_upload
from .parse_pool import AuxRawCapture, PrimaryParseJob
from .parsers import get_parser
from .persist_pool import get_persist_pool
from .spool import (
SpooledCommand,
clear_batch_spool,
count_text_lines,
persist_every_cmds,
write_meta,
write_raw_text,
)
_log = logging.getLogger("netx.biz_state.import")
def _utcnow():
return utcnow_naive()
def _vendor_fields_for_key(vendor_key: str) -> tuple[str, str]:
"""Map vendor_key → (vendor label, device_type) for offline import tasks."""
key = str(vendor_key or "").strip().lower() or "zte"
if key.startswith("huawei") or key in ("vrp", "ce", "ne"):
return "Huawei", "huawei_vrp"
if key.startswith("cisco") or key in ("ios", "nxos", "iosxe", "iosxr"):
return "Cisco", "cisco_ios"
if key.startswith("zte") or key in ("zxros", "zxr10"):
return "ZTE", "zte_zxros"
if key in ("generic", "any", "*"):
return "generic", ""
return key[:64] or "generic", ""
def create_standalone_import_task(
*,
vendor_key: str,
ne_name: str = "",
note: str = "",
filename: str = "",
) -> dict[str, Any]:
"""Create a paused offline task (source=import) with no inventory NE."""
vk = str(vendor_key or "").strip().lower() or "zte"
vendor, device_type = _vendor_fields_for_key(vk)
fname = Path(str(filename or "")).name[:120]
label = str(ne_name or "").strip() or (fname and f"import:{fname}") or "offline-import"
tid = uuid4().hex
ne_id = f"import-{tid[:12]}"
now = _utcnow()
db = SessionLocal()
try:
task = BizStateTask(
id=tid,
source="import",
ne_id=ne_id,
ne_name=label[:256],
ne_ip="",
vendor=vendor,
device_type=device_type,
note=str(note or "")[:256],
purpose="",
status="paused", # offline only — no schedule / SSH
interval_sec=3600,
retention_days=30,
daily_keep_enabled=False,
daily_keep_count=10,
retention_batches=30,
created_at=now,
updated_at=now,
)
db.add(task)
db.commit()
return {
"ok": True,
"task_id": tid,
"ne_id": ne_id,
"ne_name": task.ne_name,
"vendor": vendor,
"device_type": device_type,
"vendor_key": vk,
}
except Exception:
_log.exception("create_standalone_import_task failed")
try:
db.rollback()
except Exception:
pass
raise
finally:
db.close()
def enqueue_import(
task_id: str,
*,
filename: str = "",
vendor_key: str = "",
) -> dict[str, Any]:
"""Create a running import batch; mark task collect_running (mutex with SSH collect)."""
tid = str(task_id or "").strip()
if not tid:
return {"ok": False, "queued": False, "reason": "missing_task_id", "task_id": ""}
db = SessionLocal()
try:
task = db.get(BizStateTask, tid)
if not task:
return {"ok": False, "queued": False, "reason": "task_not_found", "task_id": tid}
if bool(task.collect_running):
return {
"ok": True,
"queued": False,
"reason": "already_collecting",
"task_id": tid,
}
st = str(task.status or "").strip()
if st in ("", "deleted"):
return {"ok": False, "queued": False, "reason": "bad_status", "task_id": tid}
now = _utcnow()
task.collect_running = True
task.last_collect_started_at = now
task.last_error = ""
task.updated_at = now
if hasattr(task, "collect_queued_at"):
task.collect_queued_at = now
fname = Path(str(filename or "import.log")).name[:120] or "import.log"
vk = str(vendor_key or "").strip().lower()
if not vk:
vk = resolve_vendor_key(task.vendor or "", task.device_type or "")
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=now,
message=f"importing:{fname}",
alias=fname[:128],
)
db.add(batch)
db.commit()
return {
"ok": True,
"queued": True,
"batch_id": batch.id,
"task_id": tid,
"vendor_key": vk,
"filename": fname,
"manual": True,
"import": True,
}
except Exception:
_log.exception("enqueue_import failed task=%s", tid)
try:
db.rollback()
except Exception:
pass
return {"ok": False, "queued": False, "reason": "enqueue_failed", "task_id": tid}
finally:
db.close()
def _segment_index(segments: list[LogSegment]) -> dict[str, LogSegment]:
"""Map normalize_command(cmd) → first matching segment."""
idx: dict[str, LogSegment] = {}
for seg in segments:
key = normalize_command(seg.command)
if key and key not in idx:
idx[key] = seg
return idx
def _build_aux_captures(
*,
profile: Any,
params: dict[str, str],
batch_id: str,
seg_index: dict[str, LogSegment],
) -> tuple[list[AuxRawCapture], list[str]]:
"""Try to satisfy aux_commands from segments already in the import log."""
captures: list[AuxRawCapture] = []
notes: list[str] = []
for aux in list(getattr(profile, "aux_commands", None) or []):
try:
ra = resolve_aux_command(aux, params)
except Exception as exc:
notes.append(f"aux:{getattr(aux, 'key', '?')}:resolve:{exc}")
continue
hit = seg_index.get(normalize_command(ra.command))
if hit is None:
notes.append(f"enrich_skipped:missing_aux:{ra.key}")
continue
aux_id = uuid4().hex
raw_rel = ""
try:
raw_rel = write_raw_text(batch_id, aux_id, hit.body)
except Exception:
_log.exception("import aux spool raw failed")
captures.append(
AuxRawCapture(
key=ra.key,
aux_id=aux_id,
profile_id=ra.profile_id,
parser_id=ra.parser_id,
metric_id=str(getattr(ra.profile, "metric_id", "") or ""),
command=ra.command,
textfsm_command=ra.textfsm_command or ra.command,
rule_keys=tuple(ra.rule_keys or ()),
raw=hit.body,
raw_rel_path=raw_rel,
cache_hit=False,
ok=True,
)
)
return captures, notes
def run_import_batch(
*,
batch_id: str,
task_id: str,
segments: list[LogSegment],
vendor_key: str,
vendor: str = "",
device_type: str = "",
filename: str = "",
) -> dict[str, Any]:
"""Parse imported segments into the batch (sync parse + persist pool)."""
bid = str(batch_id or "").strip()
tid = str(task_id or "").strip()
vk = str(vendor_key or "").strip().lower() or "zte"
any_ok = False
any_fail = False
matched = 0
unmatched = 0
cmd_count = 0
lane_errors: list[str] = []
try:
clear_batch_spool(bid)
except Exception:
_log.exception("import clear spool failed batch=%s", bid)
persist = get_persist_pool()
pending: list[SpooledCommand] = []
flush_every = persist_every_cmds()
seg_index = _segment_index(segments)
persisted: set[tuple[str, str]] = set()
cache_lock = threading.RLock()
def _submit_pending() -> None:
nonlocal pending
if not pending:
return
chunk = list(pending)
pending = []
persist.submit(bid, chunk)
def _queue(item: SpooledCommand) -> None:
nonlocal cmd_count
try:
write_meta(bid, item.id, item.to_meta())
except Exception:
_log.exception("import write meta failed cmd=%s", item.id)
pending.append(item)
cmd_count += 1
if len(pending) >= flush_every:
_submit_pending()
for seg in segments:
cmd = normalize_command(seg.command)
cmd_id = uuid4().hex
src_note = ""
if seg.source_file:
src_note = f"file={seg.source_file}"
hit = match_command(vendor_key=vk, command=cmd)
raw_rel = ""
try:
raw_rel = write_raw_text(bid, cmd_id, seg.body)
except Exception:
_log.exception("import spool raw failed cmd=%s", cmd_id)
if not hit:
unmatched += 1
any_fail = True
msg = "no profile matched concrete command"
if src_note:
msg = f"{msg};{src_note}"
_queue(
SpooledCommand(
id=cmd_id,
batch_id=bid,
profile_id="",
raw_command=cmd[:512],
parse_status="unmatched",
message=msg[:1020],
raw_rel_path=raw_rel,
raw_line_count=count_text_lines(seg.body),
)
)
continue
if not get_parser(hit.profile.parser_id):
unmatched += 1
any_fail = True
_queue(
SpooledCommand(
id=cmd_id,
batch_id=bid,
profile_id=hit.profile.profile_id,
parser_id=hit.profile.parser_id,
metric_id=hit.profile.metric_id,
raw_command=cmd[:512],
params_json=dict(hit.params or {}),
parse_status="failed",
message=f"unknown parser {hit.profile.parser_id}"[:1020],
raw_rel_path=raw_rel,
raw_line_count=count_text_lines(seg.body),
)
)
continue
matched += 1
aux_caps, aux_notes = _build_aux_captures(
profile=hit.profile,
params=dict(hit.params or {}),
batch_id=bid,
seg_index=seg_index,
)
enrich = list(hit.profile.enrich_joins or [])
# Without in-log aux, skip enrich so we don't pretend peer intent joined.
if any("enrich_skipped" in n for n in aux_notes):
enrich = []
job = PrimaryParseJob(
batch_id=bid,
cmd_id=cmd_id,
task_item_id="",
profile_id=hit.profile.profile_id,
parser_id=hit.profile.parser_id,
metric_id=hit.profile.metric_id,
concrete=cmd,
merged_params=dict(hit.params or {}),
raw_text=seg.body,
raw_rel_path=raw_rel,
raw_line_count=count_text_lines(seg.body),
textfsm_command=hit.profile.textfsm_command or cmd,
vendor=vendor,
device_type=device_type,
enrich_joins=enrich,
aux_captures=aux_caps,
persisted=persisted,
cache_lock=cache_lock,
on_done=None,
)
try:
ok, fail = _run_primary_parse_job(job)
if ok:
any_ok = True
if fail:
any_fail = True
if aux_notes:
_log.info(
"import enrich notes batch=%s cmd=%s %s",
bid,
cmd_id,
";".join(aux_notes),
)
cmd_count += 1 + len(aux_caps)
except Exception as exc:
any_fail = True
_queue(
SpooledCommand(
id=cmd_id,
batch_id=bid,
profile_id=hit.profile.profile_id,
parser_id=hit.profile.parser_id,
metric_id=hit.profile.metric_id,
raw_command=cmd[:512],
params_json=dict(hit.params or {}),
parse_status="failed",
message=f"parse: {_format_error(exc)}"[:1020],
raw_rel_path=raw_rel,
raw_line_count=count_text_lines(seg.body),
)
)
_submit_pending()
# _run_primary_parse_job also submits to this same pool — wait once for all.
if not persist.wait_idle(timeout=3600.0):
any_fail = True
lane_errors.append("import: persist_barrier_timeout")
fname = Path(str(filename or "")).name
summary = (
f"imported:{fname or 'upload'}; segments={len(segments)}; "
f"matched={matched}; unmatched={unmatched}"
)
if fname:
lane_errors.insert(0, summary)
status = ""
try:
status = _finalize_batch_status(
batch_id=bid,
task_id=tid,
cmd_count=cmd_count,
total_rows=0,
any_fail=any_fail,
any_ok=any_ok or matched > 0,
lane_errors=lane_errors,
)
except Exception as exc:
_log.exception("import finalize failed batch=%s", bid)
lane_errors.append(_format_error(exc))
try:
from .collect_runner import _fail_batch_status
_fail_batch_status(bid, "; ".join(lane_errors)[:1020])
except Exception:
pass
status = "failed"
# Full success clears message in finalize — keep import provenance visible.
if status == "success":
try:
_stamp_import_message(bid, summary)
except Exception:
_log.exception("import stamp message failed batch=%s", bid)
err = ""
if status in ("failed",) and not any_ok:
err = "; ".join(lane_errors)[:1020] or "import failed"
try:
_finish_task(tid, error=err)
except Exception:
_log.exception("import finish task failed task=%s", tid)
try:
clear_batch_spool(bid)
except Exception:
pass
return {
"ok": status in ("success", "partial"),
"batch_id": bid,
"task_id": tid,
"status": status,
"segments": len(segments),
"matched": matched,
"unmatched": unmatched,
"command_count": cmd_count,
}
def _stamp_import_message(batch_id: str, summary: str) -> None:
db = SessionLocal()
try:
batch = db.get(BizStateBatch, batch_id)
if not batch:
return
if not str(batch.message or "").strip():
batch.message = str(summary or "")[:1020]
db.commit()
finally:
db.close()
def start_standalone_import(
*,
filename: str,
data: bytes,
vendor_key: str = "",
ne_name: str = "",
note: str = "",
) -> dict[str, Any]:
"""Create offline task + unpack + enqueue import (no inventory NE)."""
vk = str(vendor_key or "").strip().lower() or "zte"
try:
segments, stats = unpack_upload(
filename=filename, data=data, vendor_key=vk
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not segments:
raise HTTPException(
status_code=400,
detail="no show/display command segments found in upload",
)
created = create_standalone_import_task(
vendor_key=vk,
ne_name=ne_name,
note=note,
filename=filename,
)
tid = str(created["task_id"])
vendor = str(created.get("vendor") or "")
device_type = str(created.get("device_type") or "")
result = enqueue_import(tid, filename=filename, vendor_key=vk)
if not result.get("queued"):
# Best-effort: leave the empty task for the user to retry / delete.
return {
"ok": bool(result.get("ok", False)),
"started": False,
"queued": False,
"reason": result.get("reason") or "enqueue_failed",
"task_id": tid,
"created_task": True,
**stats,
}
return {
"ok": True,
"started": True,
"queued": True,
"batch_id": result["batch_id"],
"task_id": tid,
"vendor_key": vk,
"filename": result.get("filename") or filename,
"vendor": vendor,
"device_type": device_type,
"ne_name": created.get("ne_name") or "",
"created_task": True,
"segments_preview": stats,
"segments": segments,
"collect_running": True,
}
def start_import_from_upload(
*,
task_id: str,
filename: str,
data: bytes,
vendor_key: str = "",
) -> dict[str, Any]:
"""Enqueue + unpack; caller should run ``execute_import`` in background."""
tid = str(task_id or "").strip()
db = SessionLocal()
try:
task = db.get(BizStateTask, tid)
if not task:
raise HTTPException(status_code=404, detail="task not found")
vendor = str(task.vendor or "")
device_type = str(task.device_type or "")
vk = str(vendor_key or "").strip().lower()
if not vk:
vk = resolve_vendor_key(vendor, device_type)
finally:
db.close()
try:
segments, stats = unpack_upload(
filename=filename, data=data, vendor_key=vk
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not segments:
raise HTTPException(
status_code=400,
detail="no show/display command segments found in upload",
)
result = enqueue_import(tid, filename=filename, vendor_key=vk)
if not result.get("queued"):
return {
"ok": bool(result.get("ok", False)),
"started": False,
"queued": False,
"reason": result.get("reason") or "enqueue_failed",
"task_id": tid,
**stats,
}
return {
"ok": True,
"started": True,
"queued": True,
"batch_id": result["batch_id"],
"task_id": tid,
"vendor_key": vk,
"filename": result.get("filename") or filename,
"vendor": vendor,
"device_type": device_type,
"segments_preview": stats,
"segments": segments, # passed to background runner (in-memory)
"collect_running": True,
}
def execute_import(
*,
batch_id: str,
task_id: str,
segments: list[LogSegment],
vendor_key: str,
vendor: str = "",
device_type: str = "",
filename: str = "",
) -> dict[str, Any]:
"""Background entry: parse imported segments."""
try:
return run_import_batch(
batch_id=batch_id,
task_id=task_id,
segments=segments,
vendor_key=vendor_key,
vendor=vendor,
device_type=device_type,
filename=filename,
)
except Exception as exc:
_log.exception("execute_import failed batch=%s", batch_id)
try:
from .collect_runner import _fail_batch_status
_fail_batch_status(batch_id, _format_error(exc))
except Exception:
pass
try:
_finish_task(task_id, error=_format_error(exc))
except Exception:
pass
return {
"ok": False,
"batch_id": batch_id,
"task_id": task_id,
"status": "failed",
"error": _format_error(exc),
}