mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Add concurrent multi-NE CLI exec batch for field sweeps.
execManagedNe can take ne_ids/ume_ne_ids/targets and POST /v1/managed-ne/exec-batch so many show commands run in parallel instead of serial one-NE MCP loops. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8a07baa018
commit
9ba4d051dc
8 changed files with 355 additions and 14 deletions
|
|
@ -114,7 +114,8 @@ def required_scope_for_request(method: str, path: str) -> str | None:
|
|||
return SCOPE_WEBCRT
|
||||
|
||||
if p.startswith("/v1/managed-ne"):
|
||||
if p.rstrip("/").endswith("/exec") and m == "POST":
|
||||
path_tail = p.rstrip("/")
|
||||
if m == "POST" and (path_tail.endswith("/exec") or path_tail.endswith("/exec-batch")):
|
||||
return SCOPE_NE_EXEC
|
||||
if m in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
return SCOPE_NE_WRITE
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ from .db import get_db
|
|||
from .device_types import SUPPORTED_VENDORS
|
||||
from .ne_connect import schedule_connect_tests
|
||||
from .ne_crypto import credentials_configured
|
||||
from .ne_exec import execute_managed_ne_commands
|
||||
from .ne_exec import execute_managed_ne_commands, execute_managed_ne_commands_batch
|
||||
from .ne_schemas import (
|
||||
BatchAccountApplyRequest,
|
||||
BatchHopApplyRequest,
|
||||
ConnectTestRequest,
|
||||
ManagedNeCreate,
|
||||
ManagedNeExecBatchRequest,
|
||||
ManagedNeExecRequest,
|
||||
ManagedNeUpdate,
|
||||
)
|
||||
|
|
@ -141,6 +142,22 @@ def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db
|
|||
)
|
||||
|
||||
|
||||
@router.post("/exec-batch")
|
||||
def api_exec_managed_ne_batch(body: ManagedNeExecBatchRequest):
|
||||
"""Run read-only CLI on many NEs concurrently (field multi-NE sweeps)."""
|
||||
targets = None
|
||||
if body.targets:
|
||||
targets = [t.model_dump() for t in body.targets]
|
||||
return execute_managed_ne_commands_batch(
|
||||
targets=targets,
|
||||
ne_ids=body.ne_ids,
|
||||
ume_ne_ids=body.ume_ne_ids,
|
||||
commands=body.commands,
|
||||
read_timeout_sec=body.read_timeout_sec,
|
||||
concurrency=body.concurrency,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/connect-test")
|
||||
def api_connect_test(body: ConnectTestRequest, db: Session = Depends(get_db)):
|
||||
ids = [str(x).strip() for x in body.ids if str(x).strip()]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -9,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from .cli_resolve import resolve_cli_target
|
||||
from .config import settings
|
||||
from .db import SessionLocal
|
||||
from .ne_collect_runner import _collect_on_device
|
||||
from .ne_crypto import credentials_configured
|
||||
from .ne_exec_guard import _validate_command, validate_ne_exec_command
|
||||
|
|
@ -17,10 +19,14 @@ _EXEC_MAX_COMMANDS_CAP = 50
|
|||
_EXEC_MAX_OUTPUT = 32_000
|
||||
_EXEC_READ_TIMEOUT_DEFAULT = 60
|
||||
_EXEC_READ_TIMEOUT_MAX = 120
|
||||
_EXEC_BATCH_MAX_TARGETS = 20
|
||||
_EXEC_BATCH_DEFAULT_CONCURRENCY = 4
|
||||
_EXEC_BATCH_MAX_CONCURRENCY = 8
|
||||
|
||||
__all__ = [
|
||||
"_validate_command",
|
||||
"execute_managed_ne_commands",
|
||||
"execute_managed_ne_commands_batch",
|
||||
"validate_ne_exec_command",
|
||||
]
|
||||
|
||||
|
|
@ -83,3 +89,124 @@ def execute_managed_ne_commands(
|
|||
"read_timeout_sec": read_timeout,
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_batch_targets(
|
||||
*,
|
||||
targets: list[dict[str, Any]] | None,
|
||||
ne_ids: list[str] | None,
|
||||
ume_ne_ids: list[str] | None,
|
||||
shared_commands: list[str] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for raw in targets or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
mid = str(raw.get("ne_id") or "").strip()
|
||||
uid = str(raw.get("ume_ne_id") or "").strip()
|
||||
cmds_raw = raw.get("commands")
|
||||
cmds = (
|
||||
[str(c).strip() for c in cmds_raw if str(c).strip()]
|
||||
if isinstance(cmds_raw, list)
|
||||
else list(shared_commands or [])
|
||||
)
|
||||
out.append({"ne_id": mid or None, "ume_ne_id": uid or None, "commands": cmds})
|
||||
for mid in ne_ids or []:
|
||||
s = str(mid or "").strip()
|
||||
if s:
|
||||
out.append({"ne_id": s, "ume_ne_id": None, "commands": list(shared_commands or [])})
|
||||
for uid in ume_ne_ids or []:
|
||||
s = str(uid or "").strip()
|
||||
if s:
|
||||
out.append({"ne_id": None, "ume_ne_id": s, "commands": list(shared_commands or [])})
|
||||
if not out:
|
||||
raise HTTPException(status_code=400, detail="targets_required")
|
||||
if len(out) > _EXEC_BATCH_MAX_TARGETS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"too_many_targets (max {_EXEC_BATCH_MAX_TARGETS})",
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def execute_managed_ne_commands_batch(
|
||||
*,
|
||||
targets: list[dict[str, Any]] | None = None,
|
||||
ne_ids: list[str] | None = None,
|
||||
ume_ne_ids: list[str] | None = None,
|
||||
commands: list[str] | None = None,
|
||||
read_timeout_sec: int | None = None,
|
||||
concurrency: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fan out read-only CLI across many NEs with bounded concurrency.
|
||||
|
||||
Each worker opens its own DB session. Partial failures stay in ``results``;
|
||||
top-level ``ok`` is true when the batch request itself is valid.
|
||||
"""
|
||||
shared = [str(c).strip() for c in (commands or []) if str(c).strip()]
|
||||
normalized = _normalize_batch_targets(
|
||||
targets=targets,
|
||||
ne_ids=ne_ids,
|
||||
ume_ne_ids=ume_ne_ids,
|
||||
shared_commands=shared,
|
||||
)
|
||||
workers = int(concurrency if concurrency is not None else _EXEC_BATCH_DEFAULT_CONCURRENCY)
|
||||
workers = max(1, min(_EXEC_BATCH_MAX_CONCURRENCY, workers, len(normalized)))
|
||||
|
||||
def _run_one(idx: int, item: dict[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
try:
|
||||
row = execute_managed_ne_commands(
|
||||
db,
|
||||
list(item.get("commands") or []),
|
||||
ne_id=item.get("ne_id"),
|
||||
ume_ne_id=item.get("ume_ne_id"),
|
||||
read_timeout_sec=read_timeout_sec,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
row = {
|
||||
"ok": False,
|
||||
"ne_id": item.get("ne_id"),
|
||||
"ume_ne_id": item.get("ume_ne_id"),
|
||||
"commands": list(item.get("commands") or []),
|
||||
"error": str(exc.detail),
|
||||
"http_status": int(exc.status_code),
|
||||
}
|
||||
except Exception as exc:
|
||||
row = {
|
||||
"ok": False,
|
||||
"ne_id": item.get("ne_id"),
|
||||
"ume_ne_id": item.get("ume_ne_id"),
|
||||
"commands": list(item.get("commands") or []),
|
||||
"error": type(exc).__name__,
|
||||
"detail": str(exc)[:2000],
|
||||
}
|
||||
if isinstance(row, dict):
|
||||
row.setdefault("ne_id", item.get("ne_id"))
|
||||
row.setdefault("ume_ne_id", item.get("ume_ne_id"))
|
||||
row["target_index"] = idx
|
||||
return idx, row if isinstance(row, dict) else {"ok": False, "error": "invalid_result", "target_index": idx}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
ordered: list[dict[str, Any] | None] = [None] * len(normalized)
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futs = [ex.submit(_run_one, i, item) for i, item in enumerate(normalized)]
|
||||
for fut in as_completed(futs):
|
||||
idx, row = fut.result()
|
||||
ordered[idx] = row
|
||||
|
||||
results = [r if isinstance(r, dict) else {"ok": False, "error": "missing_result"} for r in ordered]
|
||||
ok_n = sum(1 for r in results if r.get("ok") is True)
|
||||
fail_n = len(results) - ok_n
|
||||
return {
|
||||
"ok": True,
|
||||
"concurrency": workers,
|
||||
"summary": {"total": len(results), "ok": ok_n, "failed": fail_n},
|
||||
"results": results,
|
||||
"hint": (
|
||||
"Multi-NE CLI finished in one batch. Summarize ok/failed counts; "
|
||||
"do not re-loop execManagedNe per NE for the same commands."
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,25 @@ class ManagedNeExecRequest(BaseModel):
|
|||
read_timeout_sec: int | None = Field(default=None, ge=10, le=120)
|
||||
|
||||
|
||||
class ManagedNeExecBatchTarget(BaseModel):
|
||||
"""One NE in a concurrent exec batch (exactly one of ne_id / ume_ne_id)."""
|
||||
|
||||
ne_id: str | None = None
|
||||
ume_ne_id: str | None = None
|
||||
commands: list[str] | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class ManagedNeExecBatchRequest(BaseModel):
|
||||
"""Run the same (or per-target) read-only CLI on many NEs concurrently."""
|
||||
|
||||
targets: list[ManagedNeExecBatchTarget] | None = Field(default=None, max_length=20)
|
||||
ne_ids: list[str] | None = Field(default=None, max_length=20)
|
||||
ume_ne_ids: list[str] | None = Field(default=None, max_length=20)
|
||||
commands: list[str] | None = Field(default=None, max_length=50)
|
||||
read_timeout_sec: int | None = Field(default=None, ge=10, le=120)
|
||||
concurrency: int | None = Field(default=4, ge=1, le=8)
|
||||
|
||||
|
||||
class HopProxyConfig(BaseModel):
|
||||
"""Shared jump-host (proxy) settings applied to one or many NEs."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue