feat: 优化虚拟机列表,支持多主机聚合显示
- 新增 /vm/list-all API 聚合所有主机虚拟机 - parse_vm_info 支持 include_ip 参数控制IP获取 - VMList 添加主机选择器,显示宿主机列 - 修复 API 路径 /host/list -> /hosts/list - 新增启动脚本 scripts/start.sh - 新增 Guest Agent 安装脚本 scripts/install-guest-agent.sh - 更新 README 文档
This commit is contained in:
+150
-5
@@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from lxml import etree
|
||||
from app.hosts import list_hosts
|
||||
import os
|
||||
|
||||
from app.libvirt_conn import conn_pool
|
||||
@@ -36,14 +37,14 @@ class VMClone(BaseModel):
|
||||
# ===== API =====
|
||||
|
||||
@router.get("/list")
|
||||
async def list_vms(host_id: str = Query("local")):
|
||||
"""获取所有虚拟机列表"""
|
||||
async def list_vms(host_id: str = Query("local"), include_ip: bool = False):
|
||||
"""获取指定主机所有虚拟机列表(轻量模式,默认不获取IP)"""
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
domains = conn.listAllDomains(0)
|
||||
vms = []
|
||||
for dom in domains:
|
||||
try:
|
||||
vm_info = parse_vm_info(dom)
|
||||
vm_info = parse_vm_info(dom, host_id, include_ip=include_ip)
|
||||
vms.append(vm_info)
|
||||
except Exception as e:
|
||||
vms.append({
|
||||
@@ -52,7 +53,37 @@ async def list_vms(host_id: str = Query("local")):
|
||||
"state": "error",
|
||||
"error": str(e),
|
||||
})
|
||||
return {"vms": vms, "total": len(vms)}
|
||||
return {"vms": vms, "total": len(vms), "host_id": host_id}
|
||||
|
||||
|
||||
@router.get("/list-all")
|
||||
async def list_all_vms(include_ip: bool = False):
|
||||
"""获取所有主机所有虚拟机列表(聚合模式)"""
|
||||
hosts = list_hosts()
|
||||
all_vms = []
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
conn = conn_pool.get_conn(host.id)
|
||||
domains = conn.listAllDomains(0)
|
||||
for dom in domains:
|
||||
try:
|
||||
vm_info = parse_vm_info(dom, host.id, include_ip=include_ip)
|
||||
vm_info["host_id"] = host.id
|
||||
vm_info["host_name"] = host.name
|
||||
all_vms.append(vm_info)
|
||||
except Exception:
|
||||
all_vms.append({
|
||||
"name": dom.name(),
|
||||
"uuid": dom.UUIDString(),
|
||||
"state": "error",
|
||||
"host_id": host.id,
|
||||
"host_name": host.name,
|
||||
})
|
||||
except Exception:
|
||||
pass # 跳过无法连接的主机
|
||||
|
||||
return {"vms": all_vms, "total": len(all_vms), "host_count": len(hosts)}
|
||||
|
||||
|
||||
@router.get("/detail/{name}")
|
||||
@@ -64,7 +95,7 @@ async def get_vm_detail(name: str, host_id: str = Query("local")):
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
info = parse_vm_info(dom)
|
||||
info = parse_vm_info(dom, host_id)
|
||||
|
||||
# 运行中的虚拟机获取更多动态信息
|
||||
if info["state"] == "running":
|
||||
@@ -115,6 +146,120 @@ async def get_vm_detail(name: str, host_id: str = Query("local")):
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/ip/{name}")
|
||||
async def get_vm_ip(name: str, host_id: str = Query("local")):
|
||||
"""获取虚拟机 IP 地址"""
|
||||
conn = conn_pool.get_conn(host_id)
|
||||
try:
|
||||
dom = conn.lookupByName(name)
|
||||
except libvirt.libvirtError:
|
||||
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
|
||||
|
||||
if not dom.isActive():
|
||||
return {"name": name, "ips": [], "message": "虚拟机未运行"}
|
||||
|
||||
xml_desc = dom.XMLDesc(0)
|
||||
tree = etree.fromstring(xml_desc.encode())
|
||||
|
||||
interfaces = []
|
||||
for iface in tree.findall(".//interface"):
|
||||
source = iface.find("source")
|
||||
mac_elem = iface.find("mac")
|
||||
if mac_elem is None:
|
||||
continue
|
||||
mac = mac_elem.get("address", "")
|
||||
network = source.get("network", "") if source is not None else ""
|
||||
|
||||
ips = []
|
||||
|
||||
# 方式1: QEMU Guest Agent
|
||||
try:
|
||||
ifaces = dom.interfaceAddresses(
|
||||
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0
|
||||
)
|
||||
if ifaces:
|
||||
for ifname, ifdata in ifaces.items():
|
||||
if ifdata.get("hwaddr", "").lower() == mac.lower():
|
||||
addrs = ifdata.get("addrs", [])
|
||||
for a in addrs:
|
||||
addr = a.get("addr", "")
|
||||
if "." in addr:
|
||||
ips.append({"ip": addr, "type": "ipv4", "source": "guest-agent"})
|
||||
elif ":" in addr:
|
||||
ips.append({"ip": addr, "type": "ipv6", "source": "guest-agent"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方式2: DHCP 租约
|
||||
if not ips:
|
||||
try:
|
||||
ifaces = dom.interfaceAddresses(
|
||||
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0
|
||||
)
|
||||
if ifaces:
|
||||
for ifname, ifdata in ifaces.items():
|
||||
hwaddr = ifdata.get("hwaddr", "").lower()
|
||||
if hwaddr == mac.lower():
|
||||
addrs = ifdata.get("addrs", [])
|
||||
for a in addrs:
|
||||
addr = a.get("addr", "")
|
||||
if "." in addr:
|
||||
ips.append({"ip": addr, "type": "ipv4", "source": "dhcp-lease"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方式3: ARP 表查找
|
||||
if not ips:
|
||||
ip_list = _arp_lookup(mac, host_id)
|
||||
for ip in ip_list:
|
||||
ips.append({"ip": ip, "type": "ipv4", "source": "arp"})
|
||||
|
||||
interfaces.append({
|
||||
"mac": mac,
|
||||
"network": network,
|
||||
"ips": ips,
|
||||
})
|
||||
|
||||
return {"name": name, "interfaces": interfaces}
|
||||
|
||||
|
||||
def _arp_lookup(mac: str, host_id: str = "local") -> list:
|
||||
"""通过 ARP 表查找 MAC 对应的 IP"""
|
||||
import subprocess
|
||||
from app.hosts import get_host as get_host_info
|
||||
|
||||
host_info = get_host_info(host_id)
|
||||
is_remote = host_info and host_info.type != "local"
|
||||
|
||||
try:
|
||||
if is_remote and host_info.type == "ssh":
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(host_info.uri)
|
||||
remote_host = parsed.hostname
|
||||
ssh_args = ["ssh", "-o", "StrictHostKeyChecking=no"]
|
||||
if host_info.ssh_key_path:
|
||||
ssh_args.extend(["-i", host_info.ssh_key_path])
|
||||
ssh_args.extend([remote_host, "ip", "neigh", "show"])
|
||||
result = subprocess.run(ssh_args, capture_output=True, text=True, timeout=5)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["ip", "neigh", "show"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
found = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if mac.lower() in line.lower():
|
||||
parts = line.split()
|
||||
if parts and "." in parts[0]:
|
||||
found.append(parts[0])
|
||||
return found
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_vm(vm: VMCreate, host_id: str = Query("local")):
|
||||
"""创建虚拟机"""
|
||||
|
||||
+118
-31
@@ -96,8 +96,14 @@ def generate_vm_xml(
|
||||
return xml_parts
|
||||
|
||||
|
||||
def parse_vm_info(dom) -> dict:
|
||||
"""从 libvirt domain 对象提取虚拟机信息"""
|
||||
def parse_vm_info(dom, host_id: str = "local", include_ip: bool = False) -> dict:
|
||||
"""从 libvirt domain 对象提取虚拟机信息
|
||||
|
||||
Args:
|
||||
dom: libvirt domain 对象
|
||||
host_id: 主机ID
|
||||
include_ip: 是否获取IP地址(开启会明显变慢,默认关闭)
|
||||
"""
|
||||
from app.libvirt_conn import libvirt_conn
|
||||
|
||||
# 基本信息
|
||||
@@ -161,43 +167,37 @@ def parse_vm_info(dom) -> dict:
|
||||
for iface in tree.findall(".//interface"):
|
||||
source = iface.find("source")
|
||||
model = iface.find("model")
|
||||
target = iface.find("target")
|
||||
iface_info = {
|
||||
"type": iface.get("type", ""),
|
||||
"network": source.get("network", "") if source is not None else "",
|
||||
"bridge": source.get("bridge", "") if source is not None else "",
|
||||
"model": model.get("type", "") if model is not None else "",
|
||||
"dev": target.get("dev", "") if target 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
|
||||
mac_elem = iface.find("mac")
|
||||
if mac_elem is not None:
|
||||
iface_info["mac"] = mac_elem.get("address", "")
|
||||
interfaces.append(iface_info)
|
||||
info["interfaces"] = interfaces
|
||||
|
||||
# 获取 IP 地址(仅在需要时)
|
||||
if include_ip and info["state"] == "running":
|
||||
arp_cache = _get_arp_table(host_id)
|
||||
for iface in interfaces:
|
||||
if "mac" in iface:
|
||||
# 先尝试 Guest Agent 和 DHCP 租约
|
||||
iface["ip"] = _get_vm_ip(dom, iface["mac"], iface.get("network", ""), host_id, None)
|
||||
# 如果没找到再用 ARP 表
|
||||
if not iface.get("ip") and arp_cache:
|
||||
mac_lower = iface["mac"].lower()
|
||||
if mac_lower in arp_cache:
|
||||
iface["ip"] = arp_cache[mac_lower]
|
||||
else:
|
||||
# 不获取 IP 时,初始化 ip 字段为空
|
||||
for iface in interfaces:
|
||||
iface["ip"] = ""
|
||||
|
||||
# VNC
|
||||
graphics = tree.find(".//graphics[@type='vnc']")
|
||||
if graphics is not None:
|
||||
@@ -214,6 +214,93 @@ def parse_vm_info(dom) -> dict:
|
||||
return info
|
||||
|
||||
|
||||
def _get_arp_table(host_id: str = "local") -> dict:
|
||||
"""获取 ARP 表(MAC -> IP 映射),返回 {mac_lower: ip}"""
|
||||
import subprocess
|
||||
from app.hosts import get_host as get_host_info
|
||||
|
||||
host_info = get_host_info(host_id)
|
||||
is_remote = host_info and host_info.type != "local"
|
||||
|
||||
try:
|
||||
if is_remote and host_info.type == "ssh":
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(host_info.uri)
|
||||
remote_host = parsed.hostname
|
||||
ssh_args = ["ssh", "-o", "StrictHostKeyChecking=no"]
|
||||
if host_info.ssh_key_path:
|
||||
ssh_args.extend(["-i", host_info.ssh_key_path])
|
||||
ssh_args.extend([remote_host, "ip", "neigh", "show"])
|
||||
result = subprocess.run(ssh_args, capture_output=True, text=True, timeout=5)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["ip", "neigh", "show"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
arp = {}
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 5:
|
||||
ip = parts[0]
|
||||
# lladdr 行格式: IP dev IFACE lladdr MAC ...
|
||||
for i, p in enumerate(parts):
|
||||
if p == "lladdr" and i + 1 < len(parts) and "." in ip:
|
||||
arp[parts[i + 1].lower()] = ip
|
||||
return arp
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _get_vm_ip(dom, mac: str, network: str = "", host_id: str = "local", arp_cache: dict = None) -> str:
|
||||
"""多种方式尝试获取虚拟机 IP 地址"""
|
||||
# 方式1: QEMU Guest Agent(最准确)
|
||||
try:
|
||||
ifaces = dom.interfaceAddresses(
|
||||
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0
|
||||
)
|
||||
if ifaces:
|
||||
for ifname, ifdata in ifaces.items():
|
||||
if ifdata.get("hwaddr", "").lower() == mac.lower():
|
||||
addrs = ifdata.get("addrs", [])
|
||||
for a in addrs:
|
||||
addr = a.get("addr", "")
|
||||
# 优先返回 IPv4
|
||||
if "." in addr:
|
||||
return addr
|
||||
if addrs:
|
||||
return addrs[0].get("addr", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方式2: DHCP 租约(通过 libvirt 网络)
|
||||
try:
|
||||
ifaces = dom.interfaceAddresses(
|
||||
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0
|
||||
)
|
||||
if ifaces:
|
||||
for ifname, ifdata in ifaces.items():
|
||||
hwaddr = ifdata.get("hwaddr", "").lower()
|
||||
if hwaddr == mac.lower():
|
||||
addrs = ifdata.get("addrs", [])
|
||||
for a in addrs:
|
||||
addr = a.get("addr", "")
|
||||
if "." in addr:
|
||||
return addr
|
||||
if addrs:
|
||||
return addrs[0].get("addr", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方式3: ARP 表查找(通过 MAC 地址查 IP)
|
||||
if arp_cache is not None and mac.lower() in arp_cache:
|
||||
return arp_cache[mac.lower()]
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _get_state(dom) -> str:
|
||||
"""获取虚拟机运行状态"""
|
||||
raw = dom.info()
|
||||
|
||||
Reference in New Issue
Block a user