mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Add industrial config sync separate from collection tasks.
Periodic vendor-aware CLI sync stores zlib snapshots in Postgres with dashboard, retry, and config viewer under Network Management. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2fc47e5135
commit
0eead0e662
23 changed files with 2993 additions and 9 deletions
|
|
@ -72,6 +72,9 @@ class Settings(BaseSettings):
|
|||
ne_collect_pending_stale_sec: int = 180
|
||||
ne_collect_run_timeout_cap_sec: int = 600
|
||||
ne_collection_data_dir: str = "data/ne_collections"
|
||||
# Config sync (periodic running-config backup into DB)
|
||||
config_sync_scheduler_enabled: bool = True
|
||||
config_sync_scheduler_tick_sec: int = 60
|
||||
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
||||
ne_exec_max_commands: int = 5
|
||||
# WebCRT interactive terminal sessions
|
||||
|
|
|
|||
20
netx_api/config_sync_codec.py
Normal file
20
netx_api/config_sync_codec.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""zlib + sha256 helpers for config blobs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import zlib
|
||||
|
||||
|
||||
def compress_text(text: str) -> tuple[bytes, str, int, int]:
|
||||
"""Return (zlib_bytes, sha256_hex, plain_size, zlib_size)."""
|
||||
raw = str(text or "").encode("utf-8", errors="replace")
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
blob = zlib.compress(raw, level=6)
|
||||
return blob, digest, len(raw), len(blob)
|
||||
|
||||
|
||||
def decompress_text(blob: bytes | None) -> str:
|
||||
if not blob:
|
||||
return ""
|
||||
return zlib.decompress(bytes(blob)).decode("utf-8", errors="replace")
|
||||
59
netx_api/config_sync_commands.py
Normal file
59
netx_api/config_sync_commands.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Vendor → config-collection CLI command matrix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigCommands:
|
||||
"""primary → config_zlib; alt → config_alt_zlib (Juniper only)."""
|
||||
|
||||
primary: str
|
||||
alt: str | None = None
|
||||
vendor_key: str = "other"
|
||||
|
||||
|
||||
def normalize_vendor_key(vendor: str, device_type: str = "") -> str:
|
||||
blob = f"{vendor} {device_type}".strip().lower()
|
||||
if "juniper" in blob or "junos" in blob:
|
||||
return "juniper"
|
||||
if "nokia" in blob or "alcatel" in blob or "sros" in blob or "tiomos" in blob:
|
||||
return "nokia"
|
||||
if "ericsson" in blob:
|
||||
return "ericsson"
|
||||
if "huawei" in blob or "vrp" in blob:
|
||||
return "huawei"
|
||||
if "h3c" in blob or "comware" in blob:
|
||||
return "h3c"
|
||||
if "zte" in blob or "zxros" in blob:
|
||||
return "zte"
|
||||
if "cisco" in blob or "ios" in blob or "nx-os" in blob or "xr" in blob:
|
||||
return "cisco"
|
||||
return "other"
|
||||
|
||||
|
||||
def commands_for_vendor(vendor: str, device_type: str = "") -> ConfigCommands | None:
|
||||
key = normalize_vendor_key(vendor, device_type)
|
||||
if key in ("cisco", "zte"):
|
||||
return ConfigCommands(primary="show running-config", vendor_key=key)
|
||||
if key in ("huawei", "h3c"):
|
||||
return ConfigCommands(primary="display current-configuration", vendor_key=key)
|
||||
if key == "juniper":
|
||||
return ConfigCommands(
|
||||
primary="show configuration | display set",
|
||||
alt="show configuration | no-more",
|
||||
vendor_key=key,
|
||||
)
|
||||
if key == "nokia":
|
||||
return ConfigCommands(primary="admin display-config", vendor_key=key)
|
||||
if key == "ericsson":
|
||||
return ConfigCommands(primary="show configuration", vendor_key=key)
|
||||
return None
|
||||
|
||||
|
||||
def command_list(cmds: ConfigCommands) -> list[str]:
|
||||
out = [cmds.primary]
|
||||
if cmds.alt:
|
||||
out.append(cmds.alt)
|
||||
return out
|
||||
68
netx_api/config_sync_recovery.py
Normal file
68
netx_api/config_sync_recovery.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Startup recovery for interrupted config-sync cycles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config_sync_runner import dispatch_cycle
|
||||
from .config_sync_service import finalize_cycle, sync_cycle_progress
|
||||
from .models import ConfigSyncCycle, ConfigSyncTask
|
||||
from datetime import datetime
|
||||
|
||||
_log = logging.getLogger("netx.config_sync.recovery")
|
||||
|
||||
|
||||
def recover_config_sync_on_startup(db: Session) -> int:
|
||||
"""
|
||||
Mark orphaned running tasks as fail(orphan_recovered), then resume pending
|
||||
tasks for cycles still marked running/paused.
|
||||
"""
|
||||
cycles = (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status.in_(("running", "paused", "pending")))
|
||||
.all()
|
||||
)
|
||||
resumed = 0
|
||||
for cycle in cycles:
|
||||
cycle_id = str(cycle.id)
|
||||
orphans = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "running")
|
||||
.all()
|
||||
)
|
||||
for task in orphans:
|
||||
task.status = "fail"
|
||||
task.message = "orphan_recovered"
|
||||
task.ended_at = datetime.utcnow()
|
||||
if orphans:
|
||||
db.commit()
|
||||
_log.info("config_sync recovery cycle=%s orphaned_tasks=%s", cycle_id, len(orphans))
|
||||
|
||||
sync_cycle_progress(db, cycle_id)
|
||||
db.refresh(cycle)
|
||||
|
||||
if str(cycle.status) == "paused":
|
||||
continue
|
||||
|
||||
pending = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "pending")
|
||||
.count()
|
||||
)
|
||||
if pending <= 0:
|
||||
if str(cycle.status) in ("running", "pending"):
|
||||
finalize_cycle(db, cycle_id)
|
||||
continue
|
||||
|
||||
if str(cycle.status) == "pending":
|
||||
cycle.status = "running"
|
||||
if not cycle.started_at:
|
||||
cycle.started_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
n = dispatch_cycle(cycle_id)
|
||||
resumed += n
|
||||
_log.info("config_sync recovery resumed cycle=%s pending=%s", cycle_id, n)
|
||||
return resumed
|
||||
168
netx_api/config_sync_router.py
Normal file
168
netx_api/config_sync_router.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""HTTP API for config sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_service import write_audit
|
||||
from .config_sync_runner import dispatch_cycle
|
||||
from .config_sync_schemas import ConfigSyncCycleCreate, ConfigSyncPolicyUpdate
|
||||
from .config_sync_service import (
|
||||
create_cycle,
|
||||
dashboard,
|
||||
get_cycle,
|
||||
get_policy,
|
||||
get_snapshot_detail,
|
||||
list_cycle_tasks,
|
||||
list_cycles,
|
||||
list_snapshot_history,
|
||||
list_snapshots,
|
||||
pause_cycle,
|
||||
resume_cycle,
|
||||
update_policy,
|
||||
)
|
||||
from .db import get_db
|
||||
|
||||
router = APIRouter(prefix="/v1/config-sync", tags=["config-sync"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> tuple[str, str]:
|
||||
user = getattr(request.state, "auth_user", None)
|
||||
if not user:
|
||||
return "", ""
|
||||
return str(getattr(user, "id", "") or ""), str(getattr(user, "username", "") or "")
|
||||
|
||||
|
||||
@router.get("/policy")
|
||||
def api_get_policy(db: Session = Depends(get_db)):
|
||||
return get_policy(db).model_dump()
|
||||
|
||||
|
||||
@router.put("/policy")
|
||||
def api_put_policy(
|
||||
body: ConfigSyncPolicyUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out = update_policy(db, body)
|
||||
uid, uname = _actor(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="config_sync.policy.update",
|
||||
actor_user_id=uid,
|
||||
actor_username=uname,
|
||||
method="PUT",
|
||||
path="/v1/config-sync/policy",
|
||||
status_code=200,
|
||||
detail=body.model_dump(exclude_unset=True),
|
||||
)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def api_dashboard(db: Session = Depends(get_db)):
|
||||
return dashboard(db).model_dump()
|
||||
|
||||
|
||||
@router.get("/cycles")
|
||||
def api_list_cycles(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_cycles(db, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.post("/cycles")
|
||||
def api_create_cycle(
|
||||
body: ConfigSyncCycleCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out = create_cycle(db, body)
|
||||
background_tasks.add_task(dispatch_cycle, out.id)
|
||||
uid, uname = _actor(request)
|
||||
action = "config_sync.retry_failed" if body.mode == "retry_failed" else "config_sync.start"
|
||||
write_audit(
|
||||
db,
|
||||
action=action,
|
||||
actor_user_id=uid,
|
||||
actor_username=uname,
|
||||
method="POST",
|
||||
path="/v1/config-sync/cycles",
|
||||
status_code=200,
|
||||
detail={"mode": body.mode, "cycle_id": out.id},
|
||||
)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.get("/cycles/{cycle_id}")
|
||||
def api_get_cycle(cycle_id: str, db: Session = Depends(get_db)):
|
||||
return get_cycle(db, cycle_id).model_dump()
|
||||
|
||||
|
||||
@router.get("/cycles/{cycle_id}/tasks")
|
||||
def api_list_cycle_tasks(
|
||||
cycle_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
status: str = Query(default=""),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_cycle_tasks(
|
||||
db, cycle_id, page=page, page_size=page_size, status=status, keyword=keyword
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cycles/{cycle_id}/pause")
|
||||
def api_pause_cycle(cycle_id: str, db: Session = Depends(get_db)):
|
||||
return pause_cycle(db, cycle_id).model_dump()
|
||||
|
||||
|
||||
@router.post("/cycles/{cycle_id}/resume")
|
||||
def api_resume_cycle(
|
||||
cycle_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out = resume_cycle(db, cycle_id)
|
||||
background_tasks.add_task(dispatch_cycle, out.id)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.get("/snapshots")
|
||||
def api_list_snapshots(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
keyword: str = Query(default=""),
|
||||
source: str = Query(default=""),
|
||||
vendor: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_snapshots(
|
||||
db, page=page, page_size=page_size, keyword=keyword, source=source, vendor=vendor
|
||||
)
|
||||
|
||||
|
||||
@router.get("/snapshots/{source}/{target_id}")
|
||||
def api_get_snapshot(
|
||||
source: str,
|
||||
target_id: str,
|
||||
field: str = Query(default="both"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return get_snapshot_detail(db, source, target_id, field=field).model_dump()
|
||||
|
||||
|
||||
@router.get("/snapshots/{source}/{target_id}/history")
|
||||
def api_snapshot_history(
|
||||
source: str,
|
||||
target_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_snapshot_history(db, source, target_id, page=page, page_size=page_size)
|
||||
429
netx_api/config_sync_runner.py
Normal file
429
netx_api/config_sync_runner.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
"""Config sync worker: claim tasks, collect vendor configs, store zlib snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .cli_resolve import resolve_cli_target
|
||||
from .config import settings
|
||||
from .config_sync_codec import compress_text
|
||||
from .config_sync_commands import command_list, commands_for_vendor
|
||||
from .config_sync_service import finalize_cycle, sync_cycle_progress
|
||||
from .db import SessionLocal
|
||||
from .models import ConfigSyncCycle, ConfigSyncPolicy, ConfigSyncTask, NeConfigHistory, NeConfigSnapshot
|
||||
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
|
||||
|
||||
_log = logging.getLogger("netx.config_sync.runner")
|
||||
_pools: dict[str, ThreadPoolExecutor] = {}
|
||||
_pools_lock = Lock()
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def _format_error(exc: BaseException) -> str:
|
||||
head = f"{type(exc).__name__}: {exc}"
|
||||
return head[:1020]
|
||||
|
||||
|
||||
def _pool_for_cycle(cycle_id: str, concurrency: int) -> ThreadPoolExecutor:
|
||||
with _pools_lock:
|
||||
pool = _pools.get(cycle_id)
|
||||
if pool is None:
|
||||
workers = max(1, min(30, int(concurrency or 5)))
|
||||
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix=f"cfg-sync-{cycle_id[:8]}")
|
||||
_pools[cycle_id] = pool
|
||||
return pool
|
||||
|
||||
|
||||
def _release_pool(cycle_id: str) -> None:
|
||||
with _pools_lock:
|
||||
pool = _pools.pop(cycle_id, None)
|
||||
if pool is not None:
|
||||
try:
|
||||
pool.shutdown(wait=False, cancel_futures=False)
|
||||
except TypeError:
|
||||
pool.shutdown(wait=False)
|
||||
except Exception:
|
||||
_log.exception("config_sync pool shutdown failed cycle=%s", cycle_id)
|
||||
|
||||
|
||||
def _update_task(task_id: str, **fields: Any) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(ConfigSyncTask, task_id)
|
||||
if not row:
|
||||
return
|
||||
for key, val in fields.items():
|
||||
setattr(row, key, val)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _update_task(task_id: str, **fields: Any) -> None:
|
||||
for attempt in range(10):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.get(ConfigSyncTask, task_id)
|
||||
cycle = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not task or not cycle:
|
||||
time.sleep(0.05 * (attempt + 1))
|
||||
continue
|
||||
cycle_status = str(cycle.status or "")
|
||||
task_status = str(task.status or "")
|
||||
if cycle_status == "paused":
|
||||
return False
|
||||
if cycle_status != "running":
|
||||
return False
|
||||
if task_status == "running":
|
||||
return False
|
||||
if task_status != "pending":
|
||||
return False
|
||||
task.status = "running"
|
||||
task.message = "collecting"
|
||||
task.started_at = _utcnow()
|
||||
db.commit()
|
||||
return True
|
||||
finally:
|
||||
db.close()
|
||||
time.sleep(0.05 * (attempt + 1))
|
||||
return False
|
||||
|
||||
|
||||
def _collect_commands(creds: dict[str, Any], commands: list[str]) -> list[str]:
|
||||
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
|
||||
session_timeout = per_cmd * max(1, len(commands)) + 60
|
||||
conn = open_netmiko_connection(creds, session_timeout=session_timeout)
|
||||
try:
|
||||
outputs: list[str] = []
|
||||
for command in commands:
|
||||
out = conn.send_command(command_string=command, read_timeout=per_cmd)
|
||||
outputs.append(str(out or ""))
|
||||
return outputs
|
||||
finally:
|
||||
close_netmiko_connection(conn)
|
||||
|
||||
|
||||
def _collect_with_timeout(creds: dict[str, Any], commands: list[str]) -> list[str]:
|
||||
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
|
||||
cap = int(settings.ne_collect_run_timeout_cap_sec or 600)
|
||||
budget = min(cap, per_cmd * max(1, len(commands)) + 90)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
fut = pool.submit(_collect_commands, creds, commands)
|
||||
try:
|
||||
return fut.result(timeout=budget)
|
||||
except FuturesTimeout as exc:
|
||||
raise TimeoutError(f"config_sync_timeout ({budget}s)") from exc
|
||||
|
||||
|
||||
def _history_keep(db) -> int:
|
||||
pol = db.get(ConfigSyncPolicy, 1)
|
||||
if not pol:
|
||||
return 3
|
||||
return max(0, min(30, int(pol.history_keep if pol.history_keep is not None else 3)))
|
||||
|
||||
|
||||
def _save_success_snapshot(
|
||||
*,
|
||||
source: str,
|
||||
target_id: str,
|
||||
vendor: str,
|
||||
device_type: str,
|
||||
ne_name: str,
|
||||
ne_ip: str,
|
||||
primary_text: str,
|
||||
alt_text: str | None,
|
||||
commands: list[str],
|
||||
cycle_id: str,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
primary_blob, primary_sha, plain_size, zlib_size = compress_text(primary_text)
|
||||
alt_blob = None
|
||||
alt_sha = ""
|
||||
plain_alt = 0
|
||||
zlib_alt = 0
|
||||
if alt_text is not None:
|
||||
alt_blob, alt_sha, plain_alt, zlib_alt = compress_text(alt_text)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.get(NeConfigSnapshot, {"source": source, "target_id": target_id})
|
||||
changed = True
|
||||
if existing is not None:
|
||||
changed = (
|
||||
str(existing.config_sha256 or "") != primary_sha
|
||||
or str(existing.config_alt_sha256 or "") != alt_sha
|
||||
)
|
||||
if changed:
|
||||
keep = _history_keep(db)
|
||||
if keep > 0:
|
||||
# Archive previous successful snapshot before overwrite.
|
||||
db.add(
|
||||
NeConfigHistory(
|
||||
id=uuid4().hex,
|
||||
source=existing.source,
|
||||
target_id=existing.target_id,
|
||||
vendor=str(existing.vendor or ""),
|
||||
device_type=str(existing.device_type or ""),
|
||||
ne_name=str(existing.ne_name or ""),
|
||||
ne_ip=str(existing.ne_ip or ""),
|
||||
config_zlib=existing.config_zlib or b"",
|
||||
config_alt_zlib=existing.config_alt_zlib,
|
||||
config_sha256=str(existing.config_sha256 or ""),
|
||||
config_alt_sha256=str(existing.config_alt_sha256 or ""),
|
||||
plain_size=int(existing.plain_size or 0),
|
||||
plain_alt_size=int(existing.plain_alt_size or 0),
|
||||
zlib_size=int(existing.zlib_size or 0),
|
||||
zlib_alt_size=int(existing.zlib_alt_size or 0),
|
||||
commands_json=existing.commands_json if isinstance(existing.commands_json, list) else [],
|
||||
collected_at=existing.collected_at or _utcnow(),
|
||||
cycle_id=str(existing.last_cycle_id or ""),
|
||||
task_id=str(existing.last_task_id or ""),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
old_rows = (
|
||||
db.query(NeConfigHistory)
|
||||
.filter(NeConfigHistory.source == source, NeConfigHistory.target_id == target_id)
|
||||
.order_by(NeConfigHistory.collected_at.desc())
|
||||
.all()
|
||||
)
|
||||
for stale in old_rows[keep:]:
|
||||
db.delete(stale)
|
||||
|
||||
existing.vendor = vendor
|
||||
existing.device_type = device_type
|
||||
existing.ne_name = ne_name
|
||||
existing.ne_ip = ne_ip
|
||||
existing.config_zlib = primary_blob
|
||||
existing.config_alt_zlib = alt_blob
|
||||
existing.config_sha256 = primary_sha
|
||||
existing.config_alt_sha256 = alt_sha
|
||||
existing.plain_size = plain_size
|
||||
existing.plain_alt_size = plain_alt
|
||||
existing.zlib_size = zlib_size
|
||||
existing.zlib_alt_size = zlib_alt
|
||||
existing.commands_json = list(commands)
|
||||
existing.collected_at = _utcnow()
|
||||
existing.last_cycle_id = cycle_id
|
||||
existing.last_task_id = task_id
|
||||
else:
|
||||
db.add(
|
||||
NeConfigSnapshot(
|
||||
source=source,
|
||||
target_id=target_id,
|
||||
vendor=vendor,
|
||||
device_type=device_type,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
config_zlib=primary_blob,
|
||||
config_alt_zlib=alt_blob,
|
||||
config_sha256=primary_sha,
|
||||
config_alt_sha256=alt_sha,
|
||||
plain_size=plain_size,
|
||||
plain_alt_size=plain_alt,
|
||||
zlib_size=zlib_size,
|
||||
zlib_alt_size=zlib_alt,
|
||||
commands_json=list(commands),
|
||||
collected_at=_utcnow(),
|
||||
last_cycle_id=cycle_id,
|
||||
last_task_id=task_id,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _run_single(cycle_id: str, task_id: str) -> None:
|
||||
if not _claim_task(cycle_id, task_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sync_cycle_progress(db, cycle_id)
|
||||
finalize_cycle(db, cycle_id)
|
||||
finally:
|
||||
db.close()
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.get(ConfigSyncTask, task_id)
|
||||
if not task:
|
||||
return
|
||||
source = str(task.source or "").strip().lower()
|
||||
target_id = str(task.target_id or "").strip()
|
||||
vendor_hint = str(task.vendor or "")
|
||||
try:
|
||||
if source == "managed":
|
||||
creds, device = resolve_cli_target(db, managed_ne_id=target_id)
|
||||
elif source == "ume":
|
||||
creds, device = resolve_cli_target(db, ume_ne_id=target_id)
|
||||
else:
|
||||
_update_task(task_id, status="fail", message="invalid_source", ended_at=_utcnow())
|
||||
return
|
||||
except HTTPException as exc:
|
||||
detail = str(exc.detail) if exc.detail else "resolve_failed"
|
||||
_update_task(task_id, status="fail", message=detail[:1020], ended_at=_utcnow())
|
||||
return
|
||||
except Exception as exc:
|
||||
_update_task(task_id, status="fail", message=_format_error(exc), ended_at=_utcnow())
|
||||
return
|
||||
|
||||
vendor = str(device.get("vendor") or vendor_hint or "")
|
||||
device_type = str(device.get("device_type") or "")
|
||||
ne_name = str(device.get("name") or task.ne_name or "")
|
||||
ne_ip = str(device.get("ip_address") or task.ne_ip or "")
|
||||
cmds = commands_for_vendor(vendor, device_type)
|
||||
if cmds is None:
|
||||
_update_task(
|
||||
task_id,
|
||||
status="fail",
|
||||
message="unsupported_vendor",
|
||||
vendor=vendor,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
return
|
||||
|
||||
cmd_names = command_list(cmds)
|
||||
try:
|
||||
outputs = _collect_with_timeout(creds, cmd_names)
|
||||
except Exception as exc:
|
||||
_log.warning("config_sync collect failed task=%s: %s", task_id, _format_error(exc))
|
||||
_update_task(
|
||||
task_id,
|
||||
status="fail",
|
||||
message=_format_error(exc),
|
||||
vendor=vendor,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
return
|
||||
|
||||
if not outputs or not str(outputs[0] or "").strip():
|
||||
_update_task(
|
||||
task_id,
|
||||
status="fail",
|
||||
message="empty_config_output",
|
||||
vendor=vendor,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
return
|
||||
|
||||
primary = outputs[0]
|
||||
alt = outputs[1] if cmds.alt and len(outputs) > 1 else None
|
||||
try:
|
||||
_save_success_snapshot(
|
||||
source=source,
|
||||
target_id=target_id,
|
||||
vendor=vendor,
|
||||
device_type=device_type,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
primary_text=primary,
|
||||
alt_text=alt,
|
||||
commands=cmd_names,
|
||||
cycle_id=cycle_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
_log.exception("config_sync snapshot save failed task=%s", task_id)
|
||||
_update_task(task_id, status="fail", message=_format_error(exc), ended_at=_utcnow())
|
||||
return
|
||||
|
||||
_update_task(
|
||||
task_id,
|
||||
status="success",
|
||||
message="synced",
|
||||
vendor=vendor,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
sync_cycle_progress(db2, cycle_id)
|
||||
finalize_cycle(db2, cycle_id)
|
||||
cycle = db2.get(ConfigSyncCycle, cycle_id)
|
||||
if cycle and str(cycle.status or "") in ("success", "fail", "cancelled"):
|
||||
_release_pool(cycle_id)
|
||||
finally:
|
||||
db2.close()
|
||||
|
||||
|
||||
def _run_safe(cycle_id: str, task_id: str) -> None:
|
||||
try:
|
||||
_run_single(cycle_id, task_id)
|
||||
except Exception:
|
||||
_log.exception("config_sync worker crashed cycle=%s task=%s", cycle_id, task_id)
|
||||
_update_task(
|
||||
task_id,
|
||||
status="fail",
|
||||
message="config_sync_worker_crashed",
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sync_cycle_progress(db, cycle_id)
|
||||
finalize_cycle(db, cycle_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def schedule_cycle_tasks(cycle_id: str, task_ids: list[str], concurrency: int) -> int:
|
||||
if not task_ids:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
finalize_cycle(db, cycle_id)
|
||||
finally:
|
||||
db.close()
|
||||
return 0
|
||||
pool = _pool_for_cycle(cycle_id, concurrency)
|
||||
submitted = 0
|
||||
for tid in task_ids:
|
||||
pool.submit(_run_safe, cycle_id, str(tid))
|
||||
submitted += 1
|
||||
_log.info("scheduled config_sync cycle=%s tasks=%s concurrency=%s", cycle_id, submitted, concurrency)
|
||||
return submitted
|
||||
|
||||
|
||||
def dispatch_cycle(cycle_id: str) -> int:
|
||||
"""Load pending tasks for a cycle and schedule workers."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cycle = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not cycle:
|
||||
return 0
|
||||
if str(cycle.status or "") not in ("running", "pending"):
|
||||
return 0
|
||||
if str(cycle.status or "") == "pending":
|
||||
cycle.status = "running"
|
||||
if not cycle.started_at:
|
||||
cycle.started_at = _utcnow()
|
||||
db.commit()
|
||||
pending = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "pending")
|
||||
.all()
|
||||
)
|
||||
task_ids = [str(t.id) for t in pending]
|
||||
concurrency = max(1, min(30, int(cycle.concurrency or 5)))
|
||||
finally:
|
||||
db.close()
|
||||
return schedule_cycle_tasks(cycle_id, task_ids, concurrency)
|
||||
113
netx_api/config_sync_scheduler.py
Normal file
113
netx_api/config_sync_scheduler.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Background scheduler for periodic config sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from .config import settings
|
||||
from .config_sync_runner import dispatch_cycle
|
||||
from .config_sync_service import (
|
||||
ensure_policy,
|
||||
expand_targets,
|
||||
has_running_cycle,
|
||||
next_due_at,
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .models import ConfigSyncCycle, ConfigSyncTask
|
||||
|
||||
_log = logging.getLogger("netx.config_sync.scheduler")
|
||||
_stop = threading.Event()
|
||||
_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def try_start_scheduled_cycle() -> str | None:
|
||||
"""Create and dispatch a scheduled cycle if policy is due. Returns cycle id or None."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
policy = ensure_policy(db)
|
||||
if not policy.enabled:
|
||||
return None
|
||||
if has_running_cycle(db):
|
||||
return None
|
||||
due = next_due_at(db, policy)
|
||||
if due is not None and due > _utcnow():
|
||||
return None
|
||||
targets = expand_targets(db, policy)
|
||||
if not targets:
|
||||
_log.info("config_sync schedule skip: no targets")
|
||||
return None
|
||||
concurrency = max(1, min(30, int(policy.concurrency or 5)))
|
||||
cycle = ConfigSyncCycle(
|
||||
id=uuid4().hex,
|
||||
trigger_mode="schedule",
|
||||
status="running",
|
||||
concurrency=concurrency,
|
||||
planned_count=len(targets),
|
||||
started_at=_utcnow(),
|
||||
created_at=_utcnow(),
|
||||
)
|
||||
db.add(cycle)
|
||||
db.flush()
|
||||
for t in targets:
|
||||
db.add(
|
||||
ConfigSyncTask(
|
||||
id=uuid4().hex,
|
||||
cycle_id=cycle.id,
|
||||
source=t["source"],
|
||||
target_id=t["id"],
|
||||
ne_name=t.get("ne_name") or "",
|
||||
ne_ip=t.get("ne_ip") or "",
|
||||
vendor=t.get("vendor") or "",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
cycle_id = str(cycle.id)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
_log.exception("config_sync schedule create failed")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
dispatch_cycle(cycle_id)
|
||||
_log.info("config_sync scheduled cycle started id=%s", cycle_id)
|
||||
return cycle_id
|
||||
|
||||
|
||||
def _loop() -> None:
|
||||
tick = max(15, int(settings.config_sync_scheduler_tick_sec or 60))
|
||||
_log.info("config_sync scheduler started tick=%ss", tick)
|
||||
while not _stop.is_set():
|
||||
try:
|
||||
if bool(settings.config_sync_scheduler_enabled):
|
||||
try_start_scheduled_cycle()
|
||||
except Exception:
|
||||
_log.exception("config_sync scheduler tick failed")
|
||||
_stop.wait(tick)
|
||||
_log.info("config_sync scheduler stopped")
|
||||
|
||||
|
||||
def start_config_sync_scheduler() -> None:
|
||||
global _thread
|
||||
if not bool(settings.config_sync_scheduler_enabled):
|
||||
_log.info("config_sync scheduler disabled by settings")
|
||||
return
|
||||
if _thread and _thread.is_alive():
|
||||
return
|
||||
_stop.clear()
|
||||
_thread = threading.Thread(target=_loop, name="config-sync-scheduler", daemon=True)
|
||||
_thread.start()
|
||||
_log.info("started thread %s alive=%s", _thread.name, _thread.is_alive())
|
||||
|
||||
|
||||
def stop_config_sync_scheduler() -> None:
|
||||
_stop.set()
|
||||
104
netx_api/config_sync_schemas.py
Normal file
104
netx_api/config_sync_schemas.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Pydantic schemas for config sync API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ConfigSyncTargetRef(BaseModel):
|
||||
source: Literal["managed", "ume"]
|
||||
id: str
|
||||
|
||||
|
||||
class ConfigSyncPolicyOut(BaseModel):
|
||||
enabled: bool
|
||||
interval_days: int
|
||||
concurrency: int
|
||||
scope_mode: str
|
||||
selected_targets: list[ConfigSyncTargetRef] = Field(default_factory=list)
|
||||
history_keep: int
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ConfigSyncPolicyUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
interval_days: int | None = Field(default=None, ge=1, le=365)
|
||||
concurrency: int | None = Field(default=None, ge=1, le=30)
|
||||
scope_mode: Literal["all", "selected"] | None = None
|
||||
selected_targets: list[ConfigSyncTargetRef] | None = None
|
||||
history_keep: int | None = Field(default=None, ge=0, le=30)
|
||||
|
||||
|
||||
class ConfigSyncCycleCreate(BaseModel):
|
||||
mode: Literal["full", "retry_failed"] = "full"
|
||||
cycle_id: str | None = None
|
||||
|
||||
|
||||
class ConfigSyncCycleOut(BaseModel):
|
||||
id: str
|
||||
trigger_mode: str
|
||||
status: str
|
||||
concurrency: int
|
||||
planned_count: int
|
||||
success_count: int
|
||||
fail_count: int
|
||||
skip_count: int
|
||||
error_message: str = ""
|
||||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ConfigSyncTaskOut(BaseModel):
|
||||
id: str
|
||||
cycle_id: str
|
||||
source: str
|
||||
target_id: str
|
||||
ne_name: str
|
||||
ne_ip: str
|
||||
vendor: str
|
||||
status: str
|
||||
message: str = ""
|
||||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
|
||||
|
||||
class ConfigSyncDashboardOut(BaseModel):
|
||||
policy: ConfigSyncPolicyOut
|
||||
snapshot_count: int
|
||||
last_cycle: ConfigSyncCycleOut | None = None
|
||||
running_cycle: ConfigSyncCycleOut | None = None
|
||||
next_due_at: datetime | None = None
|
||||
fail_by_vendor: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NeConfigSnapshotMetaOut(BaseModel):
|
||||
source: str
|
||||
target_id: str
|
||||
vendor: str
|
||||
device_type: str
|
||||
ne_name: str
|
||||
ne_ip: str
|
||||
config_sha256: str
|
||||
config_alt_sha256: str
|
||||
plain_size: int
|
||||
plain_alt_size: int
|
||||
zlib_size: int
|
||||
zlib_alt_size: int
|
||||
has_alt: bool
|
||||
commands: list[str] = Field(default_factory=list)
|
||||
collected_at: datetime | None = None
|
||||
last_cycle_id: str = ""
|
||||
|
||||
|
||||
class NeConfigSnapshotDetailOut(NeConfigSnapshotMetaOut):
|
||||
config_text: str = ""
|
||||
config_alt_text: str = ""
|
||||
|
||||
|
||||
class NeConfigHistoryOut(NeConfigSnapshotMetaOut):
|
||||
id: str
|
||||
cycle_id: str = ""
|
||||
600
netx_api/config_sync_service.py
Normal file
600
netx_api/config_sync_service.py
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
"""Config sync policy, cycles, dashboard, and snapshot queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config_sync_codec import decompress_text
|
||||
from .config_sync_schemas import (
|
||||
ConfigSyncCycleCreate,
|
||||
ConfigSyncCycleOut,
|
||||
ConfigSyncDashboardOut,
|
||||
ConfigSyncPolicyOut,
|
||||
ConfigSyncPolicyUpdate,
|
||||
ConfigSyncTargetRef,
|
||||
ConfigSyncTaskOut,
|
||||
NeConfigHistoryOut,
|
||||
NeConfigSnapshotDetailOut,
|
||||
NeConfigSnapshotMetaOut,
|
||||
)
|
||||
from .models import (
|
||||
ConfigSyncCycle,
|
||||
ConfigSyncPolicy,
|
||||
ConfigSyncTask,
|
||||
ManagedNE,
|
||||
NeConfigHistory,
|
||||
NeConfigSnapshot,
|
||||
UmeInventoryNE,
|
||||
)
|
||||
from .cli_resolve import cli_profile_ready
|
||||
|
||||
_log = logging.getLogger("netx.config_sync")
|
||||
|
||||
POLICY_ID = 1
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def ensure_policy(db: Session) -> ConfigSyncPolicy:
|
||||
row = db.get(ConfigSyncPolicy, POLICY_ID)
|
||||
if row is None:
|
||||
row = ConfigSyncPolicy(id=POLICY_ID)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def _targets_from_json(raw: Any) -> list[ConfigSyncTargetRef]:
|
||||
items: list[ConfigSyncTargetRef] = []
|
||||
if not isinstance(raw, list):
|
||||
return items
|
||||
for x in raw:
|
||||
if not isinstance(x, dict):
|
||||
continue
|
||||
src = str(x.get("source") or "").strip().lower()
|
||||
tid = str(x.get("id") or "").strip()
|
||||
if src not in ("managed", "ume") or not tid:
|
||||
continue
|
||||
items.append(ConfigSyncTargetRef(source=src, id=tid)) # type: ignore[arg-type]
|
||||
return items
|
||||
|
||||
|
||||
def policy_to_out(row: ConfigSyncPolicy) -> ConfigSyncPolicyOut:
|
||||
return ConfigSyncPolicyOut(
|
||||
enabled=bool(row.enabled),
|
||||
interval_days=max(1, int(row.interval_days or 3)),
|
||||
concurrency=max(1, min(30, int(row.concurrency or 5))),
|
||||
scope_mode=str(row.scope_mode or "all"),
|
||||
selected_targets=_targets_from_json(row.selected_targets),
|
||||
history_keep=max(0, min(30, int(row.history_keep if row.history_keep is not None else 3))),
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def get_policy(db: Session) -> ConfigSyncPolicyOut:
|
||||
return policy_to_out(ensure_policy(db))
|
||||
|
||||
|
||||
def update_policy(db: Session, body: ConfigSyncPolicyUpdate) -> ConfigSyncPolicyOut:
|
||||
row = ensure_policy(db)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "enabled" in data and data["enabled"] is not None:
|
||||
row.enabled = bool(data["enabled"])
|
||||
if "interval_days" in data and data["interval_days"] is not None:
|
||||
row.interval_days = int(data["interval_days"])
|
||||
if "concurrency" in data and data["concurrency"] is not None:
|
||||
row.concurrency = max(1, min(30, int(data["concurrency"])))
|
||||
if "scope_mode" in data and data["scope_mode"] is not None:
|
||||
row.scope_mode = str(data["scope_mode"])
|
||||
if "selected_targets" in data and data["selected_targets"] is not None:
|
||||
refs = data["selected_targets"]
|
||||
row.selected_targets = [
|
||||
{"source": r.source if hasattr(r, "source") else r["source"], "id": r.id if hasattr(r, "id") else r["id"]}
|
||||
for r in refs
|
||||
]
|
||||
if "history_keep" in data and data["history_keep"] is not None:
|
||||
row.history_keep = max(0, min(30, int(data["history_keep"])))
|
||||
row.updated_at = _utcnow()
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return policy_to_out(row)
|
||||
|
||||
|
||||
def cycle_to_out(row: ConfigSyncCycle) -> ConfigSyncCycleOut:
|
||||
return ConfigSyncCycleOut(
|
||||
id=str(row.id),
|
||||
trigger_mode=str(row.trigger_mode or ""),
|
||||
status=str(row.status or ""),
|
||||
concurrency=int(row.concurrency or 0),
|
||||
planned_count=int(row.planned_count or 0),
|
||||
success_count=int(row.success_count or 0),
|
||||
fail_count=int(row.fail_count or 0),
|
||||
skip_count=int(row.skip_count or 0),
|
||||
error_message=str(row.error_message or ""),
|
||||
started_at=row.started_at,
|
||||
ended_at=row.ended_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def task_to_out(row: ConfigSyncTask) -> ConfigSyncTaskOut:
|
||||
return ConfigSyncTaskOut(
|
||||
id=str(row.id),
|
||||
cycle_id=str(row.cycle_id),
|
||||
source=str(row.source),
|
||||
target_id=str(row.target_id),
|
||||
ne_name=str(row.ne_name or ""),
|
||||
ne_ip=str(row.ne_ip or ""),
|
||||
vendor=str(row.vendor or ""),
|
||||
status=str(row.status or ""),
|
||||
message=str(row.message or ""),
|
||||
started_at=row.started_at,
|
||||
ended_at=row.ended_at,
|
||||
)
|
||||
|
||||
|
||||
def expand_targets(db: Session, policy: ConfigSyncPolicy) -> list[dict[str, str]]:
|
||||
"""Return list of {source, id, ne_name, ne_ip, vendor, device_type}."""
|
||||
mode = str(policy.scope_mode or "all").strip().lower()
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def _add(source: str, tid: str, name: str, ip: str, vendor: str, device_type: str) -> None:
|
||||
key = (source, tid)
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
out.append(
|
||||
{
|
||||
"source": source,
|
||||
"id": tid,
|
||||
"ne_name": name,
|
||||
"ne_ip": ip,
|
||||
"vendor": vendor,
|
||||
"device_type": device_type,
|
||||
}
|
||||
)
|
||||
|
||||
if mode == "selected":
|
||||
for ref in _targets_from_json(policy.selected_targets):
|
||||
if ref.source == "managed":
|
||||
ne = db.get(ManagedNE, ref.id)
|
||||
if ne:
|
||||
_add("managed", str(ne.id), str(ne.name or ""), str(ne.ip_address or ""), str(ne.vendor or ""), str(ne.device_type or ""))
|
||||
else:
|
||||
inv = db.get(UmeInventoryNE, ref.id)
|
||||
if inv:
|
||||
_add(
|
||||
"ume",
|
||||
str(inv.ne_id),
|
||||
str(inv.host_name or inv.ne_name or inv.user_label or inv.ne_id or ""),
|
||||
str(inv.ip_address or ""),
|
||||
str(inv.vendor or ""),
|
||||
str(inv.ne_type or ""),
|
||||
)
|
||||
return out
|
||||
|
||||
for ne in db.query(ManagedNE).order_by(ManagedNE.updated_at.desc()).all():
|
||||
_add("managed", str(ne.id), str(ne.name or ""), str(ne.ip_address or ""), str(ne.vendor or ""), str(ne.device_type or ""))
|
||||
|
||||
if cli_profile_ready(db):
|
||||
for inv in db.query(UmeInventoryNE).order_by(UmeInventoryNE.ne_id.asc()).all():
|
||||
if not str(inv.ip_address or "").strip():
|
||||
continue
|
||||
_add(
|
||||
"ume",
|
||||
str(inv.ne_id),
|
||||
str(inv.host_name or inv.ne_name or inv.user_label or inv.ne_id or ""),
|
||||
str(inv.ip_address or ""),
|
||||
str(inv.vendor or ""),
|
||||
str(inv.ne_type or ""),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def has_running_cycle(db: Session) -> ConfigSyncCycle | None:
|
||||
return (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status.in_(("running", "pending")))
|
||||
.order_by(ConfigSyncCycle.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def last_finished_cycle(db: Session) -> ConfigSyncCycle | None:
|
||||
return (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status.in_(("success", "fail", "cancelled")))
|
||||
.order_by(ConfigSyncCycle.ended_at.desc().nullslast(), ConfigSyncCycle.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def next_due_at(db: Session, policy: ConfigSyncPolicy | None = None) -> datetime | None:
|
||||
pol = policy or ensure_policy(db)
|
||||
if not pol.enabled:
|
||||
return None
|
||||
last = (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status == "success", ConfigSyncCycle.ended_at.isnot(None))
|
||||
.order_by(ConfigSyncCycle.ended_at.desc())
|
||||
.first()
|
||||
)
|
||||
days = max(1, int(pol.interval_days or 3))
|
||||
if last and last.ended_at:
|
||||
return last.ended_at + timedelta(days=days)
|
||||
return _utcnow()
|
||||
|
||||
|
||||
def create_cycle(db: Session, body: ConfigSyncCycleCreate) -> ConfigSyncCycleOut:
|
||||
if has_running_cycle(db):
|
||||
raise HTTPException(status_code=409, detail="config_sync_cycle_already_running")
|
||||
policy = ensure_policy(db)
|
||||
mode = str(body.mode or "full").strip().lower()
|
||||
trigger = "retry_failed" if mode == "retry_failed" else "manual"
|
||||
concurrency = max(1, min(30, int(policy.concurrency or 5)))
|
||||
|
||||
targets: list[dict[str, str]] = []
|
||||
if mode == "retry_failed":
|
||||
src_cycle_id = str(body.cycle_id or "").strip()
|
||||
src = None
|
||||
if src_cycle_id:
|
||||
src = db.get(ConfigSyncCycle, src_cycle_id)
|
||||
if src is None:
|
||||
src = (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.fail_count > 0)
|
||||
.order_by(ConfigSyncCycle.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="no_failed_cycle")
|
||||
fails = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == src.id, ConfigSyncTask.status == "fail")
|
||||
.all()
|
||||
)
|
||||
for t in fails:
|
||||
targets.append(
|
||||
{
|
||||
"source": str(t.source),
|
||||
"id": str(t.target_id),
|
||||
"ne_name": str(t.ne_name or ""),
|
||||
"ne_ip": str(t.ne_ip or ""),
|
||||
"vendor": str(t.vendor or ""),
|
||||
"device_type": "",
|
||||
}
|
||||
)
|
||||
if not targets:
|
||||
raise HTTPException(status_code=400, detail="no_failed_tasks")
|
||||
else:
|
||||
targets = expand_targets(db, policy)
|
||||
if not targets:
|
||||
raise HTTPException(status_code=400, detail="no_targets")
|
||||
|
||||
cycle = ConfigSyncCycle(
|
||||
id=uuid4().hex,
|
||||
trigger_mode=trigger,
|
||||
status="running",
|
||||
concurrency=concurrency,
|
||||
planned_count=len(targets),
|
||||
started_at=_utcnow(),
|
||||
created_at=_utcnow(),
|
||||
)
|
||||
db.add(cycle)
|
||||
db.flush()
|
||||
for t in targets:
|
||||
db.add(
|
||||
ConfigSyncTask(
|
||||
id=uuid4().hex,
|
||||
cycle_id=cycle.id,
|
||||
source=t["source"],
|
||||
target_id=t["id"],
|
||||
ne_name=t.get("ne_name") or "",
|
||||
ne_ip=t.get("ne_ip") or "",
|
||||
vendor=t.get("vendor") or "",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(cycle)
|
||||
return cycle_to_out(cycle)
|
||||
|
||||
|
||||
def list_cycles(db: Session, *, page: int, page_size: int) -> dict[str, Any]:
|
||||
q = db.query(ConfigSyncCycle).order_by(ConfigSyncCycle.created_at.desc())
|
||||
total = int(q.count())
|
||||
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": [cycle_to_out(r) for r in rows]}
|
||||
|
||||
|
||||
def get_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
|
||||
row = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="cycle_not_found")
|
||||
return cycle_to_out(row)
|
||||
|
||||
|
||||
def list_cycle_tasks(
|
||||
db: Session,
|
||||
cycle_id: str,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
status: str = "",
|
||||
keyword: str = "",
|
||||
) -> dict[str, Any]:
|
||||
if not db.get(ConfigSyncCycle, cycle_id):
|
||||
raise HTTPException(status_code=404, detail="cycle_not_found")
|
||||
q = db.query(ConfigSyncTask).filter(ConfigSyncTask.cycle_id == cycle_id)
|
||||
st = str(status or "").strip()
|
||||
if st:
|
||||
q = q.filter(ConfigSyncTask.status == st)
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
ConfigSyncTask.ne_name.ilike(like),
|
||||
ConfigSyncTask.ne_ip.ilike(like),
|
||||
ConfigSyncTask.target_id.ilike(like),
|
||||
ConfigSyncTask.message.ilike(like),
|
||||
)
|
||||
)
|
||||
total = int(q.count())
|
||||
rows = q.order_by(ConfigSyncTask.ne_name.asc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": [task_to_out(r) for r in rows]}
|
||||
|
||||
|
||||
def pause_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
|
||||
row = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="cycle_not_found")
|
||||
if str(row.status) != "running":
|
||||
raise HTTPException(status_code=400, detail="cycle_not_running")
|
||||
row.status = "paused"
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return cycle_to_out(row)
|
||||
|
||||
|
||||
def resume_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
|
||||
row = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="cycle_not_found")
|
||||
if str(row.status) != "paused":
|
||||
raise HTTPException(status_code=400, detail="cycle_not_paused")
|
||||
pending = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "pending")
|
||||
.count()
|
||||
)
|
||||
if pending <= 0:
|
||||
raise HTTPException(status_code=400, detail="no_pending_tasks")
|
||||
other = has_running_cycle(db)
|
||||
if other and str(other.id) != cycle_id:
|
||||
raise HTTPException(status_code=409, detail="config_sync_cycle_already_running")
|
||||
row.status = "running"
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return cycle_to_out(row)
|
||||
|
||||
|
||||
def dashboard(db: Session) -> ConfigSyncDashboardOut:
|
||||
policy = ensure_policy(db)
|
||||
snap_count = int(db.query(func.count()).select_from(NeConfigSnapshot).scalar() or 0)
|
||||
running = (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status.in_(("running", "paused", "pending")))
|
||||
.order_by(ConfigSyncCycle.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
last = last_finished_cycle(db)
|
||||
fail_by_vendor: dict[str, int] = {}
|
||||
if last:
|
||||
rows = (
|
||||
db.query(ConfigSyncTask.vendor, func.count())
|
||||
.filter(ConfigSyncTask.cycle_id == last.id, ConfigSyncTask.status == "fail")
|
||||
.group_by(ConfigSyncTask.vendor)
|
||||
.all()
|
||||
)
|
||||
for vendor, cnt in rows:
|
||||
fail_by_vendor[str(vendor or "unknown") or "unknown"] = int(cnt)
|
||||
return ConfigSyncDashboardOut(
|
||||
policy=policy_to_out(policy),
|
||||
snapshot_count=snap_count,
|
||||
last_cycle=cycle_to_out(last) if last else None,
|
||||
running_cycle=cycle_to_out(running) if running else None,
|
||||
next_due_at=next_due_at(db, policy),
|
||||
fail_by_vendor=fail_by_vendor,
|
||||
)
|
||||
|
||||
|
||||
def _snap_meta(row: NeConfigSnapshot) -> NeConfigSnapshotMetaOut:
|
||||
cmds = row.commands_json if isinstance(row.commands_json, list) else []
|
||||
return NeConfigSnapshotMetaOut(
|
||||
source=str(row.source),
|
||||
target_id=str(row.target_id),
|
||||
vendor=str(row.vendor or ""),
|
||||
device_type=str(row.device_type or ""),
|
||||
ne_name=str(row.ne_name or ""),
|
||||
ne_ip=str(row.ne_ip or ""),
|
||||
config_sha256=str(row.config_sha256 or ""),
|
||||
config_alt_sha256=str(row.config_alt_sha256 or ""),
|
||||
plain_size=int(row.plain_size or 0),
|
||||
plain_alt_size=int(row.plain_alt_size or 0),
|
||||
zlib_size=int(row.zlib_size or 0),
|
||||
zlib_alt_size=int(row.zlib_alt_size or 0),
|
||||
has_alt=bool(row.config_alt_zlib),
|
||||
commands=[str(c) for c in cmds],
|
||||
collected_at=row.collected_at,
|
||||
last_cycle_id=str(row.last_cycle_id or ""),
|
||||
)
|
||||
|
||||
|
||||
def list_snapshots(
|
||||
db: Session,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
keyword: str = "",
|
||||
source: str = "",
|
||||
vendor: str = "",
|
||||
) -> dict[str, Any]:
|
||||
q = db.query(NeConfigSnapshot)
|
||||
src = str(source or "").strip().lower()
|
||||
if src in ("managed", "ume"):
|
||||
q = q.filter(NeConfigSnapshot.source == src)
|
||||
vend = str(vendor or "").strip()
|
||||
if vend:
|
||||
q = q.filter(NeConfigSnapshot.vendor.ilike(f"%{vend}%"))
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
NeConfigSnapshot.ne_name.ilike(like),
|
||||
NeConfigSnapshot.ne_ip.ilike(like),
|
||||
NeConfigSnapshot.target_id.ilike(like),
|
||||
)
|
||||
)
|
||||
total = int(q.count())
|
||||
rows = q.order_by(NeConfigSnapshot.collected_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": [_snap_meta(r) for r in rows]}
|
||||
|
||||
|
||||
def get_snapshot_detail(
|
||||
db: Session,
|
||||
source: str,
|
||||
target_id: str,
|
||||
*,
|
||||
field: str = "both",
|
||||
) -> NeConfigSnapshotDetailOut:
|
||||
src = str(source or "").strip().lower()
|
||||
tid = str(target_id or "").strip()
|
||||
row = db.get(NeConfigSnapshot, {"source": src, "target_id": tid})
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="snapshot_not_found")
|
||||
meta = _snap_meta(row)
|
||||
primary = ""
|
||||
alt = ""
|
||||
f = str(field or "both").strip().lower()
|
||||
if f in ("primary", "both", ""):
|
||||
primary = decompress_text(row.config_zlib)
|
||||
if f in ("alt", "both") and row.config_alt_zlib:
|
||||
alt = decompress_text(row.config_alt_zlib)
|
||||
return NeConfigSnapshotDetailOut(**meta.model_dump(), config_text=primary, config_alt_text=alt)
|
||||
|
||||
|
||||
def list_snapshot_history(
|
||||
db: Session,
|
||||
source: str,
|
||||
target_id: str,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> dict[str, Any]:
|
||||
src = str(source or "").strip().lower()
|
||||
tid = str(target_id or "").strip()
|
||||
q = (
|
||||
db.query(NeConfigHistory)
|
||||
.filter(NeConfigHistory.source == src, NeConfigHistory.target_id == tid)
|
||||
.order_by(NeConfigHistory.collected_at.desc())
|
||||
)
|
||||
total = int(q.count())
|
||||
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
items: list[NeConfigHistoryOut] = []
|
||||
for row in rows:
|
||||
cmds = row.commands_json if isinstance(row.commands_json, list) else []
|
||||
items.append(
|
||||
NeConfigHistoryOut(
|
||||
id=str(row.id),
|
||||
source=str(row.source),
|
||||
target_id=str(row.target_id),
|
||||
vendor=str(row.vendor or ""),
|
||||
device_type=str(row.device_type or ""),
|
||||
ne_name=str(row.ne_name or ""),
|
||||
ne_ip=str(row.ne_ip or ""),
|
||||
config_sha256=str(row.config_sha256 or ""),
|
||||
config_alt_sha256=str(row.config_alt_sha256 or ""),
|
||||
plain_size=int(row.plain_size or 0),
|
||||
plain_alt_size=int(row.plain_alt_size or 0),
|
||||
zlib_size=int(row.zlib_size or 0),
|
||||
zlib_alt_size=int(row.zlib_alt_size or 0),
|
||||
has_alt=bool(row.config_alt_zlib),
|
||||
commands=[str(c) for c in cmds],
|
||||
collected_at=row.collected_at,
|
||||
last_cycle_id=str(row.cycle_id or ""),
|
||||
cycle_id=str(row.cycle_id or ""),
|
||||
)
|
||||
)
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
||||
|
||||
|
||||
def sync_cycle_progress(db: Session, cycle_id: str) -> None:
|
||||
cycle = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not cycle:
|
||||
return
|
||||
success = (
|
||||
db.query(func.count())
|
||||
.select_from(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "success")
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
fail = (
|
||||
db.query(func.count())
|
||||
.select_from(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "fail")
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
skip = (
|
||||
db.query(func.count())
|
||||
.select_from(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status.in_(("skipped", "cancelled")))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
cycle.success_count = int(success)
|
||||
cycle.fail_count = int(fail)
|
||||
cycle.skip_count = int(skip)
|
||||
db.commit()
|
||||
|
||||
|
||||
def finalize_cycle(db: Session, cycle_id: str) -> None:
|
||||
cycle = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not cycle:
|
||||
return
|
||||
if str(cycle.status) == "paused":
|
||||
return
|
||||
pending = (
|
||||
db.query(func.count())
|
||||
.select_from(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status.in_(("pending", "running")))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
if int(pending) > 0:
|
||||
return
|
||||
sync_cycle_progress(db, cycle_id)
|
||||
db.refresh(cycle)
|
||||
if int(cycle.fail_count or 0) > 0 and int(cycle.success_count or 0) == 0:
|
||||
cycle.status = "fail"
|
||||
elif int(cycle.fail_count or 0) > 0:
|
||||
cycle.status = "fail"
|
||||
cycle.error_message = cycle.error_message or "completed_with_failures"
|
||||
else:
|
||||
cycle.status = "success"
|
||||
cycle.ended_at = _utcnow()
|
||||
db.commit()
|
||||
|
|
@ -26,6 +26,7 @@ from .config import settings
|
|||
from .db import Base, SessionLocal, engine, get_db
|
||||
from .collection_router import router as collection_router
|
||||
from .cli_router import router as cli_router
|
||||
from .config_sync_router import router as config_sync_router
|
||||
from .managed_ne_router import router as managed_ne_router
|
||||
from .webcrt_router import router as webcrt_router
|
||||
from .topology_router import router as topology_router
|
||||
|
|
@ -133,6 +134,7 @@ app.include_router(auth_router)
|
|||
app.include_router(managed_ne_router)
|
||||
app.include_router(cli_router)
|
||||
app.include_router(collection_router)
|
||||
app.include_router(config_sync_router)
|
||||
app.include_router(webcrt_router)
|
||||
app.include_router(topology_router)
|
||||
parser_cfg = load_parser_config()
|
||||
|
|
@ -832,10 +834,23 @@ def on_startup() -> None:
|
|||
resumed = recover_collection_jobs_on_startup(db)
|
||||
if resumed:
|
||||
_schedule_log.info("startup: resumed %s pending ne collection runs", resumed)
|
||||
from .config_sync_recovery import recover_config_sync_on_startup
|
||||
from .config_sync_service import ensure_policy
|
||||
|
||||
ensure_policy(db)
|
||||
cfg_resumed = recover_config_sync_on_startup(db)
|
||||
if cfg_resumed:
|
||||
_schedule_log.info("startup: resumed %s pending config_sync tasks", cfg_resumed)
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: ne collection job recovery failed")
|
||||
_schedule_log.exception("startup: ne collection / config_sync recovery failed")
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
from .config_sync_scheduler import start_config_sync_scheduler
|
||||
|
||||
start_config_sync_scheduler()
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: config_sync scheduler init failed")
|
||||
# Best-effort schema evolution for new columns (no migrations framework).
|
||||
# Safe for Postgres (IF NOT EXISTS); ignored on failure.
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
|
@ -484,3 +484,107 @@ class ApiToken(Base):
|
|||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class ConfigSyncPolicy(Base):
|
||||
"""Singleton policy for periodic config sync (id=1)."""
|
||||
|
||||
__tablename__ = "config_sync_policy"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
interval_days: Mapped[int] = mapped_column(Integer, default=3)
|
||||
concurrency: Mapped[int] = mapped_column(Integer, default=5)
|
||||
scope_mode: Mapped[str] = mapped_column(String(32), default="all") # all | selected
|
||||
selected_targets: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
history_keep: Mapped[int] = mapped_column(Integer, default=3)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ConfigSyncCycle(Base):
|
||||
"""One config-sync cycle over many NEs."""
|
||||
|
||||
__tablename__ = "config_sync_cycle"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
trigger_mode: Mapped[str] = mapped_column(String(32), default="schedule", index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
concurrency: Mapped[int] = mapped_column(Integer, default=5)
|
||||
planned_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
success_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
fail_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
skip_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_message: Mapped[str] = mapped_column(String(1024), default="")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class ConfigSyncTask(Base):
|
||||
"""Per-NE work item inside a config sync cycle."""
|
||||
|
||||
__tablename__ = "config_sync_task"
|
||||
__table_args__ = (UniqueConstraint("cycle_id", "source", "target_id", name="uq_config_sync_task_target"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
cycle_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
source: Mapped[str] = mapped_column(String(32), default="managed", index=True) # managed | ume
|
||||
target_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
||||
ne_ip: Mapped[str] = mapped_column(String(128), default="")
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
message: Mapped[str] = mapped_column(String(1024), default="")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class NeConfigSnapshot(Base):
|
||||
"""Latest successful config snapshot per NE (zlib-compressed)."""
|
||||
|
||||
__tablename__ = "ne_config_snapshot"
|
||||
|
||||
source: Mapped[str] = mapped_column(String(32), primary_key=True) # managed | ume
|
||||
target_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||
device_type: Mapped[str] = mapped_column(String(64), default="")
|
||||
ne_name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
ne_ip: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
config_zlib: Mapped[bytes] = mapped_column(LargeBinary, default=b"")
|
||||
config_alt_zlib: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
config_sha256: Mapped[str] = mapped_column(String(64), default="")
|
||||
config_alt_sha256: Mapped[str] = mapped_column(String(64), default="")
|
||||
plain_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
plain_alt_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
zlib_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
zlib_alt_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
commands_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
collected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
last_cycle_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
last_task_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class NeConfigHistory(Base):
|
||||
"""Historical config versions when content changes."""
|
||||
|
||||
__tablename__ = "ne_config_history"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
source: Mapped[str] = mapped_column(String(32), index=True)
|
||||
target_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||
device_type: Mapped[str] = mapped_column(String(64), default="")
|
||||
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
||||
ne_ip: Mapped[str] = mapped_column(String(128), default="")
|
||||
config_zlib: Mapped[bytes] = mapped_column(LargeBinary, default=b"")
|
||||
config_alt_zlib: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
config_sha256: Mapped[str] = mapped_column(String(64), default="")
|
||||
config_alt_sha256: Mapped[str] = mapped_column(String(64), default="")
|
||||
plain_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
plain_alt_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
zlib_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
zlib_alt_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
commands_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
collected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
cycle_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
task_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue