feat: 多主机纳管、用户认证、noVNC控制台、深色主题
主要功能: - 多主机管理: 支持TCP/SSH方式纳管远程KVM主机 - 用户认证: JWT token认证, 默认admin/admin123 - noVNC控制台: 前端集成noVNC, WebSocket代理VNC连接 - 深色主题: 全局Element Plus深色主题覆盖 - 虚拟机操作: 克隆、迁移、XML编辑、快照管理 - 资源监控: CPU/内存/磁盘IO/网络流量实时监控 Bug修复: - libvirt getInfo()内存单位修正(MiB非KiB) - 远程主机VNC 0.0.0.0监听地址连接策略修复 - Dashboard定时器内存泄漏修复 - bcrypt版本兼容性修复
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""用户认证模块 - JWT Token 认证"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from pydantic import BaseModel
|
||||
from app.config import settings
|
||||
|
||||
# 密码加密上下文
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# OAuth2 Token 提取
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_PREFIX}/auth/login")
|
||||
|
||||
# 简易用户存储(生产环境应使用数据库)
|
||||
# 默认管理员账号: admin / admin123
|
||||
_users_db = {
|
||||
"admin": {
|
||||
"username": "admin",
|
||||
"hashed_password": pwd_context.hash("admin123"),
|
||||
"role": "admin",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
username: str = ""
|
||||
role: str = ""
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
username: str
|
||||
role: str = "user"
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user"
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""生成密码哈希"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""创建 JWT Token"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def authenticate_user(username: str, password: str) -> Optional[dict]:
|
||||
"""验证用户"""
|
||||
user = _users_db.get(username)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user["hashed_password"]):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
||||
"""从 Token 获取当前用户(依赖注入)"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭据",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = _users_db.get(username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return User(username=user["username"], role=user["role"])
|
||||
|
||||
|
||||
def get_users_db():
|
||||
"""获取用户数据库(用于路由中管理用户)"""
|
||||
return _users_db
|
||||
@@ -0,0 +1,175 @@
|
||||
"""主机注册表 - 管理多台 libvirt 宿主机"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 数据存储目录
|
||||
DATA_DIR = os.environ.get("KVM_DATA_DIR", "/var/lib/kvm-manager")
|
||||
HOSTS_FILE = os.path.join(DATA_DIR, "hosts.json")
|
||||
|
||||
|
||||
class HostInfo(BaseModel):
|
||||
"""主机信息模型"""
|
||||
id: str = Field(..., description="主机唯一ID")
|
||||
name: str = Field(..., description="主机名称")
|
||||
uri: str = Field(..., description="libvirt 连接 URI")
|
||||
type: str = Field("local", description="连接类型: local/tcp/ssh")
|
||||
ssh_key_path: Optional[str] = Field(None, description="SSH 私钥路径(ssh 模式)")
|
||||
status: str = "unknown"
|
||||
created_at: float = Field(default_factory=time.time)
|
||||
last_seen: Optional[float] = None
|
||||
|
||||
|
||||
class HostCreate(BaseModel):
|
||||
"""创建主机请求"""
|
||||
name: str = Field(..., description="主机名称")
|
||||
uri: str = Field(..., description="libvirt 连接 URI,如 qemu+tcp://192.168.1.2/system")
|
||||
ssh_key_path: Optional[str] = Field(None, description="SSH 私钥路径(ssh 模式)")
|
||||
|
||||
|
||||
def _detect_type(uri: str) -> str:
|
||||
"""根据 URI 判断连接类型"""
|
||||
if uri.startswith("qemu+ssh://"):
|
||||
return "ssh"
|
||||
elif uri.startswith("qemu+tcp://"):
|
||||
return "tcp"
|
||||
return "local"
|
||||
|
||||
|
||||
def _ensure_data_dir():
|
||||
"""确保数据目录存在"""
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _load_hosts() -> dict:
|
||||
"""从文件加载主机列表"""
|
||||
if not os.path.exists(HOSTS_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(HOSTS_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_hosts(data: dict):
|
||||
"""保存主机列表到文件"""
|
||||
_ensure_data_dir()
|
||||
with open(HOSTS_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _init_local_host() -> dict:
|
||||
"""初始化本机默认主机"""
|
||||
return HostInfo(
|
||||
id="local",
|
||||
name="本机",
|
||||
uri=settings.LIBVIRT_URI,
|
||||
type="local",
|
||||
status="unknown",
|
||||
).model_dump()
|
||||
|
||||
|
||||
def list_hosts() -> list[HostInfo]:
|
||||
"""列出所有已注册主机"""
|
||||
data = _load_hosts()
|
||||
if not data:
|
||||
# 首次运行,初始化本机
|
||||
local = _init_local_host()
|
||||
data["local"] = local
|
||||
_save_hosts(data)
|
||||
return [HostInfo(**h) for h in data.values()]
|
||||
|
||||
|
||||
def get_host(host_id: str) -> Optional[HostInfo]:
|
||||
"""获取单个主机信息"""
|
||||
data = _load_hosts()
|
||||
if host_id not in data:
|
||||
return None
|
||||
return HostInfo(**data[host_id])
|
||||
|
||||
|
||||
def add_host(req: HostCreate) -> HostInfo:
|
||||
"""添加新主机"""
|
||||
data = _load_hosts()
|
||||
if not data:
|
||||
data["local"] = _init_local_host()
|
||||
|
||||
host_id = req.name.lower().replace(" ", "-").replace(".", "-")
|
||||
# 确保ID唯一
|
||||
if host_id in data:
|
||||
host_id = f"{host_id}-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
host_type = _detect_type(req.uri)
|
||||
|
||||
# 构建 SSH URI
|
||||
uri = req.uri
|
||||
if host_type == "ssh" and req.ssh_key_path:
|
||||
# 在 URI 中嵌入 key 提示,实际连接时由 libvirt ssh driver 使用
|
||||
pass
|
||||
|
||||
host = HostInfo(
|
||||
id=host_id,
|
||||
name=req.name,
|
||||
uri=uri,
|
||||
type=host_type,
|
||||
ssh_key_path=req.ssh_key_path,
|
||||
status="unknown",
|
||||
created_at=time.time(),
|
||||
)
|
||||
data[host_id] = host.model_dump()
|
||||
_save_hosts(data)
|
||||
return host
|
||||
|
||||
|
||||
def remove_host(host_id: str) -> bool:
|
||||
"""删除主机(local 不可删)"""
|
||||
if host_id == "local":
|
||||
return False
|
||||
data = _load_hosts()
|
||||
if host_id not in data:
|
||||
return False
|
||||
del data[host_id]
|
||||
_save_hosts(data)
|
||||
return True
|
||||
|
||||
|
||||
def update_host_status(host_id: str, status: str):
|
||||
"""更新主机在线状态"""
|
||||
data = _load_hosts()
|
||||
if host_id in data:
|
||||
data[host_id]["status"] = status
|
||||
data[host_id]["last_seen"] = time.time()
|
||||
_save_hosts(data)
|
||||
|
||||
|
||||
def test_connection(uri: str) -> dict:
|
||||
"""测试 libvirt 连接是否可用"""
|
||||
import libvirt
|
||||
try:
|
||||
conn = libvirt.openReadOnly(uri)
|
||||
if conn:
|
||||
info = conn.getInfo()
|
||||
result = {
|
||||
"success": True,
|
||||
"hostname": conn.getHostname(),
|
||||
"hypervisor": conn.getType(),
|
||||
"cpu_cores": info[2],
|
||||
"memory_mb": info[1], # getInfo()[1] 已经是 MiB 单位
|
||||
"libvirt_version": conn.getLibVersion(),
|
||||
}
|
||||
conn.close()
|
||||
return result
|
||||
else:
|
||||
return {"success": False, "error": "无法建立连接"}
|
||||
except libvirt.libvirtError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
+88
-13
@@ -1,38 +1,40 @@
|
||||
"""libvirt 连接管理 - 使用连接池模式"""
|
||||
"""libvirt 多主机连接管理器"""
|
||||
import libvirt
|
||||
from contextlib import contextmanager
|
||||
from app.config import settings
|
||||
import logging
|
||||
from app.hosts import get_host, update_host_status, list_hosts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibvirtConnection:
|
||||
"""libvirt 连接管理器(只读连接保持,写操作用短连接)"""
|
||||
"""单个 libvirt 连接管理器(只读保持,读写短连接)"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, uri: str, host_id: str = "local"):
|
||||
self._uri = uri
|
||||
self._host_id = host_id
|
||||
self._conn = None
|
||||
|
||||
def _connect(self):
|
||||
"""建立新的 libvirt 连接"""
|
||||
"""建立只读连接"""
|
||||
try:
|
||||
conn = libvirt.openReadOnly(settings.LIBVIRT_URI)
|
||||
conn = libvirt.openReadOnly(self._uri)
|
||||
if conn is None:
|
||||
raise ConnectionError(f"无法连接到 libvirt: {settings.LIBVIRT_URI}")
|
||||
raise ConnectionError(f"无法连接到 libvirt: {self._uri}")
|
||||
return conn
|
||||
except libvirt.libvirtError as e:
|
||||
logger.error(f"libvirt 连接错误: {e}")
|
||||
logger.error(f"libvirt 连接错误 ({self._host_id}): {e}")
|
||||
raise
|
||||
|
||||
def _connect_rw(self):
|
||||
"""建立可读写连接"""
|
||||
try:
|
||||
conn = libvirt.open(settings.LIBVIRT_URI)
|
||||
conn = libvirt.open(self._uri)
|
||||
if conn is None:
|
||||
raise ConnectionError(f"无法连接到 libvirt (RW): {settings.LIBVIRT_URI}")
|
||||
raise ConnectionError(f"无法连接到 libvirt (RW): {self._uri}")
|
||||
return conn
|
||||
except libvirt.libvirtError as e:
|
||||
logger.error(f"libvirt RW 连接错误: {e}")
|
||||
logger.error(f"libvirt RW 连接错误 ({self._host_id}): {e}")
|
||||
raise
|
||||
|
||||
@property
|
||||
@@ -73,6 +75,79 @@ class LibvirtConnection:
|
||||
"cpu_speed": c.getInfo()[3], # MHz
|
||||
}
|
||||
|
||||
def close(self):
|
||||
"""关闭连接"""
|
||||
if self._conn is not None:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._conn = None
|
||||
|
||||
# 全局单例
|
||||
libvirt_conn = LibvirtConnection()
|
||||
|
||||
class ConnectionPool:
|
||||
"""多主机连接池"""
|
||||
|
||||
def __init__(self):
|
||||
self._pool: dict[str, LibvirtConnection] = {}
|
||||
|
||||
def get(self, host_id: str = "local") -> LibvirtConnection:
|
||||
"""获取指定主机的连接管理器"""
|
||||
if host_id not in self._pool:
|
||||
host = get_host(host_id)
|
||||
if not host:
|
||||
raise ValueError(f"主机 '{host_id}' 不存在")
|
||||
self._pool[host_id] = LibvirtConnection(host.uri, host_id)
|
||||
return self._pool[host_id]
|
||||
|
||||
def remove(self, host_id: str):
|
||||
"""移除并关闭指定主机的连接"""
|
||||
if host_id in self._pool:
|
||||
self._pool[host_id].close()
|
||||
del self._pool[host_id]
|
||||
|
||||
def get_conn(self, host_id: str = "local"):
|
||||
"""快捷获取 libvirt 只读连接"""
|
||||
return self.get(host_id).conn
|
||||
|
||||
@contextmanager
|
||||
def get_rw(self, host_id: str = "local"):
|
||||
"""快捷获取 libvirt 读写连接"""
|
||||
mgr = self.get(host_id)
|
||||
with mgr.get_rw() as conn:
|
||||
yield conn
|
||||
|
||||
def refresh_all(self):
|
||||
"""刷新所有连接状态"""
|
||||
for host_id, mgr in list(self._pool.items()):
|
||||
try:
|
||||
alive = mgr.conn.isAlive()
|
||||
update_host_status(host_id, "online" if alive else "offline")
|
||||
except Exception:
|
||||
update_host_status(host_id, "offline")
|
||||
mgr.close()
|
||||
|
||||
|
||||
# 全局连接池单例
|
||||
conn_pool = ConnectionPool()
|
||||
|
||||
# 保持向后兼容:本机连接的直接引用
|
||||
libvirt_conn = property(lambda self: conn_pool.get("local"))
|
||||
|
||||
|
||||
class _CompatConn:
|
||||
"""向后兼容包装,让现有 `libvirt_conn.conn` 等调用继续工作"""
|
||||
|
||||
@property
|
||||
def conn(self):
|
||||
return conn_pool.get_conn("local")
|
||||
|
||||
def get_rw(self):
|
||||
return conn_pool.get_rw("local")
|
||||
|
||||
def get_host_info(self):
|
||||
return conn_pool.get("local").get_host_info()
|
||||
|
||||
|
||||
# 全局兼容单例
|
||||
libvirt_conn = _CompatConn()
|
||||
|
||||
+135
-4
@@ -1,8 +1,9 @@
|
||||
"""FastAPI 主应用"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from app.config import settings
|
||||
from app.routers import vm, storage, network, snapshot, monitor
|
||||
from app.routers import vm, storage, network, snapshot, monitor, auth, host as host_router
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
@@ -20,6 +21,8 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(auth.router, prefix=f"{settings.API_PREFIX}/auth", tags=["认证"])
|
||||
app.include_router(host_router.router, prefix=f"{settings.API_PREFIX}/hosts", tags=["主机管理"])
|
||||
app.include_router(vm.router, prefix=f"{settings.API_PREFIX}/vm", tags=["虚拟机管理"])
|
||||
app.include_router(storage.router, prefix=f"{settings.API_PREFIX}/storage", tags=["存储管理"])
|
||||
app.include_router(network.router, prefix=f"{settings.API_PREFIX}/network", tags=["网络管理"])
|
||||
@@ -34,7 +37,7 @@ async def root():
|
||||
|
||||
@app.get(f"{settings.API_PREFIX}/host")
|
||||
async def host_info():
|
||||
"""获取宿主机信息"""
|
||||
"""获取本机宿主机信息(兼容旧接口)"""
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
return libvirt_conn.get_host_info()
|
||||
|
||||
@@ -42,9 +45,137 @@ async def host_info():
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""健康检查"""
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
from app.libvirt_conn import conn_pool
|
||||
try:
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn("local")
|
||||
return {"status": "ok", "libvirt": conn.isAlive()}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# VNC WebSocket 代理
|
||||
from starlette.websockets import WebSocket
|
||||
from starlette.responses import HTMLResponse
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
|
||||
|
||||
@app.websocket("/ws/vnc/{vm_name}")
|
||||
async def vnc_websocket(websocket: WebSocket, vm_name: str, host_id: str = "local"):
|
||||
"""WebSocket 代理到虚拟机 VNC"""
|
||||
await websocket.accept()
|
||||
|
||||
from app.libvirt_conn import conn_pool
|
||||
from app.hosts import get_host as get_host_info
|
||||
from lxml import etree
|
||||
|
||||
try:
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
dom = conn.lookupByName(vm_name)
|
||||
xml_desc = dom.XMLDesc(0)
|
||||
tree = etree.fromstring(xml_desc.encode())
|
||||
|
||||
# 获取 VNC 端口
|
||||
graphics = tree.find(".//graphics[@type='vnc']")
|
||||
if graphics is None:
|
||||
await websocket.close(code=1000, reason="虚拟机没有 VNC 配置")
|
||||
return
|
||||
|
||||
vnc_port = int(graphics.get("port", -1))
|
||||
vnc_listen = graphics.get("listen", "127.0.0.1")
|
||||
|
||||
if vnc_port <= 0:
|
||||
await websocket.close(code=1000, reason="VNC 端口未分配,虚拟机可能未运行")
|
||||
return
|
||||
|
||||
# 根据主机类型决定 VNC 连接目标
|
||||
host_info = get_host_info(host_id)
|
||||
target_host = vnc_listen
|
||||
is_remote = host_info and host_info.type != "local"
|
||||
|
||||
if is_remote:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(host_info.uri)
|
||||
remote_host = parsed.hostname or "127.0.0.1"
|
||||
|
||||
if vnc_listen in ("127.0.0.1", "localhost", ""):
|
||||
# VNC 只监听本地回环,需要 SSH 隧道
|
||||
if host_info.type == "ssh":
|
||||
import subprocess, time
|
||||
local_port = 20000 + (hash(vm_name + str(vnc_port)) % 10000)
|
||||
# 先杀掉可能存在的旧隧道
|
||||
subprocess.run(
|
||||
["fuser", "-k", f"{local_port}/tcp"],
|
||||
capture_output=True, timeout=3,
|
||||
)
|
||||
ssh_args = ["ssh", "-f", "-N"]
|
||||
if host_info.ssh_key_path:
|
||||
ssh_args.extend(["-i", host_info.ssh_key_path])
|
||||
ssh_args.extend([
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-L", f"127.0.0.1:{local_port}:127.0.0.1:{vnc_port}",
|
||||
remote_host,
|
||||
])
|
||||
result = subprocess.run(ssh_args, capture_output=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
err = result.stderr.decode(errors='replace').strip()
|
||||
await websocket.close(code=1011, reason=f"SSH隧道建立失败: {err}")
|
||||
return
|
||||
target_host = "127.0.0.1"
|
||||
vnc_port = local_port
|
||||
else:
|
||||
# TCP 模式但 VNC 只听 localhost,无法直接连
|
||||
await websocket.close(code=1011, reason="远程主机 VNC 监听在 localhost,需要使用 SSH 模式")
|
||||
return
|
||||
elif vnc_listen == "0.0.0.0":
|
||||
# VNC 监听所有接口,直接连远程主机 IP
|
||||
target_host = remote_host
|
||||
else:
|
||||
# VNC 监听特定地址
|
||||
target_host = vnc_listen
|
||||
else:
|
||||
# 本地主机
|
||||
if vnc_listen in ("0.0.0.0", ""):
|
||||
target_host = "127.0.0.1"
|
||||
# else 保持 vnc_listen (127.0.0.1 或其他)
|
||||
|
||||
# 连接到 VNC 服务器
|
||||
reader, writer = await asyncio.open_connection(target_host, vnc_port)
|
||||
|
||||
async def ws_to_vnc():
|
||||
"""WebSocket -> VNC"""
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_bytes()
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
async def vnc_to_ws():
|
||||
"""VNC -> WebSocket"""
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
await websocket.send_bytes(data)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(ws_to_vnc(), vnc_to_ws())
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
await websocket.close(code=1011, reason=str(e))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""认证路由"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from app.auth import (
|
||||
authenticate_user, create_access_token, get_password_hash,
|
||||
get_current_user, User, UserCreate, PasswordChange, Token,
|
||||
get_users_db, verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
"""用户登录,获取 JWT Token"""
|
||||
user = authenticate_user(form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token = create_access_token(data={"sub": user["username"], "role": user["role"]})
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
username=user["username"],
|
||||
role=user["role"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=User)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""获取当前用户信息"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
pwd: PasswordChange,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""修改密码"""
|
||||
db = get_users_db()
|
||||
user = db.get(current_user.username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if not verify_password(pwd.old_password, user["hashed_password"]):
|
||||
raise HTTPException(status_code=400, detail="旧密码错误")
|
||||
user["hashed_password"] = get_password_hash(pwd.new_password)
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(user_create: UserCreate, current_user: User = Depends(get_current_user)):
|
||||
"""注册新用户(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可注册新用户")
|
||||
|
||||
db = get_users_db()
|
||||
if user_create.username in db:
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
db[user_create.username] = {
|
||||
"username": user_create.username,
|
||||
"hashed_password": get_password_hash(user_create.password),
|
||||
"role": user_create.role,
|
||||
}
|
||||
return {"message": f"用户 '{user_create.username}' 创建成功"}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(current_user: User = Depends(get_current_user)):
|
||||
"""列出所有用户(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可查看用户列表")
|
||||
db = get_users_db()
|
||||
return {
|
||||
"users": [
|
||||
{"username": u["username"], "role": u["role"]}
|
||||
for u in db.values()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/users/{username}")
|
||||
async def delete_user(username: str, current_user: User = Depends(get_current_user)):
|
||||
"""删除用户(仅管理员,不能删除自己)"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可删除用户")
|
||||
if username == current_user.username:
|
||||
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||
db = get_users_db()
|
||||
if username not in db:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
del db[username]
|
||||
return {"message": f"用户 '{username}' 已删除"}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""主机管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.hosts import (
|
||||
list_hosts, get_host, add_host, remove_host,
|
||||
test_connection, update_host_status, HostCreate,
|
||||
)
|
||||
from app.libvirt_conn import conn_pool
|
||||
import time
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def api_list_hosts():
|
||||
"""列出所有已注册主机"""
|
||||
hosts = list_hosts()
|
||||
result = []
|
||||
for h in hosts:
|
||||
info = h.model_dump()
|
||||
# 尝试获取实时状态
|
||||
try:
|
||||
mgr = conn_pool.get(h.id)
|
||||
conn = mgr.conn
|
||||
alive = conn.isAlive()
|
||||
info["status"] = "online" if alive else "offline"
|
||||
if alive:
|
||||
host_data = conn.getInfo()
|
||||
info["cpu_cores"] = host_data[2]
|
||||
info["memory_mb"] = host_data[1] # getInfo()[1] 已经是 MiB 单位
|
||||
info["hostname"] = conn.getHostname()
|
||||
# 虚拟机数
|
||||
domains = conn.listAllDomains(0)
|
||||
info["vm_total"] = len(domains)
|
||||
info["vm_running"] = sum(1 for d in domains if d.isActive())
|
||||
update_host_status(h.id, info["status"])
|
||||
except Exception:
|
||||
info["status"] = "offline"
|
||||
update_host_status(h.id, "offline")
|
||||
result.append(info)
|
||||
return {"hosts": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/detail/{host_id}")
|
||||
async def api_get_host(host_id: str):
|
||||
"""获取单台主机详情"""
|
||||
host = get_host(host_id)
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail=f"主机 '{host_id}' 不存在")
|
||||
info = host.model_dump()
|
||||
try:
|
||||
mgr = conn_pool.get(host_id)
|
||||
info["host_info"] = mgr.get_host_info()
|
||||
info["status"] = "online"
|
||||
# 虚拟机数
|
||||
domains = mgr.conn.listAllDomains(0)
|
||||
info["vm_total"] = len(domains)
|
||||
info["vm_running"] = sum(1 for d in domains if d.isActive())
|
||||
update_host_status(host_id, "online")
|
||||
except Exception as e:
|
||||
info["status"] = "offline"
|
||||
info["error"] = str(e)
|
||||
update_host_status(host_id, "offline")
|
||||
return info
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def api_add_host(req: HostCreate):
|
||||
"""添加新主机"""
|
||||
# 先测试连接
|
||||
result = test_connection(req.uri)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=f"连接测试失败: {result['error']}")
|
||||
|
||||
host = add_host(req)
|
||||
# 更新状态为在线
|
||||
update_host_status(host.id, "online")
|
||||
return {"message": f"主机 '{host.name}' 添加成功", "host": host.model_dump()}
|
||||
|
||||
|
||||
@router.delete("/delete/{host_id}")
|
||||
async def api_delete_host(host_id: str):
|
||||
"""删除主机"""
|
||||
if host_id == "local":
|
||||
raise HTTPException(status_code=400, detail="不能删除本机")
|
||||
if not remove_host(host_id):
|
||||
raise HTTPException(status_code=404, detail=f"主机 '{host_id}' 不存在")
|
||||
conn_pool.remove(host_id)
|
||||
return {"message": f"主机 '{host_id}' 已删除"}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def api_test_connection(req: HostCreate):
|
||||
"""测试连接(不添加主机)"""
|
||||
result = test_connection(req.uri)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/refresh/{host_id}")
|
||||
async def api_refresh_host(host_id: str):
|
||||
"""刷新主机状态"""
|
||||
host = get_host(host_id)
|
||||
if not host:
|
||||
raise HTTPException(status_code=404, detail=f"主机 '{host_id}' 不存在")
|
||||
try:
|
||||
# 重新建立连接
|
||||
conn_pool.remove(host_id)
|
||||
mgr = conn_pool.get(host_id)
|
||||
conn = mgr.conn
|
||||
alive = conn.isAlive()
|
||||
status = "online" if alive else "offline"
|
||||
update_host_status(host_id, status)
|
||||
return {"status": status, "hostname": conn.getHostname()}
|
||||
except Exception as e:
|
||||
update_host_status(host_id, "offline")
|
||||
conn_pool.remove(host_id)
|
||||
return {"status": "offline", "error": str(e)}
|
||||
@@ -1,6 +1,6 @@
|
||||
"""资源监控路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from app.libvirt_conn import conn_pool
|
||||
import libvirt
|
||||
import time
|
||||
import threading
|
||||
@@ -13,23 +13,19 @@ _cache_lock = threading.Lock()
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def monitor_overview():
|
||||
async def monitor_overview(host_id: str = Query("local")):
|
||||
"""宿主机总览监控"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
|
||||
# 宿主机信息
|
||||
host_info = conn.getInfo()
|
||||
hostname = conn.getHostname()
|
||||
|
||||
# CPU 使用率(通过 node info)
|
||||
cpu_stats = conn.getCPUStats(-1, 0) # 全局 CPU 统计
|
||||
cpu_stats = conn.getCPUStats(-1, 0)
|
||||
cpu_total = cpu_stats.get("user", 0) + cpu_stats.get("system", 0) + cpu_stats.get("idle", 0)
|
||||
cpu_used = cpu_stats.get("user", 0) + cpu_stats.get("system", 0)
|
||||
cpu_percent = round(cpu_used / cpu_total * 100, 1) if cpu_total > 0 else 0
|
||||
|
||||
# 内存
|
||||
memory_total_kb = host_info[1]
|
||||
# 获取可用内存
|
||||
try:
|
||||
with open("/proc/meminfo", "r") as f:
|
||||
meminfo = {}
|
||||
@@ -48,7 +44,6 @@ async def monitor_overview():
|
||||
mem_available_mb = mem_total_mb
|
||||
mem_percent = 0
|
||||
|
||||
# 虚拟机统计
|
||||
domains = conn.listAllDomains(0)
|
||||
running = sum(1 for d in domains if d.isActive())
|
||||
stopped = len(domains) - running
|
||||
@@ -75,9 +70,9 @@ async def monitor_overview():
|
||||
|
||||
|
||||
@router.get("/vm/{name}")
|
||||
async def monitor_vm(name: str):
|
||||
async def monitor_vm(name: str, host_id: str = Query("local")):
|
||||
"""获取虚拟机实时监控数据"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -86,10 +81,9 @@ async def monitor_vm(name: str):
|
||||
if not dom.isActive():
|
||||
return {"name": name, "state": "stopped", "cpu_percent": 0, "memory": {}}
|
||||
|
||||
# CPU 百分比
|
||||
cpu_percent = _get_vm_cpu_percent(dom)
|
||||
cache_key = f"{host_id}_{name}"
|
||||
cpu_percent = _get_vm_cpu_percent(dom, cache_key)
|
||||
|
||||
# 内存
|
||||
mem_stats = {}
|
||||
try:
|
||||
raw = dom.memoryStats()
|
||||
@@ -104,10 +98,7 @@ async def monitor_vm(name: str):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 磁盘IO
|
||||
disk_stats = _get_vm_disk_stats(dom)
|
||||
|
||||
# 网络IO
|
||||
net_stats = _get_vm_net_stats(dom)
|
||||
|
||||
return {
|
||||
@@ -120,17 +111,13 @@ async def monitor_vm(name: str):
|
||||
}
|
||||
|
||||
|
||||
def _get_vm_cpu_percent(dom) -> float:
|
||||
def _get_vm_cpu_percent(dom, cache_key: str) -> float:
|
||||
"""计算虚拟机 CPU 使用率"""
|
||||
cache_key = f"cpu_{dom.name()}"
|
||||
|
||||
try:
|
||||
# 第一次采样
|
||||
info1 = dom.info()
|
||||
cpu_time1 = info1[2]
|
||||
t1 = time.time()
|
||||
|
||||
# 从缓存获取上一次数据
|
||||
with _cache_lock:
|
||||
prev = _stats_cache.get(cache_key)
|
||||
|
||||
@@ -138,13 +125,11 @@ def _get_vm_cpu_percent(dom) -> float:
|
||||
cpu_time0, t0 = prev
|
||||
elapsed = t1 - t0
|
||||
cpu_diff = cpu_time1 - cpu_time0
|
||||
# CPU时间单位是纳秒
|
||||
cpu_percent = round((cpu_diff / 1e9) / elapsed * 100, 1)
|
||||
cpu_percent = min(cpu_percent, 100.0)
|
||||
else:
|
||||
cpu_percent = 0.0
|
||||
|
||||
# 更新缓存
|
||||
with _cache_lock:
|
||||
_stats_cache[cache_key] = (cpu_time1, t1)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""网络管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from lxml import etree
|
||||
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
from app.libvirt_conn import conn_pool
|
||||
import libvirt
|
||||
|
||||
router = APIRouter()
|
||||
@@ -20,15 +20,14 @@ class NetworkCreate(BaseModel):
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_networks():
|
||||
async def list_networks(host_id: str = Query("local")):
|
||||
"""列出所有网络"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
networks = conn.listAllNetworks(0)
|
||||
result = []
|
||||
for net in networks:
|
||||
xml = etree.fromstring(net.XMLDesc(0).encode())
|
||||
|
||||
# 解析网络信息
|
||||
forward = xml.find("forward")
|
||||
mode = forward.get("mode", "isolated") if forward is not None else "isolated"
|
||||
|
||||
@@ -39,18 +38,13 @@ async def list_networks():
|
||||
bridge = xml.find("bridge")
|
||||
bridge_name = bridge.get("name", "") if bridge is not None else ""
|
||||
|
||||
# DHCP范围
|
||||
dhcp_range = None
|
||||
dhcp = xml.find(".//dhcp")
|
||||
if dhcp is not None:
|
||||
r = dhcp.find("range")
|
||||
if r is not None:
|
||||
dhcp_range = {
|
||||
"start": r.get("start", ""),
|
||||
"end": r.get("end", ""),
|
||||
}
|
||||
dhcp_range = {"start": r.get("start", ""), "end": r.get("end", "")}
|
||||
|
||||
# 活跃租约
|
||||
leases = []
|
||||
try:
|
||||
for lease in net.DHCPLeases():
|
||||
@@ -79,9 +73,9 @@ async def list_networks():
|
||||
|
||||
|
||||
@router.get("/detail/{name}")
|
||||
async def get_network(name: str):
|
||||
async def get_network(name: str, host_id: str = Query("local")):
|
||||
"""获取网络详情"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
net = conn.networkLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -92,7 +86,7 @@ async def get_network(name: str):
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_network(net: NetworkCreate):
|
||||
async def create_network(net: NetworkCreate, host_id: str = Query("local")):
|
||||
"""创建网络"""
|
||||
if net.mode == "bridge" and not net.bridge:
|
||||
raise HTTPException(status_code=400, detail="桥接模式必须指定桥接网卡")
|
||||
@@ -104,7 +98,6 @@ async def create_network(net: NetworkCreate):
|
||||
<bridge name='{net.bridge}'/>
|
||||
</network>"""
|
||||
else:
|
||||
# NAT或隔离模式
|
||||
import ipaddress
|
||||
network = ipaddress.ip_network(net.subnet, strict=False)
|
||||
gateway = str(network.network_address + 1)
|
||||
@@ -129,7 +122,7 @@ async def create_network(net: NetworkCreate):
|
||||
</ip>
|
||||
</network>"""
|
||||
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
n = rw_conn.networkDefineXML(xml)
|
||||
n.setAutostart(1)
|
||||
@@ -140,9 +133,9 @@ async def create_network(net: NetworkCreate):
|
||||
|
||||
|
||||
@router.delete("/delete/{name}")
|
||||
async def delete_network(name: str):
|
||||
async def delete_network(name: str, host_id: str = Query("local")):
|
||||
"""删除网络"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
net = rw_conn.networkLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -155,9 +148,9 @@ async def delete_network(name: str):
|
||||
|
||||
|
||||
@router.post("/action/{name}")
|
||||
async def network_action(name: str, action: str):
|
||||
async def network_action(name: str, action: str, host_id: str = Query("local")):
|
||||
"""网络操作: start/stop"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
net = rw_conn.networkLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""快照管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from lxml import etree
|
||||
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
from app.libvirt_conn import conn_pool
|
||||
import libvirt
|
||||
|
||||
router = APIRouter()
|
||||
@@ -16,9 +16,9 @@ class SnapshotCreate(BaseModel):
|
||||
|
||||
|
||||
@router.get("/list/{vm_name}")
|
||||
async def list_snapshots(vm_name: str):
|
||||
async def list_snapshots(vm_name: str, host_id: str = Query("local")):
|
||||
"""列出虚拟机的所有快照"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
dom = conn.lookupByName(vm_name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -40,15 +40,15 @@ async def list_snapshots(vm_name: str):
|
||||
"is_current": snap.isCurrent() == 1,
|
||||
})
|
||||
except libvirt.libvirtError:
|
||||
pass # 没有快照
|
||||
pass
|
||||
|
||||
return {"vm": vm_name, "snapshots": snapshots, "total": len(snapshots)}
|
||||
|
||||
|
||||
@router.post("/create/{vm_name}")
|
||||
async def create_snapshot(vm_name: str, snap: SnapshotCreate):
|
||||
async def create_snapshot(vm_name: str, snap: SnapshotCreate, host_id: str = Query("local")):
|
||||
"""创建快照"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(vm_name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -68,9 +68,9 @@ async def create_snapshot(vm_name: str, snap: SnapshotCreate):
|
||||
|
||||
|
||||
@router.post("/revert/{vm_name}/{snap_name}")
|
||||
async def revert_snapshot(vm_name: str, snap_name: str):
|
||||
async def revert_snapshot(vm_name: str, snap_name: str, host_id: str = Query("local")):
|
||||
"""恢复快照"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(vm_name)
|
||||
snap = dom.snapshotLookupByName(snap_name)
|
||||
@@ -85,9 +85,9 @@ async def revert_snapshot(vm_name: str, snap_name: str):
|
||||
|
||||
|
||||
@router.delete("/delete/{vm_name}/{snap_name}")
|
||||
async def delete_snapshot(vm_name: str, snap_name: str):
|
||||
async def delete_snapshot(vm_name: str, snap_name: str, host_id: str = Query("local")):
|
||||
"""删除快照"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(vm_name)
|
||||
snap = dom.snapshotLookupByName(snap_name)
|
||||
@@ -102,9 +102,9 @@ async def delete_snapshot(vm_name: str, snap_name: str):
|
||||
|
||||
|
||||
@router.get("/detail/{vm_name}/{snap_name}")
|
||||
async def get_snapshot_detail(vm_name: str, snap_name: str):
|
||||
async def get_snapshot_detail(vm_name: str, snap_name: str, host_id: str = Query("local")):
|
||||
"""获取快照详情"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
dom = conn.lookupByName(vm_name)
|
||||
snap = dom.snapshotLookupByName(snap_name)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""存储池管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from lxml import etree
|
||||
import os
|
||||
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
from app.libvirt_conn import conn_pool
|
||||
import libvirt
|
||||
|
||||
router = APIRouter()
|
||||
@@ -24,9 +24,9 @@ class VolCreate(BaseModel):
|
||||
|
||||
|
||||
@router.get("/pools")
|
||||
async def list_pools():
|
||||
async def list_pools(host_id: str = Query("local")):
|
||||
"""列出所有存储池"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
pools = conn.listAllStoragePools(0)
|
||||
result = []
|
||||
for pool in pools:
|
||||
@@ -47,9 +47,9 @@ async def list_pools():
|
||||
|
||||
|
||||
@router.get("/pool/{name}")
|
||||
async def get_pool(name: str):
|
||||
async def get_pool(name: str, host_id: str = Query("local")):
|
||||
"""获取存储池详情"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
pool = conn.storagePoolLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -58,7 +58,6 @@ async def get_pool(name: str):
|
||||
info = pool.info()
|
||||
xml = etree.fromstring(pool.XMLDesc(0).encode())
|
||||
|
||||
# 获取卷列表
|
||||
volumes = []
|
||||
try:
|
||||
for vol_name in pool.listVolumes():
|
||||
@@ -89,9 +88,9 @@ async def get_pool(name: str):
|
||||
|
||||
|
||||
@router.post("/pool/create")
|
||||
async def create_pool(pool: PoolCreate):
|
||||
async def create_pool(pool: PoolCreate, host_id: str = Query("local")):
|
||||
"""创建存储池"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
xml = f"""<pool type='{pool.type}'>
|
||||
<name>{pool.name}</name>
|
||||
<target>
|
||||
@@ -109,9 +108,9 @@ async def create_pool(pool: PoolCreate):
|
||||
|
||||
|
||||
@router.delete("/pool/{name}")
|
||||
async def delete_pool(name: str):
|
||||
async def delete_pool(name: str, host_id: str = Query("local")):
|
||||
"""删除存储池"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
pool = rw_conn.storagePoolLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -125,9 +124,9 @@ async def delete_pool(name: str):
|
||||
|
||||
|
||||
@router.post("/pool/{name}/volume")
|
||||
async def create_volume(name: str, vol: VolCreate):
|
||||
async def create_volume(name: str, vol: VolCreate, host_id: str = Query("local")):
|
||||
"""在存储池中创建卷"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
pool = rw_conn.storagePoolLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -149,9 +148,9 @@ async def create_volume(name: str, vol: VolCreate):
|
||||
|
||||
|
||||
@router.delete("/pool/{pool_name}/volume/{vol_name}")
|
||||
async def delete_volume(pool_name: str, vol_name: str):
|
||||
async def delete_volume(pool_name: str, vol_name: str, host_id: str = Query("local")):
|
||||
"""删除卷"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
pool = rw_conn.storagePoolLookupByName(pool_name)
|
||||
vol = pool.storageVolLookupByName(vol_name)
|
||||
@@ -162,7 +161,7 @@ async def delete_volume(pool_name: str, vol_name: str):
|
||||
|
||||
|
||||
@router.get("/isos")
|
||||
async def list_isos():
|
||||
async def list_isos(host_id: str = Query("local")):
|
||||
"""列出可用的ISO镜像"""
|
||||
iso_dirs = ["/var/lib/libvirt/iso", "/isos", "/mnt/isos"]
|
||||
isos = []
|
||||
|
||||
+23
-35
@@ -1,11 +1,11 @@
|
||||
"""虚拟机管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from lxml import etree
|
||||
import os
|
||||
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
from app.libvirt_conn import conn_pool
|
||||
from app.utils import parse_vm_info, generate_vm_xml
|
||||
import libvirt
|
||||
|
||||
@@ -36,9 +36,9 @@ class VMClone(BaseModel):
|
||||
# ===== API =====
|
||||
|
||||
@router.get("/list")
|
||||
async def list_vms():
|
||||
async def list_vms(host_id: str = Query("local")):
|
||||
"""获取所有虚拟机列表"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
domains = conn.listAllDomains(0)
|
||||
vms = []
|
||||
for dom in domains:
|
||||
@@ -56,9 +56,9 @@ async def list_vms():
|
||||
|
||||
|
||||
@router.get("/detail/{name}")
|
||||
async def get_vm_detail(name: str):
|
||||
async def get_vm_detail(name: str, host_id: str = Query("local")):
|
||||
"""获取虚拟机详情"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -69,20 +69,17 @@ async def get_vm_detail(name: str):
|
||||
# 运行中的虚拟机获取更多动态信息
|
||||
if info["state"] == "running":
|
||||
try:
|
||||
# CPU 时间
|
||||
_, _, cpu_time, _ = dom.info()
|
||||
info["cpu_time_ns"] = cpu_time
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 内存使用
|
||||
try:
|
||||
mem_stats = dom.memoryStats()
|
||||
info["memory_stats"] = mem_stats
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 块设备统计
|
||||
try:
|
||||
block_stats = []
|
||||
for disk in info.get("disks", []):
|
||||
@@ -99,7 +96,6 @@ async def get_vm_detail(name: str):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 网络统计
|
||||
try:
|
||||
net_stats = []
|
||||
for i, iface in enumerate(info.get("interfaces", [])):
|
||||
@@ -120,18 +116,18 @@ async def get_vm_detail(name: str):
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_vm(vm: VMCreate):
|
||||
async def create_vm(vm: VMCreate, host_id: str = Query("local")):
|
||||
"""创建虚拟机"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
|
||||
# 检查名称是否已存在
|
||||
try:
|
||||
conn.lookupByName(vm.name)
|
||||
raise HTTPException(status_code=400, detail=f"虚拟机 '{vm.name}' 已存在")
|
||||
except libvirt.libvirtError:
|
||||
pass # 不存在,继续创建
|
||||
pass
|
||||
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
# 确定磁盘路径
|
||||
pool = rw_conn.storagePoolLookupByName(vm.pool_name)
|
||||
@@ -143,7 +139,6 @@ async def create_vm(vm: VMCreate):
|
||||
disk_path = os.path.join(pool_path, f"{vm.name}.qcow2")
|
||||
|
||||
# 创建 qcow2 磁盘
|
||||
# 创建卷的 XML
|
||||
vol_xml = f"""<volume>
|
||||
<name>{vm.name}.qcow2</name>
|
||||
<capacity unit='GiB'>{vm.disk_gb}</capacity>
|
||||
@@ -179,15 +174,15 @@ async def create_vm(vm: VMCreate):
|
||||
|
||||
|
||||
@router.post("/action/{name}")
|
||||
async def vm_action(name: str, action: VMAction):
|
||||
async def vm_action(name: str, action: VMAction, host_id: str = Query("local")):
|
||||
"""虚拟机操作"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
rw_dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -221,9 +216,9 @@ async def vm_action(name: str, action: VMAction):
|
||||
|
||||
|
||||
@router.delete("/delete/{name}")
|
||||
async def delete_vm(name: str, force: bool = False):
|
||||
async def delete_vm(name: str, force: bool = False, host_id: str = Query("local")):
|
||||
"""删除虚拟机"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -262,9 +257,9 @@ async def delete_vm(name: str, force: bool = False):
|
||||
|
||||
|
||||
@router.post("/clone/{name}")
|
||||
async def clone_vm(name: str, clone: VMClone):
|
||||
async def clone_vm(name: str, clone: VMClone, host_id: str = Query("local")):
|
||||
"""克隆虚拟机"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -278,19 +273,15 @@ async def clone_vm(name: str, clone: VMClone):
|
||||
pass
|
||||
|
||||
try:
|
||||
# 获取源虚拟机 XML
|
||||
xml_desc = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE)
|
||||
tree = etree.fromstring(xml_desc.encode())
|
||||
|
||||
# 修改名称
|
||||
tree.find("name").text = clone.new_name
|
||||
|
||||
# 修改 UUID(删除让libvirt自动生成)
|
||||
uuid_elem = tree.find("uuid")
|
||||
if uuid_elem is not None:
|
||||
tree.remove(uuid_elem)
|
||||
|
||||
# 修改磁盘路径
|
||||
import uuid as uuid_mod
|
||||
new_uuid = str(uuid_mod.uuid4())[:8]
|
||||
for disk in tree.findall(".//disk[@device='disk']"):
|
||||
@@ -300,7 +291,6 @@ async def clone_vm(name: str, clone: VMClone):
|
||||
new_path = old_path.replace(f"{name}.qcow2", f"{clone.new_name}.qcow2")
|
||||
source.set("file", new_path)
|
||||
|
||||
# 修改 MAC 地址
|
||||
for mac in tree.findall(".//interface/mac"):
|
||||
import random
|
||||
mac_addr = "52:54:00:%02x:%02x:%02x" % (
|
||||
@@ -310,7 +300,6 @@ async def clone_vm(name: str, clone: VMClone):
|
||||
)
|
||||
mac.set("address", mac_addr)
|
||||
|
||||
# 复制磁盘
|
||||
old_disk_path = ""
|
||||
new_disk_path = ""
|
||||
for disk in tree.findall(".//disk[@device='disk']/source"):
|
||||
@@ -325,7 +314,6 @@ async def clone_vm(name: str, clone: VMClone):
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# 定义新虚拟机
|
||||
new_xml = etree.tostring(tree, encoding="unicode")
|
||||
rw_conn.defineXML(new_xml)
|
||||
|
||||
@@ -336,9 +324,9 @@ async def clone_vm(name: str, clone: VMClone):
|
||||
|
||||
|
||||
@router.get("/xml/{name}")
|
||||
async def get_vm_xml(name: str):
|
||||
async def get_vm_xml(name: str, host_id: str = Query("local")):
|
||||
"""获取虚拟机 XML 配置"""
|
||||
conn = libvirt_conn.conn
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -348,9 +336,9 @@ async def get_vm_xml(name: str):
|
||||
|
||||
|
||||
@router.put("/xml/{name}")
|
||||
async def update_vm_xml(name: str, xml: dict):
|
||||
async def update_vm_xml(name: str, xml: dict, host_id: str = Query("local")):
|
||||
"""更新虚拟机 XML 配置"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
@@ -368,9 +356,9 @@ async def update_vm_xml(name: str, xml: dict):
|
||||
|
||||
|
||||
@router.post("/migrate/{name}")
|
||||
async def migrate_vm(name: str, dest_uri: str, live: bool = True):
|
||||
async def migrate_vm(name: str, dest_uri: str, live: bool = True, host_id: str = Query("local")):
|
||||
"""迁移虚拟机"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
with conn_pool.get_rw(host_id) as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
|
||||
Reference in New Issue
Block a user