feat: KVM虚拟化管理平台初始版本
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libvirt-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8004
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8004"]
|
||||
@@ -0,0 +1,17 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "KVM Manager"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
LIBVIRT_URI: str = "qemu:///system"
|
||||
API_PREFIX: str = "/api"
|
||||
SECRET_KEY: str = "kvm-manager-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""libvirt 连接管理 - 使用连接池模式"""
|
||||
import libvirt
|
||||
from contextlib import contextmanager
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibvirtConnection:
|
||||
"""libvirt 连接管理器(只读连接保持,写操作用短连接)"""
|
||||
|
||||
def __init__(self):
|
||||
self._conn = None
|
||||
|
||||
def _connect(self):
|
||||
"""建立新的 libvirt 连接"""
|
||||
try:
|
||||
conn = libvirt.openReadOnly(settings.LIBVIRT_URI)
|
||||
if conn is None:
|
||||
raise ConnectionError(f"无法连接到 libvirt: {settings.LIBVIRT_URI}")
|
||||
return conn
|
||||
except libvirt.libvirtError as e:
|
||||
logger.error(f"libvirt 连接错误: {e}")
|
||||
raise
|
||||
|
||||
def _connect_rw(self):
|
||||
"""建立可读写连接"""
|
||||
try:
|
||||
conn = libvirt.open(settings.LIBVIRT_URI)
|
||||
if conn is None:
|
||||
raise ConnectionError(f"无法连接到 libvirt (RW): {settings.LIBVIRT_URI}")
|
||||
return conn
|
||||
except libvirt.libvirtError as e:
|
||||
logger.error(f"libvirt RW 连接错误: {e}")
|
||||
raise
|
||||
|
||||
@property
|
||||
def conn(self):
|
||||
"""获取只读连接(带自动重连)"""
|
||||
try:
|
||||
if self._conn is None or not self._conn.isAlive():
|
||||
self._conn = self._connect()
|
||||
except Exception:
|
||||
self._conn = self._connect()
|
||||
return self._conn
|
||||
|
||||
@contextmanager
|
||||
def get_rw(self):
|
||||
"""获取读写连接(短连接,用完关闭)"""
|
||||
conn = None
|
||||
try:
|
||||
conn = self._connect_rw()
|
||||
yield conn
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_host_info(self):
|
||||
"""获取宿主机信息"""
|
||||
c = self.conn
|
||||
return {
|
||||
"hostname": c.getHostname(),
|
||||
"hypervisor": c.getType(),
|
||||
"libvirt_version": c.getLibVersion(),
|
||||
"hypervisor_version": c.getVersion(),
|
||||
"cpu_model": c.getInfo()[5] if len(c.getInfo()) > 5 else "Unknown",
|
||||
"cpu_cores": c.getInfo()[2],
|
||||
"memory_total": c.getInfo()[1], # KB
|
||||
"cpu_speed": c.getInfo()[3], # MHz
|
||||
}
|
||||
|
||||
|
||||
# 全局单例
|
||||
libvirt_conn = LibvirtConnection()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""FastAPI 主应用"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.routers import vm, storage, network, snapshot, monitor
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="KVM 虚拟化管理平台 API",
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
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=["网络管理"])
|
||||
app.include_router(snapshot.router, prefix=f"{settings.API_PREFIX}/snapshot", tags=["快照管理"])
|
||||
app.include_router(monitor.router, prefix=f"{settings.API_PREFIX}/monitor", tags=["资源监控"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"name": settings.APP_NAME, "version": settings.APP_VERSION}
|
||||
|
||||
|
||||
@app.get(f"{settings.API_PREFIX}/host")
|
||||
async def host_info():
|
||||
"""获取宿主机信息"""
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
return libvirt_conn.get_host_info()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""健康检查"""
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
try:
|
||||
conn = libvirt_conn.conn
|
||||
return {"status": "ok", "libvirt": conn.isAlive()}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -0,0 +1,207 @@
|
||||
"""资源监控路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
import libvirt
|
||||
import time
|
||||
import threading
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 简易内存缓存
|
||||
_stats_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def monitor_overview():
|
||||
"""宿主机总览监控"""
|
||||
conn = libvirt_conn.conn
|
||||
|
||||
# 宿主机信息
|
||||
host_info = conn.getInfo()
|
||||
hostname = conn.getHostname()
|
||||
|
||||
# CPU 使用率(通过 node info)
|
||||
cpu_stats = conn.getCPUStats(-1, 0) # 全局 CPU 统计
|
||||
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 = {}
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
meminfo[parts[0].rstrip(":")] = int(parts[1])
|
||||
|
||||
mem_total_mb = meminfo.get("MemTotal", 0) // 1024
|
||||
mem_available_mb = meminfo.get("MemAvailable", 0) // 1024
|
||||
mem_used_mb = mem_total_mb - mem_available_mb
|
||||
mem_percent = round(mem_used_mb / mem_total_mb * 100, 1) if mem_total_mb > 0 else 0
|
||||
except Exception:
|
||||
mem_total_mb = memory_total_kb // 1024
|
||||
mem_used_mb = 0
|
||||
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
|
||||
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"cpu": {
|
||||
"cores": host_info[2],
|
||||
"speed_mhz": host_info[3],
|
||||
"usage_percent": cpu_percent,
|
||||
},
|
||||
"memory": {
|
||||
"total_mb": mem_total_mb,
|
||||
"used_mb": mem_used_mb,
|
||||
"available_mb": mem_available_mb,
|
||||
"usage_percent": mem_percent,
|
||||
},
|
||||
"vms": {
|
||||
"total": len(domains),
|
||||
"running": running,
|
||||
"stopped": stopped,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/vm/{name}")
|
||||
async def monitor_vm(name: str):
|
||||
"""获取虚拟机实时监控数据"""
|
||||
conn = libvirt_conn.conn
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
if not dom.isActive():
|
||||
return {"name": name, "state": "stopped", "cpu_percent": 0, "memory": {}}
|
||||
|
||||
# CPU 百分比
|
||||
cpu_percent = _get_vm_cpu_percent(dom)
|
||||
|
||||
# 内存
|
||||
mem_stats = {}
|
||||
try:
|
||||
raw = dom.memoryStats()
|
||||
mem_stats = {
|
||||
"rss_mb": raw.get("rss", 0) // 1024 if "rss" in raw else 0,
|
||||
"actual_mb": raw.get("actual", 0) // 1024 if "actual" in raw else 0,
|
||||
"available_mb": raw.get("available", 0) // 1024 if "available" in raw else 0,
|
||||
"usage_percent": round(
|
||||
raw.get("rss", 0) / raw.get("actual", 1) * 100, 1
|
||||
) if "rss" in raw and "actual" in raw else 0,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 磁盘IO
|
||||
disk_stats = _get_vm_disk_stats(dom)
|
||||
|
||||
# 网络IO
|
||||
net_stats = _get_vm_net_stats(dom)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"state": "running",
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory": mem_stats,
|
||||
"disk": disk_stats,
|
||||
"network": net_stats,
|
||||
}
|
||||
|
||||
|
||||
def _get_vm_cpu_percent(dom) -> 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)
|
||||
|
||||
if prev:
|
||||
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)
|
||||
|
||||
return cpu_percent
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _get_vm_disk_stats(dom) -> list:
|
||||
"""获取虚拟机磁盘IO"""
|
||||
from lxml import etree
|
||||
stats = []
|
||||
try:
|
||||
xml = etree.fromstring(dom.XMLDesc(0).encode())
|
||||
for disk in xml.findall(".//disk[@device='disk']"):
|
||||
target = disk.find("target")
|
||||
if target is not None:
|
||||
dev = target.get("dev", "")
|
||||
try:
|
||||
s = dom.blockStats(dev)
|
||||
stats.append({
|
||||
"dev": dev,
|
||||
"read_bytes": s[1],
|
||||
"write_bytes": s[3],
|
||||
"read_requests": s[0],
|
||||
"write_requests": s[2],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return stats
|
||||
|
||||
|
||||
def _get_vm_net_stats(dom) -> list:
|
||||
"""获取虚拟机网络IO"""
|
||||
from lxml import etree
|
||||
stats = []
|
||||
try:
|
||||
xml = etree.fromstring(dom.XMLDesc(0).encode())
|
||||
for iface in xml.findall(".//interface"):
|
||||
target = iface.find("target")
|
||||
if target is not None:
|
||||
dev = target.get("dev", "")
|
||||
try:
|
||||
s = dom.interfaceStats(dev)
|
||||
stats.append({
|
||||
"dev": dev,
|
||||
"rx_bytes": s[0],
|
||||
"tx_bytes": s[4],
|
||||
"rx_packets": s[1],
|
||||
"tx_packets": s[5],
|
||||
"rx_errors": s[2],
|
||||
"tx_errors": s[6],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return stats
|
||||
@@ -0,0 +1,175 @@
|
||||
"""网络管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from lxml import etree
|
||||
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
import libvirt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NetworkCreate(BaseModel):
|
||||
name: str = Field(..., description="网络名称")
|
||||
mode: str = Field("nat", description="模式: nat/bridge/isolated")
|
||||
subnet: str = Field("192.168.100.0/24", description="子网")
|
||||
bridge: Optional[str] = Field(None, description="桥接网卡名(mode=bridge时必填)")
|
||||
dhcp_start: Optional[str] = Field(None, description="DHCP起始IP")
|
||||
dhcp_end: Optional[str] = Field(None, description="DHCP结束IP")
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_networks():
|
||||
"""列出所有网络"""
|
||||
conn = libvirt_conn.conn
|
||||
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"
|
||||
|
||||
ip_elem = xml.find("ip")
|
||||
address = ip_elem.get("address", "") if ip_elem is not None else ""
|
||||
netmask = ip_elem.get("netmask", "") if ip_elem is not None else ""
|
||||
|
||||
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", ""),
|
||||
}
|
||||
|
||||
# 活跃租约
|
||||
leases = []
|
||||
try:
|
||||
for lease in net.DHCPLeases():
|
||||
leases.append({
|
||||
"ip": lease.get("ipaddr", ""),
|
||||
"mac": lease.get("mac", ""),
|
||||
"hostname": lease.get("hostname", ""),
|
||||
"expiry": lease.get("expirytime", 0),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result.append({
|
||||
"name": net.name(),
|
||||
"active": net.isActive() == 1,
|
||||
"persistent": net.isPersistent() == 1,
|
||||
"autostart": net.autostart() == 1,
|
||||
"mode": mode,
|
||||
"address": address,
|
||||
"netmask": netmask,
|
||||
"bridge": bridge_name,
|
||||
"dhcp": dhcp_range,
|
||||
"leases": leases,
|
||||
})
|
||||
return {"networks": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/detail/{name}")
|
||||
async def get_network(name: str):
|
||||
"""获取网络详情"""
|
||||
conn = libvirt_conn.conn
|
||||
try:
|
||||
net = conn.networkLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"网络 '{name}' 不存在")
|
||||
|
||||
xml_str = net.XMLDesc(0)
|
||||
return {"name": name, "xml": xml_str, "active": net.isActive() == 1}
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_network(net: NetworkCreate):
|
||||
"""创建网络"""
|
||||
if net.mode == "bridge" and not net.bridge:
|
||||
raise HTTPException(status_code=400, detail="桥接模式必须指定桥接网卡")
|
||||
|
||||
if net.mode == "bridge":
|
||||
xml = f"""<network>
|
||||
<name>{net.name}</name>
|
||||
<forward mode='bridge'/>
|
||||
<bridge name='{net.bridge}'/>
|
||||
</network>"""
|
||||
else:
|
||||
# NAT或隔离模式
|
||||
import ipaddress
|
||||
network = ipaddress.ip_network(net.subnet, strict=False)
|
||||
gateway = str(network.network_address + 1)
|
||||
|
||||
dhcp_xml = ""
|
||||
if net.mode == "nat":
|
||||
start = net.dhcp_start or str(network.network_address + 2)
|
||||
end = net.dhcp_end or str(network.network_address + 254)
|
||||
dhcp_xml = f"""
|
||||
<dhcp>
|
||||
<range start='{start}' end='{end}'/>
|
||||
</dhcp>"""
|
||||
|
||||
forward_xml = f"<forward mode='{net.mode}'/>" if net.mode == "nat" else ""
|
||||
netmask = str(network.netmask)
|
||||
|
||||
xml = f"""<network>
|
||||
<name>{net.name}</name>
|
||||
{forward_xml}
|
||||
<bridge name='virbr-{net.name[:8]}' stp='on' delay='0'/>
|
||||
<ip address='{gateway}' netmask='{netmask}'>{dhcp_xml}
|
||||
</ip>
|
||||
</network>"""
|
||||
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
n = rw_conn.networkDefineXML(xml)
|
||||
n.setAutostart(1)
|
||||
n.create()
|
||||
return {"message": f"网络 '{net.name}' 创建成功"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建网络失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/delete/{name}")
|
||||
async def delete_network(name: str):
|
||||
"""删除网络"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
net = rw_conn.networkLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"网络 '{name}' 不存在")
|
||||
|
||||
if net.isActive():
|
||||
net.destroy()
|
||||
net.undefine()
|
||||
return {"message": f"网络 '{name}' 已删除"}
|
||||
|
||||
|
||||
@router.post("/action/{name}")
|
||||
async def network_action(name: str, action: str):
|
||||
"""网络操作: start/stop"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
net = rw_conn.networkLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"网络 '{name}' 不存在")
|
||||
|
||||
try:
|
||||
if action == "start":
|
||||
net.create()
|
||||
elif action == "stop":
|
||||
net.destroy()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的操作: {action}")
|
||||
return {"message": f"网络 '{name}' {action} 成功"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""快照管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from lxml import etree
|
||||
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
import libvirt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SnapshotCreate(BaseModel):
|
||||
name: str = Field(..., description="快照名称")
|
||||
description: Optional[str] = Field(None, description="快照描述")
|
||||
|
||||
|
||||
@router.get("/list/{vm_name}")
|
||||
async def list_snapshots(vm_name: str):
|
||||
"""列出虚拟机的所有快照"""
|
||||
conn = libvirt_conn.conn
|
||||
try:
|
||||
dom = conn.lookupByName(vm_name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{vm_name}' 不存在")
|
||||
|
||||
snapshots = []
|
||||
try:
|
||||
for snap in dom.listAllSnapshots(0):
|
||||
xml = etree.fromstring(snap.getXMLDesc().encode())
|
||||
desc = xml.find("description")
|
||||
state = xml.find("state")
|
||||
creation = xml.find("creationTime")
|
||||
|
||||
snapshots.append({
|
||||
"name": snap.getName(),
|
||||
"state": state.text if state is not None else "",
|
||||
"description": desc.text if desc is not None else "",
|
||||
"creation_time": int(creation.text) if creation is not None else 0,
|
||||
"is_current": snap.isCurrent() == 1,
|
||||
})
|
||||
except libvirt.libvirtError:
|
||||
pass # 没有快照
|
||||
|
||||
return {"vm": vm_name, "snapshots": snapshots, "total": len(snapshots)}
|
||||
|
||||
|
||||
@router.post("/create/{vm_name}")
|
||||
async def create_snapshot(vm_name: str, snap: SnapshotCreate):
|
||||
"""创建快照"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(vm_name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{vm_name}' 不存在")
|
||||
|
||||
desc_xml = f"<description>{snap.description}</description>" if snap.description else ""
|
||||
xml = f"""<domainsnapshot>
|
||||
<name>{snap.name}</name>
|
||||
{desc_xml}
|
||||
</domainsnapshot>"""
|
||||
|
||||
try:
|
||||
dom.snapshotCreateXML(xml, 0)
|
||||
return {"message": f"快照 '{snap.name}' 创建成功"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建快照失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/revert/{vm_name}/{snap_name}")
|
||||
async def revert_snapshot(vm_name: str, snap_name: str):
|
||||
"""恢复快照"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(vm_name)
|
||||
snap = dom.snapshotLookupByName(snap_name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail="虚拟机或快照不存在")
|
||||
|
||||
try:
|
||||
dom.revertToSnapshot(snap)
|
||||
return {"message": f"已恢复到快照 '{snap_name}'"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"恢复快照失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/delete/{vm_name}/{snap_name}")
|
||||
async def delete_snapshot(vm_name: str, snap_name: str):
|
||||
"""删除快照"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(vm_name)
|
||||
snap = dom.snapshotLookupByName(snap_name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail="虚拟机或快照不存在")
|
||||
|
||||
try:
|
||||
snap.delete(0)
|
||||
return {"message": f"快照 '{snap_name}' 已删除"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除快照失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/detail/{vm_name}/{snap_name}")
|
||||
async def get_snapshot_detail(vm_name: str, snap_name: str):
|
||||
"""获取快照详情"""
|
||||
conn = libvirt_conn.conn
|
||||
try:
|
||||
dom = conn.lookupByName(vm_name)
|
||||
snap = dom.snapshotLookupByName(snap_name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail="虚拟机或快照不存在")
|
||||
|
||||
return {"name": snap_name, "vm": vm_name, "xml": snap.getXMLDesc()}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""存储池管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from lxml import etree
|
||||
import os
|
||||
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
import libvirt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PoolCreate(BaseModel):
|
||||
name: str = Field(..., description="存储池名称")
|
||||
path: str = Field(..., description="存储池路径")
|
||||
type: str = Field("dir", description="存储池类型: dir/fs/logical")
|
||||
|
||||
|
||||
class VolCreate(BaseModel):
|
||||
name: str = Field(..., description="卷名称")
|
||||
capacity_gb: int = Field(20, description="容量(GB)")
|
||||
format: str = Field("qcow2", description="格式: qcow2/raw")
|
||||
|
||||
|
||||
@router.get("/pools")
|
||||
async def list_pools():
|
||||
"""列出所有存储池"""
|
||||
conn = libvirt_conn.conn
|
||||
pools = conn.listAllStoragePools(0)
|
||||
result = []
|
||||
for pool in pools:
|
||||
info = pool.info()
|
||||
xml = etree.fromstring(pool.XMLDesc(0).encode())
|
||||
target = xml.find(".//target/path")
|
||||
result.append({
|
||||
"name": pool.name(),
|
||||
"state": ["inactive", "building", "running", "degraded", "inaccessible"][info[0]] if info[0] < 5 else "unknown",
|
||||
"capacity_gb": round(info[1] / (1024**3), 2),
|
||||
"allocation_gb": round(info[2] / (1024**3), 2),
|
||||
"available_gb": round(info[3] / (1024**3), 2),
|
||||
"path": target.text if target is not None else "",
|
||||
"autostart": pool.autostart() == 1,
|
||||
"persistent": pool.isPersistent() == 1,
|
||||
})
|
||||
return {"pools": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/pool/{name}")
|
||||
async def get_pool(name: str):
|
||||
"""获取存储池详情"""
|
||||
conn = libvirt_conn.conn
|
||||
try:
|
||||
pool = conn.storagePoolLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"存储池 '{name}' 不存在")
|
||||
|
||||
info = pool.info()
|
||||
xml = etree.fromstring(pool.XMLDesc(0).encode())
|
||||
|
||||
# 获取卷列表
|
||||
volumes = []
|
||||
try:
|
||||
for vol_name in pool.listVolumes():
|
||||
vol = pool.storageVolLookupByName(vol_name)
|
||||
vol_info = vol.info()
|
||||
volumes.append({
|
||||
"name": vol_name,
|
||||
"path": vol.path(),
|
||||
"type": vol_info[0],
|
||||
"capacity_gb": round(vol_info[1] / (1024**3), 2),
|
||||
"allocation_gb": round(vol_info[2] / (1024**3), 2),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
target = xml.find(".//target/path")
|
||||
return {
|
||||
"name": pool.name(),
|
||||
"state": ["inactive", "building", "running", "degraded", "inaccessible"][info[0]] if info[0] < 5 else "unknown",
|
||||
"capacity_gb": round(info[1] / (1024**3), 2),
|
||||
"allocation_gb": round(info[2] / (1024**3), 2),
|
||||
"available_gb": round(info[3] / (1024**3), 2),
|
||||
"path": target.text if target is not None else "",
|
||||
"autostart": pool.autostart() == 1,
|
||||
"volumes": volumes,
|
||||
"volume_count": len(volumes),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pool/create")
|
||||
async def create_pool(pool: PoolCreate):
|
||||
"""创建存储池"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
xml = f"""<pool type='{pool.type}'>
|
||||
<name>{pool.name}</name>
|
||||
<target>
|
||||
<path>{pool.path}</path>
|
||||
</target>
|
||||
</pool>"""
|
||||
try:
|
||||
os.makedirs(pool.path, exist_ok=True)
|
||||
p = rw_conn.storagePoolDefineXML(xml, 0)
|
||||
p.setAutostart(1)
|
||||
p.create(0)
|
||||
return {"message": f"存储池 '{pool.name}' 创建成功"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建存储池失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/pool/{name}")
|
||||
async def delete_pool(name: str):
|
||||
"""删除存储池"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
pool = rw_conn.storagePoolLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"存储池 '{name}' 不存在")
|
||||
|
||||
if pool.info()[0] == libvirt.VIR_STORAGE_POOL_RUNNING:
|
||||
pool.destroy()
|
||||
|
||||
pool.undefine()
|
||||
return {"message": f"存储池 '{name}' 已删除"}
|
||||
|
||||
|
||||
@router.post("/pool/{name}/volume")
|
||||
async def create_volume(name: str, vol: VolCreate):
|
||||
"""在存储池中创建卷"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
pool = rw_conn.storagePoolLookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"存储池 '{name}' 不存在")
|
||||
|
||||
vol_xml = f"""<volume>
|
||||
<name>{vol.name}</name>
|
||||
<capacity unit='GiB'>{vol.capacity_gb}</capacity>
|
||||
<allocation unit='GiB'>1</allocation>
|
||||
<target>
|
||||
<format type='{vol.format}'/>
|
||||
</target>
|
||||
</volume>"""
|
||||
try:
|
||||
pool.createXML(vol_xml, 0)
|
||||
return {"message": f"卷 '{vol.name}' 创建成功"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建卷失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/pool/{pool_name}/volume/{vol_name}")
|
||||
async def delete_volume(pool_name: str, vol_name: str):
|
||||
"""删除卷"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
pool = rw_conn.storagePoolLookupByName(pool_name)
|
||||
vol = pool.storageVolLookupByName(vol_name)
|
||||
vol.delete(0)
|
||||
return {"message": f"卷 '{vol_name}' 已删除"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除卷失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/isos")
|
||||
async def list_isos():
|
||||
"""列出可用的ISO镜像"""
|
||||
iso_dirs = ["/var/lib/libvirt/iso", "/isos", "/mnt/isos"]
|
||||
isos = []
|
||||
for d in iso_dirs:
|
||||
if os.path.isdir(d):
|
||||
for f in os.listdir(d):
|
||||
if f.lower().endswith(".iso"):
|
||||
fp = os.path.join(d, f)
|
||||
size = os.path.getsize(fp)
|
||||
isos.append({
|
||||
"name": f,
|
||||
"path": fp,
|
||||
"size_gb": round(size / (1024**3), 2),
|
||||
})
|
||||
return {"isos": isos, "total": len(isos)}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""虚拟机管理路由"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
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.utils import parse_vm_info, generate_vm_xml
|
||||
import libvirt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===== 请求模型 =====
|
||||
|
||||
class VMCreate(BaseModel):
|
||||
name: str = Field(..., description="虚拟机名称")
|
||||
memory_mb: int = Field(2048, description="内存大小(MB)")
|
||||
vcpus: int = Field(2, description="CPU核心数")
|
||||
disk_gb: int = Field(20, description="磁盘大小(GB)")
|
||||
pool_name: str = Field("default", description="存储池名称")
|
||||
iso_path: Optional[str] = Field(None, description="ISO安装镜像路径")
|
||||
network: str = Field("default", description="网络名称")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
|
||||
|
||||
class VMAction(BaseModel):
|
||||
action: str = Field(..., description="操作: start/stop/restart/pause/resume/force_stop")
|
||||
|
||||
|
||||
class VMClone(BaseModel):
|
||||
new_name: str = Field(..., description="新虚拟机名称")
|
||||
|
||||
|
||||
# ===== API =====
|
||||
|
||||
@router.get("/list")
|
||||
async def list_vms():
|
||||
"""获取所有虚拟机列表"""
|
||||
conn = libvirt_conn.conn
|
||||
domains = conn.listAllDomains(0)
|
||||
vms = []
|
||||
for dom in domains:
|
||||
try:
|
||||
vm_info = parse_vm_info(dom)
|
||||
vms.append(vm_info)
|
||||
except Exception as e:
|
||||
vms.append({
|
||||
"name": dom.name(),
|
||||
"uuid": dom.UUIDString(),
|
||||
"state": "error",
|
||||
"error": str(e),
|
||||
})
|
||||
return {"vms": vms, "total": len(vms)}
|
||||
|
||||
|
||||
@router.get("/detail/{name}")
|
||||
async def get_vm_detail(name: str):
|
||||
"""获取虚拟机详情"""
|
||||
conn = libvirt_conn.conn
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
info = parse_vm_info(dom)
|
||||
|
||||
# 运行中的虚拟机获取更多动态信息
|
||||
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", []):
|
||||
if disk.get("dev"):
|
||||
stats = dom.blockStats(disk["dev"])
|
||||
block_stats.append({
|
||||
"dev": disk["dev"],
|
||||
"read_bytes": stats[1],
|
||||
"write_bytes": stats[3],
|
||||
"read_requests": stats[0],
|
||||
"write_requests": stats[2],
|
||||
})
|
||||
info["block_stats"] = block_stats
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 网络统计
|
||||
try:
|
||||
net_stats = []
|
||||
for i, iface in enumerate(info.get("interfaces", [])):
|
||||
if iface.get("mac"):
|
||||
stats = dom.interfaceStats(iface["mac"])
|
||||
net_stats.append({
|
||||
"mac": iface["mac"],
|
||||
"rx_bytes": stats[0],
|
||||
"tx_bytes": stats[4],
|
||||
"rx_packets": stats[1],
|
||||
"tx_packets": stats[5],
|
||||
})
|
||||
info["net_stats"] = net_stats
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_vm(vm: VMCreate):
|
||||
"""创建虚拟机"""
|
||||
conn = libvirt_conn.conn
|
||||
|
||||
# 检查名称是否已存在
|
||||
try:
|
||||
conn.lookupByName(vm.name)
|
||||
raise HTTPException(status_code=400, detail=f"虚拟机 '{vm.name}' 已存在")
|
||||
except libvirt.libvirtError:
|
||||
pass # 不存在,继续创建
|
||||
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
# 确定磁盘路径
|
||||
pool = rw_conn.storagePoolLookupByName(vm.pool_name)
|
||||
pool_info = pool.info()
|
||||
pool_xml = etree.fromstring(pool.XMLDesc(0).encode())
|
||||
target = pool_xml.find(".//target/path")
|
||||
pool_path = target.text if target is not None else "/var/lib/libvirt/images"
|
||||
|
||||
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>
|
||||
<allocation unit='GiB'>1</allocation>
|
||||
<target>
|
||||
<format type='qcow2'/>
|
||||
<permissions>
|
||||
<mode>0644</mode>
|
||||
</permissions>
|
||||
</target>
|
||||
</volume>"""
|
||||
pool.createXML(vol_xml, 0)
|
||||
|
||||
# 生成虚拟机XML
|
||||
vm_xml = generate_vm_xml(
|
||||
name=vm.name,
|
||||
memory_mb=vm.memory_mb,
|
||||
vcpus=vm.vcpus,
|
||||
disk_path=disk_path,
|
||||
disk_size_gb=vm.disk_gb,
|
||||
iso_path=vm.iso_path,
|
||||
network=vm.network,
|
||||
)
|
||||
|
||||
# 定义并启动
|
||||
dom = rw_conn.defineXML(vm_xml)
|
||||
dom.create()
|
||||
|
||||
return {"message": f"虚拟机 '{vm.name}' 创建成功", "name": vm.name}
|
||||
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建虚拟机失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/action/{name}")
|
||||
async def vm_action(name: str, action: VMAction):
|
||||
"""虚拟机操作"""
|
||||
conn = libvirt_conn.conn
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
rw_dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
act = action.action
|
||||
try:
|
||||
if act == "start":
|
||||
rw_dom.create()
|
||||
msg = f"虚拟机 '{name}' 已启动"
|
||||
elif act == "stop":
|
||||
rw_dom.shutdown()
|
||||
msg = f"虚拟机 '{name}' 正在关闭"
|
||||
elif act == "force_stop":
|
||||
rw_dom.destroy()
|
||||
msg = f"虚拟机 '{name}' 已强制关闭"
|
||||
elif act == "restart":
|
||||
rw_dom.reboot(libvirt.VIR_DOMAIN_REBOOT_DEFAULT)
|
||||
msg = f"虚拟机 '{name}' 正在重启"
|
||||
elif act == "pause":
|
||||
rw_dom.suspend()
|
||||
msg = f"虚拟机 '{name}' 已暂停"
|
||||
elif act == "resume":
|
||||
rw_dom.resume()
|
||||
msg = f"虚拟机 '{name}' 已恢复"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的操作: {act}")
|
||||
return {"message": msg}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/delete/{name}")
|
||||
async def delete_vm(name: str, force: bool = False):
|
||||
"""删除虚拟机"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
state, _ = dom.info()[0:2]
|
||||
if state == libvirt.VIR_DOMAIN_RUNNING:
|
||||
if force:
|
||||
dom.destroy()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"虚拟机 '{name}' 正在运行,请先关闭或使用 force=true"
|
||||
)
|
||||
|
||||
# 获取磁盘路径
|
||||
xml_desc = dom.XMLDesc(0)
|
||||
tree = etree.fromstring(xml_desc.encode())
|
||||
disk_files = []
|
||||
for disk in tree.findall(".//disk[@device='disk']/source"):
|
||||
f = disk.get("file")
|
||||
if f:
|
||||
disk_files.append(f)
|
||||
|
||||
# 取消定义
|
||||
dom.undefine()
|
||||
|
||||
# 删除磁盘文件
|
||||
for f in disk_files:
|
||||
try:
|
||||
os.remove(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"message": f"虚拟机 '{name}' 已删除", "removed_disks": disk_files}
|
||||
|
||||
|
||||
@router.post("/clone/{name}")
|
||||
async def clone_vm(name: str, clone: VMClone):
|
||||
"""克隆虚拟机"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
# 检查新名称是否已存在
|
||||
try:
|
||||
rw_conn.lookupByName(clone.new_name)
|
||||
raise HTTPException(status_code=400, detail=f"虚拟机 '{clone.new_name}' 已存在")
|
||||
except libvirt.libvirtError:
|
||||
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']"):
|
||||
source = disk.find("source")
|
||||
if source is not None:
|
||||
old_path = source.get("file", "")
|
||||
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" % (
|
||||
random.randint(0, 255),
|
||||
random.randint(0, 255),
|
||||
random.randint(0, 255),
|
||||
)
|
||||
mac.set("address", mac_addr)
|
||||
|
||||
# 复制磁盘
|
||||
old_disk_path = ""
|
||||
new_disk_path = ""
|
||||
for disk in tree.findall(".//disk[@device='disk']/source"):
|
||||
old_disk_path = disk.get("file", "")
|
||||
new_disk_path = old_disk_path.replace(f"{name}.qcow2", f"{clone.new_name}.qcow2")
|
||||
|
||||
if old_disk_path and os.path.exists(old_disk_path):
|
||||
import subprocess
|
||||
subprocess.run(
|
||||
["qemu-img", "create", "-f", "qcow2", "-b", old_disk_path, "-F", "qcow2", new_disk_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# 定义新虚拟机
|
||||
new_xml = etree.tostring(tree, encoding="unicode")
|
||||
rw_conn.defineXML(new_xml)
|
||||
|
||||
return {"message": f"虚拟机 '{name}' 已克隆为 '{clone.new_name}'"}
|
||||
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"克隆失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/xml/{name}")
|
||||
async def get_vm_xml(name: str):
|
||||
"""获取虚拟机 XML 配置"""
|
||||
conn = libvirt_conn.conn
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
return {"name": name, "xml": dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE)}
|
||||
|
||||
|
||||
@router.put("/xml/{name}")
|
||||
async def update_vm_xml(name: str, xml: dict):
|
||||
"""更新虚拟机 XML 配置"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
xml_str = xml.get("xml", "")
|
||||
if not xml_str:
|
||||
raise HTTPException(status_code=400, detail="XML不能为空")
|
||||
|
||||
try:
|
||||
rw_conn.defineXML(xml_str)
|
||||
return {"message": f"虚拟机 '{name}' 配置已更新"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"更新失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/migrate/{name}")
|
||||
async def migrate_vm(name: str, dest_uri: str, live: bool = True):
|
||||
"""迁移虚拟机"""
|
||||
with libvirt_conn.get_rw() as rw_conn:
|
||||
try:
|
||||
dom = rw_conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
flags = libvirt.VIR_MIGRATE_LIVE if live else 0
|
||||
flags |= libvirt.VIR_MIGRATE_PERSIST_DEST
|
||||
|
||||
try:
|
||||
dest_conn = libvirt.open(dest_uri)
|
||||
dom.migrate(dest_conn, flags, None, None, 0)
|
||||
return {"message": f"虚拟机 '{name}' 已迁移到 {dest_uri}"}
|
||||
except libvirt.libvirtError as e:
|
||||
raise HTTPException(status_code=500, detail=f"迁移失败: {str(e)}")
|
||||
@@ -0,0 +1,247 @@
|
||||
"""虚拟机 XML 模板和工具函数"""
|
||||
import uuid
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
|
||||
|
||||
def generate_vm_xml(
|
||||
name: str,
|
||||
memory_mb: int,
|
||||
vcpus: int,
|
||||
disk_path: str,
|
||||
disk_size_gb: int = 20,
|
||||
iso_path: str = None,
|
||||
network: str = "default",
|
||||
vnc_port: int = -1,
|
||||
os_type: str = "hvm",
|
||||
arch: str = "x86_64",
|
||||
machine: str = "pc",
|
||||
) -> str:
|
||||
"""生成虚拟机 XML 定义"""
|
||||
|
||||
vm_uuid = str(uuid.uuid4())
|
||||
|
||||
# 基础 XML 结构
|
||||
xml_parts = f"""<domain type='kvm'>
|
||||
<name>{name}</name>
|
||||
<uuid>{vm_uuid}</uuid>
|
||||
<memory unit='MiB'>{memory_mb}</memory>
|
||||
<currentMemory unit='MiB'>{memory_mb}</currentMemory>
|
||||
<vcpu placement='static'>{vcpus}</vcpu>
|
||||
<os>
|
||||
<type arch='{arch}' machine='{machine}'>{os_type}</type>
|
||||
<boot dev='hd'/>
|
||||
<boot dev='cdrom'/>
|
||||
</os>
|
||||
<features>
|
||||
<acpi/>
|
||||
<apic/>
|
||||
</features>
|
||||
<cpu mode='host-passthrough'/>
|
||||
<clock offset='utc'>
|
||||
<timer name='rtc' tickpolicy='catchup'/>
|
||||
<timer name='pit' tickpolicy='delay'/>
|
||||
<timer name='hpet' present='no'/>
|
||||
</clock>
|
||||
<on_poweroff>destroy</on_poweroff>
|
||||
<on_reboot>restart</on_reboot>
|
||||
<on_crash>destroy</on_crash>
|
||||
<devices>
|
||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
||||
<disk type='file' device='disk'>
|
||||
<driver name='qemu' type='qcow2'/>
|
||||
<source file='{disk_path}'/>
|
||||
<target dev='vda' bus='virtio'/>
|
||||
</disk>"""
|
||||
|
||||
# 光驱(ISO安装)
|
||||
if iso_path:
|
||||
xml_parts += f"""
|
||||
<disk type='file' device='cdrom'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<source file='{iso_path}'/>
|
||||
<target dev='hda' bus='ide'/>
|
||||
<readonly/>
|
||||
</disk>"""
|
||||
|
||||
# VNC
|
||||
if vnc_port == -1:
|
||||
vnc_port = 5900 # auto-allocate by libvirt
|
||||
xml_parts += f"""
|
||||
<graphics type='vnc' port='{vnc_port}' autoport='yes' listen='0.0.0.0' passwd=''>
|
||||
<listen type='address' address='0.0.0.0'/>
|
||||
</graphics>
|
||||
<video>
|
||||
<model type='virtio' heads='1' primary='yes'/>
|
||||
</video>
|
||||
<serial type='pty'>
|
||||
<target port='0'/>
|
||||
</serial>
|
||||
<console type='pty'>
|
||||
<target type='serial' port='0'/>
|
||||
</console>"""
|
||||
|
||||
# 网络
|
||||
xml_parts += f"""
|
||||
<interface type='network'>
|
||||
<source network='{network}'/>
|
||||
<model type='virtio'/>
|
||||
</interface>
|
||||
<controller type='usb' model='qemu-xhci'/>
|
||||
<input type='tablet' bus='usb'/>
|
||||
<memballoon model='virtio'/>
|
||||
</devices>
|
||||
</domain>"""
|
||||
|
||||
return xml_parts
|
||||
|
||||
|
||||
def parse_vm_info(dom) -> dict:
|
||||
"""从 libvirt domain 对象提取虚拟机信息"""
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
|
||||
# 基本信息
|
||||
info = {
|
||||
"id": dom.ID(),
|
||||
"name": dom.name(),
|
||||
"uuid": dom.UUIDString(),
|
||||
"state": _get_state(dom),
|
||||
"autostart": False,
|
||||
}
|
||||
|
||||
try:
|
||||
info["autostart"] = dom.autostart() == 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 解析 XML
|
||||
xml_desc = dom.XMLDesc(0)
|
||||
tree = etree.fromstring(xml_desc.encode())
|
||||
|
||||
# 内存和CPU
|
||||
mem = tree.find(".//memory")
|
||||
cur_mem = tree.find(".//currentMemory")
|
||||
vcpu = tree.find(".//vcpu")
|
||||
|
||||
mem_unit = mem.get("unit", "KiB") if mem is not None else "KiB"
|
||||
mem_val = int(mem.text) if mem is not None else 0
|
||||
info["memory_mb"] = _to_mb(mem_val, mem_unit)
|
||||
|
||||
cur_unit = cur_mem.get("unit", "KiB") if cur_mem is not None else "KiB"
|
||||
cur_val = int(cur_mem.text) if cur_mem is not None else 0
|
||||
info["current_memory_mb"] = _to_mb(cur_val, cur_unit)
|
||||
|
||||
info["vcpus"] = int(vcpu.text) if vcpu is not None else 1
|
||||
|
||||
# CPU type
|
||||
cpu = tree.find(".//cpu")
|
||||
if cpu is not None:
|
||||
info["cpu_mode"] = cpu.get("mode", "unknown")
|
||||
else:
|
||||
info["cpu_mode"] = "unknown"
|
||||
|
||||
# 磁盘
|
||||
disks = []
|
||||
for disk in tree.findall(".//disk"):
|
||||
if disk.get("device") == "disk":
|
||||
source = disk.find("source")
|
||||
target = disk.find("target")
|
||||
driver = disk.find("driver")
|
||||
disk_info = {
|
||||
"file": source.get("file", "") if source is not None else "",
|
||||
"dev": target.get("dev", "") if target is not None else "",
|
||||
"bus": target.get("bus", "") if target is not None else "",
|
||||
"format": driver.get("type", "") if driver is not None else "",
|
||||
}
|
||||
disks.append(disk_info)
|
||||
info["disks"] = disks
|
||||
|
||||
# 网络
|
||||
interfaces = []
|
||||
for iface in tree.findall(".//interface"):
|
||||
source = iface.find("source")
|
||||
model = iface.find("model")
|
||||
iface_info = {
|
||||
"type": iface.get("type", ""),
|
||||
"network": source.get("network", "") if source is not None else "",
|
||||
"model": model.get("type", "") if model is not None else "",
|
||||
}
|
||||
# 如果运行中,获取MAC和IP
|
||||
if info["state"] == "running":
|
||||
mac = iface.find("mac")
|
||||
if mac is not None:
|
||||
iface_info["mac"] = mac.get("address", "")
|
||||
# 尝试获取IP地址
|
||||
try:
|
||||
ifaces = dom.interfaceAddresses(
|
||||
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0
|
||||
)
|
||||
if ifaces:
|
||||
for ifname, ifdata in ifaces.items():
|
||||
if mac is not None and ifdata.get("hwaddr", "") == iface_info.get("mac", ""):
|
||||
addrs = ifdata.get("addrs", [])
|
||||
if addrs:
|
||||
iface_info["ip"] = addrs[0].get("addr", "")
|
||||
except Exception:
|
||||
try:
|
||||
ifaces = dom.interfaceAddresses(
|
||||
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0
|
||||
)
|
||||
if ifaces:
|
||||
for ifname, ifdata in ifaces.items():
|
||||
addrs = ifdata.get("addrs", [])
|
||||
if addrs:
|
||||
iface_info["ip"] = addrs[0].get("addr", "")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
interfaces.append(iface_info)
|
||||
info["interfaces"] = interfaces
|
||||
|
||||
# VNC
|
||||
graphics = tree.find(".//graphics[@type='vnc']")
|
||||
if graphics is not None:
|
||||
info["vnc_port"] = int(graphics.get("port", -1))
|
||||
info["vnc_listen"] = graphics.get("listen", "127.0.0.1")
|
||||
else:
|
||||
info["vnc_port"] = -1
|
||||
info["vnc_listen"] = ""
|
||||
|
||||
# OS info
|
||||
os_type = tree.find(".//os/type")
|
||||
info["os_type"] = os_type.text if os_type is not None else "hvm"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _get_state(dom) -> str:
|
||||
"""获取虚拟机运行状态"""
|
||||
raw = dom.info()
|
||||
state = raw[0]
|
||||
state_map = {
|
||||
libvirt.VIR_DOMAIN_NOSTATE: "nostate",
|
||||
libvirt.VIR_DOMAIN_RUNNING: "running",
|
||||
libvirt.VIR_DOMAIN_BLOCKED: "blocked",
|
||||
libvirt.VIR_DOMAIN_PAUSED: "paused",
|
||||
libvirt.VIR_DOMAIN_SHUTDOWN: "shutdown",
|
||||
libvirt.VIR_DOMAIN_SHUTOFF: "shutoff",
|
||||
libvirt.VIR_DOMAIN_CRASHED: "crashed",
|
||||
libvirt.VIR_DOMAIN_PMSUSPENDED: "suspended",
|
||||
}
|
||||
return state_map.get(state, "unknown")
|
||||
|
||||
|
||||
def _to_mb(value, unit) -> int:
|
||||
"""转换为 MB"""
|
||||
unit = unit.lower()
|
||||
if unit in ("kib", "k", "kib"):
|
||||
return value // 1024
|
||||
elif unit in ("mib", "m", "mib"):
|
||||
return value
|
||||
elif unit in ("gib", "g", "gib"):
|
||||
return value * 1024
|
||||
elif unit in ("tib", "t"):
|
||||
return value * 1024 * 1024
|
||||
elif unit == "b":
|
||||
return value // (1024 * 1024)
|
||||
return value // 1024 # default KiB
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
libvirt-python==9.0.0
|
||||
python-multipart==0.0.6
|
||||
pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
aiofiles==23.2.1
|
||||
websockify==0.10.0
|
||||
lxml==4.9.3
|
||||
Reference in New Issue
Block a user