netx/netx_api/managed_ne_router.py
oliver 9085cac742 MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools
  mirroring the netxops bizMonitor group.
- Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP
  contracts; surface partial batch failures as tool errors.
- CLI hard gate: single-line show/display only; exec job IDs, path
  containment, owner and ne:exec checks.
- Cisco IOSv collection: stable prompt/echo handling over CR.
- Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json;
  sync skills and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:52:05 +08:00

402 lines
13 KiB
Python

from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from sqlalchemy.orm import Session
from .auth_deps import AuthContext, require_user
from .auth_scopes import has_scope
from .auth_service import write_audit
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_capability import build_ne_capability
from .ne_exec import execute_managed_ne_commands, execute_managed_ne_commands_batch
from .ne_exec_jobs import get_ne_exec_job, start_ne_exec_job
from .ne_schemas import (
BatchAccountApplyRequest,
BatchHopApplyRequest,
ConnectTestRequest,
ManagedNeCreate,
ManagedNeExecBatchRequest,
ManagedNeExecJobCreate,
ManagedNeExecRequest,
ManagedNeUpdate,
)
from .ne_service import (
batch_apply_account,
batch_apply_hop_proxy,
build_managed_ne_import_template,
create_managed_ne,
batch_delete_managed_ne,
delete_ume_synced_managed_ne,
delete_managed_ne,
get_ids_by_tag,
get_managed_ne,
get_managed_ne_stats,
import_managed_ne,
list_managed_ne,
sync_ume_inventory_to_managed_ne,
update_managed_ne,
)
from .models import ManagedNE
from .table_filters import TableFilterParams, table_filter_params
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
def _actor(ctx: AuthContext | None = None, request: Request | None = None) -> tuple[str, str]:
if ctx is not None and ctx.user is not None:
return str(ctx.user.id or ""), str(ctx.user.username or "")
if request is not None:
user = getattr(request.state, "auth_user", None)
if user:
return str(getattr(user, "id", "") or ""), str(getattr(user, "username", "") or "")
return "", ""
@router.get("")
def api_list_managed_ne(
keyword: str | None = Query(default=None),
vendor: str | None = Query(default=None),
connect_status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db),
):
return list_managed_ne(
db,
keyword=keyword,
vendor=vendor,
connect_status=connect_status,
page=page,
page_size=page_size,
table_filters=table_filters,
)
@router.get("/meta/device-types")
def api_device_types():
# Include generic/linux so LLDP/WebCRT placeholders can be edited without a bogus select value.
from .config import settings
from .device_types import WEBCRT_DEVICE_TYPES
return {
"device_types": list(WEBCRT_DEVICE_TYPES),
"vendors": list(SUPPORTED_VENDORS),
"exec_policy_enabled": bool(getattr(settings, "ne_exec_policy_enabled", False)),
}
@router.get("/meta/credentials-configured")
def api_credentials_configured():
return {"configured": credentials_configured()}
@router.get("/meta/stats")
def api_managed_ne_stats(db: Session = Depends(get_db)):
return get_managed_ne_stats(db)
@router.get("/meta/ids-by-tag")
def api_ids_by_tag(tag: str | None = Query(default=None), db: Session = Depends(get_db)):
"""Return all NE ids that carry the given tag (or every id when tag is omitted)."""
return {"ids": get_ids_by_tag(db, tag)}
@router.post("")
def api_create_managed_ne(body: ManagedNeCreate, db: Session = Depends(get_db)):
return create_managed_ne(db, body).model_dump()
@router.get("/import/template")
def api_managed_ne_import_template(format: str = Query(default="xlsx")):
filename, payload, media_type = build_managed_ne_import_template(format)
return Response(
content=payload,
media_type=media_type,
headers={"content-disposition": f'attachment; filename="{filename}"'},
)
@router.post("/import")
async def api_import_managed_ne(file: UploadFile = File(...), db: Session = Depends(get_db)):
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="empty_file")
return import_managed_ne(db, content, file.filename or "import.xlsx").model_dump()
@router.post("/batch-hop")
def api_batch_apply_hop(body: BatchHopApplyRequest, db: Session = Depends(get_db)):
return batch_apply_hop_proxy(db, body.ids, body.hop)
@router.post("/batch-account")
def api_batch_apply_account(body: BatchAccountApplyRequest, db: Session = Depends(get_db)):
return batch_apply_account(db, body.ids, body.account)
@router.post("/batch-delete")
def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(get_db)):
return batch_delete_managed_ne(db, body.ids)
@router.post("/ume-sync")
def api_sync_ume_inventory_to_managed_ne(db: Session = Depends(get_db)):
return sync_ume_inventory_to_managed_ne(db).model_dump()
@router.delete("/ume-sync")
def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)):
return delete_ume_synced_managed_ne(db).model_dump()
def _audit_exec(
db: Session,
*,
uid: str,
uname: str,
path: str,
out: dict,
body_ne_id: str = "",
body_ume_ne_id: str = "",
commands: list | None = None,
) -> None:
device = out.get("device") if isinstance(out.get("device"), dict) else {}
write_audit(
db,
action="ne.exec",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=path,
status_code=200 if out.get("ok") else 502,
detail={
"ne_id": body_ne_id,
"ume_ne_id": body_ume_ne_id,
"ne_name": str(device.get("name") or device.get("ne_name") or ""),
"ne_ip": str(device.get("ip_address") or device.get("ip") or device.get("mgmt_ip") or ""),
"commands": list(out.get("commands") or commands or [])[:20],
"ok": bool(out.get("ok")),
"error": str(out.get("error") or "")[:500],
"output_len": len(str(out.get("output") or "")),
},
)
@router.post("/exec")
def api_exec_managed_ne(
body: ManagedNeExecRequest,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Login to a managed NE or UME inventory NE and run CLI (policy from managed NE exec_policy)."""
uid, uname = _actor(ctx)
out = execute_managed_ne_commands(
db,
body.commands,
ne_id=body.ne_id,
ume_ne_id=body.ume_ne_id,
read_timeout_sec=body.read_timeout_sec,
)
_audit_exec(
db,
uid=uid,
uname=uname,
path="/v1/managed-ne/exec",
out=out,
body_ne_id=body.ne_id or "",
body_ume_ne_id=body.ume_ne_id or "",
commands=list(body.commands or []),
)
return out
@router.post("/exec-batch")
def api_exec_managed_ne_batch(
body: ManagedNeExecBatchRequest,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Run read-only CLI on many NEs concurrently (field multi-NE sweeps)."""
uid, uname = _actor(ctx)
targets = None
if body.targets:
targets = [t.model_dump() for t in body.targets]
out = 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,
)
items = out.get("items") if isinstance(out, dict) else None
ok_n = 0
fail_n = 0
if isinstance(items, list):
for row in items:
if isinstance(row, dict) and row.get("ok"):
ok_n += 1
else:
fail_n += 1
write_audit(
db,
action="ne.exec_batch",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-batch",
status_code=200,
detail={
"ne_ids": list(body.ne_ids or [])[:100],
"ume_ne_ids": list(body.ume_ne_ids or [])[:100],
"target_count": len(targets or []) + len(body.ne_ids or []) + len(body.ume_ne_ids or []),
"commands": list(body.commands or [])[:20],
"ok_count": ok_n,
"fail_count": fail_n,
},
)
return out
@router.post("/exec-jobs")
def api_create_exec_job(
body: ManagedNeExecJobCreate,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Start a background exec (single or batch). Poll GET /exec-jobs/{job_id}."""
from .db import SessionLocal
uid, uname = _actor(ctx)
payload = body.model_dump(exclude_none=True)
if not has_scope(ctx.scopes, "ne:exec"):
raise HTTPException(status_code=403, detail={"error": "insufficient_scope", "required": ["ne:exec"]})
if not uid:
raise HTTPException(status_code=401, detail={"error": "job_owner_required"})
is_batch = bool(
payload.get("targets")
or payload.get("ne_ids")
or payload.get("ume_ne_ids")
)
kind = "exec-batch" if is_batch else "exec"
def _runner() -> dict:
with SessionLocal() as job_db:
if is_batch:
targets = payload.get("targets")
out = execute_managed_ne_commands_batch(
targets=targets,
ne_ids=payload.get("ne_ids"),
ume_ne_ids=payload.get("ume_ne_ids"),
commands=payload.get("commands"),
read_timeout_sec=payload.get("read_timeout_sec"),
concurrency=payload.get("concurrency"),
)
write_audit(
job_db,
action="ne.exec_batch",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-jobs",
status_code=200,
detail={
"async": True,
"ne_ids": list(payload.get("ne_ids") or [])[:100],
"ume_ne_ids": list(payload.get("ume_ne_ids") or [])[:100],
"ok": bool(out.get("ok", True)),
},
)
return out
cmds = list(payload.get("commands") or [])
out = execute_managed_ne_commands(
job_db,
cmds,
ne_id=payload.get("ne_id"),
ume_ne_id=payload.get("ume_ne_id"),
read_timeout_sec=payload.get("read_timeout_sec"),
)
_audit_exec(
job_db,
uid=uid,
uname=uname,
path="/v1/managed-ne/exec-jobs",
out=out,
body_ne_id=str(payload.get("ne_id") or ""),
body_ume_ne_id=str(payload.get("ume_ne_id") or ""),
commands=cmds,
)
return out
ack = start_ne_exec_job(kind=kind, arguments=payload, owner_user_id=uid, runner=_runner)
write_audit(
db,
action="ne.exec_job_start",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-jobs",
status_code=200 if ack.get("ok") else 429,
detail={"job_id": ack.get("job_id") or "", "kind": kind, "ok": bool(ack.get("ok"))},
)
return ack
@router.get("/exec-jobs/{job_id}")
def api_get_exec_job(
job_id: str,
ctx: Annotated[AuthContext, Depends(require_user)],
):
"""Poll own jobs; admins (role or admin:users scope) may read any job."""
if not has_scope(ctx.scopes, "ne:exec"):
raise HTTPException(status_code=403, detail={"error": "insufficient_scope", "required": ["ne:exec"]})
uid, _ = _actor(ctx)
is_admin = ctx.user.role == "admin" or has_scope(ctx.scopes, "admin:users")
out = get_ne_exec_job(job_id, user_id=uid, is_admin=is_admin)
if out.get("error_code") in {"job_forbidden", "invalid_job_id", "job_id_required"}:
status = 403 if out["error_code"] == "job_forbidden" else 400
raise HTTPException(status_code=status, detail=out)
return out
@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()]
if not ids:
raise HTTPException(status_code=400, detail="ids_required")
rows = db.query(ManagedNE).filter(ManagedNE.id.in_(ids)).all()
found_ids = {str(r.id) for r in rows}
missing = [x for x in ids if x not in found_ids]
if missing:
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
submitted = schedule_connect_tests(ids)
return {"ok": True, "submitted": submitted}
@router.get("/{ne_id}")
def api_get_managed_ne(ne_id: str, db: Session = Depends(get_db)):
out = get_managed_ne(db, ne_id).model_dump()
out["capability"] = build_ne_capability(
device_type=out.get("device_type"),
exec_policy=out.get("exec_policy"),
vendor=out.get("vendor"),
hop_enabled=bool(out.get("hop_enabled")),
)
return out
@router.patch("/{ne_id}")
def api_update_managed_ne(ne_id: str, body: ManagedNeUpdate, db: Session = Depends(get_db)):
return update_managed_ne(db, ne_id, body).model_dump()
@router.delete("/{ne_id}")
def api_delete_managed_ne(ne_id: str, db: Session = Depends(get_db)):
return delete_managed_ne(db, ne_id)