From 38a1c7f3a96bae89b0c8518684abd543ac635476 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 26 Jun 2026 17:13:49 +0800 Subject: [PATCH] feat(cli): UME lazy exec via shared profiles and ume_ne_id MCP support Co-authored-by: Cursor --- netx_api/cli_resolve.py | 219 ++++++++++++ netx_api/cli_router.py | 91 +++++ netx_api/cli_schemas.py | 122 +++++++ netx_api/cli_service.py | 332 +++++++++++++++++++ netx_api/main.py | 52 +++ netx_api/managed_ne_router.py | 5 +- netx_api/models.py | 48 +++ netx_api/ne_connect.py | 72 +++- netx_api/ne_exec.py | 33 +- netx_api/ne_schemas.py | 5 +- packages/netx-mcp/src/netx_mcp/http_tools.py | 47 ++- tests/test_cli_resolve.py | 54 +++ tests/test_mcp_http.py | 7 +- tests/test_ne_exec.py | 29 +- web/src/components/UmeCliConnectPanel.tsx | 265 +++++++++++++++ web/src/constants/queryKeys.ts | 4 + web/src/i18n/en.ts | 25 ++ web/src/i18n/zh.ts | 25 ++ web/src/pages/UmePage.tsx | 49 ++- web/src/services/api.ts | 25 ++ web/src/types.ts | 49 +++ 21 files changed, 1504 insertions(+), 54 deletions(-) create mode 100644 netx_api/cli_resolve.py create mode 100644 netx_api/cli_router.py create mode 100644 netx_api/cli_schemas.py create mode 100644 netx_api/cli_service.py create mode 100644 tests/test_cli_resolve.py create mode 100644 web/src/components/UmeCliConnectPanel.tsx diff --git a/netx_api/cli_resolve.py b/netx_api/cli_resolve.py new file mode 100644 index 0000000..f097982 --- /dev/null +++ b/netx_api/cli_resolve.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from .device_types import SUPPORTED_DEVICE_TYPES +from .models import CliConnectProfile, ManagedNE, UmeCliOverride, UmeInventoryNE +from .ne_crypto import decrypt_secret +from .ne_service import get_device_credentials, row_to_out + +_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [ + (re.compile(r"ZXR|ZXCTN|M6000|\bBN\b", re.I), "zte_zxros", "ZTE"), + (re.compile(r"NE40|CE\b|ATN|MA5800|OptiX", re.I), "huawei", "Huawei"), + (re.compile(r"ASR|NCS|IOS.?XR|XR\b", re.I), "cisco_xr", "Cisco"), + (re.compile(r"Catalyst|Nexus|C9[0-9]{3}|ISR", re.I), "cisco_ios", "Cisco"), +] + + +def _parse_ne_type_rules(raw: str) -> list[tuple[re.Pattern[str], str, str]]: + out: list[tuple[re.Pattern[str], str, str]] = [] + text = str(raw or "").strip() + if not text: + return out + try: + data = json.loads(text) + except json.JSONDecodeError: + return out + if not isinstance(data, list): + return out + for item in data: + if not isinstance(item, dict): + continue + pattern = str(item.get("pattern") or item.get("match") or "").strip() + device_type = str(item.get("device_type") or "").strip() + vendor = str(item.get("vendor") or "").strip() + if not pattern or not device_type: + continue + try: + out.append((re.compile(pattern, re.I), device_type, vendor or "Other")) + except re.error: + continue + return out + + +def infer_device_type_vendor(ne_type: str, profile: CliConnectProfile) -> tuple[str, str]: + text = str(ne_type or "").strip() + for rules in (_parse_ne_type_rules(profile.ne_type_rules), _BUILTIN_NE_TYPE_RULES): + for pattern, device_type, vendor in rules: + if pattern.search(text): + dt = device_type if device_type in SUPPORTED_DEVICE_TYPES else str(profile.device_type_default or "zte_zxros") + return dt, vendor or str(profile.vendor_default or "ZTE") + dt = str(profile.device_type_default or "zte_zxros").strip() + if dt not in SUPPORTED_DEVICE_TYPES: + dt = "zte_zxros" + return dt, str(profile.vendor_default or "ZTE") + + +def get_default_profile(db: Session) -> CliConnectProfile | None: + row = ( + db.query(CliConnectProfile) + .filter(CliConnectProfile.is_default.is_(True)) + .order_by(CliConnectProfile.updated_at.desc()) + .first() + ) + if row is not None: + return row + return db.query(CliConnectProfile).order_by(CliConnectProfile.created_at.asc()).first() + + +def profile_to_creds( + profile: CliConnectProfile, + *, + ip_address: str, + username: str, + device_type: str, + vendor: str, + port: int | None = None, + protocol: str | None = None, + target_password: str = "", +) -> dict[str, Any]: + hop_password = "" + if profile.hop_enabled and str(profile.hop_password_enc or "").strip(): + hop_password = decrypt_secret(profile.hop_password_enc) + target_pass = target_password + if not target_pass and str(profile.password_enc or "").strip(): + target_pass = decrypt_secret(profile.password_enc) + return { + "ip_address": str(ip_address), + "port": int(port or profile.port or 22), + "protocol": str(protocol or profile.protocol or "ssh"), + "username": str(username), + "password": str(target_pass), + "device_type": str(device_type), + "vendor": str(vendor), + "enable_secret": "", + "hop_enabled": bool(profile.hop_enabled), + "hop_vendor": str(profile.hop_vendor or "zte"), + "hop_host": str(profile.hop_host or ""), + "hop_port": int(profile.hop_port or 22), + "hop_protocol": str(profile.hop_protocol or "ssh"), + "hop_username": str(profile.hop_username or ""), + "hop_password": hop_password, + "hop_command_template": str(profile.hop_command_template or ""), + "hop_vrf": str(profile.hop_vrf or ""), + "hop_target_auth_mode": str(profile.hop_target_auth_mode or "bastion_managed"), + } + + +def resolve_cli_target( + db: Session, + *, + managed_ne_id: str | None = None, + ume_ne_id: str | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + mid = str(managed_ne_id or "").strip() + uid = str(ume_ne_id or "").strip() + if bool(mid) == bool(uid): + raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required") + + if mid: + row = db.get(ManagedNE, mid) + if not row: + raise HTTPException(status_code=404, detail="managed_ne_not_found") + creds = get_device_credentials(row) + meta = row_to_out(row).model_dump() + device = { + "source": "managed", + "id": meta["id"], + "ume_ne_id": None, + "name": meta["name"], + "ip_address": meta["ip_address"], + "ne_type": "", + "host_name": meta["name"], + "vendor": meta["vendor"], + "device_type": meta["device_type"], + "port": meta["port"], + "protocol": meta["protocol"], + "connect_status": meta["connect_status"], + "hop_enabled": meta["hop_enabled"], + "hop_vendor": meta["hop_vendor"], + } + return creds, device + + inv = db.get(UmeInventoryNE, uid) + if not inv: + raise HTTPException(status_code=404, detail="ume_ne_not_found") + ip = str(inv.ip_address or "").strip() + if not ip: + raise HTTPException(status_code=400, detail="ume_ne_ip_missing") + + override = db.get(UmeCliOverride, uid) + profile: CliConnectProfile | None = None + if override and override.profile_id: + profile = db.get(CliConnectProfile, str(override.profile_id)) + if profile is None: + profile = get_default_profile(db) + if profile is None: + raise HTTPException(status_code=503, detail="cli_connect_profile_not_configured") + + username = str(override.username_override or "").strip() if override else "" + if not username: + username = str(profile.username or "").strip() + if not username: + raise HTTPException(status_code=400, detail="cli_username_required") + + if override and str(override.device_type_override or "").strip(): + device_type = str(override.device_type_override).strip() + vendor = str(override.vendor_override or profile.vendor_default or "ZTE").strip() + else: + device_type, vendor = infer_device_type_vendor(str(inv.ne_type or ""), profile) + if override and str(override.vendor_override or "").strip(): + vendor = str(override.vendor_override).strip() + + creds = profile_to_creds( + profile, + ip_address=ip, + username=username, + device_type=device_type, + vendor=vendor, + ) + name = str(inv.user_label or inv.ne_name or inv.host_name or ip).strip() + connect_status = str(override.connect_status or "unknown") if override else "unknown" + device = { + "source": "ume", + "id": uid, + "ume_ne_id": uid, + "name": name, + "ip_address": ip, + "ne_type": str(inv.ne_type or ""), + "host_name": str(inv.host_name or ""), + "vendor": vendor, + "device_type": device_type, + "port": int(profile.port or 22), + "protocol": str(profile.protocol or "ssh"), + "connect_status": connect_status, + "hop_enabled": bool(profile.hop_enabled), + "hop_vendor": str(profile.hop_vendor or ""), + "cli_profile_id": str(profile.id), + "cli_profile_name": str(profile.name or ""), + } + return creds, device + + +def cli_profile_ready(db: Session) -> bool: + profile = get_default_profile(db) + if profile is None: + return False + if not str(profile.username or "").strip(): + return False + if profile.hop_enabled: + if not str(profile.hop_host or "").strip() or not str(profile.hop_username or "").strip(): + return False + if not str(profile.hop_password_enc or "").strip(): + return False + return True diff --git a/netx_api/cli_router.py b/netx_api/cli_router.py new file mode 100644 index 0000000..065063c --- /dev/null +++ b/netx_api/cli_router.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from .cli_schemas import ( + CliConnectProfileCreate, + CliConnectProfileUpdate, + UmeCliOverrideUpdate, + UmeConnectTestRequest, +) +from .cli_service import ( + cli_meta, + create_cli_profile, + delete_cli_profile, + get_cli_profile, + get_ume_cli_override, + list_cli_profiles, + list_cli_targets, + set_default_cli_profile, + update_cli_profile, + upsert_ume_cli_override, +) +from .db import get_db +from .ne_connect import schedule_ume_connect_tests + +router = APIRouter(prefix="/v1/cli", tags=["cli"]) + + +@router.get("/meta") +def api_cli_meta(db: Session = Depends(get_db)): + return cli_meta(db) + + +@router.get("/profiles") +def api_list_cli_profiles(db: Session = Depends(get_db)): + return {"items": [x.model_dump() for x in list_cli_profiles(db)]} + + +@router.post("/profiles") +def api_create_cli_profile(body: CliConnectProfileCreate, db: Session = Depends(get_db)): + return create_cli_profile(db, body).model_dump() + + +@router.get("/profiles/{profile_id}") +def api_get_cli_profile(profile_id: str, db: Session = Depends(get_db)): + return get_cli_profile(db, profile_id).model_dump() + + +@router.patch("/profiles/{profile_id}") +def api_update_cli_profile(profile_id: str, body: CliConnectProfileUpdate, db: Session = Depends(get_db)): + return update_cli_profile(db, profile_id, body).model_dump() + + +@router.post("/profiles/{profile_id}/default") +def api_set_default_cli_profile(profile_id: str, db: Session = Depends(get_db)): + return set_default_cli_profile(db, profile_id).model_dump() + + +@router.delete("/profiles/{profile_id}") +def api_delete_cli_profile(profile_id: str, db: Session = Depends(get_db)): + return delete_cli_profile(db, profile_id) + + +@router.get("/targets") +def api_list_cli_targets( + source: str = Query(default="all"), + keyword: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=500), + db: Session = Depends(get_db), +): + return list_cli_targets(db, source=source, keyword=keyword, page=page, page_size=page_size) + + +@router.get("/ume-overrides/{ume_ne_id}") +def api_get_ume_cli_override(ume_ne_id: str, db: Session = Depends(get_db)): + row = get_ume_cli_override(db, ume_ne_id) + return row.model_dump() if row else None + + +@router.patch("/ume-overrides/{ume_ne_id}") +def api_upsert_ume_cli_override(ume_ne_id: str, body: UmeCliOverrideUpdate, db: Session = Depends(get_db)): + return upsert_ume_cli_override(db, ume_ne_id, body).model_dump() + + +@router.post("/ume-connect-test") +def api_ume_connect_test(body: UmeConnectTestRequest, db: Session = Depends(get_db)): + ids = [str(x).strip() for x in body.ume_ne_ids if str(x).strip()] + submitted = schedule_ume_connect_tests(ids) + return {"ok": True, "submitted": submitted} diff --git a/netx_api/cli_schemas.py b/netx_api/cli_schemas.py new file mode 100644 index 0000000..b619399 --- /dev/null +++ b/netx_api/cli_schemas.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator + +from .device_types import SUPPORTED_VENDORS + + +class CliConnectProfileCreate(BaseModel): + name: str + username: str + password: str = "" + port: int = 22 + protocol: str = "ssh" + device_type_default: str = "zte_zxros" + vendor_default: str = "ZTE" + ne_type_rules: str = "" + is_default: bool = False + hop_enabled: bool = False + hop_vendor: str = "zte" + hop_host: str = "" + hop_port: int = 22 + hop_protocol: str = "ssh" + hop_username: str = "" + hop_password: str = "" + hop_command_template: str = "" + hop_vrf: str = "" + hop_target_auth_mode: str = "bastion_managed" + + @field_validator("vendor_default") + @classmethod + def normalize_vendor(cls, v: str) -> str: + raw = str(v or "").strip() + if not raw: + return "ZTE" + for item in SUPPORTED_VENDORS: + if item.lower() == raw.lower(): + return item + return "Other" + + +class CliConnectProfileUpdate(BaseModel): + name: str | None = None + username: str | None = None + password: str | None = None + port: int | None = None + protocol: str | None = None + device_type_default: str | None = None + vendor_default: str | None = None + ne_type_rules: str | None = None + is_default: bool | None = None + hop_enabled: bool | None = None + hop_vendor: str | None = None + hop_host: str | None = None + hop_port: int | None = None + hop_protocol: str | None = None + hop_username: str | None = None + hop_password: str | None = None + hop_command_template: str | None = None + hop_vrf: str | None = None + hop_target_auth_mode: str | None = None + + +class CliConnectProfileOut(BaseModel): + id: str + name: str + is_default: bool + username: str + port: int + protocol: str + device_type_default: str + vendor_default: str + ne_type_rules: str + hop_enabled: bool + hop_vendor: str + hop_host: str + hop_port: int + hop_protocol: str + hop_username: str + hop_command_template: str + hop_vrf: str + hop_target_auth_mode: str + created_at: datetime + updated_at: datetime + + +class UmeCliOverrideUpdate(BaseModel): + profile_id: str | None = None + username_override: str | None = None + device_type_override: str | None = None + vendor_override: str | None = None + + +class UmeCliOverrideOut(BaseModel): + ume_ne_id: str + profile_id: str | None + username_override: str + device_type_override: str + vendor_override: str + connect_status: str + connect_message: str + connect_detail: str + connect_tested_at: datetime | None + updated_at: datetime + + +class UmeConnectTestRequest(BaseModel): + ume_ne_ids: list[str] = Field(min_length=1) + + +class CliTargetOut(BaseModel): + source: str + id: str + ume_ne_id: str | None = None + name: str + ip_address: str + ne_type: str = "" + vendor: str = "" + device_type: str = "" + connect_status: str = "unknown" + cli_profile_ready: bool = False diff --git a/netx_api/cli_service.py b/netx_api/cli_service.py new file mode 100644 index 0000000..e153cb0 --- /dev/null +++ b/netx_api/cli_service.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from .cli_schemas import ( + CliConnectProfileCreate, + CliConnectProfileOut, + CliConnectProfileUpdate, + CliTargetOut, + UmeCliOverrideOut, + UmeCliOverrideUpdate, +) +from .cli_resolve import cli_profile_ready, get_default_profile +from .device_types import SUPPORTED_DEVICE_TYPES +from .models import CliConnectProfile, ManagedNE, UmeCliOverride, UmeInventoryNE +from .ne_crypto import credentials_configured, encrypt_secret +from .ne_service import ( + _normalize_hop_target_auth_mode, + _normalize_hop_vendor, + _normalize_protocol, + _require_crypto, + list_managed_ne, +) + + +def _now() -> datetime: + return datetime.utcnow() + + +def _validate_profile_hop(body: CliConnectProfileCreate | CliConnectProfileUpdate, *, hop_enabled: bool) -> None: + if not hop_enabled: + return + host = str(getattr(body, "hop_host", None) or "").strip() + user = str(getattr(body, "hop_username", None) or "").strip() + if not host: + raise HTTPException(status_code=400, detail="hop_host_required") + if not user: + raise HTTPException(status_code=400, detail="hop_username_required") + pwd = getattr(body, "hop_password", None) + if isinstance(body, CliConnectProfileCreate) and not str(pwd or "").strip(): + raise HTTPException(status_code=400, detail="hop_password_required") + + +def _profile_out(row: CliConnectProfile) -> CliConnectProfileOut: + return CliConnectProfileOut( + id=str(row.id), + name=str(row.name or ""), + is_default=bool(row.is_default), + username=str(row.username or ""), + port=int(row.port or 22), + protocol=str(row.protocol or "ssh"), + device_type_default=str(row.device_type_default or ""), + vendor_default=str(row.vendor_default or ""), + ne_type_rules=str(row.ne_type_rules or ""), + hop_enabled=bool(row.hop_enabled), + hop_vendor=str(row.hop_vendor or "zte"), + hop_host=str(row.hop_host or ""), + hop_port=int(row.hop_port or 22), + hop_protocol=str(row.hop_protocol or "ssh"), + hop_username=str(row.hop_username or ""), + hop_command_template=str(row.hop_command_template or ""), + hop_vrf=str(row.hop_vrf or ""), + hop_target_auth_mode=str(row.hop_target_auth_mode or "bastion_managed"), + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _override_out(row: UmeCliOverride) -> UmeCliOverrideOut: + return UmeCliOverrideOut( + ume_ne_id=str(row.ume_ne_id), + profile_id=str(row.profile_id) if row.profile_id else None, + username_override=str(row.username_override or ""), + device_type_override=str(row.device_type_override or ""), + vendor_override=str(row.vendor_override or ""), + connect_status=str(row.connect_status or "unknown"), + connect_message=str(row.connect_message or ""), + connect_detail=str(row.connect_detail or "")[:8000], + connect_tested_at=row.connect_tested_at, + updated_at=row.updated_at, + ) + + +def list_cli_profiles(db: Session) -> list[CliConnectProfileOut]: + rows = db.query(CliConnectProfile).order_by(CliConnectProfile.is_default.desc(), CliConnectProfile.name.asc()).all() + return [_profile_out(r) for r in rows] + + +def get_cli_profile(db: Session, profile_id: str) -> CliConnectProfileOut: + row = db.get(CliConnectProfile, str(profile_id or "").strip()) + if not row: + raise HTTPException(status_code=404, detail="cli_profile_not_found") + return _profile_out(row) + + +def create_cli_profile(db: Session, body: CliConnectProfileCreate) -> CliConnectProfileOut: + _require_crypto() + if body.device_type_default not in SUPPORTED_DEVICE_TYPES: + raise HTTPException(status_code=400, detail="unsupported_device_type") + _validate_profile_hop(body, hop_enabled=bool(body.hop_enabled)) + if not str(body.username or "").strip(): + raise HTTPException(status_code=400, detail="username_required") + row = CliConnectProfile( + name=str(body.name or "").strip() or "default", + username=str(body.username).strip(), + password_enc=encrypt_secret(body.password) if str(body.password or "").strip() else "", + port=int(body.port or 22), + protocol=_normalize_protocol(body.protocol), + device_type_default=str(body.device_type_default), + vendor_default=str(body.vendor_default), + ne_type_rules=str(body.ne_type_rules or ""), + hop_enabled=bool(body.hop_enabled), + hop_vendor=_normalize_hop_vendor(body.hop_vendor), + hop_host=str(body.hop_host or "").strip(), + hop_port=int(body.hop_port or 22), + hop_protocol=_normalize_protocol(body.hop_protocol), + hop_username=str(body.hop_username or "").strip(), + hop_password_enc=encrypt_secret(body.hop_password) if body.hop_enabled and body.hop_password else "", + hop_command_template=str(body.hop_command_template or "").strip(), + hop_vrf=str(body.hop_vrf or "").strip(), + hop_target_auth_mode=_normalize_hop_target_auth_mode(body.hop_target_auth_mode), + ) + if body.is_default or db.query(CliConnectProfile).count() == 0: + db.query(CliConnectProfile).update({CliConnectProfile.is_default: False}) + row.is_default = True + db.add(row) + db.commit() + db.refresh(row) + return _profile_out(row) + + +def update_cli_profile(db: Session, profile_id: str, body: CliConnectProfileUpdate) -> CliConnectProfileOut: + row = db.get(CliConnectProfile, str(profile_id or "").strip()) + if not row: + raise HTTPException(status_code=404, detail="cli_profile_not_found") + data = body.model_dump(exclude_unset=True) + hop_enabled = bool(data["hop_enabled"]) if "hop_enabled" in data else bool(row.hop_enabled) + if hop_enabled: + _validate_profile_hop(body, hop_enabled=True) + if "name" in data and data["name"] is not None: + row.name = str(data["name"]).strip() + if "username" in data and data["username"] is not None: + row.username = str(data["username"]).strip() + if "password" in data and data["password"]: + _require_crypto() + row.password_enc = encrypt_secret(str(data["password"])) + if "port" in data and data["port"] is not None: + row.port = int(data["port"]) + if "protocol" in data and data["protocol"] is not None: + row.protocol = _normalize_protocol(data["protocol"]) + if "device_type_default" in data and data["device_type_default"] is not None: + if data["device_type_default"] not in SUPPORTED_DEVICE_TYPES: + raise HTTPException(status_code=400, detail="unsupported_device_type") + row.device_type_default = str(data["device_type_default"]) + if "vendor_default" in data and data["vendor_default"] is not None: + row.vendor_default = str(data["vendor_default"]) + if "ne_type_rules" in data and data["ne_type_rules"] is not None: + row.ne_type_rules = str(data["ne_type_rules"]) + hop_keys = ( + "hop_enabled", + "hop_vendor", + "hop_host", + "hop_port", + "hop_protocol", + "hop_username", + "hop_command_template", + "hop_vrf", + "hop_target_auth_mode", + ) + for key in hop_keys: + if key in data and data[key] is not None: + setattr(row, key, data[key]) + if "hop_vendor" in data and data["hop_vendor"] is not None: + row.hop_vendor = _normalize_hop_vendor(data["hop_vendor"]) + if "hop_protocol" in data and data["hop_protocol"] is not None: + row.hop_protocol = _normalize_protocol(data["hop_protocol"]) + if "hop_target_auth_mode" in data and data["hop_target_auth_mode"] is not None: + row.hop_target_auth_mode = _normalize_hop_target_auth_mode(data["hop_target_auth_mode"]) + if "hop_password" in data and data["hop_password"]: + _require_crypto() + row.hop_password_enc = encrypt_secret(str(data["hop_password"])) + if body.is_default is True: + db.query(CliConnectProfile).filter(CliConnectProfile.id != row.id).update({CliConnectProfile.is_default: False}) + row.is_default = True + row.updated_at = _now() + db.commit() + db.refresh(row) + return _profile_out(row) + + +def set_default_cli_profile(db: Session, profile_id: str) -> CliConnectProfileOut: + row = db.get(CliConnectProfile, str(profile_id or "").strip()) + if not row: + raise HTTPException(status_code=404, detail="cli_profile_not_found") + db.query(CliConnectProfile).update({CliConnectProfile.is_default: False}) + row.is_default = True + row.updated_at = _now() + db.commit() + db.refresh(row) + return _profile_out(row) + + +def delete_cli_profile(db: Session, profile_id: str) -> dict[str, Any]: + row = db.get(CliConnectProfile, str(profile_id or "").strip()) + if not row: + raise HTTPException(status_code=404, detail="cli_profile_not_found") + was_default = bool(row.is_default) + db.delete(row) + db.commit() + if was_default: + first = db.query(CliConnectProfile).order_by(CliConnectProfile.created_at.asc()).first() + if first is not None: + first.is_default = True + first.updated_at = _now() + db.commit() + return {"ok": True} + + +def get_ume_cli_override(db: Session, ume_ne_id: str) -> UmeCliOverrideOut | None: + row = db.get(UmeCliOverride, str(ume_ne_id or "").strip()) + return _override_out(row) if row else None + + +def upsert_ume_cli_override(db: Session, ume_ne_id: str, body: UmeCliOverrideUpdate) -> UmeCliOverrideOut: + uid = str(ume_ne_id or "").strip() + if not db.get(UmeInventoryNE, uid): + raise HTTPException(status_code=404, detail="ume_ne_not_found") + row = db.get(UmeCliOverride, uid) + if row is None: + row = UmeCliOverride(ume_ne_id=uid) + db.add(row) + data = body.model_dump(exclude_unset=True) + if "profile_id" in data: + pid = str(data["profile_id"] or "").strip() if data["profile_id"] else "" + if pid and not db.get(CliConnectProfile, pid): + raise HTTPException(status_code=404, detail="cli_profile_not_found") + row.profile_id = pid or None + if "username_override" in data and data["username_override"] is not None: + row.username_override = str(data["username_override"]).strip() + if "device_type_override" in data and data["device_type_override"] is not None: + row.device_type_override = str(data["device_type_override"]).strip() + if "vendor_override" in data and data["vendor_override"] is not None: + row.vendor_override = str(data["vendor_override"]).strip() + row.updated_at = _now() + db.commit() + db.refresh(row) + return _override_out(row) + + +def list_cli_targets( + db: Session, + *, + source: str = "all", + keyword: str | None = None, + page: int = 1, + page_size: int = 50, +) -> dict[str, Any]: + src = str(source or "all").strip().lower() + if src not in ("managed", "ume", "all"): + raise HTTPException(status_code=400, detail="invalid_source") + ready = cli_profile_ready(db) + items: list[dict[str, Any]] = [] + total = 0 + + if src in ("managed", "all"): + managed = list_managed_ne(db, keyword=keyword, page=page, page_size=page_size) + total += int(managed["total"]) + for row in managed["items"]: + items.append( + CliTargetOut( + source="managed", + id=str(row.id), + ume_ne_id=None, + name=str(row.name or row.ip_address), + ip_address=str(row.ip_address), + vendor=str(row.vendor), + device_type=str(row.device_type), + connect_status=str(row.connect_status), + cli_profile_ready=True, + ).model_dump() + ) + + if src in ("ume", "all"): + stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin( + UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id + ) + kw = str(keyword or "").strip() + if kw: + like = f"%{kw}%" + stmt = stmt.filter( + UmeInventoryNE.ne_id.ilike(like) + | UmeInventoryNE.ne_name.ilike(like) + | UmeInventoryNE.user_label.ilike(like) + | UmeInventoryNE.ip_address.ilike(like) + | UmeInventoryNE.host_name.ilike(like) + ) + ume_total = stmt.count() + total += ume_total if src == "ume" else ume_total + rows = ( + stmt.order_by(UmeInventoryNE.ne_id.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + for inv, ov in rows: + items.append( + CliTargetOut( + source="ume", + id=str(inv.ne_id), + ume_ne_id=str(inv.ne_id), + name=str(inv.user_label or inv.ne_name or inv.host_name or inv.ip_address or inv.ne_id), + ip_address=str(inv.ip_address or ""), + ne_type=str(inv.ne_type or ""), + vendor=str(inv.vendor or ""), + connect_status=str(ov.connect_status if ov else "unknown"), + cli_profile_ready=ready, + ).model_dump() + ) + + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +def cli_meta(db: Session) -> dict[str, Any]: + return { + "credentials_configured": credentials_configured(), + "default_profile_configured": get_default_profile(db) is not None, + "cli_profile_ready": cli_profile_ready(db), + } diff --git a/netx_api/main.py b/netx_api/main.py index d620cfd..813731d 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -22,6 +22,7 @@ from .ap_client import analyze_with_oclaw, health_with_oclaw 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 .managed_ne_router import router as managed_ne_router from .importer import aggregate_alarms, import_alarm_excel, query_alarms from .models import ( @@ -120,6 +121,7 @@ from .schemas import ( app = FastAPI(title="netx ops tool", version="0.1.0") app.include_router(managed_ne_router) +app.include_router(cli_router) app.include_router(collection_router) parser_cfg = load_parser_config() _UME_CLIENT_SINGLETON = UMEClient( @@ -913,6 +915,56 @@ def on_startup() -> None: "UPDATE ne_collection_job SET last_run_at = COALESCE(ended_at, started_at, created_at) " "WHERE last_run_at IS NULL" ) + conn.exec_driver_sql( + """ + CREATE TABLE IF NOT EXISTS cli_connect_profile ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(256) DEFAULT '', + is_default BOOLEAN DEFAULT FALSE, + username VARCHAR(128) DEFAULT '', + password_enc TEXT DEFAULT '', + port INTEGER DEFAULT 22, + protocol VARCHAR(16) DEFAULT 'ssh', + device_type_default VARCHAR(128) DEFAULT 'zte_zxros', + vendor_default VARCHAR(64) DEFAULT 'ZTE', + ne_type_rules TEXT DEFAULT '', + hop_enabled BOOLEAN DEFAULT FALSE, + hop_vendor VARCHAR(32) DEFAULT 'zte', + hop_host VARCHAR(128) DEFAULT '', + hop_port INTEGER DEFAULT 22, + hop_protocol VARCHAR(16) DEFAULT 'ssh', + hop_username VARCHAR(128) DEFAULT '', + hop_password_enc TEXT DEFAULT '', + hop_command_template TEXT DEFAULT '', + hop_vrf VARCHAR(128) DEFAULT '', + hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed', + created_at TIMESTAMP, + updated_at TIMESTAMP + ) + """ + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_cli_connect_profile_is_default ON cli_connect_profile (is_default)" + ) + conn.exec_driver_sql( + """ + CREATE TABLE IF NOT EXISTS ume_cli_override ( + ume_ne_id VARCHAR(128) PRIMARY KEY, + profile_id VARCHAR(64), + username_override VARCHAR(128) DEFAULT '', + device_type_override VARCHAR(128) DEFAULT '', + vendor_override VARCHAR(64) DEFAULT '', + connect_status VARCHAR(32) DEFAULT 'unknown', + connect_message VARCHAR(512) DEFAULT '', + connect_detail TEXT DEFAULT '', + connect_tested_at TIMESTAMP, + updated_at TIMESTAMP + ) + """ + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_ume_cli_override_connect_status ON ume_cli_override (connect_status)" + ) conn.exec_driver_sql("COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'") diff --git a/netx_api/managed_ne_router.py b/netx_api/managed_ne_router.py index 13c5901..ee10a25 100644 --- a/netx_api/managed_ne_router.py +++ b/netx_api/managed_ne_router.py @@ -118,11 +118,12 @@ def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends( @router.post("/exec") def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)): - """Login to a managed NE and run read-only CLI (show/display/ping). For oclaw ops tools.""" + """Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping).""" return execute_managed_ne_commands( db, - body.ne_id, body.commands, + ne_id=body.ne_id, + ume_ne_id=body.ume_ne_id, read_timeout_sec=body.read_timeout_sec, ) diff --git a/netx_api/models.py b/netx_api/models.py index 6649784..eedf56b 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -300,6 +300,54 @@ class ManagedNE(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) +class CliConnectProfile(Base): + """Reusable SSH/Telnet + hop credentials for UME lazy CLI exec.""" + + __tablename__ = "cli_connect_profile" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + name: Mapped[str] = mapped_column(String(256), default="", index=True) + is_default: Mapped[bool] = mapped_column(default=False, index=True) + username: Mapped[str] = mapped_column(String(128), default="") + password_enc: Mapped[str] = mapped_column(Text, default="") + port: Mapped[int] = mapped_column(Integer, default=22) + protocol: Mapped[str] = mapped_column(String(16), default="ssh") + device_type_default: Mapped[str] = mapped_column(String(128), default="zte_zxros") + vendor_default: Mapped[str] = mapped_column(String(64), default="ZTE") + ne_type_rules: Mapped[str] = mapped_column(Text, default="") + hop_enabled: Mapped[bool] = mapped_column(default=False) + hop_vendor: Mapped[str] = mapped_column(String(32), default="zte") + hop_host: Mapped[str] = mapped_column(String(128), default="") + hop_port: Mapped[int] = mapped_column(Integer, default=22) + hop_protocol: Mapped[str] = mapped_column(String(16), default="ssh") + hop_username: Mapped[str] = mapped_column(String(128), default="") + hop_password_enc: Mapped[str] = mapped_column(Text, default="") + hop_command_template: Mapped[str] = mapped_column(Text, default="") + hop_vrf: Mapped[str] = mapped_column(String(128), default="") + hop_target_auth_mode: Mapped[str] = mapped_column(String(32), default="bastion_managed") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + +class UmeCliOverride(Base): + """Per-UME NE CLI overrides and connect-test cache.""" + + __tablename__ = "ume_cli_override" + + ume_ne_id: Mapped[str] = mapped_column(String(128), primary_key=True) + profile_id: Mapped[str | None] = mapped_column( + String(64), ForeignKey("cli_connect_profile.id", ondelete="SET NULL"), nullable=True + ) + username_override: Mapped[str] = mapped_column(String(128), default="") + device_type_override: Mapped[str] = mapped_column(String(128), default="") + vendor_override: Mapped[str] = mapped_column(String(64), default="") + connect_status: Mapped[str] = mapped_column(String(32), default="unknown", index=True) + connect_message: Mapped[str] = mapped_column(String(512), default="") + connect_detail: Mapped[str] = mapped_column(Text, default="") + connect_tested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + class NeCollectionJob(Base): """Batch CLI collection job over managed NEs.""" diff --git a/netx_api/ne_connect.py b/netx_api/ne_connect.py index 78684ea..309eed8 100644 --- a/netx_api/ne_connect.py +++ b/netx_api/ne_connect.py @@ -9,8 +9,9 @@ from typing import Any from .config import settings from .db import SessionLocal -from .models import ManagedNE +from .models import ManagedNE, UmeCliOverride, UmeInventoryNE from .ne_crypto import CredentialCryptoError +from .cli_resolve import resolve_cli_target from .ne_service import get_device_credentials from .ne_session_factory import ( bastion_ssh_cli, @@ -356,6 +357,75 @@ def _run_single(ne_id: str) -> None: db.close() +def _update_ume_override_row( + ume_ne_id: str, + status: str, + message: str, + discovered_name: str | None = None, + *, + detail: str = "", +) -> None: + db = SessionLocal() + try: + uid = str(ume_ne_id or "").strip() + row = db.get(UmeCliOverride, uid) + if row is None: + if not db.get(UmeInventoryNE, uid): + return + row = UmeCliOverride(ume_ne_id=uid) + db.add(row) + row.connect_status = status + row.connect_message = str(message or "")[:500] + row.connect_detail = _truncate_detail(detail) + row.connect_tested_at = datetime.utcnow() + row.updated_at = datetime.utcnow() + db.commit() + finally: + db.close() + + +def _run_single_ume(ume_ne_id: str) -> None: + db = SessionLocal() + try: + uid = str(ume_ne_id or "").strip() + if not db.get(UmeInventoryNE, uid): + return + row = db.get(UmeCliOverride, uid) + if row is None: + row = UmeCliOverride(ume_ne_id=uid) + db.add(row) + row.connect_status = "testing" + row.connect_message = "" + row.connect_detail = "" + row.updated_at = datetime.utcnow() + db.commit() + try: + creds, _device = resolve_cli_target(db, ume_ne_id=uid) + except Exception as exc: + detail = _truncate_detail(traceback.format_exc()) + _update_ume_override_row(uid, "fail", str(exc)[:480], detail=detail) + return + status, message, discovered, detail = _probe_device(creds) + _update_ume_override_row(uid, status, message, discovered, detail=detail) + except Exception as exc: + _log.exception("ume connect test failed for %s", ume_ne_id) + _update_ume_override_row(ume_ne_id, "fail", str(exc)[:480], detail=_truncate_detail(traceback.format_exc())) + finally: + db.close() + + +def schedule_ume_connect_tests(ume_ne_ids: list[str]) -> int: + pool = _executor_pool() + submitted = 0 + for ume_ne_id in ume_ne_ids: + uid = str(ume_ne_id or "").strip() + if not uid: + continue + pool.submit(_run_single_ume, uid) + submitted += 1 + return submitted + + def schedule_connect_tests(ne_ids: list[str]) -> int: pool = _executor_pool() submitted = 0 diff --git a/netx_api/ne_exec.py b/netx_api/ne_exec.py index 7f10407..3427151 100644 --- a/netx_api/ne_exec.py +++ b/netx_api/ne_exec.py @@ -8,11 +8,10 @@ from typing import Any from fastapi import HTTPException from sqlalchemy.orm import Session +from .cli_resolve import resolve_cli_target from .config import settings -from .models import ManagedNE from .ne_collect_runner import _collect_on_device from .ne_crypto import credentials_configured -from .ne_service import get_device_credentials, row_to_out _EXEC_MAX_COMMANDS = 5 _EXEC_MAX_OUTPUT = 32_000 @@ -60,16 +59,18 @@ def _normalize_read_timeout(sec: int | None) -> int: def execute_managed_ne_commands( db: Session, - ne_id: str, commands: list[str], *, + ne_id: str | None = None, + ume_ne_id: str | None = None, read_timeout_sec: int | None = None, ) -> dict[str, Any]: if not credentials_configured(): raise HTTPException(status_code=503, detail="credential_secret_key_not_configured") - nid = str(ne_id or "").strip() - if not nid: - raise HTTPException(status_code=400, detail="ne_id_required") + mid = str(ne_id or "").strip() + uid = str(ume_ne_id or "").strip() + if bool(mid) == bool(uid): + raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required") cmds = [str(c).strip() for c in commands if str(c).strip()] if not cmds: raise HTTPException(status_code=400, detail="commands_required") @@ -78,26 +79,8 @@ def execute_managed_ne_commands( for c in cmds: _validate_command(c) - row = db.get(ManagedNE, nid) - if not row: - raise HTTPException(status_code=404, detail="managed_ne_not_found") - + creds, device = resolve_cli_target(db, managed_ne_id=mid or None, ume_ne_id=uid or None) read_timeout = _normalize_read_timeout(read_timeout_sec) - creds = get_device_credentials(row) - meta = row_to_out(row).model_dump() - # Shallow copy for response (no secrets). - device = { - "id": meta["id"], - "name": meta["name"], - "vendor": meta["vendor"], - "device_type": meta["device_type"], - "ip_address": meta["ip_address"], - "port": meta["port"], - "protocol": meta["protocol"], - "connect_status": meta["connect_status"], - "hop_enabled": meta["hop_enabled"], - "hop_vendor": meta["hop_vendor"], - } prev_collect_timeout = int(settings.ne_collect_read_timeout_sec or 120) try: diff --git a/netx_api/ne_schemas.py b/netx_api/ne_schemas.py index 9bc85e1..a5e2342 100644 --- a/netx_api/ne_schemas.py +++ b/netx_api/ne_schemas.py @@ -113,9 +113,10 @@ class ConnectTestRequest(BaseModel): class ManagedNeExecRequest(BaseModel): - """Run read-only show/display CLI on a managed NE (oclaw ops integration).""" + """Run read-only show/display CLI on a managed NE or UME inventory NE (oclaw ops integration).""" - ne_id: str + ne_id: str | None = None + ume_ne_id: str | None = None commands: list[str] = Field(min_length=1, max_length=5) read_timeout_sec: int | None = Field(default=None, ge=10, le=120) diff --git a/packages/netx-mcp/src/netx_mcp/http_tools.py b/packages/netx-mcp/src/netx_mcp/http_tools.py index ed4d620..d2578da 100644 --- a/packages/netx-mcp/src/netx_mcp/http_tools.py +++ b/packages/netx-mcp/src/netx_mcp/http_tools.py @@ -221,8 +221,13 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]: def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]: ne_id = str(args.get("ne_id") or "").strip() - if not ne_id: - return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"} + ume_ne_id = str(args.get("ume_ne_id") or "").strip() + if bool(ne_id) == bool(ume_ne_id): + return { + "ok": False, + "error": "exactly_one_of_ne_id_or_ume_ne_id_required", + "error_code": "exactly_one_of_ne_id_or_ume_ne_id_required", + } raw_cmds = args.get("commands") if not isinstance(raw_cmds, list) or not raw_cmds: return {"ok": False, "error": "commands_required", "error_code": "commands_required"} @@ -231,7 +236,11 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]: return {"ok": False, "error": "commands_required", "error_code": "commands_required"} if len(commands) > 5: return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"} - body: dict[str, Any] = {"ne_id": ne_id, "commands": commands} + body: dict[str, Any] = {"commands": commands} + if ne_id: + body["ne_id"] = ne_id + if ume_ne_id: + body["ume_ne_id"] = ume_ne_id rts = args.get("read_timeout_sec") if rts is not None: body["read_timeout_sec"] = int(rts) @@ -244,6 +253,17 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "data": data} +def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]: + page = max(1, int(args.get("page") or 1)) + page_size = min(500, max(1, int(args.get("page_size") or 50))) + params: dict[str, Any] = {"page": page, "page_size": page_size} + if str(args.get("source") or "").strip(): + params["source"] = str(args.get("source")).strip() + if str(args.get("keyword") or "").strip(): + params["keyword"] = str(args.get("keyword")).strip() + return http_json("GET", "/v1/cli/targets", params=params) + + HTTP_MCP_TOOLS: list[dict[str, Any]] = [ { "name": "queryUmeAlarms", @@ -391,15 +411,31 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ }, { "name": "execManagedNe", - "description": "Run read-only CLI on a managed NE via netx (show/display/ping; max 5 commands).", + "description": "Run read-only CLI via netx (show/display/ping; max 5 commands). Use ne_id (managed NE) OR ume_ne_id (UME inventory).", "inputSchema": { "type": "object", "properties": { "ne_id": {"type": "string"}, + "ume_ne_id": {"type": "string"}, "commands": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5}, "read_timeout_sec": {"type": "integer", "minimum": 10, "maximum": 120}, }, - "required": ["ne_id", "commands"], + "required": ["commands"], + "additionalProperties": False, + }, + }, + { + "name": "listCliTargets", + "description": "List CLI-capable targets (managed NE and/or UME inventory); use before execManagedNe.", + "inputSchema": { + "type": "object", + "properties": { + "source": {"type": "string", "enum": ["managed", "ume", "all"], "default": "all"}, + "keyword": {"type": "string"}, + "page": {"type": "integer", "minimum": 1, "default": 1}, + "page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}, + }, + "required": [], "additionalProperties": False, }, }, @@ -418,6 +454,7 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "listManagedNe": _list_managed_ne, "getManagedNe": _get_managed_ne, "execManagedNe": _exec_managed_ne, + "listCliTargets": _list_cli_targets, } diff --git a/tests/test_cli_resolve.py b/tests/test_cli_resolve.py new file mode 100644 index 0000000..e14edc8 --- /dev/null +++ b/tests/test_cli_resolve.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from netx_api.cli_resolve import infer_device_type_vendor +from netx_api.models import CliConnectProfile, UmeInventoryNE + + +class CliResolveTests(unittest.TestCase): + def test_infer_zte_ne_type(self) -> None: + profile = CliConnectProfile(device_type_default="zte_zxros", vendor_default="ZTE") + dt, vendor = infer_device_type_vendor("ZXCTN 6180H", profile) + self.assertEqual(dt, "zte_zxros") + self.assertEqual(vendor, "ZTE") + + def test_resolve_ume_target(self) -> None: + from netx_api.cli_resolve import resolve_cli_target + + db = MagicMock() + inv = UmeInventoryNE(ne_id="ume-1", ip_address="10.0.0.1", ne_type="ZXCTN", user_label="NE-A") + profile = CliConnectProfile( + id="p1", + name="default", + is_default=True, + username="ca-oper", + password_enc="", + device_type_default="zte_zxros", + vendor_default="ZTE", + hop_enabled=True, + hop_vendor="bastion", + hop_host="10.34.145.27", + hop_username="ZTE-FIVIE", + hop_password_enc="enc", + hop_command_template="{hop_user}@{target_user}@{target_ip}", + ) + db.get.side_effect = lambda model, key: { + (UmeInventoryNE, "ume-1"): inv, + (type(None), "ume-1"): None, + }.get((model, key)) + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = profile + + with unittest.mock.patch("netx_api.cli_resolve.decrypt_secret", return_value="vault-pass"): + creds, device = resolve_cli_target(db, ume_ne_id="ume-1") + + self.assertEqual(creds["ip_address"], "10.0.0.1") + self.assertEqual(creds["username"], "ca-oper") + self.assertEqual(creds["hop_host"], "10.34.145.27") + self.assertEqual(device["source"], "ume") + self.assertEqual(device["ume_ne_id"], "ume-1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 4181e97..38b3314 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -12,12 +12,13 @@ import pytest from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool -def test_http_mcp_tool_list_has_twelve_tools() -> None: +def test_http_mcp_tool_list_has_thirteen_tools() -> None: names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS] - assert len(names) == 12 + assert len(names) == 13 assert "queryUmeAlarms" in names assert "queryUmeAlarmsRaw" in names assert "execManagedNe" in names + assert "listCliTargets" in names def test_call_query_ume_alarms_forwards_http() -> None: @@ -81,7 +82,7 @@ def test_stdio_initialize_and_tools_list() -> None: list_line = proc.stdout.readline() list_resp = json.loads(list_line) tools = list_resp["result"]["tools"] - assert len(tools) == 12 + assert len(tools) == 13 proc.terminate() proc.wait(timeout=5) diff --git a/tests/test_ne_exec.py b/tests/test_ne_exec.py index 474dd84..d170d42 100644 --- a/tests/test_ne_exec.py +++ b/tests/test_ne_exec.py @@ -83,15 +83,14 @@ class NeExecValidationTests(unittest.TestCase): class NeExecRunTests(unittest.TestCase): @patch("netx_api.ne_exec.credentials_configured", return_value=True) @patch("netx_api.ne_exec._collect_on_device", return_value="ok-output") - @patch("netx_api.ne_exec.get_device_credentials", return_value={"ip_address": "1.1.1.1"}) - def test_execute_success(self, _creds, _collect, _configured) -> None: - row = MagicMock() - row.id = "ne-1" - db = MagicMock() - db.get.return_value = row - with patch("netx_api.ne_exec.row_to_out") as row_out: - row_out.return_value.model_dump.return_value = { + @patch("netx_api.ne_exec.resolve_cli_target") + def test_execute_success(self, resolve, _collect, _configured) -> None: + resolve.return_value = ( + {"ip_address": "1.1.1.1"}, + { + "source": "managed", "id": "ne-1", + "ume_ne_id": None, "name": "R2", "vendor": "Cisco", "device_type": "cisco_ios", @@ -101,26 +100,28 @@ class NeExecRunTests(unittest.TestCase): "connect_status": "pass", "hop_enabled": False, "hop_vendor": "zte", - } - out = execute_managed_ne_commands(db, "ne-1", ["show version"]) + }, + ) + db = MagicMock() + out = execute_managed_ne_commands(db, ["show version"], ne_id="ne-1") self.assertTrue(out["ok"]) self.assertEqual(out["output"], "ok-output") self.assertEqual(out["commands"], ["show version"]) @patch("netx_api.ne_exec.credentials_configured", return_value=True) @patch("netx_api.ne_exec._collect_on_device", return_value="ok-output") - @patch("netx_api.ne_exec.get_device_credentials", return_value={"ip_address": "1.1.1.1"}) - def test_execute_skips_device_when_any_command_invalid(self, _creds, collect, _configured) -> None: + @patch("netx_api.ne_exec.resolve_cli_target") + def test_execute_skips_device_when_any_command_invalid(self, resolve, collect, _configured) -> None: db = MagicMock() with self.assertRaises(HTTPException) as ctx: execute_managed_ne_commands( db, - "ne-1", ["show interface", "configure terminal"], + ne_id="ne-1", ) self.assertEqual(ctx.exception.detail, "command_blocked") collect.assert_not_called() - db.get.assert_not_called() + resolve.assert_not_called() if __name__ == "__main__": diff --git a/web/src/components/UmeCliConnectPanel.tsx b/web/src/components/UmeCliConnectPanel.tsx new file mode 100644 index 0000000..e781bcb --- /dev/null +++ b/web/src/components/UmeCliConnectPanel.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + apiDelete, + apiPatch, + apiPost, + fetchCliMeta, + fetchCliProfiles, + fetchManagedNeMeta, + postUmeConnectTest, +} from "../services/api"; +import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields"; +import { HelpHint } from "./HelpHint"; +import { queryKeys } from "../constants/queryKeys"; +import { useI18n } from "../i18n"; +import { useToast } from "../hooks/useToast"; +import type { CliConnectProfileItem } from "../types"; + +type ProfileForm = { + id: string; + name: string; + username: string; + password: string; + port: number; + protocol: string; + device_type_default: string; + vendor_default: string; + is_default: boolean; + hop: HopProxyFieldsState; +}; + +const emptyForm = (): ProfileForm => ({ + id: "", + name: "default", + username: "", + password: "", + port: 22, + protocol: "ssh", + device_type_default: "zte_zxros", + vendor_default: "ZTE", + is_default: true, + hop: { ...emptyHopProxyFields(), hop_vendor: "bastion", hop_port: 22 }, +}); + +function profileToForm(row: CliConnectProfileItem): ProfileForm { + return { + id: row.id, + name: row.name, + username: row.username, + password: "", + port: row.port, + protocol: row.protocol, + device_type_default: row.device_type_default, + vendor_default: row.vendor_default, + is_default: row.is_default, + hop: { + hop_vendor: row.hop_vendor as HopProxyFieldsState["hop_vendor"], + hop_host: row.hop_host, + hop_port: row.hop_port, + hop_protocol: row.hop_protocol, + hop_username: row.hop_username, + hop_password: "", + hop_command_template: row.hop_command_template, + hop_vrf: row.hop_vrf, + hop_target_auth_mode: row.hop_target_auth_mode, + }, + }; +} + +export function UmeCliConnectPanel() { + const { t } = useI18n(); + const { showOk, showError } = useToast(); + const queryClient = useQueryClient(); + const [form, setForm] = useState(emptyForm); + const [sampleUmeNeId, setSampleUmeNeId] = useState(""); + + const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta }); + const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta }); + const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles }); + + useEffect(() => { + const items = profilesQuery.data?.items || []; + const current = items.find((x) => x.id === form.id); + const picked = current || items.find((x) => x.is_default) || items[0]; + if (picked && (!form.id || !current)) { + setForm(profileToForm(picked)); + } + }, [profilesQuery.data, form.id]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const body = { + name: form.name.trim(), + username: form.username.trim(), + password: form.password || undefined, + port: form.port, + protocol: form.protocol, + device_type_default: form.device_type_default, + vendor_default: form.vendor_default, + is_default: form.is_default, + hop_enabled: form.hop.hop_vendor !== "linux" ? Boolean(form.hop.hop_host.trim()) : true, + hop_vendor: form.hop.hop_vendor, + hop_host: form.hop.hop_host, + hop_port: form.hop.hop_port, + hop_protocol: form.hop.hop_protocol, + hop_username: form.hop.hop_username, + hop_password: form.hop.hop_password || undefined, + hop_command_template: form.hop.hop_command_template, + hop_vrf: form.hop.hop_vrf, + hop_target_auth_mode: form.hop.hop_target_auth_mode, + }; + if (form.id) { + return apiPatch(`/v1/cli/profiles/${form.id}`, body); + } + return apiPost("/v1/cli/profiles", { ...body, password: form.password }); + }, + onSuccess: async (row) => { + setForm(profileToForm(row)); + await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles }); + await queryClient.invalidateQueries({ queryKey: queryKeys.cliMeta }); + showOk(t("ume.cli.saved")); + }, + onError: (e: Error) => showError(e.message), + }); + + const connectTestMutation = useMutation({ + mutationFn: async () => { + const id = sampleUmeNeId.trim(); + if (!id) throw new Error(t("ume.cli.sampleNeRequired")); + return postUmeConnectTest([id]); + }, + onSuccess: () => { + showOk(t("ume.cli.connectTestSubmitted")); + }, + onError: (e: Error) => showError(e.message), + }); + + const deviceTypes = neMetaQuery.data?.device_types ?? []; + const vendors = neMetaQuery.data?.vendors ?? []; + const cliReady = Boolean(metaQuery.data?.cli_profile_ready); + + return ( +
+
+

{t("ume.cli.title")}

+ +
+

+ {cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")} +

+
+ + + + + +
+ setForm({ ...form, hop })} + hopPasswordRequired={!form.id} + hopPasswordOptional={Boolean(form.id)} + /> +
+ + + {form.id ? ( + + ) : null} +
+
+

{t("ume.cli.connectTestTitle")}

+
+ setSampleUmeNeId(e.target.value)} + placeholder={t("ume.cli.sampleNePh")} + style={{ minWidth: 320 }} + /> + +
+
+ {(profilesQuery.data?.items || []).length > 1 ? ( +
+ {t("ume.cli.existingProfiles")} +
+ {(profilesQuery.data?.items || []).map((p) => ( + + ))} +
+
+ ) : null} +
+ ); +} diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 7d84e1d..2a613af 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -32,6 +32,10 @@ export const queryKeys = { neCollectionRunsAll: ["neCollectionRuns"] as const, neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) => ["neCollectionRuns", jobId, page, status, keyword] as const, + cliMeta: ["cliMeta"] as const, + cliProfiles: ["cliProfiles"] as const, + cliTargets: (keyword: string, page: number, pageSize: number) => + ["cliTargets", keyword, page, pageSize] as const, umeCurrentAlarms: ( severity: string, cleared: string, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index cc64aaa..c08e073 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -465,6 +465,31 @@ const en = { clearTitle: "Clear keyword and reset to page 1", expand: "Expand details", collapse: "Collapse details", + cliConnect: "CLI connect", + }, + tabs: { + main: "UME sync", + cli: "CLI connect", + }, + cli: { + title: "UME CLI connection profile", + hint: "Unified SSH credentials and hop/bastion for UME inventory NEs. OClaw MCP can exec show commands via ume_ne_id without per-device managed_ne entries.", + statusReady: "CLI profile is ready for UME NE command execution.", + statusNotReady: "Set target username and hop/bastion settings (if enabled).", + profileName: "Profile name", + targetUsername: "Target SSH username", + targetPassword: "Target SSH password", + passwordKeep: "Leave blank to keep unchanged", + newProfile: "New profile", + deleteConfirm: "Delete this CLI profile?", + deleted: "Profile deleted", + saved: "CLI profile saved", + connectTestTitle: "Sample NE connectivity test", + sampleNePh: "ume_ne_id (copy from NE list below)", + sampleNeRequired: "ume_ne_id is required", + connectTestSubmitted: "Connectivity test submitted", + existingProfiles: "Existing profiles", + default: "default", }, alarms: { title: "Current alarms", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b56e618..1251d93 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -463,6 +463,31 @@ const zh = { clearTitle: "清空 keyword,回到第 1 页", expand: "展开详情", collapse: "收起详情", + cliConnect: "CLI 连通", + }, + tabs: { + main: "UME 同步", + cli: "CLI 连接", + }, + cli: { + title: "UME CLI 连接配置", + hint: "为 UME 清单网元配置统一 SSH 凭据与跳板/堡垒机;OClaw MCP 可通过 ume_ne_id 直接执行 show 命令,无需逐台录入 managed_ne。", + statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。", + statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。", + profileName: "配置名称", + targetUsername: "目标 SSH 用户名", + targetPassword: "目标 SSH 密码", + passwordKeep: "留空则不修改", + newProfile: "新建配置", + deleteConfirm: "确定删除该 CLI 配置?", + deleted: "配置已删除", + saved: "CLI 配置已保存", + connectTestTitle: "样本网元连通性测试", + sampleNePh: "ume_ne_id(从下方网元清单复制)", + sampleNeRequired: "请填写 ume_ne_id", + connectTestSubmitted: "已提交连通性测试", + existingProfiles: "已有配置", + default: "默认", }, alarms: { title: "当前告警", diff --git a/web/src/pages/UmePage.tsx b/web/src/pages/UmePage.tsx index 6983707..8c30f2d 100644 --- a/web/src/pages/UmePage.tsx +++ b/web/src/pages/UmePage.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiPost, @@ -15,6 +15,7 @@ import { fetchUmeNotificationIds, fetchUmeSyncStatus, fetchUmeTokenStatus, + fetchCliTargets, refreshUmeToken, upsertUmeKeyAlertRule, deleteUmeKeyAlertRule, @@ -22,6 +23,7 @@ import { updateUmeKeyAlertMonitorConfig, } from "../services/api"; import { HelpHint } from "../components/HelpHint"; +import { UmeCliConnectPanel } from "../components/UmeCliConnectPanel"; import { queryKeys } from "../constants/queryKeys"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; @@ -67,6 +69,7 @@ export function UmePage() { const [keyAlertNeTypes, setKeyAlertNeTypes] = useState([]); const [keyAlertEditRule, setKeyAlertEditRule] = useState(null); const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState([]); + const [umeViewTab, setUmeViewTab] = useState<"main" | "cli">("main"); const syncMutation = useMutation({ mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }), @@ -254,6 +257,20 @@ export function UmePage() { queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }), staleTime: 5000, }); + const cliTargetsQuery = useQuery({ + queryKey: queryKeys.cliTargets(neKeyword, nePage, nePageSize), + queryFn: () => + fetchCliTargets({ source: "ume", keyword: neKeyword, page: nePage, pageSize: nePageSize }), + enabled: nePanelOpen && umeViewTab === "main", + staleTime: 5000, + }); + const cliStatusByNeId = useMemo(() => { + const map = new Map(); + for (const row of cliTargetsQuery.data?.items || []) { + map.set(row.id, row.connect_status); + } + return map; + }, [cliTargetsQuery.data]); const currentQuery = useQuery({ queryKey: queryKeys.umeCurrentAlarms(curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize), queryFn: () => @@ -442,6 +459,30 @@ export function UmePage() { return ( <> +
+
+ + +
+
+ {umeViewTab === "cli" ? ( + + ) : ( + <>

{t("ume.token.title")}

@@ -1245,6 +1286,7 @@ export function UmePage() { type device_level host_name + {t("ume.ne.cliConnect")} hw_ver last_seen @@ -1267,12 +1309,13 @@ export function UmePage() { {x.ne_type} {x.device_level || t("common.empty")} {x.host_name || t("common.empty")} + {cliStatusByNeId.get(x.ne_id) || "unknown"} {x.hardware_version || t("common.empty")} {x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")} {expandedNeId === x.ne_id ? ( - +
consistent_state: {x.consistent_state || t("common.empty")}
admin_status: {x.admin_status || t("common.empty")}
@@ -1511,5 +1554,7 @@ export function UmePage() {
) : null} + )} + ); } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 3335a19..42c71b1 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -14,6 +14,9 @@ import type { UmeNeItem, UmeSyncStatusResponse, UmeTokenStatus, + CliConnectProfileItem, + CliMeta, + CliTargetListResponse, } from "../types"; const parseApiResponse = async (res: Response): Promise> => { @@ -306,6 +309,28 @@ export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/r export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`; +export const fetchCliMeta = () => apiGet("/v1/cli/meta"); + +export const fetchCliProfiles = () => + apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles"); + +export const fetchCliTargets = (params: { + source?: "managed" | "ume" | "all"; + keyword?: string; + page?: number; + pageSize?: number; +}) => { + const p = new URLSearchParams(); + p.set("source", params.source || "all"); + if (params.keyword?.trim()) p.set("keyword", params.keyword.trim()); + p.set("page", String(Math.max(1, Number(params.page || 1)))); + p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50))))); + return apiGet(`/v1/cli/targets?${p.toString()}`); +}; + +export const postUmeConnectTest = (umeNeIds: string[]) => + apiPost<{ ok: boolean; submitted: number }>("/v1/cli/ume-connect-test", { ume_ne_ids: umeNeIds }); + export const fetchUmeCurrentAlarms = (params: { severity: string; isCleared: string; diff --git a/web/src/types.ts b/web/src/types.ts index cbfad8c..515dbfe 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -219,6 +219,55 @@ export type ManagedNeImportResult = { failed: Array<{ row: number; reason: string }>; }; +export type CliConnectProfileItem = { + id: string; + name: string; + is_default: boolean; + username: string; + port: number; + protocol: string; + device_type_default: string; + vendor_default: string; + ne_type_rules: string; + hop_enabled: boolean; + hop_vendor: string; + hop_host: string; + hop_port: number; + hop_protocol: string; + hop_username: string; + hop_command_template: string; + hop_vrf: string; + hop_target_auth_mode: string; + created_at: string; + updated_at: string; +}; + +export type CliMeta = { + credentials_configured: boolean; + default_profile_configured: boolean; + cli_profile_ready: boolean; +}; + +export type CliTargetItem = { + source: string; + id: string; + ume_ne_id?: string | null; + name: string; + ip_address: string; + ne_type?: string; + vendor?: string; + device_type?: string; + connect_status: string; + cli_profile_ready?: boolean; +}; + +export type CliTargetListResponse = { + total: number; + page: number; + page_size: number; + items: CliTargetItem[]; +}; + export type EligibleNeItem = { id: string; name: string;