554 lines
18 KiB
Go
554 lines
18 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
"log"
|
||
"net"
|
||
"strings"
|
||
"time"
|
||
"vdi-cloud/platform/internal/model"
|
||
"vdi-cloud/platform/internal/store"
|
||
"vdi-cloud/platform/pkg/proxmox"
|
||
)
|
||
|
||
func (s *Server) overview(c *gin.Context) {
|
||
var counts struct {
|
||
Terminals int `db:"terminals"`
|
||
Online int `db:"online"`
|
||
Desktops int `db:"desktops"`
|
||
Running int `db:"running"`
|
||
Sessions int `db:"sessions"`
|
||
Failed int `db:"failed"`
|
||
}
|
||
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`)
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode})
|
||
}
|
||
func (s *Server) settings(c *gin.Context) {
|
||
provider := "unconfigured"
|
||
if s.Config.Mode == "demo" {
|
||
provider = "demo"
|
||
}
|
||
if s.PVE != nil {
|
||
provider = "proxmox"
|
||
}
|
||
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}})
|
||
}
|
||
func (s *Server) resources(c *gin.Context) {
|
||
items := []model.Resource{}
|
||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM resources ORDER BY name"); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
list(c, items)
|
||
}
|
||
func (s *Server) desktops(c *gin.Context) {
|
||
items := []model.Desktop{}
|
||
q := "%" + c.Query("keyword") + "%"
|
||
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT * FROM desktops WHERE (name ILIKE $1 OR vmid::text ILIKE $1) AND ($2='' OR power_state=$2) ORDER BY name`, q, c.Query("status"))
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
list(c, items)
|
||
}
|
||
func (s *Server) desktop(c *gin.Context) {
|
||
var d model.Desktop
|
||
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
c.JSON(200, d)
|
||
}
|
||
func (s *Server) terminals(c *gin.Context) {
|
||
items := []model.Terminal{}
|
||
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
list(c, items)
|
||
}
|
||
func (s *Server) users(c *gin.Context) {
|
||
items, err := store.Users(c.Request.Context(), s.DB)
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
list(c, items)
|
||
}
|
||
func (s *Server) createUser(c *gin.Context) {
|
||
var in struct {
|
||
Username string `json:"username" binding:"required,min=2,max=80"`
|
||
DisplayName string `json:"displayName" binding:"required,max=80"`
|
||
}
|
||
if !s.decode(c, &in) {
|
||
return
|
||
}
|
||
in.Username = strings.TrimSpace(in.Username)
|
||
in.DisplayName = strings.TrimSpace(in.DisplayName)
|
||
if in.Username == "" || in.DisplayName == "" {
|
||
s.fail(c, 400, "invalid_request", "账号和姓名不能为空")
|
||
return
|
||
}
|
||
id := store.ID("user_")
|
||
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, in.Username, in.DisplayName)
|
||
if err != nil {
|
||
if conflict(err) {
|
||
s.fail(c, 409, "conflict", "该账号已存在")
|
||
} else {
|
||
s.dbError(c, err)
|
||
}
|
||
return
|
||
}
|
||
s.log(c, "create_user", id, "success")
|
||
c.JSON(201, model.User{ID: id, Username: in.Username, DisplayName: in.DisplayName, Enabled: true, DesktopIDs: []string{}})
|
||
}
|
||
func conflict(err error) bool {
|
||
var e *pgconn.PgError
|
||
return errors.As(err, &e) && (e.Code == "23505" || e.Code == "23503")
|
||
}
|
||
func (s *Server) bindings(c *gin.Context) {
|
||
var in struct {
|
||
TerminalID *string `json:"terminalId"`
|
||
DesktopIDs []string `json:"desktopIds" binding:"max=100"`
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
if !s.decode(c, &in) {
|
||
return
|
||
}
|
||
if in.TerminalID != nil && *in.TerminalID == "" {
|
||
in.TerminalID = nil
|
||
}
|
||
ctx := c.Request.Context()
|
||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
var user model.User
|
||
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
if in.Enabled != nil {
|
||
user.Enabled = *in.Enabled
|
||
}
|
||
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1,enabled=$2 WHERE id=$3", in.TerminalID, user.Enabled, user.ID)
|
||
if err != nil {
|
||
if conflict(err) {
|
||
s.fail(c, 409, "binding_conflict", "终端不存在或已关联其他用户")
|
||
} else {
|
||
s.dbError(c, err)
|
||
}
|
||
return
|
||
}
|
||
if _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1", user.ID); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
seen := map[string]bool{}
|
||
for _, id := range in.DesktopIDs {
|
||
if seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
if _, err = tx.ExecContext(ctx, "INSERT INTO bindings VALUES($1,$2)", user.ID, id); err != nil {
|
||
if conflict(err) {
|
||
s.fail(c, 400, "invalid_desktop", "所选桌面不存在")
|
||
} else {
|
||
s.dbError(c, err)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId"))
|
||
if err == nil {
|
||
err = tx.Commit()
|
||
}
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
user.TerminalID = in.TerminalID
|
||
user.DesktopIDs = in.DesktopIDs
|
||
if user.DesktopIDs == nil {
|
||
user.DesktopIDs = []string{}
|
||
}
|
||
c.JSON(200, user)
|
||
}
|
||
func (s *Server) tasks(c *gin.Context) {
|
||
items := []model.Task{}
|
||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 1000"); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
list(c, items)
|
||
}
|
||
func (s *Server) task(c *gin.Context) {
|
||
var t model.Task
|
||
if err := s.DB.GetContext(c.Request.Context(), &t, "SELECT * FROM tasks WHERE id=$1", c.Param("id")); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
c.JSON(200, t)
|
||
}
|
||
func (s *Server) audits(c *gin.Context) {
|
||
items := []model.Audit{}
|
||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1000"); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
list(c, items)
|
||
}
|
||
func (s *Server) discoverVMs(c *gin.Context) {
|
||
if s.PVE == nil {
|
||
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
|
||
return
|
||
}
|
||
items, err := s.PVE.Resources(c.Request.Context())
|
||
if err != nil {
|
||
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
|
||
return
|
||
}
|
||
vms := []gin.H{}
|
||
for _, r := range items {
|
||
if r.Type == "qemu" && r.Template == 0 {
|
||
vms = append(vms, gin.H{"vmid": r.VMID, "node": r.Node, "name": r.Name, "status": r.Status})
|
||
}
|
||
}
|
||
list(c, vms)
|
||
}
|
||
func (s *Server) syncResources(c *gin.Context) {
|
||
if s.PVE == nil {
|
||
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
|
||
return
|
||
}
|
||
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
|
||
}
|
||
func (s *Server) importDesktop(c *gin.Context) {
|
||
var in struct {
|
||
Name string `json:"name" binding:"required,max=80"`
|
||
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
|
||
Node string `json:"node" binding:"required,max=100"`
|
||
VMID int `json:"vmid" binding:"min=100"`
|
||
Endpoint string `json:"endpoint" binding:"max=253"`
|
||
Width int `json:"width" binding:"omitempty,min=640,max=7680"`
|
||
Height int `json:"height" binding:"omitempty,min=480,max=4320"`
|
||
FPS int `json:"fps" binding:"omitempty,min=10,max=120"`
|
||
}
|
||
if !s.decode(c, &in) {
|
||
return
|
||
}
|
||
if s.PVE == nil {
|
||
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
|
||
return
|
||
}
|
||
if in.Endpoint != "" && net.ParseIP(in.Endpoint) == nil {
|
||
for _, part := range strings.Split(in.Endpoint, ".") {
|
||
if part == "" || strings.ContainsAny(part, "/:@ \\") {
|
||
s.fail(c, 400, "invalid_endpoint", "连接地址应为 IP 或主机名")
|
||
return
|
||
}
|
||
}
|
||
}
|
||
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
|
||
if err != nil {
|
||
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
|
||
return
|
||
}
|
||
id := store.ID("desk_")
|
||
if in.Width == 0 {
|
||
in.Width = 2560
|
||
}
|
||
if in.Height == 0 {
|
||
in.Height = 1440
|
||
}
|
||
if in.FPS == 0 {
|
||
in.FPS = 30
|
||
}
|
||
_, err = s.DB.ExecContext(c.Request.Context(), `INSERT INTO desktops(id,name,os,node,vmid,power_state,provider,endpoint,width,height,fps) VALUES($1,$2,$3,$4,$5,$6,'proxmox',$7,$8,$9,$10)`, id, in.Name, in.OS, in.Node, in.VMID, power, in.Endpoint, in.Width, in.Height, in.FPS)
|
||
if err != nil {
|
||
if conflict(err) {
|
||
s.fail(c, 409, "already_managed", "该 VM 已纳管")
|
||
} else {
|
||
s.dbError(c, err)
|
||
}
|
||
return
|
||
}
|
||
s.log(c, "import_desktop", id, "success")
|
||
c.Params = append(c.Params, gin.Param{Key: "id", Value: id})
|
||
s.desktop(c)
|
||
}
|
||
func (s *Server) desktopAction(c *gin.Context) {
|
||
var in struct {
|
||
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
|
||
}
|
||
if !s.decode(c, &in) {
|
||
return
|
||
}
|
||
var d model.Desktop
|
||
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
if d.Provider == "demo" && s.Config.Mode != "demo" {
|
||
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
|
||
return
|
||
}
|
||
if d.Provider == "proxmox" && s.PVE == nil {
|
||
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
|
||
return
|
||
}
|
||
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
|
||
}
|
||
func (s *Server) terminalCommand(c *gin.Context) {
|
||
var in struct {
|
||
Action string `json:"action" binding:"required,oneof=reboot reset-session collect-logs"`
|
||
}
|
||
if !s.decode(c, &in) {
|
||
return
|
||
}
|
||
var name string
|
||
if err := s.DB.GetContext(c.Request.Context(), &name, "SELECT name FROM terminals WHERE id=$1", c.Param("id")); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
s.submitTask(c, "terminal", in.Action, c.Param("id"), name)
|
||
}
|
||
func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName string) {
|
||
key := c.GetHeader("Idempotency-Key")
|
||
if key == "" || len(key) > 100 {
|
||
s.fail(c, 400, "idempotency_required", "请提供不超过 100 字符的 Idempotency-Key")
|
||
return
|
||
}
|
||
actor := s.admin(c).Username
|
||
ctx := c.Request.Context()
|
||
var existing model.Task
|
||
err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key)
|
||
if err == nil {
|
||
if existing.Kind != kind || existing.Action != action || existing.TargetID != targetID {
|
||
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同操作")
|
||
return
|
||
}
|
||
c.JSON(202, existing)
|
||
return
|
||
}
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
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,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key)
|
||
if err != nil {
|
||
if conflict(err) {
|
||
s.fail(c, 409, "task_conflict", "已有相同请求或该对象有任务执行中,请刷新任务列表")
|
||
} else {
|
||
s.dbError(c, err)
|
||
}
|
||
return
|
||
}
|
||
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,'submitted',$5,now())", store.ID("audit_"), actor, action, targetName, c.GetString("requestId"))
|
||
if err == nil {
|
||
err = tx.Commit()
|
||
}
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
c.JSON(202, task)
|
||
}
|
||
|
||
// Complete persists the final result and its audit entry together.
|
||
func (s *Server) complete(ctx context.Context, t model.Task, status, message string) error {
|
||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
n, _ := result.RowsAffected()
|
||
if n == 0 {
|
||
return nil
|
||
}
|
||
// Session release is scoped to a session ID by the device event endpoint.
|
||
// A delayed command acknowledgement must not clear a newer connection.
|
||
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,$5,$6,now())", store.ID("audit_"), t.Actor, t.Action, t.TargetName, status, t.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
func ratio(a, b float64) float64 {
|
||
if b <= 0 {
|
||
return 0
|
||
}
|
||
return a / b
|
||
}
|
||
func (s *Server) sync(ctx context.Context) error {
|
||
items, err := s.PVE.Resources(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
if _, err = tx.ExecContext(ctx, "DELETE FROM resources WHERE provider='proxmox'"); err != nil {
|
||
return err
|
||
}
|
||
for _, r := range items {
|
||
if r.Type == "node" {
|
||
if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if r.Type == "qemu" {
|
||
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
func (s *Server) RunWorker(ctx context.Context) {
|
||
ticker := time.NewTicker(300 * time.Millisecond)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
// Unknown outcomes are never automatically retried after a worker restart.
|
||
var expired []model.Task
|
||
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at<now()-interval '2 minutes'") == nil {
|
||
for _, t := range expired {
|
||
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
|
||
}
|
||
}
|
||
var t model.Task
|
||
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE status='queued' AND kind!='terminal' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
s.execute(ctx, t)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Resource reads are independent of the command queue and never change VM power.
|
||
func (s *Server) RunSync(ctx context.Context) {
|
||
ticker := time.NewTicker(30 * time.Second)
|
||
defer ticker.Stop()
|
||
for {
|
||
readCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||
if err := s.sync(readCtx); err != nil {
|
||
log.Printf("PVE resource sync failed: %v", err)
|
||
}
|
||
cancel()
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
}
|
||
}
|
||
}
|
||
func (s *Server) execute(parent context.Context, t model.Task) {
|
||
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
|
||
defer cancel()
|
||
var err error
|
||
uncertain := false
|
||
message := "已收到执行结果"
|
||
if t.Kind == "resource" {
|
||
err = s.sync(ctx)
|
||
} else {
|
||
var d model.Desktop
|
||
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
|
||
if err == nil && d.Provider == "demo" {
|
||
if s.Config.Mode != "demo" {
|
||
err = errors.New("demo disabled")
|
||
} else {
|
||
select {
|
||
case <-ctx.Done():
|
||
err = ctx.Err()
|
||
case <-time.After(500 * time.Millisecond):
|
||
}
|
||
if err == nil {
|
||
power := "running"
|
||
if t.Action == "shutdown" {
|
||
power = "stopped"
|
||
}
|
||
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
|
||
message = "演示计算任务已完成;未检测真实连接服务"
|
||
}
|
||
}
|
||
} else if err == nil {
|
||
if s.PVE == nil {
|
||
err = errors.New("PVE unconfigured")
|
||
} else {
|
||
var upid string
|
||
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
|
||
uncertain = true
|
||
var httpError *proxmox.HTTPError
|
||
if errors.As(err, &httpError) && httpError.Status >= 400 && httpError.Status < 500 {
|
||
uncertain = false
|
||
}
|
||
if err == nil {
|
||
_, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID)
|
||
}
|
||
if err == nil {
|
||
err = s.PVE.Wait(ctx, d.Node, upid, time.Second)
|
||
}
|
||
if err == nil {
|
||
var power string
|
||
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
|
||
if err == nil {
|
||
expected := "running"
|
||
if t.Action == "shutdown" {
|
||
expected = "stopped"
|
||
}
|
||
if power != expected {
|
||
err = fmt.Errorf("power result not confirmed")
|
||
} else {
|
||
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
status := "success"
|
||
if err != nil {
|
||
log.Printf("task execution error taskId=%s error=%v", t.ID, err)
|
||
status = "failed"
|
||
message = "执行失败,请检查服务日志及 PVE 任务;未自动重试"
|
||
if (uncertain && !errors.Is(err, proxmox.ErrTaskFailed)) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||
status = "timeout"
|
||
message = "结果未确认,请核对实际状态;未自动重试"
|
||
}
|
||
}
|
||
finalCtx, finalCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer finalCancel()
|
||
_ = s.complete(finalCtx, t, status, message)
|
||
}
|