package server import ( "context" "encoding/json" "errors" "fmt" "log" "time" "github.com/gin-gonic/gin" "vdi-cloud/platform/internal/model" "vdi-cloud/platform/internal/store" "vdi-cloud/platform/pkg/proxmox" ) // Reconciliation only polls a known UPID. It never submits another clone request. func (s *Server) reconcileProvision(c *gin.Context) { ctx := c.Request.Context() tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { s.dbError(c, err) return } defer tx.Rollback() var task model.Task if err = tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil { s.dbError(c, err) return } if task.Kind != "provision" || task.Status != "timeout" || task.ProviderRef == "" { s.fail(c, 409, "reconcile_unavailable", "仅可核对有 PVE 任务标识且结果未确认的克隆;其他情况请在 PVE 人工核查") return } if _, err = tx.ExecContext(ctx, "UPDATE provisioning_jobs SET state='reserved' WHERE task_id=$1 AND state='uncertain'", task.ID); err != nil { s.dbError(c, err) return } err = tx.GetContext(ctx, &task, "UPDATE tasks SET status='running',message='正在重新核对原 PVE 任务,不会再次克隆',updated_at=now() WHERE id=$1 RETURNING *", task.ID) if err == nil { err = tx.Commit() } if err != nil { s.dbError(c, err) return } s.log(c, "reconcile_clone", task.ID, "submitted") c.JSON(202, task) } func (s *Server) validateTemplate(c *gin.Context) { pve := s.currentPVE() var template model.ImageTemplate ctx := c.Request.Context() if err := s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1", c.Param("id")); err != nil { s.dbError(c, err) return } if pve == nil { s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox") return } config, err := pve.VMConfig(ctx, template.Node, template.VMID) if err != nil { s.fail(c, 502, "pve_unavailable", "无法读取模板配置") return } if _, err = proxmox.TemplateDisks(config); err != nil { s.fail(c, 409, "invalid_template", err.Error()) return } digest, _ := config["digest"].(string) if digest == "" { s.fail(c, 502, "invalid_template", "模板配置摘要缺失") return } if err = s.DB.GetContext(ctx, &template, "UPDATE image_templates SET digest=$1,validated_at=now() WHERE id=$2 RETURNING *", digest, template.ID); err != nil { s.dbError(c, err) return } s.log(c, "validate_template", template.ID, "success") c.JSON(200, template) } func (s *Server) executeProvision(parent context.Context, task model.Task) { if err := s.RefreshCompute(parent); err != nil { _ = s.complete(parent, task, "failed", "计算平台配置无法加载,未提交操作") return } pve := s.currentPVE() ctx, cancel := context.WithTimeout(parent, 30*time.Minute) defer cancel() var job provisioningJob err := s.DB.GetContext(ctx, &job, "SELECT * FROM provisioning_jobs WHERE task_id=$1", task.ID) var in cloneInput if err == nil { err = json.Unmarshal([]byte(job.RequestJSON), &in) } var template model.ImageTemplate if err == nil { err = s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1", job.TemplateID) } uncertain := task.ProviderRef != "" if err == nil && pve == nil { err = errors.New("Proxmox 尚未配置") } if err == nil && task.ProviderRef == "" { _, err = s.clonePreflight(ctx, in) if err == nil && in.PoolID != nil { var enabled bool err = s.DB.GetContext(ctx, &enabled, "SELECT enabled FROM desktop_pools WHERE id=$1", *in.PoolID) if err == nil && !enabled { err = errors.New("桌面池已停用") } } if err == nil { // After a POST could have reached PVE, never submit it again automatically. uncertain = true task.ProviderRef, err = pve.Clone(ctx, template.Node, template.VMID, in.VMID, in.Full, in.Storage) var rejected *proxmox.HTTPError if errors.As(err, &rejected) && rejected.Status >= 400 && rejected.Status < 500 { uncertain = 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 { err = pve.Wait(ctx, template.Node, task.ProviderRef, time.Second) } var power string if err == nil { var config map[string]any config, err = pve.VMConfig(ctx, template.Node, in.VMID) if err == nil && (config["name"] != fmt.Sprintf("vdi-%d", in.VMID) || fmt.Sprint(config["template"]) == "1" || config["lock"] != nil) { err = errors.New("克隆目标配置尚未确认,请在 PVE 核对") } if err == nil { power, err = pve.Power(ctx, template.Node, in.VMID) } if err == nil && power != "running" && power != "stopped" { err = errors.New("目标虚拟机状态未确认") } } if err == nil { err = s.commitProvision(ctx, task, job, template, in, power) } if err == nil { return } log.Printf("provision execution error taskId=%s error=%v", task.ID, err) final, done := context.WithTimeout(context.Background(), 5*time.Second) defer done() state, status, message := "failed", "failed", "创建未执行:"+err.Error() if uncertain { state = "uncertain" status = "timeout" message = "创建结果未确认,请在 PVE 核对目标 VMID 和任务;不会自动重复克隆" } if errors.Is(err, proxmox.ErrTaskFailed) { status = "failed" message = "PVE 克隆任务失败,请核对目标 VMID 是否有残留资源;未自动删除或重试" } _, _ = s.DB.ExecContext(final, "UPDATE provisioning_jobs SET state=$1 WHERE task_id=$2 AND state='reserved'", state, task.ID) _ = s.complete(final, task, status, message) } func (s *Server) commitProvision(ctx context.Context, task model.Task, job provisioningJob, template model.ImageTemplate, in cloneInput, power string) error { tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { return err } defer tx.Rollback() if err = catalogLock(ctx, tx); err != nil { return err } var poolName string if job.PoolID != nil { if err = tx.GetContext(ctx, &poolName, "SELECT name FROM desktop_pools WHERE id=$1", *job.PoolID); err != nil { return err } } _, err = tx.ExecContext(ctx, `INSERT INTO desktops(id,name,os,node,vmid,pool,pool_id,power_state,provider,setup_required) VALUES($1,$2,$3,$4,$5,$6,$7,$8,'proxmox',true)`, job.DesktopID, in.Name, template.OS, template.Node, in.VMID, poolName, job.PoolID, power) if err != nil { return err } if _, err = tx.ExecContext(ctx, "UPDATE provisioning_jobs SET state='created' WHERE task_id=$1", task.ID); err != nil { return err } result, err := tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='VM 克隆完成并已纳管;尚需配置系统身份、IP、Sunshine 与终端配对',updated_at=now() WHERE id=$1 AND status='running'", task.ID) if err != nil { return err } n, _ := result.RowsAffected() if n != 1 { return errors.New("创建任务状态已变化,保留预留资源等待核对") } _, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'clone',$3,'success',$4,now())", store.ID("audit_"), task.Actor, task.TargetName, task.ID) if err != nil { return err } return tx.Commit() }