284 lines
9.0 KiB
Go
284 lines
9.0 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"vdi-cloud/platform/pkg/proxmox"
|
|
)
|
|
|
|
type gpuReference struct {
|
|
VMID int `json:"vmid"`
|
|
VMName string `json:"vmName"`
|
|
VMStatus string `json:"vmStatus"`
|
|
Template bool `json:"template"`
|
|
DesktopID string `json:"desktopId"`
|
|
DesktopName string `json:"desktopName"`
|
|
Slot string `json:"slot"`
|
|
Phase string `json:"phase"`
|
|
Source string `json:"source"`
|
|
Mapping string `json:"mapping"`
|
|
Mediated bool `json:"mediated"`
|
|
PendingRemoval bool `json:"pendingRemoval"`
|
|
}
|
|
type gpuItem struct {
|
|
ID string `json:"id"`
|
|
Address string `json:"address"`
|
|
Name string `json:"name"`
|
|
Vendor string `json:"vendor"`
|
|
VendorID string `json:"vendorId"`
|
|
DeviceID string `json:"deviceId"`
|
|
Class string `json:"class"`
|
|
IOMMUGroup *int `json:"iommuGroup"`
|
|
State string `json:"state"`
|
|
Health gpuHealth `json:"health"`
|
|
References []gpuReference `json:"references"`
|
|
}
|
|
type gpuHealth struct {
|
|
Status string `json:"status"`
|
|
Source string `json:"source"`
|
|
ObservedAt time.Time `json:"observedAt"`
|
|
TemperatureC *float64 `json:"temperatureC"`
|
|
UtilizationPercent *float64 `json:"utilizationPercent"`
|
|
MemoryUsedBytes *int64 `json:"memoryUsedBytes"`
|
|
MemoryTotalBytes *int64 `json:"memoryTotalBytes"`
|
|
Driver string `json:"driver"`
|
|
DriverBinding string `json:"driverBinding"`
|
|
}
|
|
type gpuIssue struct {
|
|
Code string `json:"code"`
|
|
VMID int `json:"vmid"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type gpuScan struct {
|
|
VM proxmox.Resource
|
|
Options []proxmox.PendingOption
|
|
Err error
|
|
}
|
|
|
|
// This reads the PVE configuration view, not the host driver or running QEMU state.
|
|
func (s *Server) gpuInventory(c *gin.Context) {
|
|
node := c.Query("node")
|
|
if node == "" || len(node) > 100 || strings.TrimSpace(node) != node {
|
|
s.fail(c, 400, "invalid_node", "请选择计算节点")
|
|
return
|
|
}
|
|
pve := s.currentPVE()
|
|
if pve == nil {
|
|
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox,无法读取真实显卡")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 12*time.Second)
|
|
defer cancel()
|
|
resources, err := pve.Resources(ctx)
|
|
if err != nil {
|
|
s.fail(c, 502, "pve_unavailable", "无法读取节点和虚拟机清单,请检查 PVE 接入")
|
|
return
|
|
}
|
|
found := false
|
|
vms := []proxmox.Resource{}
|
|
for _, r := range resources {
|
|
if r.Type == "node" && r.Node == node {
|
|
found = true
|
|
}
|
|
if r.Type == "qemu" && r.Node == node {
|
|
vms = append(vms, r)
|
|
}
|
|
}
|
|
if !found {
|
|
s.fail(c, 404, "node_not_found", "节点不存在或当前 PVE 凭据不可见")
|
|
return
|
|
}
|
|
issues := []gpuIssue{}
|
|
issue := func(code string, vmid int, message string) { issues = append(issues, gpuIssue{code, vmid, message}) }
|
|
devices, err := pve.PCIDevices(ctx, node)
|
|
if err != nil {
|
|
s.fail(c, 502, "pci_unavailable", "无法读取节点 PCI 清单,请检查 PVE 连通性及根路径 Sys.Audit 权限")
|
|
return
|
|
}
|
|
items := []gpuItem{}
|
|
observedAt := time.Now().UTC()
|
|
for _, d := range devices {
|
|
if !d.IsGPU() {
|
|
continue
|
|
}
|
|
address, ok := proxmox.NormalizePCIAddress(d.ID)
|
|
if !ok {
|
|
issue("pci_invalid", 0, "部分显卡 PCI 地址无法识别")
|
|
continue
|
|
}
|
|
group := d.IOMMUGroup
|
|
if group != nil && *group < 0 {
|
|
group = nil
|
|
}
|
|
driverBinding := "unknown"
|
|
if strings.TrimSpace(d.Driver) != "" {
|
|
driverBinding = "bound"
|
|
}
|
|
items = append(items, gpuItem{ID: node + ":" + address, Address: address, Name: d.DeviceName, Vendor: d.VendorName, VendorID: d.Vendor, DeviceID: d.Device, Class: d.Class, IOMMUGroup: group, Health: gpuHealth{Status: "not_collected", Source: "pve-pci", ObservedAt: observedAt, Driver: d.Driver, DriverBinding: driverBinding}, References: []gpuReference{}})
|
|
}
|
|
fullVisibility, err := pve.HasPermission(ctx, "/", "VM.Audit")
|
|
if err != nil || !fullVisibility {
|
|
issue("vm_visibility_partial", 0, "未确认根路径 VM.Audit 权限,虚拟机清单可能不完整")
|
|
}
|
|
var desktops []struct {
|
|
ID string `db:"id"`
|
|
Name string `db:"name"`
|
|
VMID int `db:"vmid"`
|
|
}
|
|
if err := s.DB.SelectContext(ctx, &desktops, "SELECT id,name,vmid FROM desktops WHERE provider='proxmox' AND node=$1 ORDER BY id", node); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
sort.Slice(vms, func(i, j int) bool { return vms[i].VMID < vms[j].VMID })
|
|
visibleCount := len(vms)
|
|
if len(vms) > 200 {
|
|
vms = vms[:200]
|
|
issue("scan_limit", 0, "本次最多检查 200 台虚拟机,当前节点超出检查范围")
|
|
}
|
|
scans := make([]gpuScan, len(vms))
|
|
jobs := make(chan int, len(vms))
|
|
for i := range vms {
|
|
jobs <- i
|
|
}
|
|
close(jobs)
|
|
var wg sync.WaitGroup
|
|
for worker := 0; worker < 4; worker++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := range jobs {
|
|
options, err := pve.VMPending(ctx, node, vms[i].VMID)
|
|
scans[i] = gpuScan{vms[i], options, err}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
var mappings []proxmox.PCIMapping
|
|
mappingsLoaded := false
|
|
scannedCount := 0
|
|
for _, scan := range scans {
|
|
if scan.Err != nil {
|
|
issue("vm_config_unavailable", scan.VM.VMID, "无法读取虚拟机当前及待应用配置")
|
|
continue
|
|
}
|
|
scannedCount++
|
|
for _, option := range scan.Options {
|
|
if option.Key == "args" || option.Key == "hookscript" {
|
|
if rawOptionText(option.Value) != "" || rawOptionText(option.Pending) != "" {
|
|
issue("custom_vm_config", scan.VM.VMID, "虚拟机包含自定义参数或宿主脚本,额外设备引用未解析")
|
|
}
|
|
continue
|
|
}
|
|
if !proxmox.IsHostPCIKey(option.Key) {
|
|
continue
|
|
}
|
|
for _, phase := range []string{"current", "pending"} {
|
|
value := option.Value
|
|
if phase == "pending" {
|
|
value = option.Pending
|
|
}
|
|
if len(value) == 0 || string(value) == "null" {
|
|
continue
|
|
}
|
|
ref := gpuReference{VMID: scan.VM.VMID, VMName: scan.VM.Name, VMStatus: scan.VM.Status, Template: scan.VM.Template == 1, Slot: option.Key, Phase: phase, Source: "direct", PendingRemoval: phase == "current" && (string(option.Delete) == "1" || string(option.Delete) == "true")}
|
|
for _, d := range desktops {
|
|
if d.VMID == scan.VM.VMID {
|
|
ref.DesktopID, ref.DesktopName = d.ID, d.Name
|
|
break
|
|
}
|
|
}
|
|
parsed, err := proxmox.ParseHostPCI(rawOptionText(value))
|
|
if err != nil {
|
|
issue("hostpci_unparsed", ref.VMID, "虚拟机直通配置无法解析")
|
|
continue
|
|
}
|
|
ref.Mediated = parsed.Mediated
|
|
selectors := parsed.Addresses
|
|
if parsed.Mapping != "" {
|
|
ref.Source, ref.Mapping = "mapping", parsed.Mapping
|
|
if !mappingsLoaded {
|
|
mappings, _ = pve.PCIMappings(ctx)
|
|
mappingsLoaded = true
|
|
}
|
|
selectors, err = gpuMappingSelectors(mappings, parsed.Mapping, node)
|
|
if err != nil {
|
|
issue("mapping_unresolved", ref.VMID, "PCI 映射不可见、配置无效或当前节点未定义")
|
|
continue
|
|
}
|
|
if len(selectors) > 1 {
|
|
issue("mapping_candidates", ref.VMID, "PCI 映射包含多个候选设备,仅展示可能的配置引用")
|
|
}
|
|
}
|
|
for i := range items {
|
|
for _, selector := range selectors {
|
|
if proxmox.PCIAddressMatches(selector, items[i].Address) {
|
|
items[i].References = append(items[i].References, ref)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for i := range items {
|
|
items[i].State = "unreferenced"
|
|
if len(issues) != 0 {
|
|
items[i].State = "unknown"
|
|
}
|
|
owners := map[int]bool{}
|
|
for _, ref := range items[i].References {
|
|
owners[ref.VMID] = true
|
|
}
|
|
if len(owners) == 1 {
|
|
items[i].State = "referenced"
|
|
} else if len(owners) > 1 {
|
|
items[i].State = "multiple"
|
|
}
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i].Address < items[j].Address })
|
|
c.JSON(200, gin.H{"node": node, "observedAt": observedAt, "complete": len(issues) == 0, "visibleVmCount": visibleCount, "scannedVmCount": scannedCount, "items": items, "issues": issues, "health": gin.H{"status": "partial", "source": "pve-pci", "temperature": "not_collected", "utilization": "not_collected", "memory": "not_collected", "driverBinding": "reported_when_pve_exposes_driver"}})
|
|
}
|
|
|
|
func rawOptionText(value json.RawMessage) string {
|
|
var text string
|
|
_ = json.Unmarshal(value, &text)
|
|
return text
|
|
}
|
|
|
|
func gpuMappingSelectors(mappings []proxmox.PCIMapping, id, node string) ([]string, error) {
|
|
selectors := []string{}
|
|
for _, mapping := range mappings {
|
|
if mapping.ID != id {
|
|
continue
|
|
}
|
|
for _, entry := range mapping.Map {
|
|
properties, err := proxmox.PCIProperties(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if properties["node"] != node {
|
|
continue
|
|
}
|
|
for _, path := range strings.Split(properties["path"], ";") {
|
|
address, ok := proxmox.NormalizePCIAddress(path)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid mapping address")
|
|
}
|
|
selectors = append(selectors, address)
|
|
}
|
|
}
|
|
}
|
|
if len(selectors) == 0 {
|
|
return nil, fmt.Errorf("mapping not visible")
|
|
}
|
|
return selectors, nil
|
|
}
|