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 }