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

470 lines
16 KiB
Go
Raw 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"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"math"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
type hardwareInput struct {
Kind string `json:"kind" binding:"required,oneof=compute disk"`
Cores int `json:"cores"`
Sockets int `json:"sockets"`
MemoryMiB int `json:"memoryMiB"`
Disk string `json:"disk" binding:"max=20"`
SizeGiB int `json:"sizeGiB"`
Digest string `json:"digest" binding:"required,max=80"`
}
func (s *Server) loadHardware(ctx context.Context, id string, db sqlx.ExtContext) (model.Desktop, proxmox.VMHardware, map[string]any, error) {
var d model.Desktop
if err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", id); err != nil {
return d, proxmox.VMHardware{}, nil, err
}
pve := s.currentPVE()
if pve == nil || d.Provider != "proxmox" {
return d, proxmox.VMHardware{}, nil, errors.New("仅支持已接入 Proxmox 的真实桌面")
}
h, config, err := pve.Hardware(ctx, d.Node, d.VMID)
return d, h, config, err
}
func (s *Server) desktopHardware(c *gin.Context) {
_, h, _, err := s.loadHardware(c.Request.Context(), c.Param("id"), s.DB)
if err != nil {
s.fail(c, 502, "hardware_unavailable", "无法读取真实规格,请检查 PVE 的 VM.Audit 权限和连接")
return
}
if h.PowerState != "stopped" {
h.Blockers = append(h.Blockers, "请先正常关闭桌面,再修改规格")
}
unresolved, err := maintenanceBlocked(c.Request.Context(), s.DB, c.Param("id"), "", true)
if err != nil {
s.dbError(c, err)
return
}
if unresolved {
h.Blockers = append(h.Blockers, "有桌面维护任务正在执行或结果未确认,请先核对任务")
}
c.JSON(200, h)
}
func (s *Server) hardwarePreflight(ctx context.Context, id string, in hardwareInput, db sqlx.ExtContext, ownTask string) (model.Desktop, proxmox.VMHardware, map[string]any, error) {
d, h, config, err := s.loadHardware(ctx, id, db)
if err != nil {
return d, h, config, errors.New("无法读取 PVE 规格")
}
reject := func(message string) (model.Desktop, proxmox.VMHardware, map[string]any, error) {
return d, h, config, errors.New(message)
}
if h.PowerState != "stopped" {
return reject("桌面仍在运行,请先正常关机")
}
if len(h.Blockers) > 0 {
return reject(strings.Join(h.Blockers, ";"))
}
if h.Digest != in.Digest {
return reject("PVE 配置已变化,请重新读取规格")
}
var busy bool
busy, err = maintenanceBlocked(ctx, db, id, ownTask, true)
if err != nil {
return d, h, config, err
}
var connected bool
if err = sqlx.GetContext(ctx, db, &connected, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", id); err != nil {
return d, h, config, err
}
busy = busy || connected
if busy {
return reject("桌面有活动连接、执行中任务或未确认规格变更")
}
pve := s.currentPVE()
permissions := []string{}
if in.Kind == "compute" {
if in.Cores < 1 || in.Cores > 256 || in.Sockets < 1 || in.Sockets > 8 || in.Cores*in.Sockets > 1024 || in.MemoryMiB < 512 || in.MemoryMiB > 1048576 || in.MemoryMiB%128 != 0 || in.Disk != "" || in.SizeGiB != 0 {
return reject("CPU 或内存规格无效;内存按 128 MiB 递增")
}
if in.MemoryMiB < h.Balloon {
return reject("目标内存低于当前 balloon 最小内存,请先在 PVE 调整")
}
if in.Cores != h.Cores || in.Sockets != h.Sockets {
permissions = append(permissions, "VM.Config.CPU")
}
if in.MemoryMiB != h.MemoryMiB {
permissions = append(permissions, "VM.Config.Memory")
}
if len(permissions) == 0 {
return reject("规格没有变化")
}
} else if in.Kind == "disk" {
if in.Cores != 0 || in.Sockets != 0 || in.MemoryMiB != 0 || in.SizeGiB < 1 || in.SizeGiB > 65536 {
return reject("磁盘目标容量无效")
}
var disk *proxmox.VMDisk
for i := range h.Disks {
if h.Disks[i].Key == in.Disk {
disk = &h.Disks[i]
}
}
if disk == nil || float64(in.SizeGiB) <= disk.SizeGiB {
return reject("只能扩容已识别的虚拟磁盘,不能缩小或保持原容量")
}
ok, e := pve.HasPermission(ctx, "/storage/"+disk.Storage, "Datastore.AllocateSpace")
if e != nil || !ok {
return reject("缺少磁盘存储分配权限")
}
storages, e := pve.Storages(ctx, d.Node)
if e != nil {
return reject("无法核查节点存储余量")
}
available := false
for _, st := range storages {
if st.Name == disk.Storage && st.Active == 1 && st.Enabled == 1 && st.Available >= (float64(in.SizeGiB)-disk.SizeGiB+1)*(1<<30) {
available = true
}
}
if !available {
return reject("存储不足或不可用;需扩容量加 1 GiB 余量")
}
permissions = append(permissions, "VM.Config.Disk")
} else {
return reject("规格操作类型无效")
}
for _, permission := range permissions {
ok, e := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), permission)
if e != nil || !ok {
return reject("缺少 " + permission + " 权限")
}
}
return d, h, config, nil
}
func (s *Server) checkHardware(c *gin.Context) {
var in hardwareInput
if !s.decodeConfiguration(c, &in) {
return
}
_, _, _, err := s.hardwarePreflight(c.Request.Context(), c.Param("id"), in, s.DB, "")
if err != nil {
s.fail(c, 409, "hardware_not_ready", err.Error())
return
}
c.JSON(200, gin.H{"available": true, "message": "规格、状态、权限与存储预检通过"})
}
func (s *Server) queueHardware(c *gin.Context) {
var in hardwareInput
if !s.decodeConfiguration(c, &in) {
return
}
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
if key == "" || len(key) > 100 {
s.fail(c, 400, "idempotency_required", "请携带有效请求标识")
return
}
tx, actor := s.accountTransaction(c, true)
if tx == nil {
return
}
defer tx.Rollback()
ctx := c.Request.Context()
if !s.lockAuthorization(c, tx) {
return
}
raw, _ := json.Marshal(in)
hash := store.Hash(string(raw))
var old struct {
TaskID string `db:"task_id"`
Hash string `db:"request_hash"`
}
err := tx.GetContext(ctx, &old, "SELECT h.task_id,h.request_hash FROM hardware_jobs h JOIN tasks t ON t.id=h.task_id WHERE t.actor=$1 AND t.idempotency_key=$2", actor.Username, key)
if err == nil {
var task model.Task
if old.Hash != hash {
s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他规格参数")
return
}
if e := tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 AND target_id=$2", old.TaskID, c.Param("id")); e != nil {
s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他桌面")
return
}
c.JSON(202, task)
return
}
if !errors.Is(err, sql.ErrNoRows) {
s.dbError(c, err)
return
}
// A target row lock serializes specifications against desktop maintenance.
var target string
if err = tx.GetContext(ctx, &target, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
d, _, _, err := s.hardwarePreflight(ctx, target, in, tx, "")
if err != nil {
s.fail(c, 409, "hardware_not_ready", err.Error())
return
}
id := store.ID("task_")
action := "configure-hardware"
if in.Kind == "disk" {
action = "expand-disk"
}
var task model.Task
err = tx.GetContext(ctx, &task, "INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'hardware',$2,$3,$4,$5,'queued','已提交,执行前会再次预检',$6) RETURNING *", id, action, d.ID, d.Name, actor.Username, key)
if conflict(err) {
s.fail(c, 409, "task_conflict", "该桌面有其他任务或请求标识已使用")
return
}
if err == nil {
_, err = tx.ExecContext(ctx, "INSERT INTO hardware_jobs(task_id,request_json,request_hash) VALUES($1,$2,$3)", id, string(raw), hash)
}
if err == nil {
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'submitted',$5)", store.ID("audit_"), actor.Username, action, d.ID, c.GetString("requestId"))
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(202, task)
}
func hardwareMatches(h proxmox.VMHardware, in hardwareInput) bool {
if len(h.Blockers) > 0 {
return false
}
if in.Kind == "compute" {
return h.Cores == in.Cores && h.Sockets == in.Sockets && h.MemoryMiB == in.MemoryMiB
}
for _, d := range h.Disks {
if d.Key == in.Disk {
return math.Abs(d.SizeGiB-float64(in.SizeGiB)) < 0.001
}
}
return false
}
func (s *Server) executeHardware(parent context.Context, task model.Task) {
ctx, cancel := context.WithTimeout(parent, 30*time.Minute)
defer cancel()
var job struct {
Raw string `db:"request_json"`
Stage string `db:"stage"`
}
err := s.DB.GetContext(ctx, &job, "SELECT request_json,stage FROM hardware_jobs WHERE task_id=$1", task.ID)
var in hardwareInput
if err == nil {
err = json.Unmarshal([]byte(job.Raw), &in)
}
submitted := job.Stage != "prepared"
pve := s.currentPVE()
if e := s.RefreshCompute(ctx); err == nil && e != nil {
err = e
}
pve = s.currentPVE()
var d model.Desktop
if err == nil {
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", task.TargetID)
}
if err == nil && pve == nil {
err = errors.New("计算平台未配置")
}
if err == nil && !submitted {
var config map[string]any
d, _, config, err = s.hardwarePreflight(ctx, d.ID, in, s.DB, task.ID)
if err == nil {
var claimed bool
claimed, err = s.claimHardware(ctx, task)
if err == nil && !claimed {
return
}
if err == nil {
submitted = true
if in.Kind == "compute" {
task.ProviderRef, err = pve.ConfigureHardware(ctx, d.Node, d.VMID, in.Cores, in.Sockets, in.MemoryMiB, in.Digest, config)
} else {
task.ProviderRef, err = pve.ResizeDisk(ctx, d.Node, d.VMID, in.Disk, in.SizeGiB, in.Digest)
}
var rejected *proxmox.HTTPError
if errors.As(err, &rejected) && rejected.Status >= 400 && rejected.Status < 500 {
submitted = false
}
if err == nil {
_, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1,updated_at=now() WHERE id=$2", task.ProviderRef, task.ID)
}
}
}
}
if err == nil && submitted && task.ProviderRef == "" {
err = errors.New("原 PVE 任务标识缺失,需人工核查")
}
if err == nil && task.ProviderRef != "" {
err = pve.Wait(ctx, d.Node, task.ProviderRef, time.Second)
}
if err == nil {
var h proxmox.VMHardware
h, _, err = pve.Hardware(ctx, d.Node, d.VMID)
if err == nil && !hardwareMatches(h, in) {
err = errors.New("实际规格尚未达到目标,请核对 PVE")
}
}
status, stage, message := "success", "completed", "PVE 任务与实际规格已核对完成;磁盘扩容后需在客体内扩展分区和文件系统"
if in.Kind == "compute" {
message = "PVE 任务与实际 CPU/内存规格已核对完成"
}
if err != nil {
status, stage, message = "failed", "failed", "规格变更未提交,请检查关机状态、权限、容量与配置是否变化"
if submitted {
status, stage, message = "timeout", "uncertain", "规格变更结果未确认,已阻止后续修改;请核对原任务,不会自动重发"
}
}
s.finishHardware(task, status, stage, message)
}
// Lock the desktop before changing job/task state, matching power submissions.
func (s *Server) claimHardware(ctx context.Context, task model.Task) (bool, error) {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
var id string
if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", task.TargetID); err != nil {
return false, err
}
result, err := tx.ExecContext(ctx, `UPDATE hardware_jobs SET stage='submitting' WHERE task_id=$1 AND stage='prepared' AND EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND status IN ('queued','running'))`, task.ID)
if err != nil {
return false, err
}
n, _ := result.RowsAffected()
return n == 1, tx.Commit()
}
func (s *Server) finishHardware(task model.Task, status, stage, message string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return
}
defer tx.Rollback()
var id, current, jobStage string
if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", task.TargetID); err != nil {
return
}
if err = tx.GetContext(ctx, &current, "SELECT status FROM tasks WHERE id=$1 FOR UPDATE", task.ID); err != nil || (current != "queued" && current != "running") {
return
}
if err = tx.GetContext(ctx, &jobStage, "SELECT stage FROM hardware_jobs WHERE task_id=$1 FOR UPDATE", task.ID); err != nil {
return
}
if stage == "expired" {
status, stage, message = "failed", "failed", "规格任务在提交前过期,未发送修改"
if jobStage != "prepared" {
status, stage, message = "timeout", "uncertain", "规格变更结果未确认;请核对原任务,不会自动重发"
}
}
if _, err = tx.ExecContext(ctx, "UPDATE hardware_jobs SET stage=$1 WHERE task_id=$2", stage, task.ID); err == nil {
err = s.completeTx(ctx, tx, task, status, message)
}
if err == nil {
_ = tx.Commit()
}
}
func (s *Server) reconcileHardware(c *gin.Context) {
ctx := c.Request.Context()
tx, actor := s.accountTransaction(c, true)
if tx == nil {
return
}
defer tx.Rollback()
var task model.Task
var in hardwareInput
var raw string
if err := tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 AND kind='hardware'", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
var locked string
if err := tx.GetContext(ctx, &locked, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", task.TargetID); err != nil {
s.dbError(c, err)
return
}
if err := tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", task.ID); err != nil {
s.dbError(c, err)
return
}
if task.Status != "timeout" {
s.fail(c, 409, "reconcile_unavailable", "仅可核对结果未确认的规格任务")
return
}
if err := tx.GetContext(ctx, &raw, "SELECT request_json FROM hardware_jobs WHERE task_id=$1", task.ID); err != nil {
s.dbError(c, err)
return
}
if json.Unmarshal([]byte(raw), &in) != nil {
s.fail(c, 500, "invalid_job", "任务记录无法读取")
return
}
d, h, _, err := s.loadHardware(ctx, task.TargetID, tx)
if err != nil {
s.fail(c, 502, "hardware_unavailable", "无法读取实际规格")
return
}
if task.ProviderRef == "" {
s.fail(c, 409, "hardware_unconfirmed", "原任务标识缺失,请在 PVE 核查并由管理员离线恢复;未重新发送修改")
return
}
if task.ProviderRef != "" {
status, e := s.currentPVE().Task(ctx, d.Node, task.ProviderRef)
if e != nil || status.Status != "stopped" {
s.fail(c, 409, "hardware_unconfirmed", "原 PVE 任务仍在执行或无法查询")
return
}
}
if !hardwareMatches(h, in) {
s.fail(c, 409, "hardware_unconfirmed", "实际规格未达到目标,请在 PVE 核对;未重新提交修改")
return
}
_, err = tx.ExecContext(ctx, "UPDATE hardware_jobs SET stage='completed' WHERE task_id=$1 AND stage='uncertain'", task.ID)
if err == nil {
_, err = tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='只读核对确认实际规格已达到目标,未重新提交修改',updated_at=now() WHERE id=$1 AND status='timeout'", task.ID)
}
if err == nil {
err = accountAudit(ctx, tx, actor.Username, "reconcile_hardware", task.ID, c.GetString("requestId"))
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true})
}
// Tickets cannot connect to a desktop while specifications are changing or unknown.
func (s *Server) hardwareAllowsConnection(c *gin.Context, id string) bool {
var blocked bool
err := deviceTx(c).GetContext(c.Request.Context(), &blocked, `SELECT EXISTS(SELECT 1 FROM tasks WHERE target_id=$1 AND kind IN ('hardware','snapshot','batch-desktop') AND status IN ('queued','running')) OR EXISTS(SELECT 1 FROM hardware_jobs h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND h.stage IN ('submitting','uncertain')) OR EXISTS(SELECT 1 FROM snapshot_jobs h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND h.stage IN ('submitting','uncertain')) OR EXISTS(SELECT 1 FROM power_batch_items h JOIN tasks t ON t.id=h.task_id WHERE t.target_id=$1 AND h.stage IN ('submitting','uncertain'))`, id)
if err != nil {
s.dbError(c, err)
return false
}
if blocked {
s.fail(c, 409, "hardware_unconfirmed", "桌面维护中或结果未确认,请联系管理员核对")
return false
}
return true
}