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

494 lines
16 KiB
Go

package server
import (
"context"
"database/sql"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
// This optional, single-host group is provisioned by the administrator. Device
// requests may select a bound desktop; they cannot choose VMIDs or PCI devices.
const exclusiveGPULock int64 = 7651092039
type gpuPrepareState struct {
Phase string `json:"phase"`
Node string `json:"node"`
Members []int `json:"members"`
Terminal string `json:"terminal"`
VMID int `json:"vmid,omitempty"`
UPID string `json:"upid,omitempty"`
}
func (s *Server) exclusiveGPU(d model.Desktop) bool {
if d.Provider != "proxmox" || d.Node != s.Config.ExclusiveGPUNode {
return false
}
for _, id := range s.Config.ExclusiveGPUVMIDs {
if d.VMID == id {
return true
}
}
return false
}
// Maintenance submissions take the same group lock as terminal preparations.
// Call inside their existing transaction; the durable reservation also covers
// the interval between enqueueing a preparation and claiming it in the worker.
func (s *Server) exclusiveGPUMaintenance(ctx context.Context, db sqlx.ExtContext, d model.Desktop) error {
if !s.exclusiveGPU(d) {
return nil
}
locked, err := tryGPULock(ctx, db)
if err != nil {
return err
}
var busy bool
if locked {
err = sqlx.GetContext(ctx, db, &busy, `SELECT EXISTS(SELECT 1 FROM tasks WHERE kind='gpu-prepare' AND status IN ('queued','running','timeout'))`)
}
if err != nil {
return err
}
if !locked || busy {
return errors.New("显卡正在切换或结果未确认,请先核对切换任务")
}
return nil
}
func (s *Server) gpuMembers(ctx context.Context, db sqlx.ExtContext) ([]model.Desktop, error) {
var members []model.Desktop
err := sqlx.SelectContext(ctx, db, &members, `SELECT * FROM desktops WHERE provider='proxmox' AND node=$1 AND vmid=ANY($2) ORDER BY vmid`, s.Config.ExclusiveGPUNode, s.Config.ExclusiveGPUVMIDs)
if err == nil && len(members) != len(s.Config.ExclusiveGPUVMIDs) {
err = errors.New("exclusive GPU group is incomplete")
}
return members, err
}
func tryGPULock(ctx context.Context, db sqlx.ExtContext) (bool, error) {
var locked bool
err := sqlx.GetContext(ctx, db, &locked, "SELECT pg_try_advisory_xact_lock($1)", exclusiveGPULock)
return locked, err
}
func (s *Server) gpuAllowsConnection(c *gin.Context, d model.Desktop) bool {
if !s.exclusiveGPU(d) {
return true
}
locked, err := tryGPULock(c.Request.Context(), deviceTx(c))
var busy bool
if err == nil && locked {
err = deviceTx(c).GetContext(c.Request.Context(), &busy, `SELECT EXISTS(SELECT 1 FROM tasks WHERE kind='gpu-prepare' AND status IN ('queued','running','timeout'))`)
}
if err != nil {
s.dbError(c, err)
return false
}
if !locked || busy {
s.fail(c, 409, "gpu_switch_busy", "显卡正在切换或上次切换尚未确认,请稍后重试或联系管理员")
return false
}
pve := s.currentPVE()
if pve == nil {
s.fail(c, 503, "pve_unavailable", "计算平台不可用")
return false
}
for _, id := range s.Config.ExclusiveGPUVMIDs {
power, e := pve.Power(c.Request.Context(), d.Node, id)
expected := "stopped"
if id == d.VMID {
expected = "running"
}
if e != nil || power != expected {
s.fail(c, 409, "gpu_owner_not_ready", "桌面电源或显卡归属尚未就绪,请先准备桌面")
return false
}
}
return true
}
func gpuGroupBusy(ctx context.Context, db sqlx.ExtContext, members []model.Desktop, taskID string) (bool, error) {
ids := make([]string, 0, len(members))
for _, d := range members {
ids = append(ids, d.ID)
}
var busy bool
err := sqlx.GetContext(ctx, db, &busy, `SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=ANY($1) AND last_seen>now()-interval '90 seconds') OR EXISTS(SELECT 1 FROM tasks WHERE target_id=ANY($1) AND id!=$2 AND kind!='terminal' AND status IN ('queued','running','timeout'))`, ids, taskID)
return busy, err
}
func (s *Server) prepareGPUDesktop(c *gin.Context) {
var in struct {
DesktopID string `json:"desktopId" binding:"required,max=100"`
}
if !s.decode(c, &in) {
return
}
tx := deviceTx(c)
ctx := c.Request.Context()
terminal := c.GetString("terminalId")
var d model.Desktop
err := tx.GetContext(ctx, &d, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id JOIN terminals t ON t.id=u.terminal_id WHERE d.id=$1 AND t.id=$2 AND u.enabled AND t.enabled`, in.DesktopID, terminal)
if err == sql.ErrNoRows {
s.fail(c, 403, "desktop_forbidden", "该终端没有桌面使用权限")
return
}
if err != nil {
s.dbError(c, err)
return
}
if !s.exclusiveGPU(d) || d.SetupRequired || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_preparable", "该桌面未启用显卡切换或连接配置尚未完成")
return
}
locked, err := tryGPULock(ctx, tx)
if err != nil {
s.dbError(c, err)
return
}
if !locked {
s.fail(c, 409, "gpu_switch_busy", "显卡正在切换,请稍后重试")
return
}
var previous model.Task
err = tx.GetContext(ctx, &previous, `SELECT * FROM tasks WHERE kind='gpu-prepare' AND status IN ('queued','running','timeout') ORDER BY created_at DESC LIMIT 1`)
if err == nil {
if previous.Actor == "device:"+terminal && previous.TargetID == d.ID && previous.Status != "timeout" {
s.deviceJSON(c, 202, gin.H{"taskId": previous.ID, "status": previous.Status, "message": previous.Message})
return
}
s.fail(c, 409, "gpu_switch_busy", "已有显卡切换任务或未确认结果,请稍后重试或联系管理员")
return
}
if err != sql.ErrNoRows {
s.dbError(c, err)
return
}
members, err := s.gpuMembers(ctx, tx)
if err != nil {
s.dbError(c, err)
return
}
for _, member := range members {
if !s.hardwareAllowsConnection(c, member.ID) {
return
}
}
busy, err := gpuGroupBusy(ctx, tx, members, "")
if err != nil {
s.dbError(c, err)
return
}
if busy {
s.fail(c, 409, "gpu_group_in_use", "请先退出正在使用的云桌面;其他终端使用中或管理任务未结束时无法切换")
return
}
state := gpuPrepareState{Phase: "queued", Node: d.Node, Members: s.Config.ExclusiveGPUVMIDs, Terminal: terminal}
encoded, _ := json.Marshal(state)
id := store.ID("task_")
_, err = tx.ExecContext(ctx, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,provider_ref,idempotency_key) VALUES($1,'gpu-prepare','prepare-desktop',$2,$3,$4,'queued','正在准备桌面,等待显卡切换',$5,$1)`, id, d.ID, d.Name, "device:"+terminal, string(encoded))
if err != nil {
s.dbError(c, err)
return
}
s.deviceJSON(c, 202, gin.H{"taskId": id, "status": "queued", "message": "正在准备桌面,首次启动可能需要两分钟"})
}
func (s *Server) gpuPrepareStatus(c *gin.Context) {
var task model.Task
err := deviceTx(c).GetContext(c.Request.Context(), &task, `SELECT * FROM tasks WHERE id=$1 AND kind='gpu-prepare' AND actor=$2`, c.Param("id"), "device:"+c.GetString("terminalId"))
if err == sql.ErrNoRows {
s.fail(c, 404, "task_not_found", "切换任务不存在")
return
}
if err != nil {
s.dbError(c, err)
return
}
s.deviceJSON(c, 200, gin.H{"taskId": task.ID, "status": task.Status, "message": task.Message})
}
func (s *Server) saveGPUStage(ctx context.Context, t model.Task, state gpuPrepareState, message string) error {
data, _ := json.Marshal(state)
result, err := s.DB.ExecContext(ctx, `UPDATE tasks SET provider_ref=$1,message=$2,updated_at=now() WHERE id=$3 AND status='running'`, string(data), message, t.ID)
if err == nil {
n, _ := result.RowsAffected()
if n != 1 {
err = errors.New("GPU task reservation lost")
}
}
return err
}
func sunshineReady(ctx context.Context, endpoint string) bool {
host := endpoint
if parsed, _, err := net.SplitHostPort(endpoint); err == nil {
host = parsed
}
// Platform endpoints are hostnames/IPs, never arbitrary URLs or paths.
if strings.ContainsAny(host, "/\\@?#") || host == "" {
return false
}
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+net.JoinHostPort(host, "47989")+"/serverinfo", nil)
if err != nil {
return false
}
client := http.Client{Timeout: 2 * time.Second, Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
defer client.CloseIdleConnections()
response, err := client.Do(req)
if err != nil {
return false
}
defer response.Body.Close()
var info struct {
XMLName xml.Name `xml:"root"`
Status string `xml:"status_code,attr"`
Hostname string `xml:"hostname"`
}
return response.StatusCode == 200 && xml.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&info) == nil && info.Status == "200" && info.Hostname != ""
}
func (s *Server) executeGPUPrepare(parent context.Context, t model.Task) {
ctx, cancel := context.WithTimeout(parent, 180*time.Second)
defer cancel()
state := gpuPrepareState{}
err := json.Unmarshal([]byte(t.ProviderRef), &state)
uncertain := false
if err == nil && state.Phase != "queued" && state.Phase != "" {
uncertain = true
}
if err == nil && (state.Phase != "queued" || state.Node != s.Config.ExclusiveGPUNode || fmt.Sprint(state.Members) != fmt.Sprint(s.Config.ExclusiveGPUVMIDs)) {
err = errors.New("GPU group changed or task already submitted")
}
var tx *sqlx.Tx
if err == nil {
tx, err = s.DB.BeginTxx(ctx, nil)
}
if tx != nil {
defer tx.Rollback()
}
// Hold this lock on a dedicated transaction throughout the switch. Tickets
// fail promptly instead of waiting behind a VM boot; no root credential leaves
// the platform. An API restart never automatically replays an uncertain POST.
if err == nil {
var locked bool
locked, err = tryGPULock(ctx, tx)
if err == nil && !locked {
err = errors.New("GPU group locked")
}
}
var members []model.Desktop
if err == nil {
members, err = s.gpuMembers(ctx, tx)
}
var target model.Desktop
if err == nil {
for _, d := range members {
if d.ID == t.TargetID {
target = d
}
}
var allowed bool
err = tx.GetContext(ctx, &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id JOIN terminals term ON term.id=u.terminal_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled AND term.enabled)`, t.TargetID, state.Terminal)
if err == nil && (!allowed || target.ID == "" || target.SetupRequired || target.Endpoint == "") {
err = errors.New("desktop authorization/configuration changed")
}
}
if err == nil {
var busy bool
busy, err = gpuGroupBusy(ctx, tx, members, t.ID)
if err == nil && busy {
err = errors.New("GPU group is in use")
}
}
if err == nil {
err = s.RefreshCompute(ctx)
}
pve := s.currentPVE()
if err == nil && pve == nil {
err = errors.New("PVE unavailable")
}
action := func(d model.Desktop, operation string) error {
state.Phase = operation + "-submitting"
state.VMID = d.VMID
state.UPID = ""
if err := s.saveGPUStage(ctx, t, state, "正在"+map[string]string{"start": "启动所选桌面", "shutdown": "正常关闭上一台桌面"}[operation]); err != nil {
return err
}
uncertain = true
upid, err := pve.Action(ctx, d.Node, d.VMID, operation)
if err != nil {
var rejected *proxmox.HTTPError
if errors.As(err, &rejected) && rejected.Status >= 400 && rejected.Status < 500 {
uncertain = false
}
return err
}
state.Phase = operation + "-waiting"
state.UPID = upid
if err = s.saveGPUStage(ctx, t, state, "正在等待虚拟机电源操作完成"); err != nil {
return err
}
if err = pve.Wait(ctx, d.Node, upid, time.Second); err != nil {
return err
}
power, err := pve.Power(ctx, d.Node, d.VMID)
expected := "running"
if operation == "shutdown" {
expected = "stopped"
}
if err == nil && power != expected {
err = errors.New("VM power transition not confirmed")
}
if err == nil {
uncertain = false
}
return err
}
if err == nil {
for _, d := range members {
if d.ID == target.ID {
continue
}
power, e := pve.Power(ctx, d.Node, d.VMID)
if e != nil {
err = e
break
}
if power == "running" {
err = action(d, "shutdown")
} else if power != "stopped" {
err = errors.New("unexpected VM power state")
}
if err != nil {
break
}
}
}
if err == nil {
power, e := pve.Power(ctx, target.Node, target.VMID)
err = e
if err == nil && power == "stopped" {
err = action(target, "start")
} else if err == nil && power != "running" {
err = errors.New("unexpected target state")
}
}
if err == nil {
state.Phase = "service-waiting"
err = s.saveGPUStage(ctx, t, state, "桌面已开机,正在等待连接服务")
for err == nil && !sunshineReady(ctx, target.Endpoint) {
select {
case <-ctx.Done():
err = ctx.Err()
case <-time.After(time.Second):
}
}
}
if err == nil {
// Recheck all owners immediately before releasing the reservation.
for _, d := range members {
power, e := pve.Power(ctx, d.Node, d.VMID)
expected := "stopped"
if d.ID == target.ID {
expected = "running"
}
if e != nil || power != expected {
err = errors.New("final GPU ownership check failed")
break
}
if _, e = s.DB.ExecContext(ctx, `UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2`, power, d.ID); e != nil {
err = e
break
}
}
}
status, message := "success", "桌面已启动,连接服务已响应"
if err != nil {
log.Printf("GPU prepare task=%s phase=%s error=%v", t.ID, state.Phase, err)
status = "failed"
message = "桌面准备失败,请检查桌面授权、占用状态及 PVE"
if uncertain {
status = "timeout"
message = "电源操作或服务状态尚未确认,已阻止再次切换;请管理员核对 PVE 原任务"
}
}
finalCtx, finish := context.WithTimeout(context.Background(), 5*time.Second)
defer finish()
if err == nil {
state.Phase = "completed"
_ = s.saveGPUStage(finalCtx, t, state, message)
}
_ = s.complete(finalCtx, t, status, message)
}
// Reconcile only a known original PVE task. An unknown submission requires
// manual operator investigation; this endpoint never resubmits a power action.
func (s *Server) reconcileGPUPrepare(c *gin.Context) {
var task model.Task
if err := s.DB.GetContext(c.Request.Context(), &task, `SELECT * FROM tasks WHERE id=$1 AND kind='gpu-prepare' AND status='timeout'`, c.Param("id")); err != nil {
s.fail(c, 409, "not_uncertain", "该任务无需核对或不存在")
return
}
var state gpuPrepareState
if json.Unmarshal([]byte(task.ProviderRef), &state) != nil || state.UPID == "" {
s.fail(c, 409, "unknown_submission", "未记录原 PVE 任务编号,需要在 PVE 手工核对,不能自动重发")
return
}
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
locked, err := tryGPULock(c.Request.Context(), tx)
if err != nil || !locked {
s.fail(c, 409, "gpu_switch_busy", "显卡正在切换")
return
}
pve := s.currentPVE()
if pve == nil {
s.fail(c, 503, "pve_unavailable", "计算平台不可用")
return
}
original, err := pve.Task(c.Request.Context(), state.Node, state.UPID)
if err != nil || original.Status != "stopped" {
s.fail(c, 409, "pve_task_pending", "原 PVE 任务未结束或无法核对")
return
}
for _, id := range state.Members {
power, e := pve.Power(c.Request.Context(), state.Node, id)
if e != nil || (power != "running" && power != "stopped") {
s.fail(c, 409, "power_unknown", "虚拟机实际电源状态未确认")
return
}
}
_, err = tx.ExecContext(c.Request.Context(), `UPDATE tasks SET status='failed',message='管理员已核对原 PVE 任务及实际电源状态,可重新准备桌面',updated_at=now() WHERE id=$1 AND status='timeout'`, task.ID)
if err == nil {
_, err = tx.ExecContext(c.Request.Context(), `INSERT INTO audit_logs VALUES($1,$2,'reconcile-gpu-switch',$3,'success',$4,now())`, store.ID("audit_"), c.MustGet("admin").(model.Administrator).Username, task.TargetName, task.ID)
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"reconciled": true, "vmid": strconv.Itoa(state.VMID)})
}