Files
vdi-cloud/platform/internal/server/gpu.go
T

311 lines
9.7 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
}
healthSamples, err := s.latestGPUHealth(ctx, node)
if err != nil {
s.dbError(c, err)
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"
}
health := gpuHealth{Status: "not_collected", Source: "pve-pci", ObservedAt: observedAt, Driver: d.Driver, DriverBinding: driverBinding}
if sample, ok := healthSamples[address]; ok {
health.Status = "collected"
health.Source = sample.Source
health.ObservedAt = sample.ObservedAt
health.TemperatureC = sample.TemperatureC
health.UtilizationPercent = sample.UtilizationPercent
health.MemoryUsedBytes = sample.MemoryUsedBytes
health.MemoryTotalBytes = sample.MemoryTotalBytes
if sample.Driver != "" {
health.Driver = sample.Driver
}
if sample.DriverBinding != "" {
health.DriverBinding = sample.DriverBinding
}
}
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: health, 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 })
healthStatus := "not_collected"
healthSource := "pve-pci"
if len(healthSamples) > 0 {
healthStatus = "collected"
healthSource = "node-agent"
}
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": healthStatus, "source": healthSource, "temperature": healthStatus, "utilization": healthStatus, "memory": healthStatus, "driverBinding": "reported_when_collected"}})
}
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
}