8ccccf8f52
主要功能: - 多主机管理: 支持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版本兼容性修复
376 lines
13 KiB
Python
376 lines
13 KiB
Python
"""虚拟机管理路由"""
|
|
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 conn_pool
|
|
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(host_id: str = Query("local")):
|
|
"""获取所有虚拟机列表"""
|
|
conn = conn_pool.get_conn(host_id)
|
|
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, host_id: str = Query("local")):
|
|
"""获取虚拟机详情"""
|
|
conn = conn_pool.get_conn(host_id)
|
|
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_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, host_id: str = Query("local")):
|
|
"""创建虚拟机"""
|
|
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
|
|
|
|
with conn_pool.get_rw(host_id) 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 磁盘
|
|
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, host_id: str = Query("local")):
|
|
"""虚拟机操作"""
|
|
conn = conn_pool.get_conn(host_id)
|
|
try:
|
|
dom = conn.lookupByName(name)
|
|
except libvirt.libvirtError:
|
|
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
|
|
|
with conn_pool.get_rw(host_id) 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, host_id: str = Query("local")):
|
|
"""删除虚拟机"""
|
|
with conn_pool.get_rw(host_id) 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, host_id: str = Query("local")):
|
|
"""克隆虚拟机"""
|
|
with conn_pool.get_rw(host_id) 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_desc = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE)
|
|
tree = etree.fromstring(xml_desc.encode())
|
|
|
|
tree.find("name").text = clone.new_name
|
|
|
|
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)
|
|
|
|
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, host_id: str = Query("local")):
|
|
"""获取虚拟机 XML 配置"""
|
|
conn = conn_pool.get_conn(host_id)
|
|
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, host_id: str = Query("local")):
|
|
"""更新虚拟机 XML 配置"""
|
|
with conn_pool.get_rw(host_id) 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, host_id: str = Query("local")):
|
|
"""迁移虚拟机"""
|
|
with conn_pool.get_rw(host_id) 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)}")
|