feat(cli): UME lazy exec via shared profiles and ume_ne_id MCP support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-26 17:13:49 +08:00
parent ba1a40f725
commit 38a1c7f3a9
21 changed files with 1504 additions and 54 deletions

219
netx_api/cli_resolve.py Normal file
View file

@ -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

91
netx_api/cli_router.py Normal file
View file

@ -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}

122
netx_api/cli_schemas.py Normal file
View file

@ -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

332
netx_api/cli_service.py Normal file
View file

@ -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),
}

View file

@ -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 '资源名称'")

View file

@ -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,
)

View file

@ -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."""

View file

@ -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

View file

@ -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:

View file

@ -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)