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

284 lines
9.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}