mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
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>
662 lines
20 KiB
Python
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),
|
|
}
|