功能:扩展云管资源运维、账号授权及监控管理
This commit is contained in:
1 parent
eef485306f
commit
6134d49c16
87 files changed
+29498
-126
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
@@ -25,7 +26,7 @@ func (s *Server) overview(c *gin.Context) {
|
||||
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`)
|
||||
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE enabled AND 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 enabled AND 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
|
||||
@@ -33,14 +34,15 @@ func (s *Server) overview(c *gin.Context) {
|
||||
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) {
|
||||
pve := s.currentPVE()
|
||||
provider := "unconfigured"
|
||||
if s.Config.Mode == "demo" {
|
||||
provider = "demo"
|
||||
}
|
||||
if s.PVE != nil {
|
||||
if 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"}})
|
||||
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": pve != nil, "terminalTransport": "https-poll", "capabilities": []string{"terminal-system-metrics", "gpu-inventory", "desktop-batches", "desktop-snapshots", "pve-connection", "desktop-hardware", "login-rate-limit", "csv-imports", "session-history", "monitor-alerts", "administrators", "password", "auth", "resources", "desktops", "templates", "pools", "clone", "desktop-connection", "terminals", "terminal-lifecycle", "terminal-policies", "users", "batch-bindings", "bindings", "tasks", "audit"}})
|
||||
}
|
||||
func (s *Server) resources(c *gin.Context) {
|
||||
items := []model.Resource{}
|
||||
@@ -70,7 +72,7 @@ func (s *Server) desktop(c *gin.Context) {
|
||||
}
|
||||
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")+"%")
|
||||
err := s.DB.SelectContext(c.Request.Context(), &items, terminalListSQL+` WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
@@ -135,6 +137,9 @@ func (s *Server) bindings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if !s.lockAuthorization(c, tx) {
|
||||
return
|
||||
}
|
||||
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)
|
||||
@@ -171,6 +176,10 @@ func (s *Server) bindings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND (terminal_id=$1 OR terminal_id=$2)", user.TerminalID, in.TerminalID); err != nil {
|
||||
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()
|
||||
@@ -211,11 +220,12 @@ func (s *Server) audits(c *gin.Context) {
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) discoverVMs(c *gin.Context) {
|
||||
if s.PVE == nil {
|
||||
pve := s.currentPVE()
|
||||
if pve == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
|
||||
return
|
||||
}
|
||||
items, err := s.PVE.Resources(c.Request.Context())
|
||||
items, err := pve.Resources(c.Request.Context())
|
||||
if err != nil {
|
||||
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
|
||||
return
|
||||
@@ -229,13 +239,15 @@ func (s *Server) discoverVMs(c *gin.Context) {
|
||||
list(c, vms)
|
||||
}
|
||||
func (s *Server) syncResources(c *gin.Context) {
|
||||
if s.PVE == nil {
|
||||
pve := s.currentPVE()
|
||||
if pve == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
|
||||
return
|
||||
}
|
||||
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
|
||||
}
|
||||
func (s *Server) importDesktop(c *gin.Context) {
|
||||
pve := s.currentPVE()
|
||||
var in struct {
|
||||
Name string `json:"name" binding:"required,max=80"`
|
||||
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
|
||||
@@ -249,7 +261,7 @@ func (s *Server) importDesktop(c *gin.Context) {
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
if s.PVE == nil {
|
||||
if pve == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
|
||||
return
|
||||
}
|
||||
@@ -261,7 +273,7 @@ func (s *Server) importDesktop(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
|
||||
power, err := pve.Power(c.Request.Context(), in.Node, in.VMID)
|
||||
if err != nil {
|
||||
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
|
||||
return
|
||||
@@ -290,6 +302,7 @@ func (s *Server) importDesktop(c *gin.Context) {
|
||||
s.desktop(c)
|
||||
}
|
||||
func (s *Server) desktopAction(c *gin.Context) {
|
||||
pve := s.currentPVE()
|
||||
var in struct {
|
||||
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
|
||||
}
|
||||
@@ -305,10 +318,19 @@ func (s *Server) desktopAction(c *gin.Context) {
|
||||
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
|
||||
return
|
||||
}
|
||||
if d.Provider == "proxmox" && s.PVE == nil {
|
||||
if d.Provider == "proxmox" && pve == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
|
||||
return
|
||||
}
|
||||
unresolved, err := maintenanceBlocked(c.Request.Context(), s.DB, d.ID, "", false)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if unresolved {
|
||||
s.fail(c, 409, "hardware_unconfirmed", "请先核对结果未确认的桌面维护任务")
|
||||
return
|
||||
}
|
||||
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
|
||||
}
|
||||
func (s *Server) terminalCommand(c *gin.Context) {
|
||||
@@ -353,6 +375,33 @@ func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName s
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if kind == "desktop" {
|
||||
var id string
|
||||
if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", targetID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
unresolved, err := maintenanceBlocked(ctx, tx, targetID, "", false)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if unresolved {
|
||||
s.fail(c, 409, "hardware_unconfirmed", "请先核对未确认的桌面维护任务")
|
||||
return
|
||||
}
|
||||
}
|
||||
if kind == "terminal" {
|
||||
var enabled bool
|
||||
if err = tx.GetContext(ctx, &enabled, "SELECT enabled FROM terminals WHERE id=$1 FOR UPDATE", targetID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
s.fail(c, 409, "terminal_disabled", "终端已停用,请重新激活后操作")
|
||||
return
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -381,6 +430,12 @@ func (s *Server) complete(ctx context.Context, t model.Task, status, message str
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = s.completeTx(ctx, tx, t, status, message); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *Server) completeTx(ctx context.Context, tx *sqlx.Tx, t model.Task, status, message string) error {
|
||||
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
|
||||
@@ -395,7 +450,7 @@ func (s *Server) complete(ctx context.Context, t model.Task, status, message str
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
return nil
|
||||
}
|
||||
func ratio(a, b float64) float64 {
|
||||
if b <= 0 {
|
||||
@@ -404,7 +459,14 @@ func ratio(a, b float64) float64 {
|
||||
return a / b
|
||||
}
|
||||
func (s *Server) sync(ctx context.Context) error {
|
||||
items, err := s.PVE.Resources(ctx)
|
||||
if err := s.RefreshCompute(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
pve := s.currentPVE()
|
||||
if pve == nil {
|
||||
return nil
|
||||
}
|
||||
items, err := pve.Resources(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -421,6 +483,9 @@ func (s *Server) sync(ctx context.Context) error {
|
||||
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 _, err = tx.ExecContext(ctx, `INSERT INTO resource_samples(resource_id,cpu,memory,storage) VALUES($1,CASE WHEN $7 THEN $2::double precision ELSE NULL END,CASE WHEN $7 AND $3::double precision>0 THEN $4::double precision ELSE NULL END,CASE WHEN $7 AND $5::double precision>0 THEN $6::double precision ELSE NULL END)`, r.Node, r.CPU, r.MaxMemory, ratio(r.Memory, r.MaxMemory), r.MaxDisk, ratio(r.Disk, r.MaxDisk), r.Status == "online"); 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 {
|
||||
@@ -432,26 +497,81 @@ func (s *Server) sync(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *Server) RunWorker(ctx context.Context) {
|
||||
// Hold a session lock so only one worker can resume PVE tasks across API replicas.
|
||||
conn, err := s.DB.Connx(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
for {
|
||||
var acquired bool
|
||||
if err = conn.GetContext(ctx, &acquired, "SELECT pg_try_advisory_lock(7651092027)"); err != nil {
|
||||
return
|
||||
}
|
||||
if acquired {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
defer conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock(7651092027)")
|
||||
workerCtx, stop := context.WithCancel(ctx)
|
||||
provisionDone := make(chan struct{}, 1)
|
||||
provisionBusy := false
|
||||
defer func() {
|
||||
stop()
|
||||
if provisionBusy {
|
||||
<-provisionDone
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(300 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-provisionDone:
|
||||
provisionBusy = false
|
||||
case <-ticker.C:
|
||||
if err = conn.PingContext(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
// 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 {
|
||||
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND ((kind NOT IN ('provision','hardware','snapshot','batch-desktop') AND created_at<now()-interval '2 minutes') OR (kind='batch-desktop' AND ((status='queued' AND created_at<now()-interval '2 hours') OR (status='running' AND updated_at<now()-interval '2 minutes'))) OR (kind IN ('provision','hardware','snapshot') AND updated_at<now()-interval '35 minutes'))") == nil {
|
||||
for _, t := range expired {
|
||||
if t.Kind == "batch-desktop" {
|
||||
s.finishBatchPower(t, "", "expired", "")
|
||||
continue
|
||||
}
|
||||
if t.Kind == "snapshot" {
|
||||
s.finishSnapshot(t, "", "expired", "")
|
||||
continue
|
||||
}
|
||||
if t.Kind == "hardware" {
|
||||
s.finishHardware(t, "", "expired", "")
|
||||
continue
|
||||
}
|
||||
if t.Kind == "provision" {
|
||||
_, _ = s.DB.ExecContext(ctx, "UPDATE provisioning_jobs SET state='uncertain' WHERE task_id=$1 AND state='reserved'", t.ID)
|
||||
}
|
||||
_ = 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 *`)
|
||||
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') OR (kind='provision' AND status='running' AND provider_ref!='') OR (kind IN ('hardware','snapshot','batch-desktop') AND status='running')) AND ($1 OR kind NOT IN ('provision','hardware','snapshot','batch-desktop')) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`, !provisionBusy)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.execute(ctx, t)
|
||||
if t.Kind == "provision" || t.Kind == "hardware" || t.Kind == "snapshot" || t.Kind == "batch-desktop" {
|
||||
provisionBusy = true
|
||||
go func(task model.Task) { s.execute(workerCtx, task); provisionDone <- struct{}{} }(t)
|
||||
} else {
|
||||
s.execute(ctx, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,6 +594,27 @@ func (s *Server) RunSync(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
func (s *Server) execute(parent context.Context, t model.Task) {
|
||||
if t.Kind == "batch-desktop" {
|
||||
s.executeBatchPower(parent, t)
|
||||
return
|
||||
}
|
||||
if t.Kind == "snapshot" {
|
||||
s.executeSnapshot(parent, t)
|
||||
return
|
||||
}
|
||||
if t.Kind == "hardware" {
|
||||
s.executeHardware(parent, t)
|
||||
return
|
||||
}
|
||||
if err := s.RefreshCompute(parent); err != nil {
|
||||
_ = s.complete(parent, t, "failed", "计算平台配置无法加载,未提交操作")
|
||||
return
|
||||
}
|
||||
pve := s.currentPVE()
|
||||
if t.Kind == "provision" {
|
||||
s.executeProvision(parent, t)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
@@ -503,11 +644,11 @@ func (s *Server) execute(parent context.Context, t model.Task) {
|
||||
}
|
||||
}
|
||||
} else if err == nil {
|
||||
if s.PVE == nil {
|
||||
if pve == nil {
|
||||
err = errors.New("PVE unconfigured")
|
||||
} else {
|
||||
var upid string
|
||||
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
|
||||
upid, err = 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 {
|
||||
@@ -517,11 +658,11 @@ func (s *Server) execute(parent context.Context, t model.Task) {
|
||||
_, 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)
|
||||
err = pve.Wait(ctx, d.Node, upid, time.Second)
|
||||
}
|
||||
if err == nil {
|
||||
var power string
|
||||
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
|
||||
power, err = pve.Power(ctx, d.Node, d.VMID)
|
||||
if err == nil {
|
||||
expected := "running"
|
||||
if t.Action == "shutdown" {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) administratorList(c *gin.Context) {
|
||||
items := []model.Administrator{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM administrators ORDER BY username"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
|
||||
func validNewPassword(password string) bool {
|
||||
return utf8.RuneCountInString(password) >= 12 && len(password) <= 72 && strings.TrimSpace(password) != ""
|
||||
}
|
||||
|
||||
// All account writes share a transaction lock, then recheck the actor's current role.
|
||||
// This also prevents concurrent administrators from disabling each other and leaving no owner.
|
||||
func (s *Server) accountTransaction(c *gin.Context, superOnly bool) (*sqlx.Tx, model.Administrator) {
|
||||
var actor model.Administrator
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return nil, actor
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(7651092028)"); err == nil {
|
||||
err = tx.GetContext(ctx, &actor, "SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE a.id=$1 AND x.id=$2 AND x.expires_at>now() FOR UPDATE OF a", s.admin(c).ID, c.GetString("sessionId"))
|
||||
}
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
|
||||
} else {
|
||||
s.dbError(c, err)
|
||||
}
|
||||
return nil, actor
|
||||
}
|
||||
if !actor.Enabled || superOnly && actor.Role != "super_admin" {
|
||||
tx.Rollback()
|
||||
s.fail(c, 403, "forbidden", "当前账号没有该操作权限")
|
||||
return nil, actor
|
||||
}
|
||||
return tx, actor
|
||||
}
|
||||
|
||||
func accountAudit(ctx context.Context, tx *sqlx.Tx, actor, action, target, requestID string) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'success',$5)", store.ID("audit_"), actor, action, target, requestID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) createAdministrator(c *gin.Context) {
|
||||
var in struct {
|
||||
Username string `json:"username" binding:"required,max=80"`
|
||||
DisplayName string `json:"displayName" binding:"required,max=80"`
|
||||
Role string `json:"role" binding:"required,oneof=super_admin operator viewer"`
|
||||
Password string `json:"password" binding:"required,max=72"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.Username = strings.TrimSpace(in.Username)
|
||||
in.DisplayName = strings.TrimSpace(in.DisplayName)
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{2,79}$`).MatchString(in.Username) || in.DisplayName == "" {
|
||||
s.fail(c, 400, "invalid_account", "账号使用 3~80 位字母、数字、点、下划线或短横线,显示名称不能为空")
|
||||
return
|
||||
}
|
||||
if !validNewPassword(in.Password) {
|
||||
s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
tx, actor := s.accountTransaction(c, true)
|
||||
if tx == nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var item model.Administrator
|
||||
err = tx.GetContext(c.Request.Context(), &item, "INSERT INTO administrators(id,username,display_name,role,password_hash) VALUES($1,$2,$3,$4,$5) RETURNING *", store.ID("adm_"), in.Username, in.DisplayName, in.Role, string(hash))
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "account_conflict", "管理员账号已存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err = accountAudit(c.Request.Context(), tx, actor.Username, "create_administrator", item.Username, c.GetString("requestId")); err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(201, item)
|
||||
}
|
||||
|
||||
func (s *Server) updateAdministrator(c *gin.Context) {
|
||||
var in struct {
|
||||
DisplayName string `json:"displayName" binding:"required,max=80"`
|
||||
Role string `json:"role" binding:"required,oneof=super_admin operator viewer"`
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.DisplayName = strings.TrimSpace(in.DisplayName)
|
||||
if in.DisplayName == "" {
|
||||
s.fail(c, 400, "invalid_account", "显示名称不能为空")
|
||||
return
|
||||
}
|
||||
tx, actor := s.accountTransaction(c, true)
|
||||
if tx == nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
ctx := c.Request.Context()
|
||||
var item model.Administrator
|
||||
if err := tx.GetContext(ctx, &item, "SELECT * FROM administrators WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if item.Enabled && item.Role == "super_admin" && (!*in.Enabled || in.Role != "super_admin") {
|
||||
var count int
|
||||
if err := tx.GetContext(ctx, &count, "SELECT count(*) FROM administrators WHERE enabled AND role='super_admin'"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
s.fail(c, 409, "last_super_admin", "至少保留一个启用的超级管理员")
|
||||
return
|
||||
}
|
||||
}
|
||||
if item.ID == actor.ID && (!*in.Enabled || in.Role != actor.Role) {
|
||||
s.fail(c, 409, "self_account_protected", "不能停用自己或修改自己的角色,请使用其他超级管理员账号操作")
|
||||
return
|
||||
}
|
||||
invalidate := item.Role != in.Role || item.Enabled != *in.Enabled
|
||||
err := tx.GetContext(ctx, &item, "UPDATE administrators SET display_name=$1,role=$2,enabled=$3 WHERE id=$4 RETURNING *", in.DisplayName, in.Role, *in.Enabled, item.ID)
|
||||
if err == nil && invalidate {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM admin_sessions WHERE admin_id=$1", item.ID)
|
||||
}
|
||||
if err == nil {
|
||||
err = accountAudit(ctx, tx, actor.Username, "update_administrator", item.Username, c.GetString("requestId"))
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, item)
|
||||
}
|
||||
|
||||
func (s *Server) resetAdministratorPassword(c *gin.Context) {
|
||||
var in struct {
|
||||
Password string `json:"password" binding:"required,max=72"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
if !validNewPassword(in.Password) {
|
||||
s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节")
|
||||
return
|
||||
}
|
||||
tx, actor := s.accountTransaction(c, true)
|
||||
if tx == nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if c.Param("id") == actor.ID {
|
||||
s.fail(c, 409, "use_own_password", "修改本人密码请使用修改密码入口并验证原密码")
|
||||
return
|
||||
}
|
||||
var item model.Administrator
|
||||
ctx := c.Request.Context()
|
||||
if err := tx.GetContext(ctx, &item, "SELECT * FROM administrators WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err := replacePassword(ctx, tx, item.ID, in.Password); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
err := accountAudit(ctx, tx, actor.Username, "reset_administrator_password", item.Username, c.GetString("requestId"))
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(204)
|
||||
}
|
||||
|
||||
func replacePassword(ctx context.Context, tx *sqlx.Tx, id, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE administrators SET password_hash=$1 WHERE id=$2", string(hash), id)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM admin_sessions WHERE admin_id=$1", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) changeOwnPassword(c *gin.Context) {
|
||||
var in struct {
|
||||
CurrentPassword string `json:"currentPassword" binding:"required,max=200"`
|
||||
NewPassword string `json:"newPassword" binding:"required,max=72"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
if !validNewPassword(in.NewPassword) {
|
||||
s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节")
|
||||
return
|
||||
}
|
||||
tx, actor := s.accountTransaction(c, false)
|
||||
if tx == nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if bcrypt.CompareHashAndPassword([]byte(actor.PasswordHash), []byte(in.CurrentPassword)) != nil {
|
||||
s.fail(c, 400, "current_password_invalid", "原密码不正确")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := replacePassword(ctx, tx, actor.ID, in.NewPassword); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
err := accountAudit(ctx, tx, actor.Username, "change_own_password", actor.Username, c.GetString("requestId"))
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(204)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
const testAccountPassword = "Account-test-2026"
|
||||
|
||||
func createTestAccount(t *testing.T, f *fixture, token, username, role string) string {
|
||||
t.Helper()
|
||||
status, out := f.request("POST", "/administrators", token, map[string]any{"username": username, "displayName": "Account test", "role": role, "password": testAccountPassword}, "")
|
||||
expect(t, 201, status, out)
|
||||
for _, field := range []string{"password", "passwordHash", "password_hash"} {
|
||||
if _, ok := out[field]; ok {
|
||||
t.Fatal("credential leaked", field)
|
||||
}
|
||||
}
|
||||
return out["id"].(string)
|
||||
}
|
||||
func accountLogin(t *testing.T, f *fixture, username, password string) string {
|
||||
t.Helper()
|
||||
status, out := f.request("POST", "/auth/login", "", map[string]any{"username": username, "password": password}, "")
|
||||
expect(t, 200, status, out)
|
||||
return out["accessToken"].(string)
|
||||
}
|
||||
|
||||
func TestAdministratorLifecycleAndSessionRevocation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
id := createTestAccount(t, f, admin, "maintainer", "operator")
|
||||
token := accountLogin(t, f, "maintainer", testAccountPassword)
|
||||
status, out := f.request("GET", "/administrators", token, nil, "")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Read-only", "role": "viewer", "enabled": true}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/auth/me", token, nil, "")
|
||||
expect(t, 401, status, out)
|
||||
token = accountLogin(t, f, "maintainer", testAccountPassword)
|
||||
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", token, map[string]any{"action": "start"}, "viewer-no-power")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("POST", "/administrators/"+id+"/password", admin, map[string]any{"password": "Reset-test-2026"}, "")
|
||||
expect(t, 204, status, out)
|
||||
status, out = f.request("GET", "/auth/me", token, nil, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "maintainer", "password": testAccountPassword}, "")
|
||||
expect(t, 401, status, out)
|
||||
token = accountLogin(t, f, "maintainer", "Reset-test-2026")
|
||||
status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Disabled", "role": "viewer", "enabled": false}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/auth/me", token, nil, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "maintainer", "password": "Reset-test-2026"}, "")
|
||||
expect(t, 401, status, out)
|
||||
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enabled bool
|
||||
if err := f.db.Get(&enabled, "SELECT enabled FROM administrators WHERE id=$1", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enabled {
|
||||
t.Fatal("restart re-enabled disabled account")
|
||||
}
|
||||
status, out = f.request("PUT", "/administrators/"+id, admin, map[string]any{"displayName": "Restored", "role": "operator", "enabled": true}, "")
|
||||
expect(t, 200, status, out)
|
||||
_ = accountLogin(t, f, "maintainer", "Reset-test-2026")
|
||||
var audit []string
|
||||
if err := f.db.Select(&audit, "SELECT action||':'||target FROM audit_logs WHERE target='maintainer' AND action LIKE '%administrator%'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(audit) != 5 {
|
||||
t.Fatal("account changes not audited", audit)
|
||||
}
|
||||
status, out = f.request("GET", "/administrators", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
encoded, _ := json.Marshal(out)
|
||||
if strings.Contains(string(encoded), "$2") || strings.Contains(string(encoded), testAccountPassword) {
|
||||
t.Fatal("credential material in account listing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnPasswordRequiresCurrentPasswordAndInvalidatesAllSessions(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
token := f.login("viewer")
|
||||
other := f.login("viewer")
|
||||
status, out := f.request("POST", "/auth/password", token, map[string]any{"currentPassword": "wrong-password", "newPassword": "Updated-test-2026"}, "")
|
||||
expect(t, 400, status, out)
|
||||
status, out = f.request("GET", "/auth/me", token, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/auth/password", token, map[string]any{"currentPassword": "Demo@2026", "newPassword": "Updated-test-2026"}, "")
|
||||
expect(t, 204, status, out)
|
||||
for _, old := range []string{token, other} {
|
||||
status, out = f.request("GET", "/auth/me", old, nil, "")
|
||||
expect(t, 401, status, out)
|
||||
}
|
||||
status, out = f.request("POST", "/auth/login", "", map[string]any{"username": "viewer", "password": "Demo@2026"}, "")
|
||||
expect(t, 401, status, out)
|
||||
_ = accountLogin(t, f, "viewer", "Updated-test-2026")
|
||||
var audit string
|
||||
if err := f.db.Get(&audit, "SELECT action FROM audit_logs WHERE action='change_own_password'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorValidationRBACAndLastOwner(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
var adminID string
|
||||
if err := f.db.Get(&adminID, "SELECT id FROM administrators WHERE username='admin'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"operator", "viewer"} {
|
||||
token := f.login(role)
|
||||
for _, r := range []struct{ method, path string }{{"GET", "/administrators"}, {"POST", "/administrators"}, {"PUT", "/administrators/" + adminID}, {"POST", "/administrators/" + adminID + "/password"}} {
|
||||
status, out := f.request(r.method, r.path, token, map[string]any{}, "")
|
||||
expect(t, 403, status, out)
|
||||
}
|
||||
}
|
||||
for _, password := range []string{"short", strings.Repeat("a", 73), strings.Repeat("密", 25)} {
|
||||
status, out := f.request("POST", "/administrators", admin, map[string]any{"username": "invalid-pass", "displayName": "Invalid", "role": "viewer", "password": password}, "")
|
||||
expect(t, 400, status, out)
|
||||
}
|
||||
body := map[string]any{"username": "test-user", "displayName": "Valid", "role": "super_admin", "password": testAccountPassword}
|
||||
status, out := f.request("POST", "/administrators", admin, body, "")
|
||||
expect(t, 201, status, out)
|
||||
status, out = f.request("POST", "/administrators", admin, body, "")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self", "role": "viewer", "enabled": true}, "")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("POST", "/administrators/"+adminID+"/password", admin, map[string]any{"password": testAccountPassword}, "")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self name", "role": "super_admin", "enabled": true}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/auth/me", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["displayName"] != "Self name" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
// The final owner's role and enabled state remain protected independently of UI restrictions.
|
||||
if _, err := f.db.Exec("UPDATE administrators SET enabled=false WHERE username='test-user'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, out = f.request("PUT", "/administrators/"+adminID, admin, map[string]any{"displayName": "Self", "role": "super_admin", "enabled": false}, "")
|
||||
expect(t, 409, status, out)
|
||||
if out["code"] != "last_super_admin" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAdministratorDisableKeepsOneOwner(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
otherID := createTestAccount(t, f, admin, "second-owner", "super_admin")
|
||||
other := accountLogin(t, f, "second-owner", testAccountPassword)
|
||||
var adminID string
|
||||
if err := f.db.Get(&adminID, "SELECT id FROM administrators WHERE username='admin'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
results := make(chan int, 2)
|
||||
var group sync.WaitGroup
|
||||
for _, r := range []struct{ token, target string }{{admin, otherID}, {other, adminID}} {
|
||||
group.Add(1)
|
||||
go func(token, target string) {
|
||||
defer group.Done()
|
||||
<-start
|
||||
status, _ := f.request("PUT", "/administrators/"+target, token, map[string]any{"displayName": "Concurrent", "role": "super_admin", "enabled": false}, "")
|
||||
results <- status
|
||||
}(r.token, r.target)
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
close(results)
|
||||
success := 0
|
||||
for status := range results {
|
||||
if status == 200 {
|
||||
success++
|
||||
} else if status != 401 && status != 403 {
|
||||
t.Fatalf("unexpected losing request status %d", status)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := f.db.Get(&count, "SELECT count(*) FROM administrators WHERE enabled AND role='super_admin'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if success != 1 || count != 1 {
|
||||
t.Fatal("concurrent writes lost owner", success, count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *Server) discoverTemplates(c *gin.Context) {
|
||||
pve := s.currentPVE()
|
||||
if pve == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox")
|
||||
return
|
||||
}
|
||||
resources, err := pve.Resources(c.Request.Context())
|
||||
if err != nil {
|
||||
s.fail(c, 502, "pve_unavailable", "无法发现模板,请检查 PVE 接入")
|
||||
return
|
||||
}
|
||||
items := []gin.H{}
|
||||
for _, r := range resources {
|
||||
if r.Type == "qemu" && r.Template == 1 {
|
||||
items = append(items, gin.H{"name": r.Name, "node": r.Node, "vmid": r.VMID, "status": r.Status})
|
||||
}
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) templateList(c *gin.Context) {
|
||||
items := []model.ImageTemplate{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM image_templates ORDER BY name,version"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) registerTemplate(c *gin.Context) {
|
||||
pve := s.currentPVE()
|
||||
var in struct {
|
||||
Name string `json:"name" binding:"required,max=80"`
|
||||
Version string `json:"version" binding:"required,max=40"`
|
||||
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
|
||||
Node string `json:"node" binding:"required,max=100"`
|
||||
VMID int `json:"vmid" binding:"min=100,max=999999999"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.Version = strings.TrimSpace(in.Version)
|
||||
if in.Name == "" || in.Version == "" {
|
||||
s.fail(c, 400, "invalid_request", "模板名称和版本不能为空")
|
||||
return
|
||||
}
|
||||
if pve == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "模板登记需要先配置 Proxmox")
|
||||
return
|
||||
}
|
||||
config, err := pve.VMConfig(c.Request.Context(), in.Node, in.VMID)
|
||||
if err != nil {
|
||||
s.fail(c, 502, "pve_unavailable", "无法读取模板配置,请核对节点、VMID 和 VM.Audit 权限")
|
||||
return
|
||||
}
|
||||
if _, err = proxmox.TemplateDisks(config); err != nil {
|
||||
s.fail(c, 400, "invalid_template", err.Error())
|
||||
return
|
||||
}
|
||||
digest, _ := config["digest"].(string)
|
||||
if digest == "" {
|
||||
s.fail(c, 502, "invalid_template", "PVE 模板配置缺少版本摘要")
|
||||
return
|
||||
}
|
||||
var item model.ImageTemplate
|
||||
err = s.DB.GetContext(c.Request.Context(), &item, `INSERT INTO image_templates(id,name,version,os,node,vmid,digest) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`, store.ID("tpl_"), in.Name, in.Version, in.OS, in.Node, in.VMID, digest)
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "template_conflict", "该模板或名称版本已经登记")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.log(c, "register_template", item.ID, "success")
|
||||
c.JSON(201, item)
|
||||
}
|
||||
func (s *Server) updateTemplate(c *gin.Context) {
|
||||
var in struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
var item model.ImageTemplate
|
||||
if err := s.DB.GetContext(c.Request.Context(), &item, "UPDATE image_templates SET enabled=$1 WHERE id=$2 RETURNING *", *in.Enabled, c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.log(c, "update_template", item.ID, "success")
|
||||
c.JSON(200, item)
|
||||
}
|
||||
|
||||
const poolSelect = `SELECT p.*,
|
||||
(SELECT count(*) FROM desktops d WHERE d.pool_id=p.id) desktop_count,
|
||||
(SELECT count(*) FROM provisioning_jobs j WHERE j.pool_id=p.id AND j.state IN ('reserved','uncertain')) reserved_count,
|
||||
(SELECT count(*) FROM desktops d WHERE d.pool_id=p.id AND EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=d.id AND u.enabled)) assigned_count,
|
||||
(SELECT count(*) FROM desktops d WHERE d.pool_id=p.id AND EXISTS(SELECT 1 FROM terminals t WHERE t.session_desktop_id=d.id AND t.last_seen>now()-interval '90 seconds')) active_count
|
||||
FROM desktop_pools p`
|
||||
|
||||
func (s *Server) poolList(c *gin.Context) {
|
||||
items := []model.DesktopPool{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, poolSelect+" ORDER BY p.name"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
|
||||
type poolInput struct {
|
||||
Name string `json:"name" binding:"required,max=80"`
|
||||
Type string `json:"type" binding:"required,oneof=dedicated shared"`
|
||||
MaxCount int `json:"maxCount" binding:"min=1,max=10000"`
|
||||
TemplateID *string `json:"templateId"`
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
// The catalog lock serializes capacity reservations, pool edits and membership changes.
|
||||
func catalogLock(ctx context.Context, tx *sqlx.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(7651092026)")
|
||||
return err
|
||||
}
|
||||
func (s *Server) savePool(c *gin.Context) {
|
||||
var in poolInput
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
s.fail(c, 400, "invalid_request", "桌面池名称不能为空")
|
||||
return
|
||||
}
|
||||
if in.TemplateID != nil && *in.TemplateID == "" {
|
||||
in.TemplateID = nil
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = catalogLock(ctx, tx); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
id = store.ID("pool_")
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO desktop_pools(id,name,type,max_count,template_id,enabled) VALUES($1,$2,$3,$4,$5,$6)", id, in.Name, in.Type, in.MaxCount, in.TemplateID, *in.Enabled)
|
||||
} else {
|
||||
var current model.DesktopPool
|
||||
if err = tx.GetContext(ctx, ¤t, poolSelect+" WHERE p.id=$1", id); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if in.MaxCount < current.DesktopCount+current.ReservedCount {
|
||||
s.fail(c, 409, "pool_capacity", "容量不能小于现有桌面与待确认创建任务的总数")
|
||||
return
|
||||
}
|
||||
if in.Type != current.Type && current.DesktopCount+current.ReservedCount > 0 {
|
||||
s.fail(c, 409, "pool_in_use", "非空桌面池不能切换公共/专属类型")
|
||||
return
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "UPDATE desktop_pools SET name=$1,type=$2,max_count=$3,template_id=$4,enabled=$5 WHERE id=$6", in.Name, in.Type, in.MaxCount, in.TemplateID, *in.Enabled, id)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE desktops SET pool=$1 WHERE pool_id=$2", in.Name, id)
|
||||
}
|
||||
}
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "pool_conflict", "名称已存在或所选模板不存在")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var item model.DesktopPool
|
||||
if err = tx.GetContext(ctx, &item, poolSelect+" WHERE p.id=$1", id); err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.log(c, "save_pool", id, "success")
|
||||
c.JSON(200, item)
|
||||
}
|
||||
func (s *Server) poolMembers(c *gin.Context) {
|
||||
var in struct {
|
||||
DesktopIDs []string `json:"desktopIds" binding:"max=1000"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = catalogLock(ctx, tx); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var pool model.DesktopPool
|
||||
if err = tx.GetContext(ctx, &pool, poolSelect+" WHERE p.id=$1", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, id := range in.DesktopIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
if len(ids)+pool.ReservedCount > pool.MaxCount {
|
||||
s.fail(c, 409, "pool_capacity", "桌面数量与创建预留超过池容量")
|
||||
return
|
||||
}
|
||||
for id := range ids {
|
||||
var desktop model.Desktop
|
||||
if err = tx.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1", id); err != nil {
|
||||
s.fail(c, 400, "invalid_desktop", "所选桌面不存在")
|
||||
return
|
||||
}
|
||||
if desktop.PoolID != nil && *desktop.PoolID != pool.ID {
|
||||
s.fail(c, 409, "pool_membership", "桌面已属于其他池,请先从原池移出")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET pool_id=NULL,pool='' WHERE pool_id=$1", pool.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
for id := range ids {
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET pool_id=$1,pool=$2 WHERE id=$3", pool.ID, pool.Name, id); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.log(c, "update_pool_members", pool.ID, "success")
|
||||
c.JSON(200, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
type cloneInput struct {
|
||||
TemplateID string `json:"templateId" binding:"required,max=100"`
|
||||
PoolID *string `json:"poolId"`
|
||||
Name string `json:"name" binding:"required,max=80"`
|
||||
VMID int `json:"vmid" binding:"min=100,max=999999999"`
|
||||
Full bool `json:"full"`
|
||||
Storage string `json:"storage" binding:"max=100"`
|
||||
}
|
||||
type provisioningJob struct {
|
||||
TaskID string `db:"task_id"`
|
||||
TemplateID string `db:"template_id"`
|
||||
PoolID *string `db:"pool_id"`
|
||||
DesktopID string `db:"desktop_id"`
|
||||
VMID int `db:"vmid"`
|
||||
RequestJSON string `db:"request_json"`
|
||||
RequestHash string `db:"request_hash"`
|
||||
State string `db:"state"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
func normalizeClone(in *cloneInput) {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.PoolID != nil && *in.PoolID == "" {
|
||||
in.PoolID = nil
|
||||
}
|
||||
}
|
||||
func (s *Server) clonePreflight(ctx context.Context, in cloneInput) (model.ImageTemplate, error) {
|
||||
pve := s.currentPVE()
|
||||
var template model.ImageTemplate
|
||||
if pve == nil {
|
||||
return template, errors.New("尚未配置 Proxmox")
|
||||
}
|
||||
if in.Name == "" || in.Full && in.Storage == "" || !in.Full && in.Storage != "" {
|
||||
return template, errors.New("请填写桌面名称;完整克隆必须选择存储,链接克隆沿用模板存储")
|
||||
}
|
||||
if err := s.DB.GetContext(ctx, &template, "SELECT * FROM image_templates WHERE id=$1 AND enabled", in.TemplateID); err != nil {
|
||||
return template, errors.New("模板不存在或已停用")
|
||||
}
|
||||
config, err := pve.VMConfig(ctx, template.Node, template.VMID)
|
||||
if err != nil {
|
||||
return template, errors.New("无法读取源模板配置")
|
||||
}
|
||||
disks, err := proxmox.TemplateDisks(config)
|
||||
if err != nil {
|
||||
return template, err
|
||||
}
|
||||
if config["digest"] != template.Digest {
|
||||
return template, errors.New("源模板已变更,请重新验证模板后再创建")
|
||||
}
|
||||
can, err := pve.CanClone(ctx, template.Node, template.VMID, in.Full)
|
||||
if err != nil || !can {
|
||||
return template, errors.New("模板所在存储不支持所选克隆方式")
|
||||
}
|
||||
resources, err := pve.Resources(ctx)
|
||||
if err != nil {
|
||||
return template, errors.New("无法检查目标 VMID")
|
||||
}
|
||||
for _, r := range resources {
|
||||
if (r.Type == "qemu" || r.Type == "lxc") && r.VMID == in.VMID {
|
||||
return template, errors.New("目标 VMID 已被 PVE 使用")
|
||||
}
|
||||
}
|
||||
for _, permission := range []struct{ path, name string }{{fmt.Sprintf("/vms/%d", template.VMID), "VM.Clone"}, {fmt.Sprintf("/vms/%d", in.VMID), "VM.Allocate"}} {
|
||||
ok, err := pve.HasPermission(ctx, permission.path, permission.name)
|
||||
if err != nil || !ok {
|
||||
return template, fmt.Errorf("PVE 平台账号缺少 %s 权限(%s)", permission.name, permission.path)
|
||||
}
|
||||
}
|
||||
storages, err := pve.Storages(ctx, template.Node)
|
||||
if err != nil {
|
||||
return template, errors.New("无法查询目标存储")
|
||||
}
|
||||
if in.Full {
|
||||
total := float64(0)
|
||||
for _, size := range disks {
|
||||
total += size
|
||||
}
|
||||
disks = map[string]float64{in.Storage: total}
|
||||
}
|
||||
for name, size := range disks {
|
||||
found := false
|
||||
for _, storage := range storages {
|
||||
if storage.Name == name && storage.Active == 1 && storage.Enabled == 1 && strings.Contains(","+storage.Content+",", ",images,") {
|
||||
found = true
|
||||
needed := float64(1 << 30)
|
||||
if in.Full {
|
||||
needed += size
|
||||
}
|
||||
if storage.Available < needed {
|
||||
return template, fmt.Errorf("存储 %s 可用空间不足,已预留 1 GiB 余量", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return template, fmt.Errorf("存储 %s 不可用或不支持虚拟机磁盘", name)
|
||||
}
|
||||
ok, err := pve.HasPermission(ctx, "/storage/"+name, "Datastore.AllocateSpace")
|
||||
if err != nil || !ok {
|
||||
return template, fmt.Errorf("存储 %s 缺少 Datastore.AllocateSpace 权限", name)
|
||||
}
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
func (s *Server) storageList(c *gin.Context) {
|
||||
pve := s.currentPVE()
|
||||
if pve == nil {
|
||||
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox")
|
||||
return
|
||||
}
|
||||
node := c.Query("node")
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.-]{0,99}$`).MatchString(node) {
|
||||
s.fail(c, 400, "invalid_node", "请选择节点")
|
||||
return
|
||||
}
|
||||
items, err := pve.Storages(c.Request.Context(), node)
|
||||
if err != nil {
|
||||
s.fail(c, 502, "pve_unavailable", "存储查询失败")
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) checkClone(c *gin.Context) {
|
||||
var in cloneInput
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
normalizeClone(&in)
|
||||
_, err := s.clonePreflight(c.Request.Context(), in)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"available": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if in.PoolID != nil {
|
||||
var pool model.DesktopPool
|
||||
if err = s.DB.GetContext(c.Request.Context(), &pool, poolSelect+" WHERE p.id=$1", *in.PoolID); err != nil {
|
||||
c.JSON(200, gin.H{"available": false, "message": "所选桌面池不存在"})
|
||||
return
|
||||
}
|
||||
if !pool.Enabled || pool.DesktopCount+pool.ReservedCount >= pool.MaxCount || (pool.TemplateID != nil && *pool.TemplateID != in.TemplateID) {
|
||||
c.JSON(200, gin.H{"available": false, "message": "桌面池已停用、容量不足或模板不匹配"})
|
||||
return
|
||||
}
|
||||
}
|
||||
var reserved bool
|
||||
if err = s.DB.GetContext(c.Request.Context(), &reserved, "SELECT EXISTS(SELECT 1 FROM provisioning_jobs WHERE vmid=$1)", in.VMID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if reserved {
|
||||
c.JSON(200, gin.H{"available": false, "message": "目标 VMID 已有创建记录,请核对任务并选择其他 VMID"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"available": true, "message": "计算侧预检通过;提交时会再次检查容量和模板,创建后需要完成系统初始化与配对"})
|
||||
}
|
||||
func (s *Server) cloneDesktop(c *gin.Context) {
|
||||
var in cloneInput
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
normalizeClone(&in)
|
||||
key := c.GetHeader("Idempotency-Key")
|
||||
if key == "" || len(key) > 100 {
|
||||
s.fail(c, 400, "idempotency_required", "请提供 Idempotency-Key")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
body, _ := json.Marshal(in)
|
||||
hash := store.Hash(string(body))
|
||||
actor := s.admin(c).Username
|
||||
var existing model.Task
|
||||
if err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key); err == nil {
|
||||
var same bool
|
||||
_ = s.DB.GetContext(ctx, &same, "SELECT EXISTS(SELECT 1 FROM provisioning_jobs WHERE task_id=$1 AND request_hash=$2)", existing.ID, hash)
|
||||
if !same {
|
||||
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同创建参数")
|
||||
return
|
||||
}
|
||||
c.JSON(202, existing)
|
||||
return
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
_, err := s.clonePreflight(ctx, in)
|
||||
if err != nil {
|
||||
s.fail(c, 409, "clone_preflight_failed", err.Error())
|
||||
return
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = catalogLock(ctx, tx); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var enabled bool
|
||||
if err = tx.GetContext(ctx, &enabled, "SELECT enabled FROM image_templates WHERE id=$1 FOR SHARE", in.TemplateID); err != nil || !enabled {
|
||||
s.fail(c, 409, "template_disabled", "模板已停用")
|
||||
return
|
||||
}
|
||||
if in.PoolID != nil {
|
||||
var pool model.DesktopPool
|
||||
if err = tx.GetContext(ctx, &pool, poolSelect+" WHERE p.id=$1", *in.PoolID); err != nil {
|
||||
s.fail(c, 400, "invalid_pool", "所选桌面池不存在")
|
||||
return
|
||||
}
|
||||
if !pool.Enabled || pool.DesktopCount+pool.ReservedCount >= pool.MaxCount {
|
||||
s.fail(c, 409, "pool_capacity", "桌面池已停用或没有剩余容量")
|
||||
return
|
||||
}
|
||||
if pool.TemplateID != nil && *pool.TemplateID != in.TemplateID {
|
||||
s.fail(c, 409, "pool_template", "所选模板与池的模板不一致")
|
||||
return
|
||||
}
|
||||
}
|
||||
var taken bool
|
||||
if err = tx.GetContext(ctx, &taken, "SELECT EXISTS(SELECT 1 FROM desktops WHERE provider='proxmox' AND vmid=$1)", in.VMID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if taken {
|
||||
s.fail(c, 409, "vmid_conflict", "目标 VMID 已纳管")
|
||||
return
|
||||
}
|
||||
var task model.Task
|
||||
desktopID := store.ID("desk_")
|
||||
err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'provision','clone',$2,$3,$4,'queued','创建请求已排队,等待 PVE 最终结果',$5) RETURNING *`, store.ID("task_"), desktopID, in.Name, actor, key)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO provisioning_jobs(task_id,template_id,pool_id,desktop_id,vmid,request_json,request_hash) VALUES($1,$2,$3,$4,$5,$6,$7)`, task.ID, in.TemplateID, in.PoolID, desktopID, in.VMID, string(body), hash)
|
||||
}
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "provision_conflict", "该 VMID 已有创建记录或重复请求,请核对任务")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'clone',$3,'submitted',$4,now())", store.ID("audit_"), actor, in.Name, c.GetString("requestId"))
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(202, task)
|
||||
}
|
||||
|
||||
func validEndpoint(endpoint string) bool {
|
||||
if net.ParseIP(endpoint) != nil {
|
||||
return true
|
||||
}
|
||||
if len(endpoint) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(endpoint, ".") {
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`).MatchString(label) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Server) configureDesktop(c *gin.Context) {
|
||||
var in struct {
|
||||
Endpoint string `json:"endpoint" binding:"required,max=253"`
|
||||
Width int `json:"width" binding:"min=640,max=7680"`
|
||||
Height int `json:"height" binding:"min=480,max=4320"`
|
||||
FPS int `json:"fps" binding:"min=10,max=120"`
|
||||
Initialized bool `json:"initialized"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
if !validEndpoint(in.Endpoint) {
|
||||
s.fail(c, 400, "invalid_endpoint", "请填写有效的 IP 或主机名")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if !s.lockAuthorization(c, tx) {
|
||||
return
|
||||
}
|
||||
var desktop model.Desktop
|
||||
if err = tx.GetContext(ctx, &desktop, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var active bool
|
||||
if err = tx.GetContext(ctx, &active, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", desktop.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if active {
|
||||
s.fail(c, 409, "desktop_busy", "桌面有连接中或活动会话,请先结束连接")
|
||||
return
|
||||
}
|
||||
if blocked, e := maintenanceBlocked(ctx, tx, desktop.ID, "", true); e != nil {
|
||||
s.dbError(c, e)
|
||||
return
|
||||
} else if blocked {
|
||||
s.fail(c, 409, "desktop_maintenance", "桌面维护任务进行中或结果未确认,请先核对")
|
||||
return
|
||||
}
|
||||
if desktop.SetupRequired && !in.Initialized {
|
||||
s.fail(c, 400, "initialization_required", "请先确认系统身份、IP、Sunshine 服务和终端配对已经完成")
|
||||
return
|
||||
}
|
||||
err = tx.GetContext(ctx, &desktop, `UPDATE desktops SET endpoint=$1,width=$2,height=$3,fps=$4,setup_required=false,service_state='unknown',updated_at=now() WHERE id=$5 RETURNING *`, in.Endpoint, in.Width, in.Height, in.FPS, desktop.ID)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE desktop_id=$1", desktop.ID)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.log(c, "configure_desktop", desktop.ID, "success")
|
||||
c.JSON(200, desktop)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
type catalogPVE struct {
|
||||
posts atomic.Int32
|
||||
deny atomic.Bool
|
||||
changed atomic.Bool
|
||||
lowSpace atomic.Bool
|
||||
pollFailure atomic.Bool
|
||||
holdPoll atomic.Bool
|
||||
outcome string
|
||||
}
|
||||
|
||||
func fakeCatalogPVE(t *testing.T, f *fixture, outcome string) *catalogPVE {
|
||||
t.Helper()
|
||||
p := &catalogPVE{outcome: outcome}
|
||||
p.pollFailure.Store(outcome == "poll-failure")
|
||||
p.holdPoll.Store(outcome == "running")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api2/json")
|
||||
var data any
|
||||
switch {
|
||||
case path == "/cluster/resources":
|
||||
data = []map[string]any{{"type": "qemu", "node": "node-a", "vmid": 8000, "template": 1, "name": "ubuntu-template", "status": "stopped"}}
|
||||
case path == "/nodes/node-a/qemu/8000/config":
|
||||
digest := "v1"
|
||||
if p.changed.Load() {
|
||||
digest = "v2"
|
||||
}
|
||||
data = map[string]any{"template": 1, "digest": digest, "scsi0": "local:base-8000,size=4G"}
|
||||
case path == "/nodes/node-a/qemu/8000/feature":
|
||||
data = map[string]any{"hasFeature": 1}
|
||||
case path == "/access/permissions":
|
||||
allowed := 1
|
||||
if p.deny.Load() {
|
||||
allowed = 0
|
||||
}
|
||||
data = map[string]any{r.URL.Query().Get("path"): map[string]int{"VM.Clone": allowed, "VM.Allocate": allowed, "Datastore.AllocateSpace": allowed}}
|
||||
case path == "/nodes/node-a/storage":
|
||||
avail := float64(100 * (1 << 30))
|
||||
if p.lowSpace.Load() {
|
||||
avail = 0
|
||||
}
|
||||
data = []map[string]any{{"storage": "local", "avail": avail, "active": 1, "enabled": 1, "content": "images"}}
|
||||
case path == "/nodes/node-a/qemu/8000/clone":
|
||||
p.posts.Add(1)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if r.Method != "POST" || r.Form.Get("name") != "vdi-9000" || r.Form.Get("newid") != "9000" || r.Form.Get("full") != "1" || r.Form.Get("storage") != "local" {
|
||||
t.Errorf("incorrect clone form: %v", r.Form)
|
||||
}
|
||||
if outcome == "ambiguous" {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
data = "UPID:test-clone"
|
||||
case strings.Contains(path, "/tasks/"):
|
||||
if p.pollFailure.Load() {
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
if p.holdPoll.Load() {
|
||||
data = map[string]any{"status": "running"}
|
||||
break
|
||||
}
|
||||
exit := "OK"
|
||||
if outcome == "failed" {
|
||||
exit = "clone failed"
|
||||
}
|
||||
data = map[string]any{"status": "stopped", "exitstatus": exit}
|
||||
case path == "/nodes/node-a/qemu/9000/config":
|
||||
data = map[string]any{"name": "vdi-9000", "scsi0": "local:vm-9000,size=4G"}
|
||||
case path == "/nodes/node-a/qemu/9000/status/current":
|
||||
data = map[string]any{"status": "stopped"}
|
||||
default:
|
||||
t.Errorf("unexpected PVE request: %s", path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
return p
|
||||
}
|
||||
func registerCatalog(t *testing.T, f *fixture, admin string) (string, string) {
|
||||
t.Helper()
|
||||
status, out := f.request("POST", "/templates", admin, map[string]any{"name": "Ubuntu base", "version": "1.0", "os": "Ubuntu", "node": "node-a", "vmid": 8000}, "")
|
||||
expect(t, 201, status, out)
|
||||
tpl := out["id"].(string)
|
||||
status, out = f.request("POST", "/pools", admin, map[string]any{"name": "Engineering", "type": "dedicated", "maxCount": 1, "templateId": tpl, "enabled": true}, "")
|
||||
expect(t, 200, status, out)
|
||||
return tpl, out["id"].(string)
|
||||
}
|
||||
func cloneBody(tpl, pool string) map[string]any {
|
||||
return map[string]any{"templateId": tpl, "poolId": pool, "name": "New desktop", "vmid": 9000, "full": true, "storage": "local"}
|
||||
}
|
||||
func waitCatalogTask(t *testing.T, f *fixture, admin, id string) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(6 * time.Second)
|
||||
for {
|
||||
status, out := f.request("GET", "/tasks/"+id, admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["status"] != "queued" && out["status"] != "running" {
|
||||
return out
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("clone never completed", out)
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
func TestCloneCreatesDurableUninitializedDesktop(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
p := fakeCatalogPVE(t, f, "success")
|
||||
admin := f.login("admin")
|
||||
tpl, pool := registerCatalog(t, f, admin)
|
||||
body := cloneBody(tpl, pool)
|
||||
status, out := f.request("POST", "/desktops/clone/check", admin, body, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["available"] != true {
|
||||
t.Fatal(out)
|
||||
}
|
||||
status, out = f.request("POST", "/desktops/clone", admin, body, "clone-once")
|
||||
expect(t, 202, status, out)
|
||||
id := out["id"].(string)
|
||||
task := waitCatalogTask(t, f, admin, id)
|
||||
if task["status"] != "success" {
|
||||
t.Fatal(task)
|
||||
}
|
||||
status, again := f.request("POST", "/desktops/clone", admin, body, "clone-once")
|
||||
expect(t, 202, status, again)
|
||||
if again["id"] != id || p.posts.Load() != 1 {
|
||||
t.Fatal("clone replayed", again, p.posts.Load())
|
||||
}
|
||||
body["name"] = "different"
|
||||
status, out = f.request("POST", "/desktops/clone", admin, body, "clone-once")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("GET", "/desktops/"+task["targetId"].(string), admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["setupRequired"] != true || out["endpoint"] != "" || out["poolId"] != pool {
|
||||
t.Fatal("new VM incorrectly advertised ready", out)
|
||||
}
|
||||
connection := map[string]any{"endpoint": "192.0.2.20", "width": 1920, "height": 1080, "fps": 30, "initialized": false}
|
||||
desk := task["targetId"].(string)
|
||||
status, out = f.request("PUT", "/desktops/"+desk+"/connection", admin, connection, "")
|
||||
expect(t, 400, status, out)
|
||||
connection["initialized"] = true
|
||||
connection["endpoint"] = "https://invalid"
|
||||
status, out = f.request("PUT", "/desktops/"+desk+"/connection", admin, connection, "")
|
||||
expect(t, 400, status, out)
|
||||
connection["endpoint"] = "192.0.2.20"
|
||||
status, out = f.request("PUT", "/desktops/"+desk+"/connection", admin, connection, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["setupRequired"] != false {
|
||||
t.Fatal(out)
|
||||
}
|
||||
body["vmid"] = 9001
|
||||
status, out = f.request("POST", "/desktops/clone/check", admin, body, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["available"] != false {
|
||||
t.Fatal("full pool passed preflight", out)
|
||||
}
|
||||
status, out = f.request("POST", "/desktops/clone", admin, body, "over-capacity")
|
||||
expect(t, 409, status, out)
|
||||
var count int
|
||||
_ = f.db.Get(&count, "SELECT count(*) FROM audit_logs WHERE action='clone' AND result='success'")
|
||||
if count != 1 {
|
||||
t.Fatal("missing final audit")
|
||||
}
|
||||
}
|
||||
func TestClonePreflightAndCatalogRBAC(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
p := fakeCatalogPVE(t, f, "success")
|
||||
admin := f.login("admin")
|
||||
tpl, pool := registerCatalog(t, f, admin)
|
||||
body := cloneBody(tpl, pool)
|
||||
for _, role := range []string{"viewer", "operator"} {
|
||||
token := f.login(role)
|
||||
for _, endpoint := range []string{"/templates", "/pools", "/desktops/clone", "/desktops/clone/check"} {
|
||||
status, out := f.request("POST", endpoint, token, body, "forbidden")
|
||||
expect(t, 403, status, out)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flag *atomic.Bool
|
||||
}{{"permission", &p.deny}, {"template-changed", &p.changed}, {"storage-capacity", &p.lowSpace}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.flag.Store(true)
|
||||
defer tc.flag.Store(false)
|
||||
status, out := f.request("POST", "/desktops/clone/check", admin, body, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["available"] != false {
|
||||
t.Fatal(out)
|
||||
}
|
||||
status, out = f.request("POST", "/desktops/clone", admin, body, tc.name)
|
||||
expect(t, 409, status, out)
|
||||
})
|
||||
}
|
||||
if p.posts.Load() != 0 {
|
||||
t.Fatal("preflight failure still issued clone")
|
||||
}
|
||||
status, out := f.request("PATCH", "/templates/"+tpl, admin, map[string]any{"enabled": false}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/desktops/clone", admin, body, "disabled")
|
||||
expect(t, 409, status, out)
|
||||
}
|
||||
func TestAmbiguousAndFailedCloneRetainPoolCapacity(t *testing.T) {
|
||||
for _, outcome := range []string{"ambiguous", "failed"} {
|
||||
t.Run(outcome, func(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
p := fakeCatalogPVE(t, f, outcome)
|
||||
admin := f.login("admin")
|
||||
tpl, pool := registerCatalog(t, f, admin)
|
||||
status, out := f.request("POST", "/desktops/clone", admin, cloneBody(tpl, pool), "uncertain")
|
||||
expect(t, 202, status, out)
|
||||
task := waitCatalogTask(t, f, admin, out["id"].(string))
|
||||
want := "timeout"
|
||||
if outcome == "failed" {
|
||||
want = "failed"
|
||||
}
|
||||
if task["status"] != want {
|
||||
t.Fatal(task)
|
||||
}
|
||||
var state string
|
||||
_ = f.db.Get(&state, "SELECT state FROM provisioning_jobs WHERE task_id=$1", task["id"])
|
||||
if state != "uncertain" {
|
||||
t.Fatal("lost uncertain reservation", state)
|
||||
}
|
||||
status, out = f.request("GET", "/pools", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["items"].([]any)[0].(map[string]any)["reservedCount"] != float64(1) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
if p.posts.Load() != 1 {
|
||||
t.Fatal("ambiguous POST replayed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestKnownUPIDReconcilesWithoutAnotherClone(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
p := fakeCatalogPVE(t, f, "poll-failure")
|
||||
admin := f.login("admin")
|
||||
tpl, pool := registerCatalog(t, f, admin)
|
||||
status, out := f.request("POST", "/desktops/clone", admin, cloneBody(tpl, pool), "resume-known")
|
||||
expect(t, 202, status, out)
|
||||
id := out["id"].(string)
|
||||
task := waitCatalogTask(t, f, admin, id)
|
||||
if task["status"] != "timeout" || task["providerRef"] == "" {
|
||||
t.Fatal(task)
|
||||
}
|
||||
p.pollFailure.Store(false)
|
||||
status, out = f.request("POST", "/tasks/"+id+"/reconcile", admin, nil, "")
|
||||
expect(t, 202, status, out)
|
||||
task = waitCatalogTask(t, f, admin, id)
|
||||
if task["status"] != "success" || p.posts.Load() != 1 {
|
||||
t.Fatal("known UPID was not safely resumed", task, p.posts.Load())
|
||||
}
|
||||
status, out = f.request("POST", "/tasks/"+id+"/reconcile", admin, nil, "")
|
||||
expect(t, 409, status, out)
|
||||
}
|
||||
|
||||
func TestCloneDoesNotBlockOrdinaryTasks(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
p := fakeCatalogPVE(t, f, "running")
|
||||
admin := f.login("admin")
|
||||
tpl, pool := registerCatalog(t, f, admin)
|
||||
status, out := f.request("POST", "/desktops/clone", admin, cloneBody(tpl, pool), "long-clone")
|
||||
expect(t, 202, status, out)
|
||||
cloneID := out["id"].(string)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for p.posts.Load() == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("clone did not start")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", admin, map[string]any{"action": "start"}, "during-clone")
|
||||
expect(t, 202, status, out)
|
||||
power := waitCatalogTask(t, f, admin, out["id"].(string))
|
||||
if power["status"] != "success" {
|
||||
t.Fatal("ordinary task blocked by clone", power)
|
||||
}
|
||||
status, out = f.request("GET", "/tasks/"+cloneID, admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["status"] != "running" {
|
||||
t.Fatal("clone should still be polling", out)
|
||||
}
|
||||
p.holdPoll.Store(false)
|
||||
if task := waitCatalogTask(t, f, admin, cloneID); task["status"] != "success" {
|
||||
t.Fatal(task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolMembershipCapacityAndConnectionBusy(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
input := map[string]any{"name": "Pool A", "type": "shared", "maxCount": 1, "templateId": nil, "enabled": true}
|
||||
status, out := f.request("POST", "/pools", admin, input, "")
|
||||
expect(t, 200, status, out)
|
||||
pool := out["id"].(string)
|
||||
status, out = f.request("PUT", "/pools/"+pool+"/members", admin, map[string]any{"desktopIds": []string{"desk_demo_ubuntu", "desk_demo_kylin"}}, "")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("PUT", "/pools/"+pool+"/members", admin, map[string]any{"desktopIds": []string{"desk_demo_ubuntu"}}, "")
|
||||
expect(t, 200, status, out)
|
||||
input["name"] = "Pool B"
|
||||
status, out = f.request("POST", "/pools", admin, input, "")
|
||||
expect(t, 200, status, out)
|
||||
other := out["id"].(string)
|
||||
status, out = f.request("PUT", "/pools/"+other+"/members", admin, map[string]any{"desktopIds": []string{"desk_demo_ubuntu"}}, "")
|
||||
expect(t, 409, status, out)
|
||||
input["type"] = "dedicated"
|
||||
input["name"] = "Renamed"
|
||||
status, out = f.request("PUT", "/pools/"+pool, admin, input, "")
|
||||
expect(t, 409, status, out)
|
||||
input["type"] = "shared"
|
||||
status, out = f.request("PUT", "/pools/"+pool, admin, input, "")
|
||||
expect(t, 200, status, out)
|
||||
var name string
|
||||
_ = f.db.Get(&name, "SELECT pool FROM desktops WHERE id='desk_demo_ubuntu'")
|
||||
if name != "Renamed" {
|
||||
t.Fatal("pool display name stale", name)
|
||||
}
|
||||
_, err := f.db.Exec("UPDATE terminals SET session_desktop_id='desk_demo_ubuntu',last_seen=now() WHERE id='term_demo_001'")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, out = f.request("PUT", "/desktops/desk_demo_ubuntu/connection", admin, map[string]any{"endpoint": "192.0.2.1", "width": 1920, "height": 1080, "fps": 30, "initialized": true}, "")
|
||||
expect(t, 409, status, out)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
type pveInput struct {
|
||||
URL string `json:"url" binding:"required,max=250"`
|
||||
TokenID string `json:"tokenId" binding:"required,max=200"`
|
||||
TokenSecret string `json:"tokenSecret" binding:"max=200"`
|
||||
CAPEM string `json:"caPem" binding:"max=32768"`
|
||||
Revision string `json:"revision" binding:"max=80"`
|
||||
}
|
||||
|
||||
// New configuration writes reject fields outside the supported API contract.
|
||||
func (s *Server) decodeConfiguration(c *gin.Context, value any) bool {
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
err := decoder.Decode(value)
|
||||
if err == nil {
|
||||
var trailing any
|
||||
if decoder.Decode(&trailing) != io.EOF {
|
||||
err = errors.New("trailing JSON")
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = binding.Validator.ValidateStruct(value)
|
||||
}
|
||||
if err != nil {
|
||||
s.fail(c, 400, "invalid_request", "请检查必填字段和格式,仅接受当前支持的配置参数")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) cryptPVE(value string, seal bool) (string, error) {
|
||||
key := sha256.Sum256([]byte("vdi-compute-config-v1:" + s.Config.JWTSecret))
|
||||
block, _ := aes.NewCipher(key[:])
|
||||
gcm, _ := cipher.NewGCM(block)
|
||||
aad := []byte("pve_connection:v1")
|
||||
if seal {
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(value), aad)), nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil || len(data) < gcm.NonceSize() {
|
||||
return "", errors.New("invalid encrypted connection")
|
||||
}
|
||||
data, err = gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], aad)
|
||||
return string(data), err
|
||||
}
|
||||
func (s *Server) effectivePVE(ctx context.Context, db sqlx.ExtContext) (pveInput, error) {
|
||||
var row struct {
|
||||
Revision string `db:"revision"`
|
||||
Encrypted string `db:"encrypted_config"`
|
||||
}
|
||||
var in pveInput
|
||||
err := sqlx.GetContext(ctx, db, &row, "SELECT revision,encrypted_config FROM pve_connection WHERE singleton")
|
||||
if err == sql.ErrNoRows {
|
||||
in = pveInput{URL: s.Config.PVEURL, TokenID: s.Config.PVETokenID, TokenSecret: s.Config.PVETokenSecret, Revision: "environment"}
|
||||
if s.Config.PVECAFile != "" {
|
||||
data, e := os.ReadFile(s.Config.PVECAFile)
|
||||
if e != nil {
|
||||
return in, e
|
||||
}
|
||||
in.CAPEM = string(data)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
plain, err := s.cryptPVE(row.Encrypted, false)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
err = json.Unmarshal([]byte(plain), &in)
|
||||
in.Revision = row.Revision
|
||||
return in, err
|
||||
}
|
||||
func (s *Server) currentPVE() *proxmox.Client {
|
||||
if p := s.computeRuntime.Load(); p != nil {
|
||||
return p
|
||||
}
|
||||
return s.PVE
|
||||
}
|
||||
func (s *Server) RefreshCompute(ctx context.Context) error {
|
||||
s.computeMu.Lock()
|
||||
defer s.computeMu.Unlock()
|
||||
var revision string
|
||||
err := s.DB.GetContext(ctx, &revision, "SELECT revision FROM pve_connection WHERE singleton")
|
||||
if err == sql.ErrNoRows {
|
||||
s.computeRuntime.Store(nil)
|
||||
s.computeRevision = ""
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if revision == s.computeRevision {
|
||||
return nil
|
||||
}
|
||||
in, err := s.effectivePVE(ctx, s.DB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := proxmox.NewWithCA(in.URL, in.TokenID, in.TokenSecret, in.CAPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.computeRuntime.Store(client)
|
||||
s.computeRevision = in.Revision
|
||||
return nil
|
||||
}
|
||||
func (s *Server) refreshComputeRequest(c *gin.Context) {
|
||||
if err := s.RefreshCompute(c.Request.Context()); err != nil {
|
||||
s.fail(c, 503, "compute_config_unavailable", "无法加载计算平台配置,请检查服务端密钥和数据库")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
func (s *Server) getPVEConnection(c *gin.Context) {
|
||||
in, err := s.effectivePVE(c.Request.Context(), s.DB)
|
||||
if err != nil {
|
||||
s.fail(c, 503, "compute_config_unavailable", "无法读取接入配置")
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"url": in.URL, "tokenId": in.TokenID, "caPem": in.CAPEM, "revision": in.Revision, "tokenConfigured": in.TokenSecret != "", "source": map[bool]string{true: "environment", false: "database"}[in.Revision == "environment"]})
|
||||
}
|
||||
func (s *Server) resolvePVEInput(c *gin.Context, in *pveInput, db sqlx.ExtContext) bool {
|
||||
old, err := s.effectivePVE(c.Request.Context(), db)
|
||||
if err != nil {
|
||||
s.fail(c, 503, "compute_config_unavailable", "无法读取原接入配置")
|
||||
return false
|
||||
}
|
||||
in.URL = strings.TrimRight(strings.TrimSpace(in.URL), "/")
|
||||
in.TokenID = strings.TrimSpace(in.TokenID)
|
||||
if in.Revision != old.Revision {
|
||||
s.fail(c, 409, "connection_changed", "配置已变化,请重新加载")
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(in.TokenID, "@") || !strings.Contains(in.TokenID, "!") || strings.ContainsFunc(in.TokenID, unicode.IsSpace) || strings.ContainsFunc(in.TokenSecret, unicode.IsControl) {
|
||||
s.fail(c, 400, "invalid_connection", "请输入有效的 API Token ID 和密钥")
|
||||
return false
|
||||
}
|
||||
if in.TokenSecret == "" {
|
||||
if in.TokenID != old.TokenID || in.URL != strings.TrimRight(old.URL, "/") {
|
||||
s.fail(c, 400, "token_required", "更换接入地址或 Token ID 时必须填写新密钥")
|
||||
return false
|
||||
}
|
||||
in.TokenSecret = old.TokenSecret
|
||||
}
|
||||
// Once attached, keep one cluster identity even if inventory is currently empty.
|
||||
// This prevents an in-flight sync/import from writing the previous cluster's data.
|
||||
if old.URL != "" && in.URL != strings.TrimRight(old.URL, "/") {
|
||||
s.fail(c, 409, "compute_in_use", "已配置接入地址,本期仅支持维护同一地址的凭据与证书;切换集群需离线迁移")
|
||||
return false
|
||||
}
|
||||
if in.TokenSecret == "" {
|
||||
s.fail(c, 400, "token_required", "请填写 API Token 密钥")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Server) testPVE(c *gin.Context, in pveInput) bool {
|
||||
client, err := proxmox.NewWithCA(in.URL, in.TokenID, in.TokenSecret, in.CAPEM)
|
||||
if err != nil {
|
||||
s.fail(c, 400, "invalid_connection", "请检查 HTTPS 地址和 PEM CA 证书")
|
||||
return false
|
||||
}
|
||||
resources, err := client.Resources(c.Request.Context())
|
||||
if err != nil {
|
||||
s.fail(c, 502, "pve_connection_failed", "连接检查失败,请检查网络、TLS 证书与 Token 的资源查看权限")
|
||||
return false
|
||||
}
|
||||
nodes, vms := 0, 0
|
||||
for _, r := range resources {
|
||||
if r.Type == "node" {
|
||||
nodes++
|
||||
}
|
||||
if r.Type == "qemu" {
|
||||
vms++
|
||||
}
|
||||
}
|
||||
if nodes == 0 {
|
||||
s.fail(c, 403, "pve_audit_required", "未读取到节点,请核查 Token 的资源查看权限")
|
||||
return false
|
||||
}
|
||||
c.Set("connectionNodes", nodes)
|
||||
c.Set("connectionVMs", vms)
|
||||
return true
|
||||
}
|
||||
func (s *Server) checkPVEConnection(c *gin.Context) {
|
||||
var in pveInput
|
||||
if !s.decodeConfiguration(c, &in) || !s.resolvePVEInput(c, &in, s.DB) || !s.testPVE(c, in) {
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"available": true, "nodes": c.GetInt("connectionNodes"), "vms": c.GetInt("connectionVMs"), "message": "TLS、身份与资源读取检查通过"})
|
||||
}
|
||||
func (s *Server) savePVEConnection(c *gin.Context) {
|
||||
var in pveInput
|
||||
if !s.decodeConfiguration(c, &in) {
|
||||
return
|
||||
}
|
||||
tx, actor := s.accountTransaction(c, true)
|
||||
if tx == nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(c.Request.Context(), "SELECT pg_advisory_xact_lock(76511102027)"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if !s.resolvePVEInput(c, &in, tx) {
|
||||
return
|
||||
}
|
||||
var busy bool
|
||||
if err := tx.GetContext(c.Request.Context(), &busy, "SELECT EXISTS(SELECT 1 FROM tasks WHERE kind IN ('desktop','hardware','snapshot','batch-desktop','provision','resource') AND status IN ('queued','running'))"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if busy {
|
||||
s.fail(c, 409, "compute_busy", "计算任务执行中,请完成后再修改接入配置")
|
||||
return
|
||||
}
|
||||
if !s.testPVE(c, in) {
|
||||
return
|
||||
}
|
||||
in.Revision = store.ID("connection_")
|
||||
plain, _ := json.Marshal(in)
|
||||
encrypted, err := s.cryptPVE(string(plain), true)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(c.Request.Context(), "INSERT INTO pve_connection(singleton,revision,encrypted_config) VALUES(true,$1,$2) ON CONFLICT(singleton) DO UPDATE SET revision=$1,encrypted_config=$2,updated_at=now()", in.Revision, encrypted)
|
||||
}
|
||||
if err == nil {
|
||||
err = accountAudit(c.Request.Context(), tx, actor.Username, "save_pve_connection", "pve", c.GetString("requestId"))
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err = s.RefreshCompute(c.Request.Context()); err != nil {
|
||||
s.fail(c, 503, "compute_reload_failed", "配置已保存,加载失败,请检查服务状态")
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"accepted": true})
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"vdi-cloud/platform/internal/config"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
func TestPVEConnectionEncryptedReloadAndRestrictions(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
admin := f.login("admin")
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Method != "GET" || r.URL.Path != "/api2/json/cluster/resources" {
|
||||
t.Error("unexpected connection write", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "PVEAPIToken=test@pve!console=private-test-token" {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"type": "node", "node": "test-node", "status": "online"}}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
ca := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}))
|
||||
f.server.Config.PVEURL = srv.URL
|
||||
f.server.Config.PVETokenID = "test@pve!console"
|
||||
f.server.Config.PVETokenSecret = "private-test-token"
|
||||
code, out := f.request("GET", "/settings/pve", admin, nil, "")
|
||||
expect(t, 200, code, out)
|
||||
if out["source"] != "environment" || out["tokenConfigured"] != true || out["tokenSecret"] != nil {
|
||||
t.Fatal(out)
|
||||
}
|
||||
body := map[string]any{"url": srv.URL, "tokenId": "test@pve!console", "tokenSecret": "", "caPem": ca, "revision": "environment"}
|
||||
bad := map[string]any{}
|
||||
for k, v := range body {
|
||||
bad[k] = v
|
||||
}
|
||||
bad["caPem"] = ""
|
||||
code, out = f.request("POST", "/settings/pve/check", admin, bad, "")
|
||||
expect(t, 502, code, out)
|
||||
code, out = f.request("POST", "/settings/pve/check", admin, body, "")
|
||||
expect(t, 200, code, out)
|
||||
code, out = f.request("PUT", "/settings/pve", f.login("operator"), body, "")
|
||||
expect(t, 403, code, out)
|
||||
code, out = f.request("PUT", "/settings/pve", admin, body, "")
|
||||
expect(t, 200, code, out)
|
||||
var encrypted string
|
||||
if err := f.db.Get(&encrypted, "SELECT encrypted_config FROM pve_connection"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(encrypted, "private-test-token") || strings.Contains(encrypted, "test@pve") {
|
||||
t.Fatal("plaintext credential persisted")
|
||||
}
|
||||
code, out = f.request("GET", "/settings/pve", admin, nil, "")
|
||||
expect(t, 200, code, out)
|
||||
raw, _ := json.Marshal(out)
|
||||
if bytes.Contains(raw, []byte("private-test-token")) || out["source"] != "database" {
|
||||
t.Fatal("credential leaked", out)
|
||||
}
|
||||
revision := out["revision"]
|
||||
replica := New(f.server.Config, f.db, nil)
|
||||
if err := replica.RefreshCompute(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replica.currentPVE() == nil || replica.currentPVE().TokenSecret != "private-test-token" {
|
||||
t.Fatal("replica failed to load")
|
||||
}
|
||||
code, out = f.request("PUT", "/settings/pve", admin, body, "")
|
||||
expect(t, 409, code, out)
|
||||
body["revision"] = revision
|
||||
body["tokenId"] = "other@pve!console"
|
||||
code, out = f.request("PUT", "/settings/pve", admin, body, "")
|
||||
expect(t, 400, code, out)
|
||||
body["tokenSecret"] = "wrong"
|
||||
code, out = f.request("PUT", "/settings/pve", admin, body, "")
|
||||
expect(t, 502, code, out)
|
||||
body["url"] = "https://different.example:8006"
|
||||
code, out = f.request("PUT", "/settings/pve", admin, body, "")
|
||||
expect(t, 409, code, out)
|
||||
body["url"] = srv.URL
|
||||
body["tokenId"] = "test@pve!console"
|
||||
body["tokenSecret"] = ""
|
||||
code, out = f.request("PUT", "/settings/pve", admin, body, "")
|
||||
expect(t, 200, code, out)
|
||||
if err := replica.RefreshCompute(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replica.computeRevision == revision {
|
||||
t.Fatal("replica kept stale revision")
|
||||
}
|
||||
replica.Config.JWTSecret = strings.Repeat("different", 10)
|
||||
replica.computeRevision = ""
|
||||
if err := replica.RefreshCompute(context.Background()); err == nil {
|
||||
t.Fatal("wrong encryption key accepted")
|
||||
}
|
||||
var leaked bool
|
||||
f.db.Get(&leaked, "SELECT EXISTS(SELECT 1 FROM audit_logs WHERE target LIKE '%private-test-token%' OR action LIKE '%private-test-token%')")
|
||||
if leaked || calls.Load() < 3 {
|
||||
t.Fatal("audit leak or connection never checked")
|
||||
}
|
||||
}
|
||||
|
||||
func loginRecorder(s *Server, username, remote, forwarded string) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(map[string]string{"username": username, "password": "wrong-password"})
|
||||
r := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(raw))
|
||||
r.RemoteAddr = remote
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("X-Forwarded-For", forwarded)
|
||||
w := httptest.NewRecorder()
|
||||
s.Router.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
func TestLoginLimitsPersistentConcurrentAndForwarding(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
for i := 0; i < 10; i++ {
|
||||
w := loginRecorder(f.server, "missing", "192.0.2.1:1000", fmt.Sprintf("198.51.100.%d", i))
|
||||
if w.Code != 401 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
replica := New(f.server.Config, f.db, nil)
|
||||
w := loginRecorder(replica, " MISSING ", "192.0.2.2:1000", "")
|
||||
if w.Code != 429 || w.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("account budget reset", w.Code)
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE login_limits SET window_started=now()-interval '6 minutes'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = loginRecorder(replica, "missing", "192.0.2.1:1000", "")
|
||||
if w.Code != 401 {
|
||||
t.Fatal("window did not reopen")
|
||||
}
|
||||
// Each account is different; spoofed forwarding must not bypass the source budget.
|
||||
for i := 0; i < 29; i++ {
|
||||
w = loginRecorder(f.server, fmt.Sprintf("account-%d", i), "192.0.2.1:1000", fmt.Sprintf("203.0.113.%d", i))
|
||||
if w.Code != 401 {
|
||||
t.Fatal(i, w.Code)
|
||||
}
|
||||
}
|
||||
w = loginRecorder(f.server, "last-account", "192.0.2.1:1000", "203.0.113.250")
|
||||
if w.Code != 429 {
|
||||
t.Fatal("forged forwarding bypassed limit", w.Code)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var limited atomic.Int32
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if loginRecorder(f.server, "parallel", "192.0.2.9:1000", "").Code == 429 {
|
||||
limited.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if limited.Load() != 10 {
|
||||
t.Fatal("non-atomic account budget", limited.Load())
|
||||
}
|
||||
var key string
|
||||
if err := f.db.Get(&key, "SELECT key FROM login_limits LIMIT 1"); err != nil || len(key) != 64 {
|
||||
t.Fatal("cleartext login key")
|
||||
}
|
||||
// Only the explicitly trusted proxy may supply a client address.
|
||||
cfg := config.Config{Mode: "demo", JWTSecret: f.server.Config.JWTSecret, TrustedProxies: []string{"10.20.0.2"}}
|
||||
proxy := New(cfg, f.db, nil)
|
||||
for i := 0; i < 31; i++ {
|
||||
w = loginRecorder(proxy, fmt.Sprintf("proxy-%d", i), "10.20.0.2:80", "198.51.100.10")
|
||||
if i == 30 && w.Code != 429 {
|
||||
t.Fatal("trusted proxy IP budget ignored")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type hardwarePVE struct {
|
||||
mu sync.Mutex
|
||||
config map[string]any
|
||||
state string
|
||||
pending bool
|
||||
deny bool
|
||||
lowSpace bool
|
||||
outcome string
|
||||
posts int
|
||||
forms []map[string]string
|
||||
}
|
||||
|
||||
func fakeHardwarePVE(t *testing.T, f *fixture) *hardwarePVE {
|
||||
p := &hardwarePVE{state: "stopped", config: map[string]any{"digest": "d1", "cores": 2, "sockets": 1, "memory": 2048, "balloon": 1024, "vcpus": 2, "scsi0": "local:vm-9000,size=4G", "hostpci0": "0000:01:00.0", "cpu": "host", "args": "preserved"}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
var data any
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api2/json")
|
||||
switch {
|
||||
case strings.Contains(path, "/tasks/"):
|
||||
if p.outcome == "poll-failure" {
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
data = map[string]any{"status": "stopped", "exitstatus": "OK"}
|
||||
case path == "/access/permissions":
|
||||
yes := 1
|
||||
if p.deny {
|
||||
yes = 0
|
||||
}
|
||||
data = map[string]any{r.URL.Query().Get("path"): map[string]int{"VM.Config.CPU": yes, "VM.Config.Memory": yes, "VM.Config.Disk": yes, "Datastore.AllocateSpace": yes}}
|
||||
case path == "/nodes/node-a/storage":
|
||||
space := float64(100 << 30)
|
||||
if p.lowSpace {
|
||||
space = 0
|
||||
}
|
||||
data = []map[string]any{{"storage": "local", "avail": space, "active": 1, "enabled": 1}}
|
||||
case strings.HasSuffix(path, "/status/current"):
|
||||
data = map[string]any{"status": p.state}
|
||||
case strings.HasSuffix(path, "/pending"):
|
||||
data = []map[string]any{}
|
||||
if p.pending {
|
||||
data = []map[string]any{{"key": "memory", "pending": 4096}}
|
||||
}
|
||||
case strings.HasSuffix(path, "/config") && r.Method == "GET":
|
||||
if r.URL.Query().Get("current") != "1" {
|
||||
t.Error("read pending configuration as current")
|
||||
}
|
||||
data = p.config
|
||||
case strings.HasSuffix(path, "/config") || strings.HasSuffix(path, "/resize"):
|
||||
p.posts++
|
||||
_ = r.ParseForm()
|
||||
form := map[string]string{}
|
||||
for k := range r.Form {
|
||||
form[k] = r.Form.Get(k)
|
||||
}
|
||||
p.forms = append(p.forms, form)
|
||||
if p.outcome == "reject" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
if p.outcome == "ambiguous" {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(path, "/resize") {
|
||||
if r.Method != "PUT" || form["disk"] != "scsi0" || form["size"] != "8G" {
|
||||
t.Error("unsafe disk resize", r.Method, form)
|
||||
}
|
||||
p.config["scsi0"] = "local:vm-9000,size=8G"
|
||||
} else {
|
||||
if r.Method != "POST" {
|
||||
t.Error("wrong config method")
|
||||
}
|
||||
for _, key := range []string{"cores", "sockets", "memory", "vcpus"} {
|
||||
if v := form[key]; v != "" {
|
||||
var n int
|
||||
fmt.Sscan(v, &n)
|
||||
p.config[key] = n
|
||||
}
|
||||
}
|
||||
for key := range form {
|
||||
if !strings.Contains("|cores|sockets|memory|vcpus|digest|", "|"+key+"|") {
|
||||
t.Error("non-whitelisted config write", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.config["digest"] = "d2"
|
||||
data = "UPID:hardware-test"
|
||||
default:
|
||||
t.Error("unexpected hardware request", r.Method, path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox',node='node-a',vmid=9000,power_state='stopped' WHERE id='desk_demo_windows'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
func runHardwareTask(t *testing.T, f *fixture, admin string, body map[string]any, key string) model.Task {
|
||||
code, out := f.request("POST", "/desktops/desk_demo_windows/hardware", admin, body, key)
|
||||
expect(t, 202, code, out)
|
||||
var task model.Task
|
||||
if err := f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", out["id"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.server.execute(context.Background(), task)
|
||||
if err := f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
func TestHardwarePreflightTasksAndDiskAbsoluteSize(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeHardwarePVE(t, f)
|
||||
admin := f.login("admin")
|
||||
body := map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"}
|
||||
endpoint := "/desktops/desk_demo_windows/hardware"
|
||||
code, out := f.request("GET", endpoint, f.login("viewer"), nil, "")
|
||||
expect(t, 200, code, out)
|
||||
code, out = f.request("POST", endpoint+"/check", f.login("operator"), body, "")
|
||||
expect(t, 403, code, out)
|
||||
for _, block := range []string{"running", "pending", "lock", "balloon", "digest", "deny"} {
|
||||
p.mu.Lock()
|
||||
switch block {
|
||||
case "running":
|
||||
p.state = "running"
|
||||
case "pending":
|
||||
p.pending = true
|
||||
case "lock":
|
||||
p.config["lock"] = "backup"
|
||||
case "balloon":
|
||||
p.config["balloon"] = 8192
|
||||
case "digest":
|
||||
p.config["digest"] = "changed"
|
||||
case "deny":
|
||||
p.deny = true
|
||||
}
|
||||
p.mu.Unlock()
|
||||
code, out = f.request("POST", endpoint+"/check", admin, body, "")
|
||||
expect(t, 409, code, out)
|
||||
p.mu.Lock()
|
||||
p.state = "stopped"
|
||||
p.pending = false
|
||||
delete(p.config, "lock")
|
||||
p.config["balloon"] = 1024
|
||||
p.config["digest"] = "d1"
|
||||
p.deny = false
|
||||
p.mu.Unlock()
|
||||
}
|
||||
body["cpu"] = "malicious"
|
||||
code, out = f.request("POST", endpoint, admin, body, "unknown-field")
|
||||
expect(t, 400, code, out)
|
||||
delete(body, "cpu")
|
||||
task := runHardwareTask(t, f, admin, body, "compute-once")
|
||||
if task.Status != "success" {
|
||||
t.Fatal(task)
|
||||
}
|
||||
var submitted int
|
||||
if err := f.db.Get(&submitted, "SELECT count(*) FROM audit_logs WHERE action='configure-hardware' AND result='submitted'"); err != nil || submitted != 1 {
|
||||
t.Fatal("submission audit is not distinct from completion", submitted, err)
|
||||
}
|
||||
code, out = f.request("POST", endpoint, admin, body, "compute-once")
|
||||
expect(t, 202, code, out)
|
||||
if out["id"] != task.ID {
|
||||
t.Fatal("replay lost task")
|
||||
}
|
||||
body["memoryMiB"] = 8192
|
||||
code, out = f.request("POST", endpoint, admin, body, "compute-once")
|
||||
expect(t, 409, code, out)
|
||||
p.mu.Lock()
|
||||
if p.posts != 1 || p.config["hostpci0"] != "0000:01:00.0" || p.config["cpu"] != "host" || p.config["args"] != "preserved" {
|
||||
t.Fatal("hardware changed twice or passthrough altered")
|
||||
}
|
||||
p.mu.Unlock()
|
||||
disk := map[string]any{"kind": "disk", "disk": "scsi0", "sizeGiB": 8, "digest": "d2"}
|
||||
p.mu.Lock()
|
||||
p.lowSpace = true
|
||||
p.mu.Unlock()
|
||||
code, out = f.request("POST", endpoint+"/check", admin, disk, "")
|
||||
expect(t, 409, code, out)
|
||||
p.mu.Lock()
|
||||
p.lowSpace = false
|
||||
p.mu.Unlock()
|
||||
task = runHardwareTask(t, f, admin, disk, "disk-once")
|
||||
if task.Status != "success" {
|
||||
t.Fatal(task)
|
||||
}
|
||||
code, out = f.request("POST", endpoint+"/check", admin, disk, "")
|
||||
expect(t, 409, code, out)
|
||||
}
|
||||
func TestHardwareUnknownRestartReconcileAndExpiration(t *testing.T) {
|
||||
for _, outcome := range []string{"ambiguous", "poll-failure", "reject"} {
|
||||
t.Run(outcome, func(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeHardwarePVE(t, f)
|
||||
p.outcome = outcome
|
||||
admin := f.login("admin")
|
||||
body := map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"}
|
||||
task := runHardwareTask(t, f, admin, body, "unknown")
|
||||
want := "timeout"
|
||||
if outcome == "reject" {
|
||||
want = "failed"
|
||||
}
|
||||
if task.Status != want {
|
||||
t.Fatal(task)
|
||||
}
|
||||
if want == "failed" {
|
||||
return
|
||||
}
|
||||
code, out := f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "power")
|
||||
expect(t, 409, code, out)
|
||||
code, out = f.request("POST", "/desktops/desk_demo_windows/hardware", admin, body, "another")
|
||||
expect(t, 409, code, out)
|
||||
// A worker restarted after submitting must never issue the mutation again.
|
||||
if _, err := f.db.Exec("UPDATE tasks SET status='running' WHERE id=$1", task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.server.execute(context.Background(), task)
|
||||
p.mu.Lock()
|
||||
if p.posts != 1 {
|
||||
t.Fatal("hardware replayed")
|
||||
}
|
||||
p.outcome = ""
|
||||
p.mu.Unlock()
|
||||
code, out = f.request("POST", "/tasks/"+task.ID+"/hardware-reconcile", admin, nil, "")
|
||||
if outcome == "poll-failure" {
|
||||
expect(t, 200, code, out)
|
||||
} else {
|
||||
expect(t, 409, code, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
f := newFixtureWorker(t, false)
|
||||
fakeHardwarePVE(t, f)
|
||||
admin := f.login("admin")
|
||||
body := map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"}
|
||||
code, out := f.request("POST", "/desktops/desk_demo_windows/hardware", admin, body, "expire")
|
||||
expect(t, 202, code, out)
|
||||
var task model.Task
|
||||
_ = f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", out["id"])
|
||||
f.server.finishHardware(task, "", "expired", "")
|
||||
var stage string
|
||||
_ = f.db.Get(&stage, "SELECT stage FROM hardware_jobs WHERE task_id=$1", task.ID)
|
||||
if stage != "failed" {
|
||||
t.Fatal(stage)
|
||||
}
|
||||
claimed, err := f.server.claimHardware(context.Background(), task)
|
||||
if err != nil || claimed {
|
||||
t.Fatal("expired task could submit", err)
|
||||
}
|
||||
// Power and specification submissions share a target lock and unique active slot.
|
||||
code, out = f.request("POST", "/desktops/desk_demo_windows/hardware", admin, body, "submit-expire")
|
||||
expect(t, 202, code, out)
|
||||
if err = f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", out["id"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out = f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "blocked-power")
|
||||
expect(t, 409, code, out)
|
||||
claimed, err = f.server.claimHardware(context.Background(), task)
|
||||
if err != nil || !claimed {
|
||||
t.Fatal("could not claim prepared task", err)
|
||||
}
|
||||
f.server.finishHardware(task, "", "expired", "")
|
||||
if err = f.db.Get(&stage, "SELECT stage FROM hardware_jobs WHERE task_id=$1", task.ID); err != nil || stage != "uncertain" {
|
||||
t.Fatal(stage, err)
|
||||
}
|
||||
// Even if inventory still says running, uncertain specifications block new tickets.
|
||||
if _, err = f.db.Exec("UPDATE desktops SET power_state='running' WHERE id='desk_demo_windows'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = f.db.Exec("UPDATE terminals SET token_hash=$1,last_seen=now() WHERE id='term_demo_001'", store.Hash("hardware-device")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out = f.request("POST", "/terminal/connect-ticket", "hardware-device", map[string]string{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 409, code, out)
|
||||
if out["code"] != "hardware_unconfirmed" {
|
||||
t.Fatal("ticket did not check hardware maintenance", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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"`
|
||||
References []gpuReference `json:"references"`
|
||||
}
|
||||
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{}
|
||||
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
|
||||
}
|
||||
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, 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": time.Now().UTC(), "complete": len(issues) == 0, "visibleVmCount": visibleCount, "scannedVmCount": scannedCount, "items": items, "issues": issues})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
func fakeGPUPVE(t *testing.T, f *fixture, mode string) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("GPU endpoint attempted write: %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api2/json")
|
||||
var data any
|
||||
switch path {
|
||||
case "/cluster/resources":
|
||||
if mode == "cluster-failed" {
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
data = []map[string]any{{"type": "node", "node": "node-a", "name": "node-a", "status": "online"}, {"type": "qemu", "node": "node-a", "vmid": 8000, "name": "managed-windows", "status": "stopped"}, {"type": "qemu", "node": "node-a", "vmid": 8001, "name": "unmanaged-ubuntu", "status": "running"}}
|
||||
case "/nodes/node-a/hardware/pci":
|
||||
if mode == "pci-denied" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
data = []map[string]any{{"id": "0000:00:02.0", "class": "0x030000", "device_name": "Intel GPU", "vendor_name": "Intel", "iommugroup": 0}, {"id": "0000:01:00.0", "class": "0x030200", "device_name": "NVIDIA GPU", "vendor": "0x10de", "iommugroup": 2}, {"id": "0000:01:00.1", "class": "0x040300", "device_name": "Audio"}, {"id": "0000:02:00.0", "class": "0x030000", "device_name": "AMD GPU", "iommugroup": -1}}
|
||||
case "/access/permissions":
|
||||
allowed := 1
|
||||
if mode == "partial-visibility" {
|
||||
allowed = 0
|
||||
}
|
||||
data = map[string]any{"/": map[string]int{"VM.Audit": allowed}}
|
||||
case "/nodes/node-a/qemu/8000/pending":
|
||||
value := "01:00,pcie=1"
|
||||
if mode == "mapping" || mode == "mapping-missing" || mode == "mapping-candidates" {
|
||||
value = "mapping=gpu-map"
|
||||
}
|
||||
if mode == "malformed" {
|
||||
value = "host=invalid,SECRET=do-not-return"
|
||||
}
|
||||
rows := []map[string]any{{"key": "hostpci0", "value": value, "pending": "host=02:00.0", "delete": 1}}
|
||||
if mode == "custom" {
|
||||
rows = append(rows, map[string]any{"key": "args", "value": "-device vfio-pci,host=00:02.0 SECRET-do-not-return"})
|
||||
}
|
||||
data = rows
|
||||
case "/nodes/node-a/qemu/8001/pending":
|
||||
if mode == "config-failed" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
data = []map[string]any{}
|
||||
if mode == "multiple" {
|
||||
data = []map[string]any{{"key": "hostpci0", "value": "0000:01:00.0,mdev=test"}}
|
||||
}
|
||||
case "/cluster/mapping/pci":
|
||||
if mode == "mapping-missing" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
entries := []string{"node=node-a,path=0000:01:00.0,id=10de:1234", "node=node-b,path=0000:03:00.0,id=10de:1234"}
|
||||
if mode == "mapping-candidates" {
|
||||
entries = append(entries, "node=node-a,path=0000:00:02.0,id=8086:1234")
|
||||
}
|
||||
data = []map[string]any{{"id": "gpu-map", "map": entries}}
|
||||
default:
|
||||
t.Errorf("unexpected GPU PVE read: %s", path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
}
|
||||
|
||||
func TestGPUInventoryReadOnlyCoverage(t *testing.T) {
|
||||
for _, mode := range []string{"normal", "partial-visibility", "config-failed", "mapping", "mapping-missing", "mapping-candidates", "custom", "malformed", "multiple"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
fakeGPUPVE(t, f, mode)
|
||||
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox',node='node-a',vmid=8000 WHERE id='desk_demo_windows'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out := f.request("GET", "/resources/gpus?node=node-a", f.login("viewer"), nil, "")
|
||||
expect(t, 200, code, out)
|
||||
raw, _ := json.Marshal(out)
|
||||
if strings.Contains(string(raw), "SECRET") {
|
||||
t.Fatal("raw VM config leaked")
|
||||
}
|
||||
var view struct {
|
||||
Complete bool
|
||||
VisibleVMCount int
|
||||
ScannedVMCount int
|
||||
Items []gpuItem
|
||||
Issues []gpuIssue
|
||||
}
|
||||
if err := json.Unmarshal(raw, &view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(view.Items) != 3 || view.VisibleVMCount != 2 || view.Items[0].IOMMUGroup == nil || *view.Items[0].IOMMUGroup != 0 || view.Items[2].IOMMUGroup != nil {
|
||||
t.Fatalf("incorrect inventory %s", raw)
|
||||
}
|
||||
complete := mode == "normal" || mode == "mapping" || mode == "multiple"
|
||||
if view.Complete != complete || (len(view.Issues) == 0) != complete {
|
||||
t.Fatalf("coverage state %s", raw)
|
||||
}
|
||||
if complete && view.Items[0].State != "unreferenced" {
|
||||
t.Fatalf("unreferenced classification %s", raw)
|
||||
}
|
||||
if mode == "config-failed" && view.ScannedVMCount != 1 {
|
||||
t.Fatalf("failed config counted as scanned %s", raw)
|
||||
}
|
||||
if mode == "custom" || mode == "partial-visibility" || mode == "config-failed" || mode == "mapping-missing" || mode == "malformed" {
|
||||
if view.Items[0].State != "unknown" {
|
||||
t.Fatalf("unknown classified as free %s", raw)
|
||||
}
|
||||
}
|
||||
if mode == "multiple" {
|
||||
if view.Items[1].State != "multiple" || len(view.Items[1].References) != 2 {
|
||||
t.Fatalf("multiple references lost %s", raw)
|
||||
}
|
||||
}
|
||||
if mode == "normal" || mode == "mapping" {
|
||||
owner := view.Items[1].References[0]
|
||||
if owner.DesktopID != "desk_demo_windows" || !owner.PendingRemoval || owner.Phase != "current" || owner.VMStatus != "stopped" {
|
||||
t.Fatalf("owner metadata %s", raw)
|
||||
}
|
||||
if view.Items[2].References[0].Phase != "pending" {
|
||||
t.Fatalf("pending reservation lost %s", raw)
|
||||
}
|
||||
if mode == "mapping" && (owner.Source != "mapping" || owner.Mapping != "gpu-map") {
|
||||
t.Fatalf("mapping owner %s", raw)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := f.db.Get(&count, "SELECT count(*) FROM tasks"); err != nil || count != 0 {
|
||||
t.Fatalf("read created tasks: %d %v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUInventoryErrorsAndAuthentication(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
admin := f.login("admin")
|
||||
code, out := f.request("GET", "/resources/gpus?node=node-a", "", nil, "")
|
||||
expect(t, 401, code, out)
|
||||
code, out = f.request("GET", "/resources/gpus?node=node-a", admin, nil, "")
|
||||
expect(t, 503, code, out)
|
||||
for _, mode := range []string{"pci-denied", "cluster-failed", "normal"} {
|
||||
fakeGPUPVE(t, f, mode)
|
||||
code, out = f.request("GET", "/resources/gpus?node=node-a", admin, nil, "")
|
||||
want := 502
|
||||
if mode == "normal" {
|
||||
want = 200
|
||||
}
|
||||
expect(t, want, code, out)
|
||||
}
|
||||
code, out = f.request("GET", "/resources/gpus", admin, nil, "")
|
||||
expect(t, 400, code, out)
|
||||
code, out = f.request("GET", "/resources/gpus?node=unknown", admin, nil, "")
|
||||
expect(t, 404, code, out)
|
||||
code, out = f.request("GET", "/resources/gpus?node=..%2Fsecret", admin, nil, "")
|
||||
expect(t, 404, code, out)
|
||||
r := httptest.NewRequest("POST", "/api/v1/resources/gpus?node=node-a", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+admin)
|
||||
w := httptest.NewRecorder()
|
||||
f.server.Router.ServeHTTP(w, r)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("GPU write route exists: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
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, ¤t, "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
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
type importInput struct {
|
||||
Kind string `json:"kind" binding:"required,oneof=users terminals bindings"`
|
||||
CSV string `json:"csv" binding:"required,max=524288"`
|
||||
PreviewToken string `json:"previewToken"`
|
||||
}
|
||||
type importRow struct {
|
||||
Line int `json:"line"`
|
||||
Key string `json:"key"`
|
||||
Values map[string]string `json:"values"`
|
||||
Errors []string `json:"errors"`
|
||||
userID, terminalID string
|
||||
desktops []string
|
||||
state string
|
||||
}
|
||||
type importPreview struct {
|
||||
Kind string `json:"kind"`
|
||||
TotalRows int `json:"totalRows"`
|
||||
ValidRows int `json:"validRows"`
|
||||
ErrorRows int `json:"errorRows"`
|
||||
PreviewToken string `json:"previewToken"`
|
||||
Rows []importRow `json:"rows"`
|
||||
}
|
||||
type importItem struct {
|
||||
Line int `json:"line"`
|
||||
Key string `json:"key"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
type activationItem struct {
|
||||
SN string `json:"sn"`
|
||||
ActivationSecret string `json:"activationSecret"`
|
||||
}
|
||||
type importResult struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Count int `json:"count"`
|
||||
Items []importItem `json:"items"`
|
||||
ActivationSecrets []activationItem `json:"activationSecrets"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
func importText(v string, min, max int) bool {
|
||||
n := utf8.RuneCountInString(v)
|
||||
return n >= min && n <= max && !strings.ContainsFunc(v, func(r rune) bool { return unicode.IsControl(r) })
|
||||
}
|
||||
func parseImport(in importInput) ([]importRow, string) {
|
||||
if !utf8.ValidString(in.CSV) || len(in.CSV) > 524288 {
|
||||
return nil, "请使用不超过 512 KiB 的 UTF-8 CSV 文件"
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(strings.TrimPrefix(in.CSV, "\ufeff")))
|
||||
r.FieldsPerRecord = -1
|
||||
header, err := r.Read()
|
||||
if err != nil {
|
||||
return nil, "无法读取 CSV 表头"
|
||||
}
|
||||
required := map[string][]string{"users": {"username", "displayName"}, "terminals": {"sn", "name", "group"}, "bindings": {"username", "terminalSn", "desktopIds"}}[in.Kind]
|
||||
if len(header) != len(required) {
|
||||
return nil, "CSV 表头必须为:" + strings.Join(required, ",")
|
||||
}
|
||||
positions := map[string]int{}
|
||||
for i, h := range header {
|
||||
h = strings.TrimSpace(h)
|
||||
if _, ok := positions[h]; ok {
|
||||
return nil, "CSV 表头重复"
|
||||
}
|
||||
positions[h] = i
|
||||
}
|
||||
for _, h := range required {
|
||||
if _, ok := positions[h]; !ok {
|
||||
return nil, "CSV 表头必须为:" + strings.Join(required, ",")
|
||||
}
|
||||
}
|
||||
rows := []importRow{}
|
||||
seen := map[string]bool{}
|
||||
seenSN := map[string]bool{}
|
||||
for {
|
||||
cells, err := r.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "CSV 引号或字段格式错误,请检查文件"
|
||||
}
|
||||
if len(rows) >= 200 {
|
||||
return nil, "每次最多导入 200 行,请拆分文件"
|
||||
}
|
||||
line, _ := r.FieldPos(0)
|
||||
row := importRow{Line: line, Values: map[string]string{}, Errors: []string{}}
|
||||
if len(cells) != len(header) {
|
||||
row.Errors = append(row.Errors, "字段数量与表头不一致")
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
for h, i := range positions {
|
||||
row.Values[h] = strings.TrimSpace(cells[i])
|
||||
}
|
||||
row.Key = row.Values["username"]
|
||||
if in.Kind == "terminals" {
|
||||
row.Key = row.Values["sn"]
|
||||
}
|
||||
if seen[row.Key] {
|
||||
row.Errors = append(row.Errors, "文件内账号或 SN 重复")
|
||||
}
|
||||
seen[row.Key] = true
|
||||
if in.Kind == "users" {
|
||||
if !importText(row.Key, 2, 80) || !importText(row.Values["displayName"], 1, 80) {
|
||||
row.Errors = append(row.Errors, "账号需 2~80 字符,姓名需 1~80 字符,不能包含控制字符")
|
||||
}
|
||||
} else if in.Kind == "terminals" {
|
||||
if !importText(row.Key, 3, 100) || !importText(row.Values["name"], 1, 80) || !importText(row.Values["group"], 0, 80) {
|
||||
row.Errors = append(row.Errors, "SN 需 3~100 字符,名称需 1~80 字符,分组最多 80 字符,不能包含控制字符")
|
||||
}
|
||||
} else {
|
||||
sn := row.Values["terminalSn"]
|
||||
if !importText(row.Key, 2, 80) || !importText(sn, 0, 100) {
|
||||
row.Errors = append(row.Errors, "用户账号或终端 SN 格式无效")
|
||||
}
|
||||
if sn != "" {
|
||||
if seenSN[sn] {
|
||||
row.Errors = append(row.Errors, "文件内同一终端不能分配给多位用户")
|
||||
}
|
||||
seenSN[sn] = true
|
||||
}
|
||||
if row.Values["desktopIds"] != "" {
|
||||
row.desktops = uniqueIDs(strings.Split(row.Values["desktopIds"], ";"))
|
||||
for _, id := range row.desktops {
|
||||
if !importText(id, 1, 100) {
|
||||
row.Errors = append(row.Errors, "桌面 ID 格式无效,多个 ID 使用英文分号分隔")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(row.desktops) > 100 {
|
||||
row.Errors = append(row.Errors, "每位用户最多追加 100 台桌面")
|
||||
}
|
||||
if sn == "" && len(row.desktops) == 0 {
|
||||
row.Errors = append(row.Errors, "终端 SN 和桌面 ID 至少填写一项")
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, "CSV 没有数据行"
|
||||
}
|
||||
return rows, ""
|
||||
}
|
||||
func (s *Server) validateImport(c *gin.Context, tx *sqlx.Tx, in importInput) (importPreview, bool) {
|
||||
rows, message := parseImport(in)
|
||||
if message != "" {
|
||||
s.fail(c, 400, "invalid_csv", message)
|
||||
return importPreview{}, false
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
state := []string{}
|
||||
for i := range rows {
|
||||
row := &rows[i]
|
||||
if len(row.Errors) > 0 {
|
||||
continue
|
||||
}
|
||||
if in.Kind == "users" || in.Kind == "terminals" {
|
||||
var exists bool
|
||||
query := "SELECT EXISTS(SELECT 1 FROM users WHERE username=$1)"
|
||||
if in.Kind == "terminals" {
|
||||
query = "SELECT EXISTS(SELECT 1 FROM terminals WHERE sn=$1)"
|
||||
}
|
||||
if err := tx.GetContext(ctx, &exists, query, row.Key); err != nil {
|
||||
s.dbError(c, err)
|
||||
return importPreview{}, false
|
||||
}
|
||||
if exists {
|
||||
row.Errors = append(row.Errors, "该账号或 SN 已存在;导入仅新增,不覆盖现有资料")
|
||||
}
|
||||
} else {
|
||||
var u struct {
|
||||
ID string `db:"id"`
|
||||
TerminalID string `db:"terminal_id"`
|
||||
Enabled bool `db:"enabled"`
|
||||
}
|
||||
err := tx.GetContext(ctx, &u, "SELECT id,COALESCE(terminal_id,'') terminal_id,enabled FROM users WHERE username=$1", row.Key)
|
||||
if err == sql.ErrNoRows {
|
||||
row.Errors = append(row.Errors, "用户不存在,请先导入用户")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return importPreview{}, false
|
||||
}
|
||||
row.userID = u.ID
|
||||
oldDesktops := []string{}
|
||||
if err = tx.SelectContext(ctx, &oldDesktops, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", u.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return importPreview{}, false
|
||||
}
|
||||
snapshot, _ := json.Marshal([]any{u.ID, u.TerminalID, u.Enabled, oldDesktops})
|
||||
row.state = string(snapshot)
|
||||
if sn := row.Values["terminalSn"]; sn != "" {
|
||||
var t struct {
|
||||
ID string `db:"id"`
|
||||
Enabled bool `db:"enabled"`
|
||||
Owner string `db:"owner"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &t, "SELECT t.id,t.enabled,COALESCE(u.id,'') owner FROM terminals t LEFT JOIN users u ON u.terminal_id=t.id WHERE t.sn=$1", sn)
|
||||
if err == sql.ErrNoRows {
|
||||
row.Errors = append(row.Errors, "终端 SN 不存在")
|
||||
} else if err != nil {
|
||||
s.dbError(c, err)
|
||||
return importPreview{}, false
|
||||
} else {
|
||||
if !t.Enabled {
|
||||
row.Errors = append(row.Errors, "终端已停用")
|
||||
}
|
||||
if t.Owner != "" && t.Owner != u.ID {
|
||||
row.Errors = append(row.Errors, "该终端已关联其他用户")
|
||||
}
|
||||
if u.TerminalID != "" && u.TerminalID != t.ID {
|
||||
row.Errors = append(row.Errors, "该用户已关联其他终端,请在用户页面明确换绑")
|
||||
}
|
||||
row.terminalID = t.ID
|
||||
row.state += "|" + t.ID
|
||||
}
|
||||
}
|
||||
if len(row.desktops) > 0 {
|
||||
var count int
|
||||
if err = tx.GetContext(ctx, &count, "SELECT count(*) FROM desktops WHERE id=ANY($1)", row.desktops); err != nil {
|
||||
s.dbError(c, err)
|
||||
return importPreview{}, false
|
||||
}
|
||||
if count != len(row.desktops) {
|
||||
row.Errors = append(row.Errors, "存在未纳管的桌面 ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
state = append(state, row.state)
|
||||
}
|
||||
out := importPreview{Kind: in.Kind, TotalRows: len(rows), Rows: rows}
|
||||
for _, row := range rows {
|
||||
if len(row.Errors) == 0 {
|
||||
out.ValidRows++
|
||||
} else {
|
||||
out.ErrorRows++
|
||||
}
|
||||
}
|
||||
if out.ErrorRows == 0 {
|
||||
raw, _ := json.Marshal([]any{in.Kind, in.CSV, state})
|
||||
mac := hmac.New(sha256.New, []byte(s.Config.JWTSecret))
|
||||
mac.Write(raw)
|
||||
out.PreviewToken = hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
func (s *Server) previewImport(c *gin.Context) {
|
||||
var in importInput
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(c.Request.Context(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if out, ok := s.validateImport(c, tx, in); ok {
|
||||
c.JSON(200, out)
|
||||
}
|
||||
}
|
||||
func (s *Server) commitImport(c *gin.Context) {
|
||||
var in importInput
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||
if key == "" || len(key) > 100 || len(in.PreviewToken) != 64 {
|
||||
s.fail(c, 400, "invalid_request", "请先完成预检,并携带有效请求标识")
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal([]string{in.Kind, in.CSV})
|
||||
hash := store.Hash(string(raw))
|
||||
ctx := c.Request.Context()
|
||||
actor := s.admin(c)
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if !s.lockAuthorization(c, tx) {
|
||||
return
|
||||
}
|
||||
var previous struct {
|
||||
Hash string `db:"request_hash"`
|
||||
Result []byte `db:"result"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &previous, "SELECT request_hash,result FROM import_batches WHERE actor_id=$1 AND idempotency_key=$2", actor.ID, key)
|
||||
if err == nil {
|
||||
if hash != previous.Hash {
|
||||
s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他导入内容")
|
||||
return
|
||||
}
|
||||
var out importResult
|
||||
if err = json.Unmarshal(previous.Result, &out); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
out.Replayed = true
|
||||
c.JSON(200, out)
|
||||
return
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
err = nil
|
||||
preview, ok := s.validateImport(c, tx, in)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if preview.ErrorRows > 0 || !hmac.Equal([]byte(preview.PreviewToken), []byte(in.PreviewToken)) {
|
||||
s.fail(c, 409, "import_changed", "数据或授权状态已变化,请重新预检;本次没有导入任何行")
|
||||
return
|
||||
}
|
||||
out := importResult{ID: store.ID("import_"), Kind: in.Kind, Count: len(preview.Rows), Items: []importItem{}, ActivationSecrets: []activationItem{}}
|
||||
for _, row := range preview.Rows {
|
||||
id := row.userID
|
||||
switch in.Kind {
|
||||
case "users":
|
||||
id = store.ID("user_")
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, row.Key, row.Values["displayName"])
|
||||
case "terminals":
|
||||
id = store.ID("term_")
|
||||
secret := store.ID("activate_")
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, row.Key, row.Values["name"], row.Values["group"], store.Hash(secret))
|
||||
out.ActivationSecrets = append(out.ActivationSecrets, activationItem{row.Key, secret})
|
||||
case "bindings":
|
||||
if row.terminalID != "" {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1 WHERE id=$2", row.terminalID, row.userID)
|
||||
}
|
||||
if err == nil && len(row.desktops) > 0 {
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING", row.userID, row.desktops)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", row.userID)
|
||||
}
|
||||
}
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "import_changed", "数据已变化,请重新预检;本次没有导入任何行")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
out.Items = append(out.Items, importItem{row.Line, row.Key, id})
|
||||
}
|
||||
// Keep the reusable result, never plaintext activation credentials or source CSV.
|
||||
safe := out
|
||||
safe.ActivationSecrets = []activationItem{}
|
||||
result, _ := json.Marshal(safe)
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO import_batches(id,actor_id,kind,row_count,idempotency_key,request_hash,result) VALUES($1,$2,$3,$4,$5,$6,$7)", out.ID, actor.ID, in.Kind, out.Count, key, hash, string(result))
|
||||
if err == nil {
|
||||
err = accountAudit(ctx, tx, actor.Username, "import_"+in.Kind, out.ID, c.GetString("requestId"))
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, out)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"strings"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
const terminalListSQL = `SELECT id,sn,name,group_name,enabled,
|
||||
CASE WHEN NOT enabled THEN 'disabled' WHEN status='unregistered' THEN 'unregistered'
|
||||
WHEN last_seen>now()-interval '90 seconds' THEN 'online' ELSE 'offline' END status,
|
||||
agent_version,last_seen,CASE WHEN enabled AND last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,
|
||||
session_state,config_version,fps,rtt FROM terminals`
|
||||
|
||||
// Only authorization and identity changes share this lock. Device heartbeats and
|
||||
// command delivery use their own terminal row and remain independent.
|
||||
func (s *Server) lockAuthorization(c *gin.Context, tx *sqlx.Tx) bool {
|
||||
if _, err := tx.ExecContext(c.Request.Context(), "SELECT pg_advisory_xact_lock(7651092029)"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) updateTerminal(c *gin.Context) {
|
||||
var in struct {
|
||||
Name string `json:"name" binding:"required,max=80"`
|
||||
Group string `json:"group" binding:"max=80"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.Group = strings.TrimSpace(in.Group)
|
||||
if in.Name == "" {
|
||||
s.fail(c, 400, "invalid_request", "终端名称不能为空")
|
||||
return
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var id string
|
||||
if err = tx.GetContext(c.Request.Context(), &id, "UPDATE terminals SET name=$1,group_name=$2 WHERE id=$3 RETURNING id", in.Name, in.Group, c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err = accountAudit(c.Request.Context(), tx, s.admin(c).Username, "update_terminal", id, c.GetString("requestId")); err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
func (s *Server) disableTerminal(c *gin.Context) { s.resetTerminalIdentity(c, false) }
|
||||
func (s *Server) reactivateTerminal(c *gin.Context) { s.resetTerminalIdentity(c, true) }
|
||||
func (s *Server) resetTerminalIdentity(c *gin.Context, reactivate bool) {
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if !s.lockAuthorization(c, tx) {
|
||||
return
|
||||
}
|
||||
var state struct {
|
||||
ID string `db:"id"`
|
||||
Enabled bool `db:"enabled"`
|
||||
Active bool `db:"active"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &state, "SELECT id,enabled,(session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') active FROM terminals WHERE id=$1 FOR UPDATE", c.Param("id"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if !state.Enabled && !reactivate {
|
||||
c.JSON(200, gin.H{"accepted": true})
|
||||
return
|
||||
}
|
||||
if state.Active {
|
||||
s.fail(c, 409, "terminal_in_use", "终端有活动会话或连接预约,请先结束或重置会话")
|
||||
return
|
||||
}
|
||||
var pending bool
|
||||
if err = tx.GetContext(ctx, &pending, "SELECT EXISTS(SELECT 1 FROM tasks WHERE kind='terminal' AND target_id=$1 AND status IN ('queued','running'))", state.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if pending {
|
||||
s.fail(c, 409, "terminal_task_pending", "终端有未完成命令,请等待回执或核对超时结果")
|
||||
return
|
||||
}
|
||||
secret, bootstrap, status, action := "", "", "disabled", "disable_terminal"
|
||||
if reactivate {
|
||||
secret = store.ID("activate_")
|
||||
bootstrap = store.Hash(secret)
|
||||
status = "unregistered"
|
||||
action = "reactivate_terminal"
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE terminals SET enabled=$1,status=$2,token_hash=NULL,bootstrap_hash=$3,
|
||||
last_seen=NULL,agent_version='',fps=NULL,rtt=NULL,session_id='',session_state='idle',session_desktop_id=NULL WHERE id=$4`, reactivate, status, bootstrap, state.ID)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE terminal_id=$1", state.ID)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM terminal_policy_capabilities WHERE terminal_id=$1", state.ID)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM terminal_metrics_current WHERE terminal_id=$1", state.ID)
|
||||
}
|
||||
if err == nil {
|
||||
err = closeSessionRecords(ctx, tx, state.ID, "identity_reset")
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE terminal_policy_deliveries SET status='pending',error_code='',acknowledged_at=NULL WHERE terminal_id=$1 AND config_version=(SELECT config_version FROM terminals WHERE id=$1)`, state.ID)
|
||||
}
|
||||
if err == nil {
|
||||
err = accountAudit(ctx, tx, s.admin(c).Username, action, state.ID, c.GetString("requestId"))
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if reactivate {
|
||||
c.JSON(200, gin.H{"id": state.ID, "activationSecret": secret})
|
||||
} else {
|
||||
c.JSON(200, gin.H{"accepted": true})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) updateUser(c *gin.Context) {
|
||||
var in struct {
|
||||
DisplayName string `json:"displayName" binding:"required,max=80"`
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.DisplayName = strings.TrimSpace(in.DisplayName)
|
||||
if in.DisplayName == "" {
|
||||
s.fail(c, 400, "invalid_request", "用户姓名不能为空")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if !s.lockAuthorization(c, tx) {
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
err = tx.GetContext(ctx, &user, "UPDATE users SET display_name=$1,enabled=$2 WHERE id=$3 RETURNING *", in.DisplayName, *in.Enabled, c.Param("id"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE terminal_id=$1 AND consumed_at IS NULL", user.TerminalID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
user.DesktopIDs = []string{}
|
||||
if err = tx.SelectContext(ctx, &user.DesktopIDs, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", user.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err = accountAudit(ctx, tx, s.admin(c).Username, "update_user", user.ID, c.GetString("requestId")); err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, user)
|
||||
}
|
||||
|
||||
func uniqueIDs(items []string) []string {
|
||||
result := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range items {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (s *Server) batchBindings(c *gin.Context) {
|
||||
var in struct {
|
||||
UserIDs []string `json:"userIds" binding:"required,min=1,max=100,dive,required,max=100"`
|
||||
DesktopIDs []string `json:"desktopIds" binding:"required,min=1,max=100,dive,required,max=100"`
|
||||
Action string `json:"action" binding:"required,oneof=grant revoke"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.UserIDs = uniqueIDs(in.UserIDs)
|
||||
in.DesktopIDs = uniqueIDs(in.DesktopIDs)
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if !s.lockAuthorization(c, tx) {
|
||||
return
|
||||
}
|
||||
var ids []string
|
||||
if err = tx.SelectContext(ctx, &ids, "SELECT id FROM users WHERE id=ANY($1) ORDER BY id FOR UPDATE", in.UserIDs); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if len(ids) != len(in.UserIDs) {
|
||||
s.fail(c, 400, "invalid_user", "所选用户不存在,请刷新列表")
|
||||
return
|
||||
}
|
||||
var count int
|
||||
if err = tx.GetContext(ctx, &count, "SELECT count(*) FROM desktops WHERE id=ANY($1)", in.DesktopIDs); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if count != len(in.DesktopIDs) {
|
||||
s.fail(c, 400, "invalid_desktop", "所选桌面不存在,请刷新列表")
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
if in.Action == "grant" {
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING", id, in.DesktopIDs)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1 AND desktop_id=ANY($2)", id, in.DesktopIDs)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", id)
|
||||
}
|
||||
if err == nil {
|
||||
err = accountAudit(ctx, tx, s.admin(c).Username, "batch_"+in.Action+"_bindings", id, c.GetString("requestId"))
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"accepted": true, "userCount": len(ids)})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
func lifecycleDevice(t *testing.T, f *fixture, admin, sn string) (string, string, string) {
|
||||
t.Helper()
|
||||
status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": "测试设备", "group": "原分组"}, "")
|
||||
expect(t, 201, status, out)
|
||||
id, secret := out["id"].(string), out["activationSecret"].(string)
|
||||
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
|
||||
expect(t, 200, status, out)
|
||||
return id, out["accessToken"].(string), secret
|
||||
}
|
||||
func lifecycleBind(t *testing.T, f *fixture, admin, id string) {
|
||||
t.Helper()
|
||||
if _, err := f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100',power_state='running' WHERE id='desk_demo_windows'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows"}, "enabled": true}, "")
|
||||
expect(t, 200, status, out)
|
||||
}
|
||||
|
||||
func TestTerminalLifecycleRevokesIdentityAndTickets(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
id, device, originalSecret := lifecycleDevice(t, f, admin, "LIFECYCLE-001")
|
||||
lifecycleBind(t, f, admin, id)
|
||||
status, out := f.request("PUT", "/terminals/"+id, admin, map[string]any{"name": " 新设备名 ", "group": " 财务 "}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 201, status, out)
|
||||
ticket := out["ticket"].(string)
|
||||
status, out = f.request("POST", "/terminals/"+id+"/disable", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
for _, path := range []string{"/terminal/config", "/terminal/desktops", "/terminal/commands"} {
|
||||
status, out = f.request("GET", path, device, nil, "")
|
||||
expect(t, 401, status, out)
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/heartbeat", device, map[string]any{"agentVersion": "stale"}, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": originalSecret, "agentVersion": "stale"}, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "disabled-command")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("GET", "/terminals", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
var found bool
|
||||
for _, raw := range out["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["id"] == id {
|
||||
found = true
|
||||
if item["status"] != "disabled" || item["enabled"] != false || item["name"] != "新设备名" || item["group"] != "财务" {
|
||||
t.Fatal("incorrect terminal state", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("terminal disappeared")
|
||||
}
|
||||
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enabled bool
|
||||
if err := f.db.Get(&enabled, "SELECT enabled FROM terminals WHERE id=$1", id); err != nil || enabled {
|
||||
t.Fatal("restart enabled disabled terminal", err)
|
||||
}
|
||||
status, out = f.request("POST", "/terminals/"+id+"/reactivate", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
firstSecret := out["activationSecret"].(string)
|
||||
status, out = f.request("POST", "/terminals/"+id+"/reactivate", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
secret := out["activationSecret"].(string)
|
||||
if secret == firstSecret || secret == originalSecret {
|
||||
t.Fatal("reactivation reused credentials")
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": firstSecret, "agentVersion": "test"}, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": secret, "agentVersion": "test"}, "")
|
||||
expect(t, 200, status, out)
|
||||
newDevice := out["accessToken"].(string)
|
||||
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "LIFECYCLE-001", "activationSecret": secret, "agentVersion": "test"}, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket/consume", newDevice, map[string]any{"ticket": ticket}, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("GET", "/terminal/desktops", newDevice, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if len(out["items"].([]any)) != 1 {
|
||||
t.Fatal("reactivation lost authorization")
|
||||
}
|
||||
status, out = f.request("GET", "/terminal/config", device, nil, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("GET", "/audit-logs", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
audit, _ := json.Marshal(out)
|
||||
if strings.Contains(string(audit), secret) || strings.Contains(string(audit), newDevice) || !strings.Contains(string(audit), "reactivate_terminal") {
|
||||
t.Fatal("audit missing or leaked credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalLifecycleGuardsSessionsTasksAndRoles(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
id, device, _ := lifecycleDevice(t, f, admin, "LIFECYCLE-002")
|
||||
lifecycleBind(t, f, admin, id)
|
||||
for _, role := range []string{"viewer", "operator"} {
|
||||
token := f.login(role)
|
||||
for _, path := range []string{"/disable", "/reactivate"} {
|
||||
status, out := f.request("POST", "/terminals/"+id+path, token, nil, "")
|
||||
expect(t, 403, status, out)
|
||||
}
|
||||
status, out := f.request("PUT", "/terminals/"+id, token, map[string]any{"name": "无权限", "group": ""}, "")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("PUT", "/users/user_demo_wang", token, map[string]any{"displayName": "无权限", "enabled": false}, "")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("POST", "/users/batch-bindings", token, map[string]any{"userIds": []string{"user_demo_wang"}, "desktopIds": []string{"desk_demo_windows"}, "action": "revoke"}, "")
|
||||
expect(t, 403, status, out)
|
||||
}
|
||||
status, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 201, status, out)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": out["ticket"]}, "")
|
||||
expect(t, 200, status, out)
|
||||
session := out["sessionId"].(string)
|
||||
for _, path := range []string{"/disable", "/reactivate"} {
|
||||
status, out = f.request("POST", "/terminals/"+id+path, admin, nil, "")
|
||||
expect(t, 409, status, out)
|
||||
if out["code"] != "terminal_in_use" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "ended"}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "pending-command")
|
||||
expect(t, 202, status, out)
|
||||
task := out["id"].(string)
|
||||
for _, path := range []string{"/disable", "/reactivate"} {
|
||||
status, out = f.request("POST", "/terminals/"+id+path, admin, nil, "")
|
||||
expect(t, 409, status, out)
|
||||
if out["code"] != "terminal_task_pending" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
status, out = f.request("GET", "/terminal/commands", device, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/commands/"+task+"/ack", device, map[string]any{"status": "failed"}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminals/"+id+"/disable", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("PUT", "/terminals/"+id, admin, map[string]any{"name": " ", "group": ""}, "")
|
||||
expect(t, 400, status, out)
|
||||
status, out = f.request("POST", "/terminals/missing/reactivate", admin, nil, "")
|
||||
expect(t, 404, status, out)
|
||||
}
|
||||
|
||||
func TestUserMaintenanceAndBatchBindingsAreAtomic(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
id, device, _ := lifecycleDevice(t, f, admin, "LIFECYCLE-003")
|
||||
lifecycleBind(t, f, admin, id)
|
||||
status, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 201, status, out)
|
||||
ticket := out["ticket"]
|
||||
status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": " 新姓名 ", "enabled": false}, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["displayName"] != "新姓名" || out["username"] != "wang.demo" || len(out["desktopIds"].([]any)) != 1 {
|
||||
t.Fatal("profile update changed authorization", out)
|
||||
}
|
||||
status, out = f.request("GET", "/terminal/desktops", device, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if len(out["items"].([]any)) != 0 {
|
||||
t.Fatal("disabled user still authorized")
|
||||
}
|
||||
status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": "新姓名", "enabled": true}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/users", admin, map[string]any{"username": "batch-other", "displayName": "其他用户"}, "")
|
||||
expect(t, 201, status, out)
|
||||
other := out["id"].(string)
|
||||
body := map[string]any{"userIds": []string{"user_demo_wang", other, other}, "desktopIds": []string{"desk_demo_ubuntu", "desk_demo_ubuntu"}, "action": "grant"}
|
||||
for i := 0; i < 2; i++ {
|
||||
status, out = f.request("POST", "/users/batch-bindings", admin, body, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["userCount"] != float64(2) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 2 {
|
||||
t.Fatal("grant replaced existing grants", count, err)
|
||||
}
|
||||
body["desktopIds"] = []string{"desk_demo_ubuntu", "missing"}
|
||||
body["action"] = "revoke"
|
||||
status, out = f.request("POST", "/users/batch-bindings", admin, body, "")
|
||||
expect(t, 400, status, out)
|
||||
f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
|
||||
if count != 2 {
|
||||
t.Fatal("invalid batch partially committed")
|
||||
}
|
||||
body["desktopIds"] = []string{"desk_demo_ubuntu"}
|
||||
body["userIds"] = []string{"user_demo_wang", "missing"}
|
||||
status, out = f.request("POST", "/users/batch-bindings", admin, body, "")
|
||||
expect(t, 400, status, out)
|
||||
body["userIds"] = []string{"user_demo_wang", other}
|
||||
status, out = f.request("POST", "/users/batch-bindings", admin, body, "")
|
||||
expect(t, 200, status, out)
|
||||
f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
|
||||
if count != 1 {
|
||||
t.Fatal("revoke removed unrelated desktop")
|
||||
}
|
||||
status, out = f.request("POST", "/users/batch-bindings", admin, map[string]any{"userIds": []string{}, "desktopIds": []string{}, "action": "grant"}, "")
|
||||
expect(t, 400, status, out)
|
||||
status, out = f.request("PUT", "/users/user_demo_wang", admin, map[string]any{"displayName": " ", "enabled": true}, "")
|
||||
expect(t, 400, status, out)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"github.com/gin-gonic/gin"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Count attempts before password hashing. Shared storage keeps the budget across
|
||||
// API replicas and restarts; neither passwords nor cleartext account/IP keys persist.
|
||||
func (s *Server) allowLoginAttempt(c *gin.Context, username string) bool {
|
||||
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return false
|
||||
}
|
||||
defer tx.Rollback()
|
||||
retry := 0
|
||||
for _, limit := range []struct {
|
||||
key string
|
||||
max int
|
||||
}{{"account:" + strings.ToLower(strings.TrimSpace(username)), 10}, {"ip:" + c.ClientIP(), 30}} {
|
||||
mac := hmac.New(sha256.New, []byte(s.Config.JWTSecret))
|
||||
mac.Write([]byte("login-limit:" + limit.key))
|
||||
key := hex.EncodeToString(mac.Sum(nil))
|
||||
var row struct {
|
||||
Attempts int `db:"attempts"`
|
||||
Started time.Time `db:"window_started"`
|
||||
}
|
||||
err = tx.GetContext(c.Request.Context(), &row, `INSERT INTO login_limits(key,attempts) VALUES($1,1)
|
||||
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN login_limits.window_started<now()-interval '5 minutes' THEN 1 ELSE least(login_limits.attempts+1,$2+1) END,
|
||||
window_started=CASE WHEN login_limits.window_started<now()-interval '5 minutes' THEN now() ELSE login_limits.window_started END RETURNING attempts,window_started`, key, limit.max)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return false
|
||||
}
|
||||
if row.Attempts > limit.max {
|
||||
seconds := int(math.Ceil(time.Until(row.Started.Add(5 * time.Minute)).Seconds()))
|
||||
if seconds > retry {
|
||||
retry = seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
s.dbError(c, err)
|
||||
return false
|
||||
}
|
||||
if retry > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retry))
|
||||
s.fail(c, 429, "login_rate_limited", "登录尝试过于频繁,请在 "+strconv.Itoa(retry)+" 秒后重试")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Call with the desktop row locked when changing maintenance or power state.
|
||||
// This is also used by preflight reads; the active task index handles submissions.
|
||||
func maintenanceBlocked(ctx context.Context, db sqlx.ExtContext, id, own string, active bool) (bool, error) {
|
||||
var blocked bool
|
||||
err := sqlx.GetContext(ctx, db, &blocked, `SELECT ($3 AND EXISTS(SELECT 1 FROM tasks WHERE target_id=$1 AND id!=$2 AND kind IN ('desktop','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 t.id!=$2 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 t.id!=$2 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 t.id!=$2 AND h.stage IN ('submitting','uncertain'))`, id, own, active)
|
||||
return blocked, err
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
type alertRule struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name" binding:"required,max=80"`
|
||||
Metric string `json:"metric" db:"metric" binding:"required,oneof=node_cpu node_memory node_storage node_unavailable terminal_offline"`
|
||||
Threshold float64 `json:"threshold" db:"threshold" binding:"required"`
|
||||
HoldSeconds int `json:"holdSeconds" db:"hold_seconds" binding:"min=0,max=3600"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
}
|
||||
type alertRecord struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
RuleID string `json:"ruleId" db:"rule_id"`
|
||||
RuleName string `json:"ruleName" db:"rule_name"`
|
||||
Metric string `json:"metric" db:"metric"`
|
||||
TargetID string `json:"targetId" db:"target_id"`
|
||||
TargetName string `json:"targetName" db:"target_name"`
|
||||
Value float64 `json:"value" db:"value"`
|
||||
Threshold float64 `json:"threshold" db:"threshold"`
|
||||
Status string `json:"status" db:"status"`
|
||||
StartedAt time.Time `json:"startedAt" db:"started_at"`
|
||||
OpenedAt time.Time `json:"openedAt" db:"opened_at"`
|
||||
ResolvedAt *time.Time `json:"resolvedAt" db:"resolved_at"`
|
||||
ResolveReason string `json:"resolveReason" db:"resolve_reason"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt" db:"acknowledged_at"`
|
||||
AcknowledgedBy string `json:"acknowledgedBy" db:"acknowledged_by"`
|
||||
}
|
||||
type resourcePoint struct {
|
||||
Time time.Time `json:"time" db:"time"`
|
||||
CPU *float64 `json:"cpu" db:"cpu"`
|
||||
Memory *float64 `json:"memory" db:"memory"`
|
||||
Storage *float64 `json:"storage" db:"storage"`
|
||||
Samples int `json:"samples" db:"samples"`
|
||||
}
|
||||
|
||||
func (s *Server) resourceSeries(c *gin.Context) {
|
||||
from, to, ok := timeRange(c, 7)
|
||||
if !ok {
|
||||
s.fail(c, 400, "invalid_range", "请选择有效时间范围,最长 7 天")
|
||||
return
|
||||
}
|
||||
id := c.Query("resourceId")
|
||||
if id == "" || len(id) > 100 {
|
||||
s.fail(c, 400, "invalid_request", "请选择节点")
|
||||
return
|
||||
}
|
||||
bucket := 60
|
||||
if to.Sub(from) > 6*time.Hour {
|
||||
bucket = 300
|
||||
}
|
||||
if to.Sub(from) > 24*time.Hour {
|
||||
bucket = 1800
|
||||
}
|
||||
items := []resourcePoint{}
|
||||
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT date_bin(make_interval(secs=>$4),sampled_at,'2000-01-01'::timestamptz) time,
|
||||
avg(cpu)*100 cpu,avg(memory)*100 memory,avg(storage)*100 storage,count(*) samples FROM resource_samples
|
||||
WHERE resource_id=$1 AND sampled_at>=$2 AND sampled_at<=$3 GROUP BY 1 ORDER BY 1`, id, from, to, bucket)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"items": items, "bucketSeconds": bucket, "retentionDays": 30})
|
||||
}
|
||||
func (s *Server) alertRules(c *gin.Context) {
|
||||
items := []alertRule{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM alert_rules ORDER BY name,id"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func observabilityLock(ctx context.Context, tx *sqlx.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(76511102026)")
|
||||
return err
|
||||
}
|
||||
func (s *Server) saveAlertRule(c *gin.Context) {
|
||||
var in alertRule
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
offline := in.Metric == "terminal_offline" || in.Metric == "node_unavailable"
|
||||
if in.Name == "" || math.IsNaN(in.Threshold) || math.IsInf(in.Threshold, 0) || (!offline && (in.Threshold < 1 || in.Threshold > 100)) || (offline && (in.Threshold < 90 || in.Threshold > 86400 || in.HoldSeconds != 0)) {
|
||||
s.fail(c, 400, "invalid_request", "水位阈值为 1~100%;离线阈值为 90~86400 秒且不额外设置持续时间")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = observabilityLock(ctx, tx); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
in.ID = c.Param("id")
|
||||
if in.ID == "" {
|
||||
in.ID = store.ID("rule_")
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO alert_rules(id,name,metric,threshold,hold_seconds,enabled) VALUES($1,$2,$3,$4,$5,$6)", in.ID, in.Name, in.Metric, in.Threshold, in.HoldSeconds, in.Enabled)
|
||||
} else {
|
||||
var id string
|
||||
err = tx.GetContext(ctx, &id, "UPDATE alert_rules SET name=$1,metric=$2,threshold=$3,hold_seconds=$4,enabled=$5 WHERE id=$6 RETURNING id", in.Name, in.Metric, in.Threshold, in.HoldSeconds, in.Enabled, in.ID)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE alerts SET status='resolved',resolved_at=now(),resolve_reason='rule_changed' WHERE rule_id=$1 AND status='firing'", in.ID)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM alert_states WHERE rule_id=$1", in.ID)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = accountAudit(ctx, tx, s.admin(c).Username, "save_alert_rule", in.ID, c.GetString("requestId"))
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, in)
|
||||
}
|
||||
func (s *Server) alerts(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
if status != "" && status != "firing" && status != "resolved" {
|
||||
s.fail(c, 400, "invalid_request", "告警状态无效")
|
||||
return
|
||||
}
|
||||
page, size := pageParams(c)
|
||||
filter := " FROM alerts WHERE ($1='' OR status=$1) AND (target_name ILIKE $2 OR rule_name ILIKE $2)"
|
||||
args := []any{status, "%" + c.Query("keyword") + "%"}
|
||||
var total, firing, unacknowledged int
|
||||
if err := s.DB.GetContext(c.Request.Context(), &total, "SELECT count(*)"+filter, args...); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err := s.DB.QueryRowContext(c.Request.Context(), "SELECT count(*),count(*) FILTER(WHERE acknowledged_at IS NULL) FROM alerts WHERE status='firing'").Scan(&firing, &unacknowledged); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
items := []alertRecord{}
|
||||
args = append(args, size, (page-1)*size)
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT *"+filter+" ORDER BY opened_at DESC,id LIMIT $3 OFFSET $4", args...); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"items": items, "total": total, "page": page, "pageSize": size, "firingCount": firing, "unacknowledgedCount": unacknowledged, "requestId": c.GetString("requestId")})
|
||||
}
|
||||
func (s *Server) acknowledgeAlert(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 row alertRecord
|
||||
if err = tx.GetContext(ctx, &row, "SELECT * FROM alerts WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if row.Status != "firing" {
|
||||
s.fail(c, 409, "alert_resolved", "告警已经恢复")
|
||||
return
|
||||
}
|
||||
if row.AcknowledgedAt == nil {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE alerts SET acknowledged_at=now(),acknowledged_by=$1 WHERE id=$2", s.admin(c).Username, row.ID)
|
||||
if err == nil {
|
||||
err = accountAudit(ctx, tx, s.admin(c).Username, "acknowledge_alert", row.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})
|
||||
}
|
||||
|
||||
type observation struct {
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Value *float64 `db:"value"`
|
||||
Unavailable bool `db:"unavailable"`
|
||||
}
|
||||
|
||||
// One transaction serializes rule edits, expiry and condition evaluation across replicas.
|
||||
func (s *Server) observe(ctx context.Context) error {
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var acquired bool
|
||||
if err = tx.GetContext(ctx, &acquired, "SELECT pg_try_advisory_xact_lock(76511102026)"); err != nil || !acquired {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE session_history SET state='interrupted',ended_at=last_reported_at+interval '90 seconds',end_reason='heartbeat_timeout'
|
||||
WHERE ended_at IS NULL AND last_reported_at<now()-interval '90 seconds'`); err != nil {
|
||||
return err
|
||||
}
|
||||
rules := []alertRule{}
|
||||
if err = tx.SelectContext(ctx, &rules, "SELECT * FROM alert_rules WHERE enabled ORDER BY id"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
targets := []observation{}
|
||||
query := `SELECT id,name,extract(epoch FROM(now()-last_seen))::double precision value,false unavailable FROM terminals WHERE enabled AND token_hash IS NOT NULL AND last_seen IS NOT NULL AND status!='unregistered'`
|
||||
if rule.Metric == "node_unavailable" {
|
||||
query = `SELECT id,name,extract(epoch FROM(now()-updated_at))::double precision value,(status!='online') unavailable FROM resources WHERE provider='proxmox'`
|
||||
}
|
||||
column := map[string]string{"node_cpu": "cpu", "node_memory": "memory", "node_storage": "storage"}[rule.Metric]
|
||||
if column != "" {
|
||||
query = `SELECT r.id,r.name,CASE WHEN r.status='online' AND r.updated_at>now()-interval '90 seconds' THEN s.` + column + `*100 ELSE NULL END value,false unavailable
|
||||
FROM resources r LEFT JOIN LATERAL(SELECT ` + column + ` FROM resource_samples WHERE resource_id=r.id AND sampled_at>now()-interval '90 seconds' ORDER BY sampled_at DESC LIMIT 1) s ON true WHERE r.provider='proxmox'`
|
||||
}
|
||||
if err = tx.SelectContext(ctx, &targets, query); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := []string{}
|
||||
for _, target := range targets {
|
||||
ids = append(ids, target.ID)
|
||||
if target.Value == nil {
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM alert_states WHERE rule_id=$1 AND target_id=$2 AND NOT EXISTS(SELECT 1 FROM alerts WHERE rule_id=$1 AND target_id=$2 AND status='firing')", rule.ID, target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if *target.Value < rule.Threshold && !target.Unavailable {
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM alert_states WHERE rule_id=$1 AND target_id=$2", rule.ID, target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE alerts SET status='resolved',resolved_at=now(),resolve_reason='condition_cleared' WHERE rule_id=$1 AND target_id=$2 AND status='firing'", rule.ID, target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO alert_states(rule_id,target_id) VALUES($1,$2) ON CONFLICT DO NOTHING", rule.ID, target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
hold := rule.HoldSeconds
|
||||
if rule.Metric == "node_unavailable" && target.Unavailable {
|
||||
hold = int(math.Ceil(rule.Threshold))
|
||||
if err = tx.GetContext(ctx, target.Value, "SELECT extract(epoch FROM(now()-first_seen))::double precision FROM alert_states WHERE rule_id=$1 AND target_id=$2", rule.ID, target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO alerts(id,rule_id,rule_name,metric,target_id,target_name,value,threshold,started_at)
|
||||
SELECT $1,$2,$3,$4,$5,$6,$7,$8,first_seen FROM alert_states WHERE rule_id=$2 AND target_id=$5 AND first_seen<=now()-make_interval(secs=>$9)
|
||||
ON CONFLICT(rule_id,target_id) WHERE status='firing' DO UPDATE SET value=EXCLUDED.value`, store.ID("alert_"), rule.ID, rule.Name, rule.Metric, target.ID, target.Name, *target.Value, rule.Threshold, hold); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE alerts SET status='resolved',resolved_at=now(),resolve_reason='target_unavailable' WHERE rule_id=$1 AND NOT(target_id=ANY($2::text[])) AND status='firing'", rule.ID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM alert_states WHERE rule_id=$1 AND NOT(target_id=ANY($2::text[]))", rule.ID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Bounded retention. Current incidents and active session records are preserved.
|
||||
for _, query := range []string{"DELETE FROM login_limits WHERE window_started<now()-interval '1 day'", "DELETE FROM resource_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM terminal_metric_samples WHERE sampled_at<now()-interval '30 days'", "DELETE FROM session_history WHERE ended_at<now()-interval '90 days'", "DELETE FROM alerts WHERE resolved_at<now()-interval '90 days'"} {
|
||||
if _, err = tx.ExecContext(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *Server) RunObservability(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
readCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
if err := s.observe(readCtx); err != nil {
|
||||
log.Printf("observability evaluation failed: %v", err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
func TestCSVParsingAndAtomicImport(t *testing.T) {
|
||||
rows, message := parseImport(importInput{Kind: "users", CSV: "\ufeffusername,displayName\r\nworker,\"研发,一组\"\r\nworker,重复\r\n"})
|
||||
if message != "" || len(rows) != 2 || rows[0].Values["displayName"] != "研发,一组" || len(rows[1].Errors) == 0 {
|
||||
t.Fatalf("CSV parsing: %v %s", rows, message)
|
||||
}
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
viewer := f.login("viewer")
|
||||
in := map[string]any{"kind": "users", "csv": "username,displayName\nimported,导入用户\n"}
|
||||
code, out := f.request("POST", "/imports/preview", viewer, in, "")
|
||||
expect(t, 403, code, out)
|
||||
code, out = f.request("POST", "/imports/preview", admin, in, "")
|
||||
expect(t, 200, code, out)
|
||||
if out["validRows"] != float64(1) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
in["previewToken"] = out["previewToken"]
|
||||
var count int
|
||||
f.db.Get(&count, "SELECT count(*) FROM users WHERE username='imported'")
|
||||
if count != 0 {
|
||||
t.Fatal("preview wrote a user")
|
||||
}
|
||||
code, out = f.request("POST", "/imports/commit", admin, in, "csv-users")
|
||||
expect(t, 200, code, out)
|
||||
code, out = f.request("POST", "/imports/commit", admin, in, "csv-users")
|
||||
expect(t, 200, code, out)
|
||||
if out["replayed"] != true {
|
||||
t.Fatal("replay not recognized")
|
||||
}
|
||||
bad := map[string]any{"kind": "users", "csv": "username,displayName\nfresh,有效\nimported,已存在\n"}
|
||||
code, out = f.request("POST", "/imports/preview", admin, bad, "")
|
||||
expect(t, 200, code, out)
|
||||
if out["errorRows"] != float64(1) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
bad["previewToken"] = strings.Repeat("0", 64)
|
||||
code, out = f.request("POST", "/imports/commit", admin, bad, "csv-invalid")
|
||||
expect(t, 409, code, out)
|
||||
f.db.Get(&count, "SELECT count(*) FROM users WHERE username='fresh'")
|
||||
if count != 0 {
|
||||
t.Fatal("partial import")
|
||||
}
|
||||
terminals := map[string]any{"kind": "terminals", "csv": "sn,name,group\nCSV-TERM-001,批量终端,研发\n"}
|
||||
code, out = f.request("POST", "/imports/preview", admin, terminals, "")
|
||||
expect(t, 200, code, out)
|
||||
terminals["previewToken"] = out["previewToken"]
|
||||
code, out = f.request("POST", "/imports/commit", admin, terminals, "csv-terminals")
|
||||
expect(t, 200, code, out)
|
||||
keys := out["activationSecrets"].([]any)
|
||||
if len(keys) != 1 {
|
||||
t.Fatal(out)
|
||||
}
|
||||
secret := keys[0].(map[string]any)["activationSecret"].(string)
|
||||
var stored string
|
||||
f.db.Get(&stored, "SELECT result::text FROM import_batches WHERE id=$1", out["id"])
|
||||
if strings.Contains(stored, secret) {
|
||||
t.Fatal("plaintext activation secret persisted")
|
||||
}
|
||||
code, out = f.request("POST", "/imports/commit", admin, terminals, "csv-terminals")
|
||||
expect(t, 200, code, out)
|
||||
if len(out["activationSecrets"].([]any)) != 0 {
|
||||
t.Fatal("replay exposed credentials")
|
||||
}
|
||||
binding := map[string]any{"kind": "bindings", "csv": "username,terminalSn,desktopIds\nimported,CSV-TERM-001,desk_demo_windows\n"}
|
||||
code, out = f.request("POST", "/imports/preview", admin, binding, "")
|
||||
expect(t, 200, code, out)
|
||||
binding["previewToken"] = out["previewToken"]
|
||||
var uid string
|
||||
f.db.Get(&uid, "SELECT id FROM users WHERE username='imported'")
|
||||
if _, err := f.db.Exec("INSERT INTO bindings(user_id,desktop_id) VALUES($1,'desk_demo_ubuntu')", uid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out = f.request("POST", "/imports/commit", admin, binding, "csv-bindings")
|
||||
expect(t, 409, code, out)
|
||||
code, out = f.request("POST", "/imports/preview", admin, binding, "")
|
||||
expect(t, 200, code, out)
|
||||
binding["previewToken"] = out["previewToken"]
|
||||
code, out = f.request("POST", "/imports/commit", admin, binding, "csv-bindings")
|
||||
expect(t, 200, code, out)
|
||||
f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id=$1", uid)
|
||||
if count != 2 {
|
||||
t.Fatal("existing grant was lost")
|
||||
}
|
||||
var sn string
|
||||
f.db.Get(&sn, "SELECT t.sn FROM users u JOIN terminals t ON t.id=u.terminal_id WHERE u.id=$1", uid)
|
||||
if sn != "CSV-TERM-001" {
|
||||
t.Fatal("terminal not bound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHistoryLifecycle(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
code, out := f.request("POST", "/terminals", admin, map[string]any{"sn": "HISTORY-001", "name": "历史终端", "group": "测试"}, "")
|
||||
expect(t, 201, code, out)
|
||||
id := out["id"].(string)
|
||||
code, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "HISTORY-001", "activationSecret": out["activationSecret"], "agentVersion": "test"}, "")
|
||||
expect(t, 200, code, out)
|
||||
device := out["accessToken"].(string)
|
||||
code, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows"}, "enabled": true}, "")
|
||||
expect(t, 200, code, out)
|
||||
if _, err := f.db.Exec("UPDATE desktops SET endpoint='192.0.2.1' WHERE id='desk_demo_windows'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reserve := func() string {
|
||||
code, out := f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 201, code, out)
|
||||
code, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": out["ticket"]}, "")
|
||||
expect(t, 200, code, out)
|
||||
return out["sessionId"].(string)
|
||||
}
|
||||
session := reserve()
|
||||
var row sessionRecord
|
||||
if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.State != "connecting" || row.ConnectedAt != nil {
|
||||
t.Fatal("reservation claimed stream ready")
|
||||
}
|
||||
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "connected"}, "")
|
||||
expect(t, 200, code, out)
|
||||
f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session)
|
||||
first := *row.ConnectedAt
|
||||
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "connected"}, "")
|
||||
expect(t, 200, code, out)
|
||||
f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session)
|
||||
if !row.ConnectedAt.Equal(first) {
|
||||
t.Fatal("connected time reset")
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE terminals SET name='改名' WHERE id=$1", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := reserve()
|
||||
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": session, "state": "ended"}, "")
|
||||
expect(t, 409, code, out)
|
||||
f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1", session)
|
||||
if row.EndReason != "replaced" || row.TerminalName != "历史终端" {
|
||||
t.Fatal("lost snapshot or replacement")
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE session_history SET last_reported_at=now()-interval '100 seconds' WHERE session_id=$1", second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.server.observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": second, "state": "connected"}, "")
|
||||
expect(t, 200, code, out)
|
||||
if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1 AND ended_at IS NULL", second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Source != "observed" || row.ConnectedAt == nil {
|
||||
t.Fatal("did not resume observed history")
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE session_history SET last_reported_at=now()-interval '100 seconds' WHERE session_id=$1 AND ended_at IS NULL", second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.server.observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out = f.request("POST", "/terminal/heartbeat", device, map[string]any{"agentVersion": "test"}, "")
|
||||
expect(t, 200, code, out)
|
||||
if err := f.db.Get(&row, "SELECT * FROM session_history WHERE session_id=$1 AND ended_at IS NULL", second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Source != "observed" || row.State != "connected" {
|
||||
t.Fatal("heartbeat did not resume observed history")
|
||||
}
|
||||
code, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": second, "state": "ended"}, "")
|
||||
expect(t, 200, code, out)
|
||||
code, out = f.request("GET", "/sessions/history?keyword="+url.QueryEscape("历史终端")+"&pageSize=1", admin, nil, "")
|
||||
expect(t, 200, code, out)
|
||||
if out["total"] != float64(1) || len(out["items"].([]any)) != 1 {
|
||||
t.Fatal(out)
|
||||
}
|
||||
code, out = f.request("GET", "/sessions/history?status=invalid", admin, nil, "")
|
||||
expect(t, 400, code, out)
|
||||
}
|
||||
|
||||
func TestMonitoringHoldAcknowledgeRecoveryAndSeries(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
viewer := f.login("viewer")
|
||||
operator := f.login("operator")
|
||||
rule := map[string]any{"name": "测试CPU", "metric": "node_cpu", "threshold": 90, "holdSeconds": 120, "enabled": true}
|
||||
code, out := f.request("POST", "/monitor/rules", viewer, rule, "")
|
||||
expect(t, 403, code, out)
|
||||
code, out = f.request("POST", "/monitor/rules", admin, rule, "")
|
||||
expect(t, 200, code, out)
|
||||
rid := out["id"].(string)
|
||||
if _, err := f.db.Exec("INSERT INTO resources VALUES('test-node','测试节点','online',0.95,0.5,0.5,'proxmox',now()); INSERT INTO resource_samples(resource_id,cpu,memory) VALUES('test-node',0.95,0.5)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evaluate := func() {
|
||||
t.Helper()
|
||||
if err := f.server.observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
evaluate()
|
||||
var count int
|
||||
f.db.Get(&count, "SELECT count(*) FROM alerts")
|
||||
if count != 0 {
|
||||
t.Fatal("triggered before hold")
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE alert_states SET first_seen=now()-interval '121 seconds'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evaluate()
|
||||
evaluate()
|
||||
f.db.Get(&count, "SELECT count(*) FROM alerts")
|
||||
if count != 1 {
|
||||
t.Fatal("alert missing or duplicated")
|
||||
}
|
||||
var aid string
|
||||
f.db.Get(&aid, "SELECT id FROM alerts")
|
||||
code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", viewer, nil, "")
|
||||
expect(t, 403, code, out)
|
||||
code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", operator, nil, "")
|
||||
expect(t, 200, code, out)
|
||||
code, out = f.request("POST", "/monitor/alerts/"+aid+"/acknowledge", admin, nil, "")
|
||||
expect(t, 200, code, out)
|
||||
var actor string
|
||||
f.db.Get(&actor, "SELECT acknowledged_by FROM alerts WHERE id=$1", aid)
|
||||
if actor != "operator" {
|
||||
t.Fatal("first acknowledgment overwritten")
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE resources SET updated_at=now()-interval '100 seconds' WHERE id='test-node'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evaluate()
|
||||
var state string
|
||||
f.db.Get(&state, "SELECT status FROM alerts WHERE id=$1", aid)
|
||||
if state != "firing" {
|
||||
t.Fatal("missing data claimed recovery")
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE resources SET updated_at=now() WHERE id='test-node'; UPDATE resource_samples SET cpu=0.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evaluate()
|
||||
f.db.Get(&state, "SELECT status FROM alerts WHERE id=$1", aid)
|
||||
if state != "resolved" {
|
||||
t.Fatal("did not recover")
|
||||
}
|
||||
code, out = f.request("GET", "/monitor/series?resourceId=test-node", viewer, nil, "")
|
||||
expect(t, 200, code, out)
|
||||
points := out["items"].([]any)
|
||||
if len(points) != 1 || points[0].(map[string]any)["storage"] != nil || points[0].(map[string]any)["cpu"] != float64(10) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
rule["enabled"] = false
|
||||
code, out = f.request("PUT", "/monitor/rules/"+rid, admin, rule, "")
|
||||
expect(t, 200, code, out)
|
||||
code, out = f.request("GET", "/monitor/alerts?status=resolved&pageSize=1", viewer, nil, "")
|
||||
expect(t, 200, code, out)
|
||||
if out["total"] != float64(1) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeAndTerminalOfflineThresholds(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
for _, metric := range []string{"node_unavailable", "terminal_offline"} {
|
||||
code, out := f.request("POST", "/monitor/rules", admin, map[string]any{"name": metric, "metric": metric, "threshold": 120, "holdSeconds": 0, "enabled": true}, "")
|
||||
expect(t, 200, code, out)
|
||||
}
|
||||
if _, err := f.db.Exec(`INSERT INTO resources VALUES('offline-node','离线节点','offline',0,0,0,'proxmox',now());
|
||||
INSERT INTO terminals(id,sn,name,bootstrap_hash,token_hash,last_seen,status) VALUES('offline-box','OFFLINE-001','离线终端','hash','token',now()-interval '130 seconds','online'),('new-box','NEW-001','未激活终端','hash',NULL,NULL,'unregistered')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.server.observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
f.db.Get(&count, "SELECT count(*) FROM alerts")
|
||||
if count != 1 {
|
||||
t.Fatal("offline node fired early or inactive terminal included")
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE alert_states SET first_seen=now()-interval '121 seconds' WHERE target_id='offline-node'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.server.observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.db.Get(&count, "SELECT count(*) FROM alerts WHERE status='firing'")
|
||||
if count != 2 {
|
||||
t.Fatal("node never fired")
|
||||
}
|
||||
if _, err := f.db.Exec("UPDATE terminals SET enabled=false WHERE id='offline-box'; UPDATE resources SET status='online',updated_at=now() WHERE id='offline-node'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.server.observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.db.Get(&count, "SELECT count(*) FROM alerts WHERE status='firing'")
|
||||
if count != 0 {
|
||||
t.Fatal("disabled or recovered target stayed firing")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure nullable fields retain their JSON representation for clients drawing gaps.
|
||||
func TestResourcePointNullJSON(t *testing.T) {
|
||||
b, err := json.Marshal(resourcePoint{Time: time.Now()})
|
||||
if err != nil || !strings.Contains(string(b), `"cpu":null`) {
|
||||
t.Fatal(string(b), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVESyncPersistsSamplesAndOfflineGaps(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
online := true
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
status := "offline"
|
||||
if online {
|
||||
status = "online"
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"type": "node", "node": "sample-node", "status": status, "cpu": 0.4, "mem": float64(16 << 30), "maxmem": float64(32 << 30), "disk": float64(40 << 30), "maxdisk": float64(100 << 30)}}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
if err := f.server.sync(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var point resourcePoint
|
||||
if err := f.db.Get(&point, "SELECT sampled_at time,cpu,memory,storage,1 samples FROM resource_samples WHERE resource_id='sample-node'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if point.Memory == nil || *point.Memory != 0.5 {
|
||||
t.Fatal("incorrect memory ratio")
|
||||
}
|
||||
online = false
|
||||
if err := f.server.sync(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.db.Get(&point, "SELECT sampled_at time,cpu,memory,storage,1 samples FROM resource_samples WHERE resource_id='sample-node' ORDER BY sampled_at DESC LIMIT 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if point.CPU != nil || point.Memory != nil || point.Storage != nil {
|
||||
t.Fatal("offline node claimed a valid sample")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
// These settings affect management polling only, never the active stream.
|
||||
type PolicySettings struct {
|
||||
HeartbeatInterval int `json:"heartbeatInterval" db:"heartbeat_interval" binding:"min=15,max=60"`
|
||||
CommandPollInterval int `json:"commandPollInterval" db:"command_poll_interval" binding:"min=3,max=30"`
|
||||
DesktopRefreshInterval int `json:"desktopRefreshInterval" db:"desktop_refresh_interval" binding:"min=5,max=120"`
|
||||
}
|
||||
type terminalPolicy struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name" binding:"required,max=80"`
|
||||
Version string `json:"version" db:"version" binding:"required,max=40"`
|
||||
PolicySettings
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
type policyDeployment struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TemplateID string `json:"templateId" db:"template_id"`
|
||||
TemplateName string `json:"templateName" db:"template_name"`
|
||||
TemplateVersion string `json:"templateVersion" db:"template_version"`
|
||||
Actor string `json:"actor" db:"actor"`
|
||||
TargetCount int `json:"targetCount" db:"target_count"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
}
|
||||
type policyResult struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TerminalID string `json:"terminalId" db:"terminal_id"`
|
||||
TerminalName string `json:"terminalName" db:"terminal_name"`
|
||||
Group string `json:"group" db:"group_name"`
|
||||
Protocol int `json:"protocol" db:"protocol"`
|
||||
DeploymentID string `json:"deploymentId" db:"deployment_id"`
|
||||
TemplateName string `json:"templateName" db:"template_name"`
|
||||
TemplateVersion string `json:"templateVersion" db:"template_version"`
|
||||
ConfigVersion int `json:"configVersion" db:"config_version"`
|
||||
Status string `json:"status" db:"status"`
|
||||
ErrorCode string `json:"errorCode" db:"error_code"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt" db:"acknowledged_at"`
|
||||
Online bool `json:"online" db:"online"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
Registered bool `json:"registered" db:"registered"`
|
||||
}
|
||||
|
||||
const deploymentSQL = `SELECT d.id,d.template_id,p.name template_name,p.version template_version,d.actor,
|
||||
(SELECT count(*) FROM terminal_policy_deliveries r WHERE r.deployment_id=d.id) target_count,d.created_at
|
||||
FROM terminal_policy_deployments d JOIN terminal_policy_templates p ON p.id=d.template_id`
|
||||
const resultSQL = `SELECT COALESCE(r.id,'') id,t.id terminal_id,t.name terminal_name,t.group_name,
|
||||
COALESCE(c.protocol,0) protocol,COALESCE(r.deployment_id,'') deployment_id,
|
||||
COALESCE(p.name,'') template_name,COALESCE(p.version,'') template_version,
|
||||
t.config_version,COALESCE(r.status,'unconfigured') status,COALESCE(r.error_code,'') error_code,r.acknowledged_at,
|
||||
COALESCE(t.last_seen>now()-interval '90 seconds',false) online,t.enabled,(t.token_hash IS NOT NULL) registered
|
||||
FROM terminals t LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id
|
||||
LEFT JOIN terminal_policy_deliveries r ON r.terminal_id=t.id AND r.config_version=t.config_version
|
||||
LEFT JOIN terminal_policy_deployments d ON d.id=r.deployment_id
|
||||
LEFT JOIN terminal_policy_templates p ON p.id=d.template_id`
|
||||
|
||||
func (s *Server) policyTemplates(c *gin.Context) {
|
||||
items := []terminalPolicy{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM terminal_policy_templates ORDER BY created_at DESC,id"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) createPolicyTemplate(c *gin.Context) {
|
||||
var in terminalPolicy
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.Version = strings.TrimSpace(in.Version)
|
||||
if in.Name == "" || in.Version == "" {
|
||||
s.fail(c, 400, "invalid_request", "策略名称和版本不能为空")
|
||||
return
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err = tx.GetContext(c.Request.Context(), &in, `INSERT INTO terminal_policy_templates(id,name,version,heartbeat_interval,command_poll_interval,desktop_refresh_interval)
|
||||
VALUES($1,$2,$3,$4,$5,$6) RETURNING *`, store.ID("policy_"), in.Name, in.Version, in.HeartbeatInterval, in.CommandPollInterval, in.DesktopRefreshInterval)
|
||||
if conflict(err) {
|
||||
s.fail(c, 409, "policy_exists", "该策略名称和版本已存在,请创建新版本")
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
err = accountAudit(c.Request.Context(), tx, s.admin(c).Username, "create_terminal_policy", in.ID, c.GetString("requestId"))
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(201, in)
|
||||
}
|
||||
func (s *Server) policyDeployments(c *gin.Context) {
|
||||
items := []policyDeployment{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, deploymentSQL+" ORDER BY d.created_at DESC,d.id"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) policyStates(c *gin.Context) {
|
||||
items := []policyResult{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, resultSQL+" ORDER BY t.name,t.id"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) policyResults(c *gin.Context) {
|
||||
var exists bool
|
||||
if err := s.DB.GetContext(c.Request.Context(), &exists, "SELECT EXISTS(SELECT 1 FROM terminal_policy_deployments WHERE id=$1)", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
s.fail(c, 404, "not_found", "下发记录不存在")
|
||||
return
|
||||
}
|
||||
// Historical results retain their own version and status after a newer dispatch.
|
||||
items := []policyResult{}
|
||||
query := `SELECT r.id,t.id terminal_id,t.name terminal_name,t.group_name,COALESCE(c.protocol,0) protocol,
|
||||
r.deployment_id,p.name template_name,p.version template_version,r.config_version,r.status,r.error_code,r.acknowledged_at,
|
||||
COALESCE(t.last_seen>now()-interval '90 seconds',false) online,t.enabled,(t.token_hash IS NOT NULL) registered
|
||||
FROM terminal_policy_deliveries r JOIN terminals t ON t.id=r.terminal_id
|
||||
LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id
|
||||
JOIN terminal_policy_deployments d ON d.id=r.deployment_id JOIN terminal_policy_templates p ON p.id=d.template_id
|
||||
WHERE r.deployment_id=$1 ORDER BY t.name,t.id`
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, query, c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func (s *Server) deployPolicy(c *gin.Context) {
|
||||
var in struct {
|
||||
TemplateID string `json:"templateId" binding:"required,max=100"`
|
||||
TargetMode string `json:"targetMode" binding:"required,oneof=devices group"`
|
||||
TerminalIDs []string `json:"terminalIds" binding:"max=200,dive,required,max=100"`
|
||||
Group string `json:"group" binding:"max=80"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
in.Group = strings.TrimSpace(in.Group)
|
||||
in.TerminalIDs = uniqueIDs(in.TerminalIDs)
|
||||
sort.Strings(in.TerminalIDs)
|
||||
if (in.TargetMode == "devices" && (len(in.TerminalIDs) == 0 || in.Group != "")) || (in.TargetMode == "group" && (in.Group == "" || len(in.TerminalIDs) > 0)) {
|
||||
s.fail(c, 400, "invalid_request", "请选择设备或一个非空分组")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||
if key == "" || len(key) > 100 {
|
||||
s.fail(c, 400, "invalid_request", "下发需要有效的请求标识")
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(in)
|
||||
fingerprint := store.Hash(string(raw))
|
||||
ctx := c.Request.Context()
|
||||
actor := s.admin(c).Username
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,765110))", actor+":"+key); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var old struct {
|
||||
ID string `db:"id"`
|
||||
Hash string `db:"request_hash"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &old, "SELECT id,request_hash FROM terminal_policy_deployments WHERE actor=$1 AND idempotency_key=$2", actor, key)
|
||||
if err == nil {
|
||||
if old.Hash != fingerprint {
|
||||
s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他下发参数")
|
||||
return
|
||||
}
|
||||
var out policyDeployment
|
||||
if err = tx.GetContext(ctx, &out, deploymentSQL+" WHERE d.id=$1", old.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(202, out)
|
||||
return
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var template terminalPolicy
|
||||
if err = tx.GetContext(ctx, &template, "SELECT * FROM terminal_policy_templates WHERE id=$1", in.TemplateID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var targets []struct {
|
||||
ID string `db:"id"`
|
||||
Enabled bool `db:"enabled"`
|
||||
Registered bool `db:"registered"`
|
||||
Protocol int `db:"protocol"`
|
||||
}
|
||||
query := `SELECT t.id,t.enabled,(t.token_hash IS NOT NULL) registered,COALESCE(c.protocol,0) protocol FROM terminals t
|
||||
LEFT JOIN terminal_policy_capabilities c ON c.terminal_id=t.id WHERE `
|
||||
var arg any = in.TerminalIDs
|
||||
if in.TargetMode == "group" {
|
||||
query += "t.group_name=$1"
|
||||
arg = in.Group
|
||||
} else {
|
||||
query += "t.id=ANY($1)"
|
||||
}
|
||||
if err = tx.SelectContext(ctx, &targets, query+" ORDER BY t.id FOR UPDATE OF t", arg); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 || len(targets) > 200 || (in.TargetMode == "devices" && len(targets) != len(in.TerminalIDs)) {
|
||||
s.fail(c, 400, "invalid_targets", "目标为空、设备不存在或超过 200 台,请缩小范围")
|
||||
return
|
||||
}
|
||||
for _, t := range targets {
|
||||
if !t.Enabled || !t.Registered || t.Protocol != 1 {
|
||||
s.fail(c, 409, "policy_unsupported", "所选设备包含已停用、未激活或不支持策略的终端,请检查设备结果")
|
||||
return
|
||||
}
|
||||
}
|
||||
id := store.ID("dispatch_")
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO terminal_policy_deployments(id,template_id,actor,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5)", id, in.TemplateID, actor, key, fingerprint); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
for _, t := range targets {
|
||||
var version int
|
||||
if err = tx.GetContext(ctx, &version, "UPDATE terminals SET config_version=config_version+1 WHERE id=$1 RETURNING config_version", t.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE terminal_policy_deliveries SET status='superseded' WHERE terminal_id=$1 AND status IN ('pending','failed')", t.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO terminal_policy_deliveries(id,deployment_id,terminal_id,config_version) VALUES($1,$2,$3,$4)", store.ID("delivery_"), id, t.ID, version); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
var out policyDeployment
|
||||
if err = tx.GetContext(ctx, &out, deploymentSQL+" WHERE d.id=$1", id); err == nil {
|
||||
err = accountAudit(ctx, tx, actor, "deploy_terminal_policy", id, c.GetString("requestId"))
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(202, out)
|
||||
}
|
||||
|
||||
func (s *Server) policyAck(c *gin.Context) {
|
||||
var in struct {
|
||||
DeploymentID string `json:"deploymentId" binding:"required,max=100"`
|
||||
ConfigVersion int `json:"configVersion" binding:"min=2"`
|
||||
Status string `json:"status" binding:"required,oneof=applied failed"`
|
||||
ErrorCode string `json:"errorCode" binding:"omitempty,oneof=invalid_policy persist_failed runtime_unavailable"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
if (in.Status == "applied" && in.ErrorCode != "") || (in.Status == "failed" && in.ErrorCode == "") {
|
||||
s.fail(c, 400, "invalid_request", "请提供匹配应用结果的错误代码")
|
||||
return
|
||||
}
|
||||
tx := deviceTx(c)
|
||||
ctx := c.Request.Context()
|
||||
var row struct {
|
||||
ID string `db:"id"`
|
||||
Status string `db:"status"`
|
||||
ErrorCode string `db:"error_code"`
|
||||
}
|
||||
err := tx.GetContext(ctx, &row, `SELECT r.id,r.status,r.error_code FROM terminal_policy_deliveries r JOIN terminals t ON t.id=r.terminal_id
|
||||
WHERE r.terminal_id=$1 AND r.deployment_id=$2 AND r.config_version=$3 AND t.config_version=r.config_version`, c.GetString("terminalId"), in.DeploymentID, in.ConfigVersion)
|
||||
if err == sql.ErrNoRows {
|
||||
s.fail(c, 409, "stale_policy", "该策略已被更新,请重新拉取配置")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
// A successful application cannot be downgraded by a delayed failed response.
|
||||
if row.Status == "applied" || (row.Status == in.Status && row.ErrorCode == in.ErrorCode) {
|
||||
s.deviceJSON(c, 200, gin.H{"accepted": true})
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "UPDATE terminal_policy_deliveries SET status=$1,error_code=$2,acknowledged_at=now() WHERE id=$3", in.Status, in.ErrorCode, row.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err = accountAudit(ctx, tx, "device", "terminal_policy_"+in.Status, row.ID, c.GetString("requestId")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.deviceJSON(c, 200, gin.H{"accepted": true})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func policyTemplate(t *testing.T, f *fixture, admin string) string {
|
||||
t.Helper()
|
||||
status, out := f.request("POST", "/terminal-policy-templates", admin, map[string]any{"name": "办公策略", "version": "1.0", "heartbeatInterval": 30, "commandPollInterval": 5, "desktopRefreshInterval": 20}, "")
|
||||
expect(t, 201, status, out)
|
||||
return out["id"].(string)
|
||||
}
|
||||
func policyCap(t *testing.T, f *fixture, device string, protocol int) {
|
||||
t.Helper()
|
||||
status, out := f.request("POST", "/terminal/heartbeat", device, map[string]any{"agentVersion": "qt-platform/0.3.0", "policyProtocol": protocol}, "")
|
||||
expect(t, 200, status, out)
|
||||
}
|
||||
func TestPolicyDispatchActualReceiptsAndHistory(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
id, device, _ := lifecycleDevice(t, f, admin, "POLICY-001")
|
||||
template := policyTemplate(t, f, admin)
|
||||
status, out := f.request("GET", "/terminal/config", device, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["heartbeatInterval"] != float64(30) || out["configVersion"] != float64(1) || out["deploymentId"] != "" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
body := map[string]any{"templateId": template, "targetMode": "devices", "terminalIds": []string{id}}
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "policy-1")
|
||||
expect(t, 409, status, out)
|
||||
policyCap(t, f, device, 1)
|
||||
// Offline capable devices must queue without pretending to have applied.
|
||||
if _, err := f.db.Exec("UPDATE terminals SET last_seen=now()-interval '5 minutes' WHERE id=$1", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, first := f.request("POST", "/terminal-policy-deployments", admin, body, "policy-1")
|
||||
expect(t, 202, status, first)
|
||||
deployment := first["id"].(string)
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "policy-1")
|
||||
expect(t, 202, status, out)
|
||||
if out["id"] != deployment {
|
||||
t.Fatal("duplicate dispatch", out)
|
||||
}
|
||||
status, out = f.request("GET", "/terminal-policy-deployments/"+deployment+"/results", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
row := out["items"].([]any)[0].(map[string]any)
|
||||
if row["status"] != "pending" || row["online"] != false || row["acknowledgedAt"] != nil {
|
||||
t.Fatal(row)
|
||||
}
|
||||
status, out = f.request("GET", "/terminal/config", device, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["commandPollInterval"] != float64(5) || out["desktopRefreshInterval"] != float64(20) || out["deploymentId"] != deployment {
|
||||
t.Fatal(out)
|
||||
}
|
||||
version := out["configVersion"]
|
||||
ack := map[string]any{"deploymentId": deployment, "configVersion": version, "status": "failed", "errorCode": "persist_failed"}
|
||||
status, out = f.request("POST", "/terminal/config/ack", device, ack, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/terminal-policy-deployments/"+deployment+"/results", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
row = out["items"].([]any)[0].(map[string]any)
|
||||
if row["status"] != "failed" || row["errorCode"] != "persist_failed" {
|
||||
t.Fatal(row)
|
||||
}
|
||||
delete(ack, "errorCode")
|
||||
ack["status"] = "applied"
|
||||
status, out = f.request("POST", "/terminal/config/ack", device, ack, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/config/ack", device, ack, "")
|
||||
expect(t, 200, status, out)
|
||||
// A later deployment gets a new revision, even when restoring the same template.
|
||||
status, second := f.request("POST", "/terminal-policy-deployments", admin, body, "policy-2")
|
||||
expect(t, 202, status, second)
|
||||
status, out = f.request("POST", "/terminal/config/ack", device, ack, "")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("GET", "/terminal-policy-deployments/"+deployment+"/results", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
row = out["items"].([]any)[0].(map[string]any)
|
||||
if row["status"] != "applied" || row["configVersion"] != version {
|
||||
t.Fatal("history changed", row)
|
||||
}
|
||||
status, out = f.request("GET", "/terminal-policy-deployments/"+second["id"].(string)+"/results", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
row = out["items"].([]any)[0].(map[string]any)
|
||||
if row["status"] != "pending" || row["configVersion"] == version {
|
||||
t.Fatal(row)
|
||||
}
|
||||
// Resetting identity requires a fresh application and capability report.
|
||||
status, out = f.request("POST", "/terminals/"+id+"/reactivate", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/config/ack", device, ack, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "policy-3")
|
||||
expect(t, 409, status, out)
|
||||
}
|
||||
|
||||
func TestPolicyValidationRBACAndGroupSnapshot(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
admin := f.login("admin")
|
||||
viewer := f.login("viewer")
|
||||
operator := f.login("operator")
|
||||
template := policyTemplate(t, f, admin)
|
||||
valid := map[string]any{"name": "办公策略", "version": "1.0", "heartbeatInterval": 30, "commandPollInterval": 5, "desktopRefreshInterval": 20}
|
||||
status, out := f.request("POST", "/terminal-policy-templates", admin, valid, "")
|
||||
expect(t, 409, status, out)
|
||||
for _, field := range []string{"heartbeatInterval", "commandPollInterval", "desktopRefreshInterval"} {
|
||||
in := map[string]any{}
|
||||
for k, v := range valid {
|
||||
in[k] = v
|
||||
}
|
||||
in["version"] = "invalid-" + field
|
||||
in[field] = 0
|
||||
status, out = f.request("POST", "/terminal-policy-templates", admin, in, "")
|
||||
expect(t, 400, status, out)
|
||||
}
|
||||
id, device, _ := lifecycleDevice(t, f, admin, "POLICY-GROUP-001")
|
||||
policyCap(t, f, device, 1)
|
||||
body := map[string]any{"templateId": template, "targetMode": "group", "group": "原分组"}
|
||||
for _, role := range []string{viewer, operator} {
|
||||
status, out = f.request("POST", "/terminal-policy-templates", role, valid, "")
|
||||
expect(t, 403, status, out)
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", role, body, "rbac-policy")
|
||||
expect(t, 403, status, out)
|
||||
for _, path := range []string{"/terminal-policy-templates", "/terminal-policy-states", "/terminal-policy-deployments"} {
|
||||
status, out = f.request("GET", path, role, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
}
|
||||
}
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "")
|
||||
expect(t, 400, status, out)
|
||||
status, first := f.request("POST", "/terminal-policy-deployments", admin, body, "group-1")
|
||||
expect(t, 202, status, first)
|
||||
_, late, _ := lifecycleDevice(t, f, admin, "POLICY-GROUP-002")
|
||||
policyCap(t, f, late, 1)
|
||||
status, out = f.request("GET", "/terminal-policy-deployments/"+first["id"].(string)+"/results", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if len(out["items"].([]any)) != 1 {
|
||||
t.Fatal("group membership retroactively changed", out)
|
||||
}
|
||||
body["group"] = "不存在"
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "group-1")
|
||||
expect(t, 409, status, out)
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "empty")
|
||||
expect(t, 400, status, out)
|
||||
for i, bad := range []map[string]any{
|
||||
{"templateId": template, "targetMode": "devices", "terminalIds": []string{id, "missing"}},
|
||||
{"templateId": template, "targetMode": "devices", "terminalIds": []string{id}, "group": "原分组"},
|
||||
{"templateId": template, "targetMode": "group", "group": "原分组", "terminalIds": []string{id}},
|
||||
} {
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", admin, bad, fmt.Sprint("invalid", i))
|
||||
expect(t, 400, status, out)
|
||||
}
|
||||
// Downgraded clients explicitly revoke their capability on the next heartbeat.
|
||||
policyCap(t, f, late, 0)
|
||||
body["group"] = "原分组"
|
||||
status, out = f.request("POST", "/terminal-policy-deployments", admin, body, "unsupported")
|
||||
expect(t, 409, status, out)
|
||||
var revision int
|
||||
if err := f.db.Get(&revision, "SELECT config_version FROM terminals WHERE id=$1", id); err != nil || revision != 2 {
|
||||
t.Fatal("partial group dispatch", revision, err)
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/config/ack", device, map[string]any{"deploymentId": first["id"], "configVersion": 2, "status": "failed", "errorCode": "token=secret"}, "")
|
||||
expect(t, 400, status, out)
|
||||
// Replacing an unapplied configuration preserves that outcome as superseded.
|
||||
status, second := f.request("POST", "/terminal-policy-deployments", admin, map[string]any{"templateId": template, "targetMode": "devices", "terminalIds": []string{id}}, "replace")
|
||||
expect(t, 202, status, second)
|
||||
status, out = f.request("GET", "/terminal-policy-deployments/"+first["id"].(string)+"/results", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["items"].([]any)[0].(map[string]any)["status"] != "superseded" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
type powerBatchJob struct {
|
||||
Stage string `db:"stage"`
|
||||
Fingerprint string `db:"fingerprint"`
|
||||
}
|
||||
|
||||
func (s *Server) claimBatchPower(ctx context.Context, t 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", t.TargetID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
r, err := tx.ExecContext(ctx, `UPDATE power_batch_items SET stage='submitting' WHERE task_id=$1 AND stage='prepared' AND EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND kind='batch-desktop' AND status IN ('queued','running'))`, t.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := r.RowsAffected()
|
||||
if n != 1 {
|
||||
return false, nil
|
||||
}
|
||||
err = tx.Commit()
|
||||
return err == nil, err
|
||||
}
|
||||
func (s *Server) confirmBatchPower(ctx context.Context, db sqlx.ExtContext, t model.Task) error {
|
||||
var d model.Desktop
|
||||
if err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID); err != nil {
|
||||
return err
|
||||
}
|
||||
power := d.PowerState
|
||||
if d.Provider == "proxmox" {
|
||||
pve := s.currentPVE()
|
||||
if pve == nil || t.ProviderRef == "" {
|
||||
return errors.New("missing provider task")
|
||||
}
|
||||
task, err := pve.Task(ctx, d.Node, t.ProviderRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != "stopped" || task.ExitStatus != "OK" {
|
||||
return errors.New("original power task not successful")
|
||||
}
|
||||
power, err = pve.Power(ctx, d.Node, d.VMID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if d.Provider != "demo" || s.Config.Mode != "demo" {
|
||||
return errors.New("unsupported provider")
|
||||
}
|
||||
expected := "running"
|
||||
if t.Action == "shutdown" {
|
||||
expected = "stopped"
|
||||
}
|
||||
if power != expected {
|
||||
return errors.New("actual power differs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Server) executeBatchPower(parent context.Context, t model.Task) {
|
||||
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
|
||||
defer cancel()
|
||||
var job powerBatchJob
|
||||
err := s.DB.GetContext(ctx, &job, "SELECT stage,fingerprint FROM power_batch_items WHERE task_id=$1", t.ID)
|
||||
submitted := job.Stage == "submitting" || job.Stage == "uncertain"
|
||||
if err == nil {
|
||||
err = s.RefreshCompute(ctx)
|
||||
}
|
||||
var d model.Desktop
|
||||
if err == nil && !submitted {
|
||||
var item powerBatchCheckItem
|
||||
d, item, err = s.powerBatchPreflight(ctx, s.DB, t.TargetID, t.Action, t.ID)
|
||||
if err == nil && (!item.Available || powerFingerprint(d, item.PowerState) != job.Fingerprint) {
|
||||
err = errors.New("power preflight changed")
|
||||
}
|
||||
if err == nil {
|
||||
var claimed bool
|
||||
claimed, err = s.claimBatchPower(ctx, t)
|
||||
if err == nil && !claimed {
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
submitted = true
|
||||
if d.Provider == "demo" {
|
||||
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)
|
||||
} else {
|
||||
t.ProviderRef, err = s.currentPVE().Action(ctx, d.Node, d.VMID, t.Action)
|
||||
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", t.ProviderRef, t.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
|
||||
if err == nil && d.Provider == "proxmox" {
|
||||
if t.ProviderRef == "" {
|
||||
err = errors.New("missing original UPID")
|
||||
} else {
|
||||
err = s.currentPVE().Wait(ctx, d.Node, t.ProviderRef, time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = s.confirmBatchPower(ctx, s.DB, t)
|
||||
}
|
||||
status, stage, message := "success", "completed", "电源任务和实际状态已确认;连接服务需另行确认"
|
||||
if err != nil {
|
||||
status, stage, message = "failed", "failed", "执行前状态发生变化或 PVE 拒绝请求,未发送有效修改"
|
||||
if submitted {
|
||||
status, stage, message = "timeout", "uncertain", "批量电源结果未确认,已阻止后续修改和新连接;只核对原任务,不会自动重发"
|
||||
}
|
||||
}
|
||||
s.finishBatchPower(t, status, stage, message)
|
||||
}
|
||||
func (s *Server) finishBatchPower(t 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", t.TargetID); err != nil {
|
||||
return
|
||||
}
|
||||
if err = tx.GetContext(ctx, ¤t, "SELECT status FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil || current != "queued" && current != "running" {
|
||||
return
|
||||
}
|
||||
if err = tx.GetContext(ctx, &jobStage, "SELECT stage FROM power_batch_items WHERE task_id=$1 FOR UPDATE", t.ID); err != nil {
|
||||
return
|
||||
}
|
||||
if stage == "expired" {
|
||||
status, stage, message = "failed", "failed", "批量电源任务提交前过期,未发送修改"
|
||||
if jobStage != "prepared" {
|
||||
status, stage, message = "timeout", "uncertain", "原批量电源任务结果未确认,不会自动重发"
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "UPDATE power_batch_items SET stage=$1 WHERE task_id=$2", stage, t.ID)
|
||||
if err == nil && status == "success" {
|
||||
power := "running"
|
||||
if t.Action == "shutdown" {
|
||||
power = "stopped"
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, t.TargetID)
|
||||
}
|
||||
if err == nil {
|
||||
err = s.completeTx(ctx, tx, t, status, message)
|
||||
}
|
||||
if err == nil {
|
||||
_ = tx.Commit()
|
||||
}
|
||||
}
|
||||
func (s *Server) reconcileBatchPower(c *gin.Context) {
|
||||
tx, actor := s.accountTransaction(c, false)
|
||||
if tx == nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if actor.Role != "super_admin" && actor.Role != "operator" {
|
||||
s.fail(c, 403, "forbidden", "当前账号不能核对电源任务")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
var t model.Task
|
||||
if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 AND kind='batch-desktop'", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var id string
|
||||
if err := tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var stage string
|
||||
if err := tx.GetContext(ctx, &stage, "SELECT stage FROM power_batch_items WHERE task_id=$1 FOR UPDATE", t.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if t.Status != "timeout" || stage != "uncertain" {
|
||||
s.fail(c, 409, "reconcile_unavailable", "仅可核对结果未确认的批量电源任务")
|
||||
return
|
||||
}
|
||||
if err := s.confirmBatchPower(ctx, tx, t); err != nil {
|
||||
s.fail(c, 409, "power_unconfirmed", "原 PVE 任务和实际电源状态尚未确认;未重新发送修改")
|
||||
return
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, "UPDATE power_batch_items SET stage='completed' WHERE task_id=$1", t.ID)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='只读核对原任务和实际电源状态通过,未重新发送修改',updated_at=now() WHERE id=$1", t.ID)
|
||||
}
|
||||
if err == nil {
|
||||
power := "running"
|
||||
if t.Action == "shutdown" {
|
||||
power = "stopped"
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, t.TargetID)
|
||||
}
|
||||
if err == nil {
|
||||
err = accountAudit(ctx, tx, actor.Username, "reconcile_batch_power", t.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})
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
type powerBatchInput struct {
|
||||
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
|
||||
DesktopIDs []string `json:"desktopIds" binding:"required,min=1,max=50,dive,required,max=100"`
|
||||
PreviewToken string `json:"previewToken" binding:"max=20000"`
|
||||
}
|
||||
type powerBatchCheckItem struct {
|
||||
DesktopID string `json:"desktopId"`
|
||||
Name string `json:"name"`
|
||||
VMID int `json:"vmid"`
|
||||
PowerState string `json:"powerState"`
|
||||
Available bool `json:"available"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
type powerBatchClaims struct {
|
||||
Hash string `json:"requestHash"`
|
||||
Session string `json:"session"`
|
||||
Ready map[string]string `json:"ready"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func normalizePowerBatch(in *powerBatchInput) (string, error) {
|
||||
sort.Strings(in.DesktopIDs)
|
||||
for i, id := range in.DesktopIDs {
|
||||
if strings.TrimSpace(id) != id || id == "" || i > 0 && id == in.DesktopIDs[i-1] {
|
||||
return "", errors.New("桌面列表不能包含空值、重复值或多余空格")
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
Action string
|
||||
IDs []string
|
||||
}{in.Action, in.DesktopIDs})
|
||||
return store.Hash(string(raw)), nil
|
||||
}
|
||||
func powerFingerprint(d model.Desktop, power string) string {
|
||||
raw, _ := json.Marshal([]any{d.ID, d.Name, d.Provider, d.Node, d.VMID, power})
|
||||
return store.Hash(string(raw))
|
||||
}
|
||||
func (s *Server) powerBatchPreflight(ctx context.Context, db sqlx.ExtContext, id, action, own string) (model.Desktop, powerBatchCheckItem, error) {
|
||||
item := powerBatchCheckItem{DesktopID: id, PowerState: "unknown"}
|
||||
var d model.Desktop
|
||||
err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
item.Code = "not_found"
|
||||
item.Message = "桌面不存在或已移除"
|
||||
return d, item, nil
|
||||
}
|
||||
if err != nil {
|
||||
return d, item, err
|
||||
}
|
||||
item.Name = d.Name
|
||||
item.VMID = d.VMID
|
||||
reject := func(code, message string) (model.Desktop, powerBatchCheckItem, error) {
|
||||
item.Code = code
|
||||
item.Message = message
|
||||
return d, item, nil
|
||||
}
|
||||
blocked, err := maintenanceBlocked(ctx, db, id, own, true)
|
||||
if err != nil {
|
||||
return d, item, err
|
||||
}
|
||||
if blocked {
|
||||
return reject("maintenance", "有维护任务或未确认结果,请先处理")
|
||||
}
|
||||
var latest string
|
||||
err = sqlx.GetContext(ctx, db, &latest, "SELECT status FROM tasks WHERE target_id=$1 AND id!=$2 AND kind IN ('desktop','batch-desktop') ORDER BY created_at DESC,id DESC LIMIT 1", id, own)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return d, item, err
|
||||
}
|
||||
if latest == "timeout" {
|
||||
return reject("power_unconfirmed", "最近电源任务结果未确认,请先核对")
|
||||
}
|
||||
var active bool
|
||||
if err = sqlx.GetContext(ctx, db, &active, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", id); err != nil {
|
||||
return d, item, err
|
||||
}
|
||||
if active {
|
||||
return reject("active_session", "有活动会话,批量操作跳过;请先结束会话")
|
||||
}
|
||||
power := d.PowerState
|
||||
if d.Provider == "proxmox" {
|
||||
pve := s.currentPVE()
|
||||
if pve == nil {
|
||||
return reject("pve_unconfigured", "PVE 未配置")
|
||||
}
|
||||
config, e := pve.VMConfig(ctx, d.Node, d.VMID)
|
||||
if e != nil {
|
||||
return reject("provider_unavailable", "无法读取 PVE 配置")
|
||||
}
|
||||
if proxmox.ConfigInt(config, "template", 0) != 0 || config["lock"] != nil && fmt.Sprint(config["lock"]) != "" {
|
||||
return reject("pve_locked", "PVE 模板或锁定状态不支持批量电源操作")
|
||||
}
|
||||
power, e = pve.Power(ctx, d.Node, d.VMID)
|
||||
if e != nil {
|
||||
return reject("provider_unavailable", "无法读取真实电源状态")
|
||||
}
|
||||
item.PowerState = power
|
||||
ok, e := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), "VM.PowerMgmt")
|
||||
if e != nil || !ok {
|
||||
return reject("permission_missing", "无法确认 VM.PowerMgmt 权限")
|
||||
}
|
||||
} else if d.Provider != "demo" || s.Config.Mode != "demo" {
|
||||
return reject("provider_disabled", "当前环境不允许该桌面操作")
|
||||
}
|
||||
item.PowerState = power
|
||||
if action == "start" && power != "stopped" || action != "start" && power != "running" {
|
||||
return reject("power_mismatch", "当前电源状态不适用此操作")
|
||||
}
|
||||
item.Available = true
|
||||
item.Code = "ready"
|
||||
item.Message = "可提交,执行前将再次核对"
|
||||
return d, item, nil
|
||||
}
|
||||
func (s *Server) checkPowerBatch(c *gin.Context) {
|
||||
var in powerBatchInput
|
||||
if !s.decodeConfiguration(c, &in) {
|
||||
return
|
||||
}
|
||||
hash, err := normalizePowerBatch(&in)
|
||||
if err != nil || in.PreviewToken != "" {
|
||||
s.fail(c, 400, "invalid_request", "请检查桌面列表,仅接受预检参数")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
items := []powerBatchCheckItem{}
|
||||
ready := map[string]string{}
|
||||
for _, id := range in.DesktopIDs {
|
||||
d, item, e := s.powerBatchPreflight(ctx, s.DB, id, in.Action, "")
|
||||
if e != nil {
|
||||
s.fail(c, 503, "batch_check_unavailable", "批量预检未完成,请稍后重试")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
if item.Available {
|
||||
ready[id] = powerFingerprint(d, item.PowerState)
|
||||
}
|
||||
}
|
||||
expires := time.Now().Add(5 * time.Minute)
|
||||
claims := powerBatchClaims{Hash: hash, Session: c.GetString("sessionId"), Ready: ready, RegisteredClaims: jwt.RegisteredClaims{Issuer: "vdi-power-batch-preview", Subject: s.admin(c).ID, ExpiresAt: jwt.NewNumericDate(expires)}}
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.Config.JWTSecret))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"items": items, "eligibleCount": len(ready), "skippedCount": len(items) - len(ready), "previewToken": token, "expiresAt": expires})
|
||||
}
|
||||
|
||||
type powerBatch struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Action string `json:"action" db:"action"`
|
||||
Actor string `json:"actor" db:"actor"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
TotalCount int `json:"totalCount" db:"total_count"`
|
||||
SubmittedCount int `json:"submittedCount" db:"submitted_count"`
|
||||
SkippedCount int `json:"skippedCount" db:"skipped_count"`
|
||||
ActiveCount int `json:"activeCount" db:"active_count"`
|
||||
SuccessCount int `json:"successCount" db:"success_count"`
|
||||
FailedCount int `json:"failedCount" db:"failed_count"`
|
||||
UncertainCount int `json:"uncertainCount" db:"uncertain_count"`
|
||||
}
|
||||
type powerBatchItem struct {
|
||||
DesktopID string `json:"desktopId" db:"desktop_id"`
|
||||
Name string `json:"name" db:"desktop_name"`
|
||||
VMID int `json:"vmid" db:"vmid"`
|
||||
TaskID string `json:"taskId" db:"task_id"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Code string `json:"code" db:"code"`
|
||||
Message string `json:"message" db:"message"`
|
||||
}
|
||||
|
||||
const powerBatchQuery = `SELECT b.id,b.action,b.actor,b.created_at,count(*) total_count,count(i.task_id) submitted_count,count(*) FILTER(WHERE i.task_id IS NULL) skipped_count,count(*) FILTER(WHERE t.status IN ('queued','running')) active_count,count(*) FILTER(WHERE t.status='success') success_count,count(*) FILTER(WHERE t.status='failed') failed_count,count(*) FILTER(WHERE t.status='timeout') uncertain_count FROM power_batches b JOIN power_batch_items i ON i.batch_id=b.id LEFT JOIN tasks t ON t.id=i.task_id`
|
||||
|
||||
func (s *Server) powerBatches(c *gin.Context) {
|
||||
items := []powerBatch{}
|
||||
if err := s.DB.SelectContext(c.Request.Context(), &items, powerBatchQuery+" GROUP BY b.id ORDER BY b.created_at DESC,b.id DESC LIMIT 100"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
}
|
||||
func powerBatchDetail(ctx context.Context, db sqlx.ExtContext, id string) (gin.H, error) {
|
||||
var batch powerBatch
|
||||
if err := sqlx.GetContext(ctx, db, &batch, powerBatchQuery+" WHERE b.id=$1 GROUP BY b.id", id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []powerBatchItem{}
|
||||
err := sqlx.SelectContext(ctx, db, &items, `SELECT i.desktop_id,i.desktop_name,i.vmid,coalesce(i.task_id,'') task_id,coalesce(t.status,'skipped') status,i.code,coalesce(t.message,i.message) message FROM power_batch_items i LEFT JOIN tasks t ON t.id=i.task_id WHERE i.batch_id=$1 ORDER BY i.desktop_id`, id)
|
||||
return gin.H{"batch": batch, "items": items}, err
|
||||
}
|
||||
func (s *Server) getPowerBatch(c *gin.Context) {
|
||||
out, err := powerBatchDetail(c.Request.Context(), s.DB, c.Param("id"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, out)
|
||||
}
|
||||
func (s *Server) queuePowerBatch(c *gin.Context) {
|
||||
var in powerBatchInput
|
||||
if !s.decodeConfiguration(c, &in) {
|
||||
return
|
||||
}
|
||||
hash, err := normalizePowerBatch(&in)
|
||||
if err != nil {
|
||||
s.fail(c, 400, "invalid_request", err.Error())
|
||||
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, false)
|
||||
if tx == nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if actor.Role != "super_admin" && actor.Role != "operator" {
|
||||
s.fail(c, 403, "forbidden", "当前账号不能执行批量电源操作")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
var old struct {
|
||||
ID string `db:"id"`
|
||||
Hash string `db:"request_hash"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &old, "SELECT id,request_hash FROM power_batches WHERE actor=$1 AND idempotency_key=$2", actor.Username, key)
|
||||
if err == nil {
|
||||
if old.Hash != hash {
|
||||
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于其他桌面或操作")
|
||||
return
|
||||
}
|
||||
out, e := powerBatchDetail(ctx, tx, old.ID)
|
||||
if e != nil {
|
||||
s.dbError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(202, out)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
claims := &powerBatchClaims{}
|
||||
token, e := jwt.ParseWithClaims(in.PreviewToken, claims, func(t *jwt.Token) (any, error) { return []byte(s.Config.JWTSecret), nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vdi-power-batch-preview"), jwt.WithExpirationRequired())
|
||||
if e != nil || !token.Valid || claims.Subject != actor.ID || claims.Session != c.GetString("sessionId") || claims.Hash != hash || len(claims.Ready) == 0 {
|
||||
s.fail(c, 409, "batch_preview_expired", "请重新预检,并确认至少一台桌面可执行")
|
||||
return
|
||||
}
|
||||
var locked []string
|
||||
if err = tx.SelectContext(ctx, &locked, "SELECT id FROM desktops WHERE id=ANY($1) ORDER BY id FOR UPDATE", in.DesktopIDs); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
id := store.ID("batch_")
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO power_batches(id,actor,action,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5)", id, actor.Username, in.Action, key, hash)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
for _, desktopID := range in.DesktopIDs {
|
||||
d, item, e := s.powerBatchPreflight(ctx, tx, desktopID, in.Action, "")
|
||||
if e != nil {
|
||||
s.dbError(c, e)
|
||||
return
|
||||
}
|
||||
fingerprint, approved := claims.Ready[desktopID]
|
||||
if item.Available && (!approved || fingerprint != powerFingerprint(d, item.PowerState)) {
|
||||
item.Available = false
|
||||
item.Code = "preview_changed"
|
||||
item.Message = "与已确认的预检不一致,已跳过,请重新预检"
|
||||
}
|
||||
taskID, stage := "", "skipped"
|
||||
if item.Available {
|
||||
taskID = store.ID("task_")
|
||||
stage = "prepared"
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'batch-desktop',$2,$3,$4,$5,'queued','已提交批量电源任务,执行前再次预检',$6)`, taskID, in.Action, d.ID, d.Name, actor.Username, id+":"+d.ID)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
_, 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, in.Action, d.ID, c.GetString("requestId"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO power_batch_items(batch_id,desktop_id,desktop_name,vmid,task_id,code,message,fingerprint,stage) VALUES($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9)`, id, desktopID, item.Name, item.VMID, taskID, item.Code, item.Message, fingerprint, stage)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
_, 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, "batch-"+in.Action, id, c.GetString("requestId"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
out, err := powerBatchDetail(ctx, tx, id)
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(202, out)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
type batchPVE struct {
|
||||
mu sync.Mutex
|
||||
state, outcome string
|
||||
locked, deny bool
|
||||
writes int
|
||||
}
|
||||
|
||||
func fakeBatchPVE(t *testing.T, f *fixture) *batchPVE {
|
||||
p := &batchPVE{state: "stopped"}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api2/json")
|
||||
var data any
|
||||
switch {
|
||||
case strings.Contains(path, "/tasks/"):
|
||||
if p.outcome == "poll-failure" {
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
status, exit := "stopped", "OK"
|
||||
if p.outcome == "running" {
|
||||
status = "running"
|
||||
}
|
||||
if p.outcome == "failed" {
|
||||
exit = "TASK ERROR"
|
||||
}
|
||||
data = map[string]any{"status": status, "exitstatus": exit}
|
||||
case path == "/access/permissions":
|
||||
n := 1
|
||||
if p.deny {
|
||||
n = 0
|
||||
}
|
||||
data = map[string]any{r.URL.Query().Get("path"): map[string]int{"VM.PowerMgmt": n, "VM.Snapshot": n, "VM.Config.CPU": n, "VM.Config.Memory": n}}
|
||||
case strings.HasSuffix(path, "/status/current"):
|
||||
data = map[string]string{"status": p.state}
|
||||
case strings.HasSuffix(path, "/config"):
|
||||
c := map[string]any{"digest": "d1", "cores": 2, "memory": 2048, "scsi0": "local:vm-9000,size=4G"}
|
||||
if p.locked {
|
||||
c["lock"] = "backup"
|
||||
}
|
||||
data = c
|
||||
case strings.HasSuffix(path, "/pending"):
|
||||
data = []any{}
|
||||
case r.Method == "POST" && strings.Contains(path, "/status/"):
|
||||
p.writes++
|
||||
if p.outcome == "reject" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
if p.outcome == "ambiguous" {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
p.state = "running"
|
||||
if strings.HasSuffix(path, "/shutdown") {
|
||||
p.state = "stopped"
|
||||
}
|
||||
data = "UPID:batch-power"
|
||||
if p.outcome == "missing-upid" {
|
||||
data = ""
|
||||
}
|
||||
default:
|
||||
t.Error("unexpected batch PVE call", r.Method, path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox',node='node-a',vmid=9000,power_state='stopped' WHERE id='desk_demo_windows'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
func batchRequest(action string, ids ...string) map[string]any {
|
||||
return map[string]any{"action": action, "desktopIds": ids}
|
||||
}
|
||||
func checkedBatch(t *testing.T, f *fixture, token string, body map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
code, out := f.request("POST", "/desktop-batches/check", token, body, "")
|
||||
expect(t, 200, code, out)
|
||||
b := copySnapshotConfig(body)
|
||||
b["previewToken"] = out["previewToken"]
|
||||
return b
|
||||
}
|
||||
func queueBatchTest(t *testing.T, f *fixture, token string, body map[string]any, key string) map[string]any {
|
||||
t.Helper()
|
||||
code, out := f.request("POST", "/desktop-batches", token, body, key)
|
||||
expect(t, 202, code, out)
|
||||
return out
|
||||
}
|
||||
func batchTask(t *testing.T, f *fixture, out map[string]any) model.Task {
|
||||
t.Helper()
|
||||
for _, value := range out["items"].([]any) {
|
||||
item := value.(map[string]any)
|
||||
if id, _ := item["taskId"].(string); id != "" {
|
||||
return readSnapshotTask(t, f, id)
|
||||
}
|
||||
}
|
||||
t.Fatal("no batch task", out)
|
||||
return model.Task{}
|
||||
}
|
||||
func TestPowerBatchLifecycleAndPartialResults(t *testing.T) {
|
||||
for _, action := range []string{"start", "shutdown", "reboot"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeBatchPVE(t, f)
|
||||
if action != "start" {
|
||||
p.state = "running"
|
||||
}
|
||||
operator := f.login("operator")
|
||||
body := checkedBatch(t, f, operator, batchRequest(action, "desk_demo_windows", "not-managed"))
|
||||
out := queueBatchTest(t, f, operator, body, "batch-once")
|
||||
batch := out["batch"].(map[string]any)
|
||||
if batch["submittedCount"] != float64(1) || batch["skippedCount"] != float64(1) || batch["activeCount"] != float64(1) || batch["successCount"] != float64(0) {
|
||||
t.Fatal(batch)
|
||||
}
|
||||
task := batchTask(t, f, out)
|
||||
f.server.execute(context.Background(), task)
|
||||
task = readSnapshotTask(t, f, task.ID)
|
||||
if task.Status != "success" || p.writes != 1 || task.ProviderRef == "" {
|
||||
t.Fatal(task, p.writes)
|
||||
}
|
||||
code, view := f.request("GET", "/desktop-batches/"+batch["id"].(string), f.login("viewer"), nil, "")
|
||||
expect(t, 200, code, view)
|
||||
counts := view["batch"].(map[string]any)
|
||||
if counts["successCount"] != float64(1) || counts["activeCount"] != float64(0) {
|
||||
t.Fatal(counts)
|
||||
}
|
||||
code, view = f.request("GET", "/desktop-batches", operator, nil, "")
|
||||
expect(t, 200, code, view)
|
||||
if view["total"] != float64(1) {
|
||||
t.Fatal(view)
|
||||
}
|
||||
// A retry after completion, even without a preview token, returns the same batch.
|
||||
delete(body, "previewToken")
|
||||
again := queueBatchTest(t, f, operator, body, "batch-once")
|
||||
if again["batch"].(map[string]any)["id"] != batch["id"] {
|
||||
t.Fatal("duplicate batch")
|
||||
}
|
||||
body["action"] = "start"
|
||||
if action == "start" {
|
||||
body["action"] = "shutdown"
|
||||
}
|
||||
code, view = f.request("POST", "/desktop-batches", operator, body, "batch-once")
|
||||
expect(t, 409, code, view)
|
||||
var submitted, completed int
|
||||
_ = f.db.Get(&submitted, "SELECT count(*) FROM audit_logs WHERE result='submitted'")
|
||||
_ = f.db.Get(&completed, "SELECT count(*) FROM audit_logs WHERE result='success' AND action=$1", action)
|
||||
if submitted != 2 || completed != 1 {
|
||||
t.Fatal("audit missing", submitted, completed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestPowerBatchPreviewValidationAndRBAC(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
fakeBatchPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
viewer := f.login("viewer")
|
||||
operator := f.login("operator")
|
||||
for _, path := range []string{"/desktop-batches/check", "/desktop-batches"} {
|
||||
code, out := f.request("POST", path, viewer, batchRequest("start", "desk_demo_windows"), "no")
|
||||
expect(t, 403, code, out)
|
||||
}
|
||||
for _, body := range []map[string]any{batchRequest("reset", "desk_demo_windows"), batchRequest("start"), batchRequest("start", "desk_demo_windows", "desk_demo_windows"), batchRequest("start", " desk_demo_windows"), {"action": "start", "desktopIds": []string{"desk_demo_windows"}, "force": true}} {
|
||||
code, out := f.request("POST", "/desktop-batches/check", admin, body, "")
|
||||
expect(t, 400, code, out)
|
||||
}
|
||||
ids := []string{}
|
||||
for i := 0; i < 51; i++ {
|
||||
ids = append(ids, store.ID("d_"))
|
||||
}
|
||||
code, out := f.request("POST", "/desktop-batches/check", admin, batchRequest("start", ids...), "")
|
||||
expect(t, 400, code, out)
|
||||
body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows"))
|
||||
preview := body["previewToken"].(string)
|
||||
code, out = f.request("GET", "/auth/me", preview, nil, "")
|
||||
expect(t, 401, code, out)
|
||||
code, out = f.request("POST", "/desktop-batches", admin, body, "")
|
||||
expect(t, 400, code, out)
|
||||
code, out = f.request("POST", "/desktop-batches", operator, body, "wrong-user")
|
||||
expect(t, 409, code, out)
|
||||
body["action"] = "shutdown"
|
||||
code, out = f.request("POST", "/desktop-batches", admin, body, "changed")
|
||||
expect(t, 409, code, out)
|
||||
body["action"] = "start"
|
||||
body["previewToken"] = admin
|
||||
code, out = f.request("POST", "/desktop-batches", admin, body, "access-not-preview")
|
||||
expect(t, 409, code, out)
|
||||
cl := &powerBatchClaims{}
|
||||
_, err := jwt.ParseWithClaims(preview, cl, func(*jwt.Token) (any, error) { return []byte(f.server.Config.JWTSecret), nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Minute))
|
||||
expired, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, cl).SignedString([]byte(f.server.Config.JWTSecret))
|
||||
body["previewToken"] = expired
|
||||
code, out = f.request("POST", "/desktop-batches", admin, body, "expired")
|
||||
expect(t, 409, code, out)
|
||||
body["previewToken"] = preview
|
||||
secondSession := f.login("admin")
|
||||
code, out = f.request("POST", "/desktop-batches", secondSession, body, "wrong-session")
|
||||
expect(t, 409, code, out)
|
||||
var n int
|
||||
_ = f.db.Get(&n, "SELECT count(*) FROM power_batches")
|
||||
if n != 0 {
|
||||
t.Fatal("invalid request persisted batch")
|
||||
}
|
||||
}
|
||||
func TestPowerBatchPreflightBlockersAndRecheck(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeBatchPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
for _, block := range []string{"running", "lock", "deny", "active-session", "maintenance", "unknown"} {
|
||||
t.Run(block, func(t *testing.T) {
|
||||
p.state = "stopped"
|
||||
p.locked = false
|
||||
p.deny = false
|
||||
switch block {
|
||||
case "running":
|
||||
p.state = "running"
|
||||
case "lock":
|
||||
p.locked = true
|
||||
case "deny":
|
||||
p.deny = true
|
||||
case "active-session":
|
||||
_, _ = f.db.Exec("UPDATE terminals SET session_desktop_id='desk_demo_windows',last_seen=now() WHERE id='term_demo_001'")
|
||||
case "maintenance":
|
||||
_, _ = f.db.Exec("INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,idempotency_key) VALUES('block','desktop','start','desk_demo_windows','blocked','admin','queued','block')")
|
||||
case "unknown":
|
||||
_, _ = f.db.Exec("INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,idempotency_key) VALUES('block','desktop','start','desk_demo_windows','blocked','admin','timeout','block')")
|
||||
}
|
||||
code, out := f.request("POST", "/desktop-batches/check", admin, batchRequest("start", "desk_demo_windows"), "")
|
||||
expect(t, 200, code, out)
|
||||
if out["eligibleCount"] != float64(0) {
|
||||
t.Fatal(block, out)
|
||||
}
|
||||
body := batchRequest("start", "desk_demo_windows")
|
||||
body["previewToken"] = out["previewToken"]
|
||||
code, out = f.request("POST", "/desktop-batches", admin, body, "blocked-"+block)
|
||||
expect(t, 409, code, out)
|
||||
_, _ = f.db.Exec("DELETE FROM tasks WHERE id='block'")
|
||||
_, _ = f.db.Exec("UPDATE terminals SET session_desktop_id=NULL WHERE id='term_demo_001'")
|
||||
})
|
||||
}
|
||||
p.state = "stopped"
|
||||
p.locked = false
|
||||
p.deny = false
|
||||
// A desktop that was excluded from the preview cannot become a newly queued target.
|
||||
body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows", "desk_demo_ubuntu"))
|
||||
_, _ = f.db.Exec("UPDATE desktops SET power_state='stopped' WHERE id='desk_demo_ubuntu'")
|
||||
out := queueBatchTest(t, f, admin, body, "preview-fence")
|
||||
if out["batch"].(map[string]any)["submittedCount"] != float64(1) {
|
||||
t.Fatal("added unapproved target", out)
|
||||
}
|
||||
task := batchTask(t, f, out)
|
||||
p.state = "running"
|
||||
f.server.execute(context.Background(), task)
|
||||
if readSnapshotTask(t, f, task.ID).Status != "failed" || p.writes != 0 {
|
||||
t.Fatal("worker did not recheck before send")
|
||||
}
|
||||
// Queueing rechecks newly active sessions and records a skipped row, never sends.
|
||||
p.state = "stopped"
|
||||
body = checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows"))
|
||||
_, _ = f.db.Exec("UPDATE terminals SET session_desktop_id='desk_demo_windows',last_seen=now() WHERE id='term_demo_001'")
|
||||
out = queueBatchTest(t, f, admin, body, "session-after-preview")
|
||||
if out["batch"].(map[string]any)["submittedCount"] != float64(0) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
func TestPowerBatchUnknownAndMaintenanceProtection(t *testing.T) {
|
||||
for _, outcome := range []string{"reject", "ambiguous", "missing-upid", "poll-failure", "failed"} {
|
||||
t.Run(outcome, func(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeBatchPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows"))
|
||||
out := queueBatchTest(t, f, admin, body, "uncertain")
|
||||
task := batchTask(t, f, out)
|
||||
p.outcome = outcome
|
||||
f.server.execute(context.Background(), task)
|
||||
task = readSnapshotTask(t, f, task.ID)
|
||||
want := "timeout"
|
||||
if outcome == "reject" {
|
||||
want = "failed"
|
||||
}
|
||||
if task.Status != want {
|
||||
t.Fatal(task)
|
||||
}
|
||||
if want == "failed" {
|
||||
return
|
||||
}
|
||||
code, view := f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "blocked-single")
|
||||
expect(t, 409, code, view)
|
||||
_, _ = f.db.Exec("UPDATE desktops SET power_state='running' WHERE id='desk_demo_windows'")
|
||||
_, _ = f.db.Exec("UPDATE terminals SET token_hash=$1,last_seen=now() WHERE id='term_demo_001'", store.Hash("batch-device"))
|
||||
code, view = f.request("POST", "/terminal/connect-ticket", "batch-device", map[string]string{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 409, code, view)
|
||||
if view["code"] != "hardware_unconfirmed" {
|
||||
t.Fatal(view)
|
||||
}
|
||||
code, view = f.request("PUT", "/desktops/desk_demo_windows/connection", admin, map[string]any{"endpoint": "192.0.2.90", "width": 1920, "height": 1080, "fps": 30, "initialized": true}, "")
|
||||
expect(t, 409, code, view)
|
||||
_, _ = f.db.Exec("UPDATE tasks SET status='running' WHERE id=$1", task.ID)
|
||||
f.server.execute(context.Background(), task)
|
||||
task = readSnapshotTask(t, f, task.ID)
|
||||
if p.writes != 1 {
|
||||
t.Fatal("power replayed", p.writes)
|
||||
}
|
||||
code, view = f.request("POST", "/tasks/"+task.ID+"/batch-power-reconcile", admin, nil, "")
|
||||
expect(t, 409, code, view)
|
||||
p.outcome = ""
|
||||
f.db.SetMaxOpenConns(1)
|
||||
code, view = f.request("POST", "/tasks/"+task.ID+"/batch-power-reconcile", admin, nil, "")
|
||||
if outcome == "poll-failure" || outcome == "failed" {
|
||||
expect(t, 200, code, view)
|
||||
} else {
|
||||
expect(t, 409, code, view)
|
||||
}
|
||||
if p.writes != 1 {
|
||||
t.Fatal("read-only reconcile wrote PVE")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestPowerBatchConcurrentReplayAndExclusivity(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
fakeBatchPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows"))
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan string, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
out := queueBatchTest(t, f, admin, body, "concurrent")
|
||||
results <- out["batch"].(map[string]any)["id"].(string)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
first := ""
|
||||
for id := range results {
|
||||
if first == "" {
|
||||
first = id
|
||||
}
|
||||
if id != first {
|
||||
t.Fatal("duplicate batch")
|
||||
}
|
||||
}
|
||||
code, out := f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "single-conflict")
|
||||
expect(t, 409, code, out)
|
||||
code, out = f.request("POST", "/desktops/desk_demo_windows/hardware", admin, map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"}, "hardware-conflict")
|
||||
expect(t, 409, code, out)
|
||||
var n int
|
||||
_ = f.db.Get(&n, "SELECT count(*) FROM tasks WHERE kind='batch-desktop'")
|
||||
if n != 1 {
|
||||
t.Fatal(n)
|
||||
}
|
||||
}
|
||||
func TestPowerBatchExpiryAndOrdinaryLane(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeBatchPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
body := checkedBatch(t, f, admin, batchRequest("start", "desk_demo_windows"))
|
||||
out := queueBatchTest(t, f, admin, body, "prepared-expire")
|
||||
task := batchTask(t, f, out)
|
||||
f.server.finishBatchPower(task, "", "expired", "")
|
||||
claimed, err := f.server.claimBatchPower(context.Background(), task)
|
||||
if err != nil || claimed {
|
||||
t.Fatal("expired task claimed", err)
|
||||
}
|
||||
out = queueBatchTest(t, f, admin, body, "submitting-expire")
|
||||
task = batchTask(t, f, out)
|
||||
claimed, err = f.server.claimBatchPower(context.Background(), task)
|
||||
if err != nil || !claimed {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.server.finishBatchPower(task, "", "expired", "")
|
||||
if readSnapshotTask(t, f, task.ID).Status != "timeout" {
|
||||
t.Fatal("submitted expiry not protected")
|
||||
}
|
||||
// Clear only the isolated test job, then verify a queued batch older than 2 minutes remains eligible.
|
||||
_, _ = f.db.Exec("UPDATE power_batch_items SET stage='failed' WHERE task_id=$1", task.ID)
|
||||
_, _ = f.db.Exec("UPDATE tasks SET status='failed' WHERE id=$1", task.ID)
|
||||
out = queueBatchTest(t, f, admin, body, "long")
|
||||
task = batchTask(t, f, out)
|
||||
_, _ = f.db.Exec("UPDATE tasks SET created_at=now()-interval '3 minutes' WHERE id=$1", task.ID)
|
||||
p.outcome = "running"
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { f.server.RunWorker(ctx); close(done) }()
|
||||
defer func() { cancel(); <-done }()
|
||||
deadline := time.Now().Add(6 * time.Second)
|
||||
for readSnapshotTask(t, f, task.ID).ProviderRef == "" {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("batch failed to start")
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
code, view := f.request("POST", "/desktops/desk_demo_kylin/actions", admin, map[string]string{"action": "start"}, "ordinary")
|
||||
expect(t, 202, code, view)
|
||||
for readSnapshotTask(t, f, view["id"].(string)).Status != "success" {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("batch blocked normal task lane")
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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()
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/config"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
@@ -20,10 +22,13 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Config config.Config
|
||||
DB *sqlx.DB
|
||||
PVE *proxmox.Client
|
||||
Router *gin.Engine
|
||||
Config config.Config
|
||||
DB *sqlx.DB
|
||||
PVE *proxmox.Client
|
||||
Router *gin.Engine
|
||||
computeMu sync.Mutex
|
||||
computeRevision string
|
||||
computeRuntime atomic.Pointer[proxmox.Client]
|
||||
}
|
||||
type claims struct {
|
||||
AdminID string `json:"adminId"`
|
||||
@@ -34,6 +39,7 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
s := &Server{Config: cfg, DB: db, PVE: pve}
|
||||
r := gin.New()
|
||||
_ = r.SetTrustedProxies(cfg.TrustedProxies)
|
||||
r.Use(gin.Recovery(), func(c *gin.Context) {
|
||||
c.Set("requestId", store.ID("req_"))
|
||||
c.Header("X-Request-ID", c.GetString("requestId"))
|
||||
@@ -56,32 +62,90 @@ func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
|
||||
t := r.Group("/api/v1/terminal", s.deviceAuth)
|
||||
t.POST("/heartbeat", s.heartbeat)
|
||||
t.GET("/config", s.deviceConfig)
|
||||
t.POST("/config/ack", s.policyAck)
|
||||
t.GET("/desktops", s.deviceDesktops)
|
||||
t.GET("/commands", s.deviceCommands)
|
||||
t.POST("/commands/:id/ack", s.deviceAck)
|
||||
t.POST("/connect-ticket", s.createTicket)
|
||||
t.POST("/connect-ticket/consume", s.consumeTicket)
|
||||
t.POST("/session", s.deviceSession)
|
||||
a := r.Group("/api/v1", s.adminAuth)
|
||||
a := r.Group("/api/v1", s.adminAuth, s.refreshComputeRequest)
|
||||
a.GET("/auth/me", func(c *gin.Context) { c.JSON(200, c.MustGet("admin")) })
|
||||
a.POST("/auth/logout", s.logout)
|
||||
a.POST("/auth/password", s.changeOwnPassword)
|
||||
a.GET("/administrators", s.allow("super_admin"), s.administratorList)
|
||||
a.POST("/administrators", s.allow("super_admin"), s.createAdministrator)
|
||||
a.PUT("/administrators/:id", s.allow("super_admin"), s.updateAdministrator)
|
||||
a.POST("/administrators/:id/password", s.allow("super_admin"), s.resetAdministratorPassword)
|
||||
a.GET("/overview", s.overview)
|
||||
a.GET("/sessions/history", s.sessionHistory)
|
||||
a.GET("/monitor/series", s.resourceSeries)
|
||||
a.GET("/monitor/rules", s.alertRules)
|
||||
a.POST("/monitor/rules", s.allow("super_admin"), s.saveAlertRule)
|
||||
a.PUT("/monitor/rules/:id", s.allow("super_admin"), s.saveAlertRule)
|
||||
a.GET("/monitor/alerts", s.alerts)
|
||||
a.POST("/monitor/alerts/:id/acknowledge", s.allow("super_admin", "operator"), s.acknowledgeAlert)
|
||||
a.POST("/imports/preview", s.allow("super_admin"), s.previewImport)
|
||||
a.POST("/imports/commit", s.allow("super_admin"), s.commitImport)
|
||||
a.GET("/settings", s.settings)
|
||||
a.GET("/settings/pve", s.allow("super_admin"), s.getPVEConnection)
|
||||
a.POST("/settings/pve/check", s.allow("super_admin"), s.checkPVEConnection)
|
||||
a.PUT("/settings/pve", s.allow("super_admin"), s.savePVEConnection)
|
||||
a.GET("/desktops/:id/hardware", s.desktopHardware)
|
||||
a.GET("/desktops/:id/snapshots", s.snapshotView)
|
||||
a.POST("/desktops/:id/snapshots/check", s.allow("super_admin"), s.checkSnapshot)
|
||||
a.POST("/desktops/:id/snapshots/actions", s.allow("super_admin"), s.queueSnapshot)
|
||||
a.POST("/tasks/:id/snapshot-reconcile", s.allow("super_admin"), s.reconcileSnapshot)
|
||||
a.POST("/desktops/:id/hardware/check", s.allow("super_admin"), s.checkHardware)
|
||||
a.POST("/desktops/:id/hardware", s.allow("super_admin"), s.queueHardware)
|
||||
a.GET("/resources", s.resources)
|
||||
a.GET("/resources/gpus", s.gpuInventory)
|
||||
a.GET("/resources/vms", s.discoverVMs)
|
||||
a.GET("/resources/templates", s.discoverTemplates)
|
||||
a.GET("/resources/storage", s.storageList)
|
||||
a.GET("/templates", s.templateList)
|
||||
a.POST("/templates", s.allow("super_admin"), s.registerTemplate)
|
||||
a.PATCH("/templates/:id", s.allow("super_admin"), s.updateTemplate)
|
||||
a.POST("/templates/:id/validate", s.allow("super_admin"), s.validateTemplate)
|
||||
a.GET("/pools", s.poolList)
|
||||
a.POST("/pools", s.allow("super_admin"), s.savePool)
|
||||
a.PUT("/pools/:id", s.allow("super_admin"), s.savePool)
|
||||
a.PUT("/pools/:id/members", s.allow("super_admin"), s.poolMembers)
|
||||
a.POST("/desktops/clone/check", s.allow("super_admin"), s.checkClone)
|
||||
a.POST("/desktops/clone", s.allow("super_admin"), s.cloneDesktop)
|
||||
a.POST("/resources/sync", s.allow("super_admin"), s.syncResources)
|
||||
a.GET("/desktop-batches", s.powerBatches)
|
||||
a.GET("/desktop-batches/:id", s.getPowerBatch)
|
||||
a.POST("/desktop-batches/check", s.allow("super_admin", "operator"), s.checkPowerBatch)
|
||||
a.POST("/desktop-batches", s.allow("super_admin", "operator"), s.queuePowerBatch)
|
||||
a.POST("/tasks/:id/batch-power-reconcile", s.allow("super_admin", "operator"), s.reconcileBatchPower)
|
||||
a.GET("/desktops", s.desktops)
|
||||
a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop)
|
||||
a.GET("/desktops/:id", s.desktop)
|
||||
a.PUT("/desktops/:id/connection", s.allow("super_admin"), s.configureDesktop)
|
||||
a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction)
|
||||
a.GET("/terminals", s.terminals)
|
||||
a.GET("/terminals/:id/metrics", s.terminalMetrics)
|
||||
a.GET("/terminal-policy-templates", s.policyTemplates)
|
||||
a.POST("/terminal-policy-templates", s.allow("super_admin"), s.createPolicyTemplate)
|
||||
a.GET("/terminal-policy-states", s.policyStates)
|
||||
a.GET("/terminal-policy-deployments", s.policyDeployments)
|
||||
a.POST("/terminal-policy-deployments", s.allow("super_admin"), s.deployPolicy)
|
||||
a.GET("/terminal-policy-deployments/:id/results", s.policyResults)
|
||||
a.POST("/terminals", s.allow("super_admin"), s.enroll)
|
||||
a.PUT("/terminals/:id", s.allow("super_admin"), s.updateTerminal)
|
||||
a.POST("/terminals/:id/disable", s.allow("super_admin"), s.disableTerminal)
|
||||
a.POST("/terminals/:id/reactivate", s.allow("super_admin"), s.reactivateTerminal)
|
||||
a.POST("/terminals/:id/commands", s.allow("super_admin", "operator"), s.terminalCommand)
|
||||
a.GET("/users", s.users)
|
||||
a.POST("/users", s.allow("super_admin"), s.createUser)
|
||||
a.PUT("/users/:id", s.allow("super_admin"), s.updateUser)
|
||||
a.POST("/users/batch-bindings", s.allow("super_admin"), s.batchBindings)
|
||||
a.PUT("/users/:id/bindings", s.allow("super_admin"), s.bindings)
|
||||
a.GET("/tasks", s.tasks)
|
||||
a.GET("/tasks/:id", s.task)
|
||||
a.POST("/tasks/:id/hardware-reconcile", s.allow("super_admin"), s.reconcileHardware)
|
||||
a.POST("/tasks/:id/reconcile", s.allow("super_admin"), s.reconcileProvision)
|
||||
a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics)
|
||||
a.GET("/audit-logs", s.audits)
|
||||
r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") })
|
||||
@@ -139,7 +203,7 @@ func (s *Server) adminAuth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var a model.Administrator
|
||||
err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND x.expires_at>now()`, cl.ID, cl.AdminID)
|
||||
err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND a.enabled AND x.expires_at>now()`, cl.ID, cl.AdminID)
|
||||
if err != nil {
|
||||
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
|
||||
return
|
||||
@@ -156,8 +220,18 @@ func (s *Server) login(c *gin.Context) {
|
||||
if !s.decode(c, &input) {
|
||||
return
|
||||
}
|
||||
if !s.allowLoginAttempt(c, input.Username) {
|
||||
return
|
||||
}
|
||||
var a model.Administrator
|
||||
err := s.DB.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1", input.Username)
|
||||
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Serialize session creation against account disable, role changes and password reset.
|
||||
err = tx.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1 AND enabled FOR SHARE", input.Username)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(input.Password)) != nil {
|
||||
s.fail(c, 401, "invalid_credentials", "用户名或密码错误")
|
||||
return
|
||||
@@ -170,7 +244,11 @@ func (s *Server) login(c *gin.Context) {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil {
|
||||
if _, err = tx.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ type fixture struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
func newFixture(t *testing.T) *fixture { return newFixtureWorker(t, true) }
|
||||
func newFixtureWorker(t *testing.T, worker bool) *fixture {
|
||||
t.Helper()
|
||||
url := os.Getenv("VDI_TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
@@ -35,7 +36,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(`TRUNCATE connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
|
||||
if _, err = db.Exec(`TRUNCATE terminal_metric_samples,terminal_metrics_current,power_batch_items,power_batches,snapshot_jobs,hardware_jobs,pve_connection,login_limits,alert_states,alerts,alert_rules,resource_samples,session_history,import_batches,terminal_policy_deliveries,terminal_policy_deployments,terminal_policy_templates,provisioning_jobs,desktop_pools,image_templates,connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil {
|
||||
@@ -43,8 +44,14 @@ func newFixture(t *testing.T) *fixture {
|
||||
}
|
||||
s := New(config.Config{Mode: "demo", JWTSecret: strings.Repeat("test-", 10)}, db, nil)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go s.RunWorker(ctx)
|
||||
t.Cleanup(func() { cancel(); time.Sleep(30 * time.Millisecond); db.Close() })
|
||||
workerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(workerDone)
|
||||
if worker {
|
||||
s.RunWorker(ctx)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() { cancel(); <-workerDone; db.Close() })
|
||||
return &fixture{s, db, t}
|
||||
}
|
||||
func (f *fixture) request(method, path, token string, body any, key string) (int, map[string]any) {
|
||||
@@ -166,6 +173,10 @@ func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
|
||||
t.Fatal("missing allowed desktops")
|
||||
}
|
||||
f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100' WHERE id='desk_demo_windows'")
|
||||
f.db.Exec("UPDATE desktops SET setup_required=true WHERE id='desk_demo_windows'")
|
||||
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 409, status, out)
|
||||
f.db.Exec("UPDATE desktops SET setup_required=false WHERE id='desk_demo_windows'")
|
||||
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 201, status, out)
|
||||
ticket := out["ticket"].(string)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
type sessionRecord struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
SessionID string `json:"sessionId" db:"session_id"`
|
||||
TerminalID string `json:"terminalId" db:"terminal_id"`
|
||||
TerminalName string `json:"terminalName" db:"terminal_name"`
|
||||
DesktopID string `json:"desktopId" db:"desktop_id"`
|
||||
DesktopName string `json:"desktopName" db:"desktop_name"`
|
||||
Username string `json:"username" db:"username"`
|
||||
DisplayName string `json:"displayName" db:"display_name"`
|
||||
State string `json:"state" db:"state"`
|
||||
Source string `json:"source" db:"source"`
|
||||
ReservedAt time.Time `json:"reservedAt" db:"reserved_at"`
|
||||
ConnectedAt *time.Time `json:"connectedAt" db:"connected_at"`
|
||||
LastReportedAt time.Time `json:"lastReportedAt" db:"last_reported_at"`
|
||||
EndedAt *time.Time `json:"endedAt" db:"ended_at"`
|
||||
EndReason string `json:"endReason" db:"end_reason"`
|
||||
}
|
||||
|
||||
func startSessionRecord(ctx context.Context, tx *sqlx.Tx, terminal, session, desktop, state, source string) error {
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO session_history(id,session_id,terminal_id,terminal_name,desktop_id,desktop_name,username,display_name,state,source,connected_at)
|
||||
SELECT $1,$2,t.id,t.name,d.id,d.name,COALESCE(u.username,''),COALESCE(u.display_name,''),$5,$6,CASE WHEN $5='connected' THEN now() ELSE NULL END
|
||||
FROM terminals t JOIN desktops d ON d.id=$4 LEFT JOIN users u ON u.terminal_id=t.id WHERE t.id=$3`, store.ID("history_"), session, terminal, desktop, state, source)
|
||||
return err
|
||||
}
|
||||
func closeSessionRecords(ctx context.Context, tx *sqlx.Tx, terminal, reason string) error {
|
||||
_, err := tx.ExecContext(ctx, "UPDATE session_history SET state='interrupted',ended_at=now(),end_reason=$2 WHERE terminal_id=$1 AND ended_at IS NULL", terminal, reason)
|
||||
return err
|
||||
}
|
||||
func pageParams(c *gin.Context) (int, int) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > 1000000 {
|
||||
page = 1000000
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
func timeRange(c *gin.Context, maxDays int) (time.Time, time.Time, bool) {
|
||||
to := time.Now().UTC()
|
||||
from := to.Add(-24 * time.Hour)
|
||||
var err error
|
||||
if v := c.Query("to"); v != "" {
|
||||
to, err = time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return from, to, false
|
||||
}
|
||||
from = to.Add(-24 * time.Hour)
|
||||
}
|
||||
if v := c.Query("from"); v != "" {
|
||||
from, err = time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return from, to, false
|
||||
}
|
||||
}
|
||||
return from, to, to.After(from) && to.Sub(from) <= time.Duration(maxDays)*24*time.Hour
|
||||
}
|
||||
func (s *Server) sessionHistory(c *gin.Context) {
|
||||
from, to, ok := timeRange(c, 90)
|
||||
if !ok {
|
||||
s.fail(c, 400, "invalid_range", "请选择有效时间范围,最长 90 天")
|
||||
return
|
||||
}
|
||||
state := c.Query("status")
|
||||
if state != "" && state != "connecting" && state != "connected" && state != "ended" && state != "interrupted" {
|
||||
s.fail(c, 400, "invalid_request", "会话状态无效")
|
||||
return
|
||||
}
|
||||
page, size := pageParams(c)
|
||||
filter := ` FROM session_history WHERE reserved_at>=$1 AND reserved_at<=$2 AND ($3='' OR state=$3)
|
||||
AND (terminal_name ILIKE $4 OR desktop_name ILIKE $4 OR username ILIKE $4 OR display_name ILIKE $4)`
|
||||
args := []any{from, to, state, "%" + c.Query("keyword") + "%"}
|
||||
var total int
|
||||
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = tx.GetContext(c.Request.Context(), &total, "SELECT count(*)"+filter, args...); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
items := []sessionRecord{}
|
||||
args = append(args, size, (page-1)*size)
|
||||
if err = tx.SelectContext(c.Request.Context(), &items, "SELECT *"+filter+" ORDER BY reserved_at DESC,id LIMIT $5 OFFSET $6", args...); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"items": items, "total": total, "page": page, "pageSize": size, "requestId": c.GetString("requestId")})
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
type snapshotInput struct {
|
||||
Action string `json:"action" binding:"required,oneof=create delete rollback"`
|
||||
Name string `json:"name" binding:"required,max=40"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
Digest string `json:"digest" binding:"required,max=80"`
|
||||
Revision string `json:"revision" binding:"max=80"`
|
||||
ConfirmVMID int `json:"confirmVmid"`
|
||||
}
|
||||
type snapshotItem struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
Parent string `json:"parent"`
|
||||
IncludesMemory bool `json:"includesMemory"`
|
||||
State string `json:"state"`
|
||||
Revision string `json:"revision"`
|
||||
}
|
||||
|
||||
func snapshotRevision(item proxmox.Snapshot) string {
|
||||
raw, _ := json.Marshal(item)
|
||||
return store.Hash(string(raw))
|
||||
}
|
||||
func snapshotConfigHash(config map[string]any) string {
|
||||
// PVE refreshes vmgenid on rollback. Other fields here describe the snapshot
|
||||
// tree or transient execution state, rather than the guest configuration.
|
||||
skip := map[string]bool{"digest": true, "parent": true, "snaptime": true, "snapstate": true, "vmstate": true, "description": true, "snapshots": true, "pending": true, "lock": true, "running": true, "vmgenid": true, "runningmachine": true, "runningcpu": true, "running-nets-host-mtu": true}
|
||||
out := map[string]any{}
|
||||
for k, v := range config {
|
||||
if !skip[k] {
|
||||
out[k] = fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
return store.Hash(string(raw))
|
||||
}
|
||||
func snapshotBaseBlockers(h proxmox.VMHardware, config map[string]any) ([]string, []string) {
|
||||
blockers := append([]string{}, h.Blockers...)
|
||||
if h.PowerState != "stopped" {
|
||||
blockers = append(blockers, "请先正常关闭桌面,再管理快照")
|
||||
}
|
||||
disks, e := proxmox.SnapshotDisks(config)
|
||||
scope := []string{}
|
||||
if e != nil {
|
||||
blockers = append(blockers, e.Error())
|
||||
} else {
|
||||
for key := range disks {
|
||||
scope = append(scope, key)
|
||||
}
|
||||
sort.Strings(scope)
|
||||
}
|
||||
return blockers, scope
|
||||
}
|
||||
func (s *Server) snapshotView(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
d, h, config, err := s.loadHardware(ctx, c.Param("id"), s.DB)
|
||||
if err != nil {
|
||||
s.fail(c, 502, "snapshots_unavailable", "无法读取真实桌面配置,请检查 PVE 接入和 VM.Audit 权限")
|
||||
return
|
||||
}
|
||||
items, err := s.currentPVE().Snapshots(ctx, d.Node, d.VMID)
|
||||
if err != nil {
|
||||
s.fail(c, 502, "snapshots_unavailable", "无法读取 PVE 快照,请检查 VM.Audit 权限")
|
||||
return
|
||||
}
|
||||
blockers, scope := snapshotBaseBlockers(h, config)
|
||||
blocked, err := maintenanceBlocked(ctx, s.DB, d.ID, "", true)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if blocked {
|
||||
blockers = append(blockers, "有桌面维护任务正在执行或结果未确认,请先核对任务")
|
||||
}
|
||||
permission := func(name string) bool {
|
||||
ok, e := s.currentPVE().HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), name)
|
||||
return e == nil && ok
|
||||
}
|
||||
manage, rollback := permission("VM.Snapshot"), permission("VM.Snapshot.Rollback")
|
||||
out := []snapshotItem{}
|
||||
for _, item := range items {
|
||||
out = append(out, snapshotItem{item.Name, item.Description, item.Time, item.Parent, item.Memory != 0, item.State, snapshotRevision(item)})
|
||||
}
|
||||
c.JSON(200, gin.H{"items": out, "digest": h.Digest, "powerState": h.PowerState, "blockers": blockers, "disks": scope, "canCreate": manage, "canDelete": manage, "canRestore": manage || rollback})
|
||||
}
|
||||
|
||||
var snapshotName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,39}$`)
|
||||
|
||||
func (s *Server) snapshotPreflight(ctx context.Context, db sqlx.ExtContext, id string, in snapshotInput, own string) (model.Desktop, string, error) {
|
||||
var d model.Desktop
|
||||
if !snapshotName.MatchString(in.Name) || strings.EqualFold(in.Name, "current") || strings.EqualFold(in.Name, "pending") {
|
||||
return d, "", errors.New("快照名称使用字母开头的 1~40 位字母、数字、下划线或短横线,不能使用保留名称")
|
||||
}
|
||||
if in.Action != "create" && in.Description != "" || in.Action != "rollback" && in.ConfirmVMID != 0 {
|
||||
return d, "", errors.New("请求包含不适用于当前操作的参数")
|
||||
}
|
||||
d, h, config, err := s.loadHardware(ctx, id, db)
|
||||
if err != nil {
|
||||
return d, "", errors.New("无法读取 PVE 当前配置")
|
||||
}
|
||||
blockers, _ := snapshotBaseBlockers(h, config)
|
||||
if len(blockers) > 0 {
|
||||
return d, "", errors.New(strings.Join(blockers, ";"))
|
||||
}
|
||||
if h.Digest != in.Digest {
|
||||
return d, "", errors.New("桌面配置已变化,请重新读取快照页面")
|
||||
}
|
||||
blocked, err := maintenanceBlocked(ctx, db, id, own, true)
|
||||
if err != nil {
|
||||
return d, "", err
|
||||
}
|
||||
if blocked {
|
||||
return d, "", errors.New("桌面有执行中任务或未确认维护结果")
|
||||
}
|
||||
var active bool
|
||||
if err = sqlx.GetContext(ctx, db, &active, "SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND last_seen>now()-interval '90 seconds')", id); err != nil {
|
||||
return d, "", err
|
||||
}
|
||||
if active {
|
||||
return d, "", errors.New("桌面有活动连接,请先结束会话")
|
||||
}
|
||||
pve := s.currentPVE()
|
||||
permission := "VM.Snapshot"
|
||||
if in.Action == "rollback" {
|
||||
if in.ConfirmVMID != d.VMID {
|
||||
return d, "", errors.New("请填写当前 VMID,确认回退快照内磁盘数据和虚拟机配置")
|
||||
}
|
||||
ok, e := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), "VM.Snapshot.Rollback")
|
||||
if e != nil {
|
||||
return d, "", errors.New("无法核查快照还原权限")
|
||||
}
|
||||
if ok {
|
||||
permission = "VM.Snapshot.Rollback"
|
||||
}
|
||||
}
|
||||
ok, err := pve.HasPermission(ctx, fmt.Sprintf("/vms/%d", d.VMID), permission)
|
||||
if err != nil || !ok {
|
||||
return d, "", errors.New("缺少 " + permission + " 权限")
|
||||
}
|
||||
items, err := pve.Snapshots(ctx, d.Node, d.VMID)
|
||||
if err != nil {
|
||||
return d, "", errors.New("无法核对快照列表")
|
||||
}
|
||||
var selected *proxmox.Snapshot
|
||||
for i := range items {
|
||||
if items[i].Name == in.Name {
|
||||
selected = &items[i]
|
||||
}
|
||||
}
|
||||
if in.Action == "create" {
|
||||
if in.Revision != "" {
|
||||
return d, "", errors.New("创建快照不应携带已有快照版本")
|
||||
}
|
||||
if selected != nil {
|
||||
return d, "", errors.New("该快照名称已存在")
|
||||
}
|
||||
ok, err = pve.CanSnapshot(ctx, d.Node, d.VMID)
|
||||
if err != nil || !ok {
|
||||
return d, "", errors.New("PVE 当前磁盘或存储不支持快照")
|
||||
}
|
||||
return d, snapshotConfigHash(config), nil
|
||||
}
|
||||
if selected == nil || in.Revision == "" || in.Revision != snapshotRevision(*selected) {
|
||||
return d, "", errors.New("快照已删除或发生变化,请重新读取")
|
||||
}
|
||||
if selected.State != "" {
|
||||
return d, "", errors.New("快照状态未完成,请先在 PVE 核查")
|
||||
}
|
||||
if in.Action == "delete" {
|
||||
for _, item := range items {
|
||||
if item.Parent == in.Name {
|
||||
return d, "", errors.New("该快照仍有子快照,请先处理子快照")
|
||||
}
|
||||
}
|
||||
}
|
||||
target, err := pve.SnapshotConfig(ctx, d.Node, d.VMID, in.Name)
|
||||
if err != nil {
|
||||
return d, "", errors.New("无法读取快照配置")
|
||||
}
|
||||
if in.Action == "rollback" {
|
||||
if selected.Memory != 0 || fmt.Sprint(target["vmstate"]) != "<nil>" && fmt.Sprint(target["vmstate"]) != "" && fmt.Sprint(target["vmstate"]) != "0" {
|
||||
return d, "", errors.New("带内存状态的快照可能自动启动 VM,本期不支持还原")
|
||||
}
|
||||
currentDisks, e := proxmox.SnapshotDisks(config)
|
||||
if e != nil {
|
||||
return d, "", e
|
||||
}
|
||||
targetDisks, e := proxmox.SnapshotDisks(target)
|
||||
if e != nil {
|
||||
return d, "", e
|
||||
}
|
||||
a, _ := json.Marshal(currentDisks)
|
||||
b, _ := json.Marshal(targetDisks)
|
||||
if string(a) != string(b) {
|
||||
return d, "", errors.New("当前磁盘布局或容量与快照不同,请在 PVE 人工核查还原范围")
|
||||
}
|
||||
if proxmox.ConfigInt(target, "template", 0) != 0 {
|
||||
return d, "", errors.New("不能将桌面还原为模板")
|
||||
}
|
||||
}
|
||||
return d, snapshotConfigHash(target), nil
|
||||
}
|
||||
func (s *Server) checkSnapshot(c *gin.Context) {
|
||||
var in snapshotInput
|
||||
if !s.decodeConfiguration(c, &in) {
|
||||
return
|
||||
}
|
||||
_, _, err := s.snapshotPreflight(c.Request.Context(), s.DB, c.Param("id"), in, "")
|
||||
if err != nil {
|
||||
s.fail(c, 409, "snapshot_not_ready", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"available": true, "message": "快照状态、桌面状态与权限预检通过"})
|
||||
}
|
||||
func (s *Server) queueSnapshot(c *gin.Context) {
|
||||
var in snapshotInput
|
||||
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()
|
||||
if !s.lockAuthorization(c, tx) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
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 j.task_id,j.request_hash FROM snapshot_jobs j JOIN tasks t ON t.id=j.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 err = tx.GetContext(ctx, &task, "SELECT * FROM tasks WHERE id=$1 AND target_id=$2", old.TaskID, c.Param("id")); err != nil {
|
||||
s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他桌面")
|
||||
return
|
||||
}
|
||||
c.JSON(202, task)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var id string
|
||||
if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
d, targetHash, err := s.snapshotPreflight(ctx, tx, id, in, "")
|
||||
if err != nil {
|
||||
s.fail(c, 409, "snapshot_not_ready", err.Error())
|
||||
return
|
||||
}
|
||||
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,'snapshot',$2,$3,$4,$5,'queued','已提交,执行前会再次预检',$6) RETURNING *", store.ID("task_"), "snapshot-"+in.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 snapshot_jobs(task_id,request_json,request_hash,target_hash) VALUES($1,$2,$3,$4)", task.ID, string(raw), hash, targetHash)
|
||||
}
|
||||
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, task.Action, d.ID, c.GetString("requestId"))
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(202, task)
|
||||
}
|
||||
|
||||
type snapshotJob struct {
|
||||
Raw string `db:"request_json"`
|
||||
Stage string `db:"stage"`
|
||||
TargetHash string `db:"target_hash"`
|
||||
}
|
||||
|
||||
func (s *Server) claimSnapshot(ctx context.Context, t model.Task, in snapshotInput) (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", t.TargetID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE snapshot_jobs SET stage='submitting' WHERE task_id=$1 AND stage='prepared' AND EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND kind='snapshot' AND status IN ('queued','running'))`, t.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n != 1 {
|
||||
return false, nil
|
||||
}
|
||||
if in.Action == "rollback" {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE desktops SET setup_required=true,service_state='unknown',updated_at=now() WHERE id=$1", t.TargetID)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE desktop_id=$1", t.TargetID)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
func (s *Server) confirmSnapshot(ctx context.Context, db sqlx.ExtContext, t model.Task, in snapshotInput, targetHash string) error {
|
||||
if t.ProviderRef == "" {
|
||||
return errors.New("缺失原 PVE 任务标识,需人工核查")
|
||||
}
|
||||
pve := s.currentPVE()
|
||||
if pve == nil {
|
||||
return errors.New("计算平台未配置")
|
||||
}
|
||||
var d model.Desktop
|
||||
if err := sqlx.GetContext(ctx, db, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID); err != nil {
|
||||
return err
|
||||
}
|
||||
task, err := pve.Task(ctx, d.Node, t.ProviderRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != "stopped" || task.ExitStatus != "OK" {
|
||||
return errors.New("原 PVE 任务未成功完成,请在 PVE 核查")
|
||||
}
|
||||
h, config, err := pve.Hardware(ctx, d.Node, d.VMID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.PowerState != "stopped" || len(h.Blockers) > 0 {
|
||||
return errors.New("桌面状态仍未稳定,需核查")
|
||||
}
|
||||
items, err := pve.Snapshots(ctx, d.Node, d.VMID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var found *proxmox.Snapshot
|
||||
for i := range items {
|
||||
if items[i].Name == in.Name {
|
||||
found = &items[i]
|
||||
}
|
||||
}
|
||||
switch in.Action {
|
||||
case "delete":
|
||||
if found != nil {
|
||||
return errors.New("快照尚未删除")
|
||||
}
|
||||
case "create":
|
||||
if found == nil || found.Memory != 0 || found.State != "" || found.Description != in.Description {
|
||||
return errors.New("新快照尚未确认")
|
||||
}
|
||||
snap, e := pve.SnapshotConfig(ctx, d.Node, d.VMID, in.Name)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if snapshotConfigHash(snap) != targetHash {
|
||||
return errors.New("新快照配置与创建前不一致")
|
||||
}
|
||||
case "rollback":
|
||||
if found == nil || found.Memory != 0 || found.State != "" || snapshotConfigHash(config) != targetHash {
|
||||
return errors.New("还原后的配置尚未确认")
|
||||
}
|
||||
default:
|
||||
return errors.New("未知快照操作")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Server) executeSnapshot(parent context.Context, t model.Task) {
|
||||
ctx, cancel := context.WithTimeout(parent, 30*time.Minute)
|
||||
defer cancel()
|
||||
var job snapshotJob
|
||||
err := s.DB.GetContext(ctx, &job, "SELECT request_json,stage,target_hash FROM snapshot_jobs WHERE task_id=$1", t.ID)
|
||||
var in snapshotInput
|
||||
if err == nil {
|
||||
err = json.Unmarshal([]byte(job.Raw), &in)
|
||||
}
|
||||
submitted := job.Stage != "prepared"
|
||||
if e := s.RefreshCompute(ctx); err == nil && e != nil {
|
||||
err = e
|
||||
}
|
||||
if err == nil && !submitted {
|
||||
var d model.Desktop
|
||||
var targetHash string
|
||||
d, targetHash, err = s.snapshotPreflight(ctx, s.DB, t.TargetID, in, t.ID)
|
||||
if err == nil && targetHash != job.TargetHash {
|
||||
err = errors.New("快照配置已变化")
|
||||
}
|
||||
if err == nil {
|
||||
var claimed bool
|
||||
claimed, err = s.claimSnapshot(ctx, t, in)
|
||||
if err == nil && !claimed {
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
submitted = true
|
||||
t.ProviderRef, err = s.currentPVE().SnapshotAction(ctx, d.Node, d.VMID, in.Action, in.Name, in.Description)
|
||||
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", t.ProviderRef, t.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && t.ProviderRef == "" {
|
||||
err = errors.New("原 PVE 任务标识缺失")
|
||||
}
|
||||
if err == nil {
|
||||
var d model.Desktop
|
||||
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
|
||||
if err == nil {
|
||||
err = s.currentPVE().Wait(ctx, d.Node, t.ProviderRef, time.Second)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = s.confirmSnapshot(ctx, s.DB, t, in, job.TargetHash)
|
||||
}
|
||||
status, stage, message := "success", "completed", "PVE 快照任务及实际状态已确认"
|
||||
if in.Action == "rollback" {
|
||||
message = "快照还原已确认,桌面保持关机;请重新确认 IP、系统身份、Sunshine 服务与终端配对后开放连接"
|
||||
}
|
||||
if err != nil {
|
||||
status, stage, message = "failed", "failed", "快照操作未提交,请重新检查关机状态、快照版本、权限和存储能力"
|
||||
if submitted {
|
||||
status, stage, message = "timeout", "uncertain", "快照操作结果未确认,已阻止后续桌面修改和新连接;仅核对原任务,不会自动重发"
|
||||
}
|
||||
}
|
||||
s.finishSnapshot(t, status, stage, message)
|
||||
}
|
||||
func (s *Server) finishSnapshot(t 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", t.TargetID); err != nil {
|
||||
return
|
||||
}
|
||||
if err = tx.GetContext(ctx, ¤t, "SELECT status FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil || (current != "queued" && current != "running") {
|
||||
return
|
||||
}
|
||||
if err = tx.GetContext(ctx, &jobStage, "SELECT stage FROM snapshot_jobs WHERE task_id=$1 FOR UPDATE", t.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 snapshot_jobs SET stage=$1 WHERE task_id=$2", stage, t.ID); err == nil {
|
||||
err = s.completeTx(ctx, tx, t, status, message)
|
||||
}
|
||||
if err == nil {
|
||||
_ = tx.Commit()
|
||||
}
|
||||
}
|
||||
func (s *Server) reconcileSnapshot(c *gin.Context) {
|
||||
tx, actor := s.accountTransaction(c, true)
|
||||
if tx == nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
ctx := c.Request.Context()
|
||||
var t model.Task
|
||||
if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 AND kind='snapshot'", c.Param("id")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var id string
|
||||
if err := tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", t.TargetID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err := tx.GetContext(ctx, &t, "SELECT * FROM tasks WHERE id=$1 FOR UPDATE", t.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
var job snapshotJob
|
||||
if err := tx.GetContext(ctx, &job, "SELECT request_json,stage,target_hash FROM snapshot_jobs WHERE task_id=$1 FOR UPDATE", t.ID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if t.Status != "timeout" || job.Stage != "uncertain" {
|
||||
s.fail(c, 409, "reconcile_unavailable", "仅可核对结果未确认的快照任务")
|
||||
return
|
||||
}
|
||||
var in snapshotInput
|
||||
if json.Unmarshal([]byte(job.Raw), &in) != nil {
|
||||
s.fail(c, 500, "invalid_job", "任务记录无法读取")
|
||||
return
|
||||
}
|
||||
if err := s.confirmSnapshot(ctx, tx, t, in, job.TargetHash); err != nil {
|
||||
s.fail(c, 409, "snapshot_unconfirmed", "原任务或实际状态尚未确认,请在 PVE 核查;未重新发送修改")
|
||||
return
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, "UPDATE snapshot_jobs SET stage='completed' WHERE task_id=$1", t.ID)
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE tasks SET status='success',message='只读核对原 PVE 任务和实际状态通过,未重新发送修改',updated_at=now() WHERE id=$1", t.ID)
|
||||
}
|
||||
if err == nil {
|
||||
err = accountAudit(ctx, tx, actor.Username, "reconcile_snapshot", t.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})
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
"vdi-cloud/platform/pkg/proxmox"
|
||||
)
|
||||
|
||||
type snapshotPVE struct {
|
||||
mu sync.Mutex
|
||||
config map[string]any
|
||||
target map[string]any
|
||||
items []proxmox.Snapshot
|
||||
state, outcome string
|
||||
pending, denyManage, denyRestore, unsupported bool
|
||||
writes int
|
||||
}
|
||||
|
||||
func copySnapshotConfig(c map[string]any) map[string]any {
|
||||
o := map[string]any{}
|
||||
for k, v := range c {
|
||||
o[k] = v
|
||||
}
|
||||
return o
|
||||
}
|
||||
func fakeSnapshotPVE(t *testing.T, f *fixture) *snapshotPVE {
|
||||
p := &snapshotPVE{state: "stopped", config: map[string]any{"digest": "d1", "cores": 2, "memory": 2048, "scsi0": "local:vm-9000,size=4G", "efidisk0": "local:vm-9000-efi,size=4M", "vmgenid": "old"}, items: []proxmox.Snapshot{{Name: "base", Time: 123, Description: "baseline"}}}
|
||||
p.target = copySnapshotConfig(p.config)
|
||||
p.target["cores"] = 1
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api2/json")
|
||||
var data any
|
||||
switch {
|
||||
case strings.Contains(path, "/tasks/"):
|
||||
if p.outcome == "poll-failure" {
|
||||
w.WriteHeader(503)
|
||||
return
|
||||
}
|
||||
status, exit := "stopped", "OK"
|
||||
if p.outcome == "failed" {
|
||||
exit = "TASK ERROR"
|
||||
}
|
||||
if p.outcome == "running" {
|
||||
status = "running"
|
||||
}
|
||||
data = map[string]any{"status": status, "exitstatus": exit}
|
||||
case path == "/access/permissions":
|
||||
a, b := 1, 1
|
||||
if p.denyManage {
|
||||
a = 0
|
||||
}
|
||||
if p.denyRestore {
|
||||
b = 0
|
||||
}
|
||||
data = map[string]any{r.URL.Query().Get("path"): map[string]int{"VM.Snapshot": a, "VM.Snapshot.Rollback": b}}
|
||||
case strings.HasSuffix(path, "/status/current"):
|
||||
data = map[string]any{"status": p.state}
|
||||
case strings.HasSuffix(path, "/pending"):
|
||||
data = []map[string]any{}
|
||||
if p.pending {
|
||||
data = []map[string]any{{"key": "memory", "pending": 4096}}
|
||||
}
|
||||
case strings.HasSuffix(path, "/feature"):
|
||||
n := 1
|
||||
if p.unsupported {
|
||||
n = 0
|
||||
}
|
||||
data = map[string]int{"hasFeature": n}
|
||||
case strings.HasSuffix(path, "/snapshot") && r.Method == "GET":
|
||||
data = append([]proxmox.Snapshot{{Name: "current"}}, p.items...)
|
||||
case strings.Contains(path, "/snapshot/") && strings.HasSuffix(path, "/config"):
|
||||
data = p.target
|
||||
case strings.HasSuffix(path, "/config") && r.Method == "GET":
|
||||
data = p.config
|
||||
case r.Method != "GET" && strings.Contains(path, "/snapshot"):
|
||||
p.writes++
|
||||
_ = r.ParseForm()
|
||||
if p.outcome == "reject" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
if p.outcome == "ambiguous" {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/rollback"):
|
||||
if r.Method != "POST" || r.Form.Get("start") != "0" || len(r.Form) != 1 {
|
||||
t.Error("unsafe rollback", r.Method, r.Form)
|
||||
}
|
||||
p.config = copySnapshotConfig(p.target)
|
||||
p.config["vmgenid"] = "new"
|
||||
p.config["digest"] = "d2"
|
||||
case r.Method == "DELETE":
|
||||
if len(r.Form) != 0 {
|
||||
t.Error("unexpected deletion flags", r.Form)
|
||||
}
|
||||
p.items = []proxmox.Snapshot{}
|
||||
case r.Method == "POST":
|
||||
if r.Form.Get("vmstate") != "0" || len(r.Form) != 3 {
|
||||
t.Error("unsafe snapshot create", r.Form)
|
||||
}
|
||||
p.items = append(p.items, proxmox.Snapshot{Name: r.Form.Get("snapname"), Description: r.Form.Get("description"), Time: 456})
|
||||
p.target = copySnapshotConfig(p.config)
|
||||
default:
|
||||
t.Error("unexpected snapshot write", r.Method, path)
|
||||
}
|
||||
data = "UPID:snapshot-test"
|
||||
if p.outcome == "missing-upid" {
|
||||
data = ""
|
||||
}
|
||||
default:
|
||||
t.Error("unexpected snapshot request", r.Method, path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f.server.PVE = &proxmox.Client{BaseURL: srv.URL, HTTP: srv.Client()}
|
||||
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox',node='node-a',vmid=9000,power_state='stopped' WHERE id='desk_demo_windows'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
const snapshotEndpoint = "/desktops/desk_demo_windows/snapshots"
|
||||
|
||||
func snapshotBody(p *snapshotPVE, action string) map[string]any {
|
||||
b := map[string]any{"action": action, "name": "base", "digest": "d1"}
|
||||
if action == "create" {
|
||||
b["name"] = "new_point"
|
||||
b["description"] = "before upgrade"
|
||||
} else {
|
||||
b["revision"] = snapshotRevision(p.items[0])
|
||||
}
|
||||
if action == "rollback" {
|
||||
b["confirmVmid"] = 9000
|
||||
}
|
||||
return b
|
||||
}
|
||||
func queueSnapshotTest(t *testing.T, f *fixture, admin string, b map[string]any, key string) model.Task {
|
||||
code, out := f.request("POST", snapshotEndpoint+"/actions", admin, b, key)
|
||||
expect(t, 202, code, out)
|
||||
var task model.Task
|
||||
if err := f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", out["id"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
func readSnapshotTask(t *testing.T, f *fixture, id string) model.Task {
|
||||
var task model.Task
|
||||
if err := f.db.Get(&task, "SELECT * FROM tasks WHERE id=$1", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
func TestSnapshotRealTaskLifecycle(t *testing.T) {
|
||||
for _, action := range []string{"create", "delete", "rollback"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeSnapshotPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
b := snapshotBody(p, action)
|
||||
code, out := f.request("GET", snapshotEndpoint, f.login("viewer"), nil, "")
|
||||
expect(t, 200, code, out)
|
||||
if len(out["items"].([]any)) != 1 {
|
||||
t.Fatal("synthetic current leaked", out)
|
||||
}
|
||||
for _, role := range []string{"operator", "viewer"} {
|
||||
token := f.login(role)
|
||||
code, out = f.request("POST", snapshotEndpoint+"/check", token, b, "")
|
||||
expect(t, 403, code, out)
|
||||
code, out = f.request("POST", snapshotEndpoint+"/actions", token, b, "denied")
|
||||
expect(t, 403, code, out)
|
||||
}
|
||||
code, out = f.request("POST", snapshotEndpoint+"/check", admin, b, "")
|
||||
expect(t, 200, code, out)
|
||||
task := queueSnapshotTest(t, f, admin, b, "snapshot-once")
|
||||
code, out = f.request("POST", snapshotEndpoint+"/actions", admin, b, "snapshot-once")
|
||||
expect(t, 202, code, out)
|
||||
if out["id"] != task.ID {
|
||||
t.Fatal("duplicate task")
|
||||
}
|
||||
changed := copySnapshotConfig(b)
|
||||
changed["name"] = "different"
|
||||
code, out = f.request("POST", snapshotEndpoint+"/actions", admin, changed, "snapshot-once")
|
||||
expect(t, 409, code, out)
|
||||
code, out = f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "power")
|
||||
expect(t, 409, code, out)
|
||||
code, out = f.request("POST", "/desktops/desk_demo_windows/hardware", admin, map[string]any{"kind": "compute", "cores": 4, "sockets": 1, "memoryMiB": 4096, "digest": "d1"}, "hw")
|
||||
expect(t, 409, code, out)
|
||||
code, out = f.request("PUT", "/desktops/desk_demo_windows/connection", admin, map[string]any{"endpoint": "192.0.2.90", "width": 1920, "height": 1080, "fps": 30, "initialized": true}, "")
|
||||
expect(t, 409, code, out)
|
||||
if action == "rollback" {
|
||||
_, err := f.db.Exec("INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES('stale','term_demo_001','desk_demo_windows',now()+interval '1 minute')")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.server.execute(context.Background(), task)
|
||||
task = readSnapshotTask(t, f, task.ID)
|
||||
if task.Status != "success" || task.ProviderRef == "" {
|
||||
t.Fatal(task)
|
||||
}
|
||||
var stage string
|
||||
_ = f.db.Get(&stage, "SELECT stage FROM snapshot_jobs WHERE task_id=$1", task.ID)
|
||||
if stage != "completed" || p.writes != 1 {
|
||||
t.Fatal(stage, p.writes)
|
||||
}
|
||||
var n int
|
||||
_ = f.db.Get(&n, "SELECT count(*) FROM audit_logs WHERE action=$1", task.Action)
|
||||
if n != 2 {
|
||||
t.Fatal("missing submit or completion audit", n)
|
||||
}
|
||||
if action == "rollback" {
|
||||
var setup bool
|
||||
_ = f.db.Get(&setup, "SELECT setup_required FROM desktops WHERE id='desk_demo_windows'")
|
||||
_ = f.db.Get(&n, "SELECT count(*) FROM connection_tickets WHERE desktop_id='desk_demo_windows'")
|
||||
if !setup || n != 0 {
|
||||
t.Fatal("rollback did not invalidate initialization and tickets")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestSnapshotPreflightRestrictions(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeSnapshotPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
base := copySnapshotConfig(p.config)
|
||||
target := copySnapshotConfig(p.target)
|
||||
for _, condition := range []string{"running", "pending", "lock", "passthrough", "excluded-disk", "physical", "layout", "memory-list", "memory-config", "revision", "digest", "children", "permissions", "unsupported", "confirm", "active-session", "target-template", "invalid-name", "reserved", "extra-description", "extra-confirm", "extra-revision"} {
|
||||
t.Run(condition, func(t *testing.T) {
|
||||
p.config = copySnapshotConfig(base)
|
||||
p.target = copySnapshotConfig(target)
|
||||
p.items = []proxmox.Snapshot{{Name: "base", Time: 123, Description: "baseline"}}
|
||||
p.state = "stopped"
|
||||
p.pending = false
|
||||
p.denyManage = false
|
||||
p.denyRestore = false
|
||||
p.unsupported = false
|
||||
b := snapshotBody(p, "rollback")
|
||||
switch condition {
|
||||
case "running":
|
||||
p.state = "running"
|
||||
case "pending":
|
||||
p.pending = true
|
||||
case "lock":
|
||||
p.config["lock"] = "backup"
|
||||
case "passthrough":
|
||||
p.config["hostpci0"] = "0000:01:00.0"
|
||||
case "excluded-disk":
|
||||
p.config["scsi0"] = "local:vm-9000,size=4G,snapshot=0"
|
||||
case "physical":
|
||||
p.config["scsi0"] = "/dev/disk/by-id/test,size=4G"
|
||||
case "layout":
|
||||
p.target["scsi0"] = "local:vm-9000,size=8G"
|
||||
case "memory-list":
|
||||
p.items[0].Memory = 1
|
||||
b["revision"] = snapshotRevision(p.items[0])
|
||||
case "memory-config":
|
||||
p.target["vmstate"] = "local:vm-9000-state"
|
||||
case "revision":
|
||||
b["revision"] = "stale"
|
||||
case "digest":
|
||||
b["digest"] = "stale"
|
||||
case "children":
|
||||
b = snapshotBody(p, "delete")
|
||||
p.items = append(p.items, proxmox.Snapshot{Name: "child", Parent: "base"})
|
||||
case "permissions":
|
||||
p.denyManage = true
|
||||
p.denyRestore = true
|
||||
case "unsupported":
|
||||
b = snapshotBody(p, "create")
|
||||
p.unsupported = true
|
||||
case "confirm":
|
||||
b["confirmVmid"] = 100
|
||||
case "active-session":
|
||||
_, _ = f.db.Exec("UPDATE terminals SET session_desktop_id='desk_demo_windows',last_seen=now() WHERE id='term_demo_001'")
|
||||
case "target-template":
|
||||
p.target["template"] = 1
|
||||
case "invalid-name":
|
||||
b["name"] = "../base"
|
||||
case "reserved":
|
||||
b = snapshotBody(p, "create")
|
||||
b["name"] = "current"
|
||||
case "extra-description":
|
||||
b["description"] = "ignored?"
|
||||
case "extra-confirm":
|
||||
b = snapshotBody(p, "create")
|
||||
b["confirmVmid"] = 9000
|
||||
case "extra-revision":
|
||||
b = snapshotBody(p, "create")
|
||||
b["revision"] = "extra"
|
||||
}
|
||||
code, out := f.request("POST", snapshotEndpoint+"/check", admin, b, "")
|
||||
expect(t, 409, code, out)
|
||||
code, out = f.request("POST", snapshotEndpoint+"/actions", admin, b, "block-"+condition)
|
||||
expect(t, 409, code, out)
|
||||
_, _ = f.db.Exec("UPDATE terminals SET session_desktop_id=NULL WHERE id='term_demo_001'")
|
||||
if p.writes != 0 {
|
||||
t.Fatal("preflight performed mutation")
|
||||
}
|
||||
})
|
||||
}
|
||||
p.config = base
|
||||
p.target = target
|
||||
p.state = "stopped"
|
||||
p.denyManage = true
|
||||
p.denyRestore = false
|
||||
b := snapshotBody(p, "rollback")
|
||||
code, out := f.request("POST", snapshotEndpoint+"/check", admin, b, "")
|
||||
expect(t, 200, code, out)
|
||||
p.denyManage = false
|
||||
p.denyRestore = true
|
||||
code, out = f.request("POST", snapshotEndpoint+"/check", admin, b, "")
|
||||
expect(t, 200, code, out)
|
||||
b["force"] = true
|
||||
code, out = f.request("POST", snapshotEndpoint+"/actions", admin, b, "strict")
|
||||
expect(t, 400, code, out)
|
||||
}
|
||||
func TestSnapshotUnknownNeverReplayed(t *testing.T) {
|
||||
for _, outcome := range []string{"reject", "ambiguous", "missing-upid", "poll-failure", "failed"} {
|
||||
t.Run(outcome, func(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeSnapshotPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
b := snapshotBody(p, "rollback")
|
||||
p.outcome = outcome
|
||||
task := queueSnapshotTest(t, f, admin, b, "unknown")
|
||||
f.server.execute(context.Background(), task)
|
||||
task = readSnapshotTask(t, f, task.ID)
|
||||
want := "timeout"
|
||||
if outcome == "reject" {
|
||||
want = "failed"
|
||||
}
|
||||
if task.Status != want {
|
||||
t.Fatal(task)
|
||||
}
|
||||
if want == "failed" {
|
||||
return
|
||||
}
|
||||
_, _ = f.db.Exec("UPDATE desktops SET power_state='running',setup_required=false WHERE id='desk_demo_windows'")
|
||||
_, _ = f.db.Exec("UPDATE terminals SET token_hash=$1,last_seen=now() WHERE id='term_demo_001'", store.Hash("snapshot-device"))
|
||||
code, out := f.request("POST", "/terminal/connect-ticket", "snapshot-device", map[string]string{"desktopId": "desk_demo_windows"}, "")
|
||||
expect(t, 409, code, out)
|
||||
if out["code"] != "hardware_unconfirmed" {
|
||||
t.Fatal(out)
|
||||
}
|
||||
code, out = f.request("POST", "/desktops/desk_demo_windows/actions", admin, map[string]string{"action": "start"}, "blocked")
|
||||
expect(t, 409, code, out)
|
||||
_, _ = f.db.Exec("UPDATE tasks SET status='running' WHERE id=$1", task.ID)
|
||||
f.server.execute(context.Background(), task)
|
||||
task = readSnapshotTask(t, f, task.ID)
|
||||
if p.writes != 1 {
|
||||
t.Fatal("snapshot replayed", p.writes)
|
||||
}
|
||||
code, out = f.request("POST", "/tasks/"+task.ID+"/snapshot-reconcile", admin, nil, "")
|
||||
expect(t, 409, code, out)
|
||||
p.outcome = ""
|
||||
// Reconciliation must use its own SQL transaction, even with a single connection.
|
||||
f.db.SetMaxOpenConns(1)
|
||||
code, out = f.request("POST", "/tasks/"+task.ID+"/snapshot-reconcile", admin, nil, "")
|
||||
if outcome == "poll-failure" || outcome == "failed" {
|
||||
expect(t, 200, code, out)
|
||||
} else {
|
||||
expect(t, 409, code, out)
|
||||
}
|
||||
if p.writes != 1 {
|
||||
t.Fatal("reconcile mutated PVE")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestSnapshotRechecksAndExpiry(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeSnapshotPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
b := snapshotBody(p, "rollback")
|
||||
task := queueSnapshotTest(t, f, admin, b, "changed")
|
||||
p.target["memory"] = 4096
|
||||
f.server.execute(context.Background(), task)
|
||||
if readSnapshotTask(t, f, task.ID).Status != "failed" || p.writes != 0 {
|
||||
t.Fatal("changed target submitted")
|
||||
}
|
||||
task = queueSnapshotTest(t, f, admin, b, "prepared-expire")
|
||||
f.server.finishSnapshot(task, "", "expired", "")
|
||||
claimed, err := f.server.claimSnapshot(context.Background(), task, snapshotInput{Action: "rollback"})
|
||||
if err != nil || claimed {
|
||||
t.Fatal("expired task could submit", err)
|
||||
}
|
||||
task = queueSnapshotTest(t, f, admin, b, "submitting-expire")
|
||||
claimed, err = f.server.claimSnapshot(context.Background(), task, snapshotInput{Action: "rollback"})
|
||||
if err != nil || !claimed {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.server.finishSnapshot(task, "", "expired", "")
|
||||
if readSnapshotTask(t, f, task.ID).Status != "timeout" {
|
||||
t.Fatal("submitting expiry released protection")
|
||||
}
|
||||
}
|
||||
func TestSnapshotWorkerDoesNotBlockOrdinaryTasks(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
p := fakeSnapshotPVE(t, f)
|
||||
admin := f.login("admin")
|
||||
p.outcome = "running"
|
||||
task := queueSnapshotTest(t, f, admin, snapshotBody(p, "create"), "long")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { f.server.RunWorker(ctx); close(done) }()
|
||||
defer func() { cancel(); <-done }()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for readSnapshotTask(t, f, task.ID).ProviderRef == "" {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("snapshot never started")
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
code, out := f.request("POST", "/desktops/desk_demo_kylin/actions", admin, map[string]string{"action": "start"}, "normal")
|
||||
expect(t, 202, code, out)
|
||||
for readSnapshotTask(t, f, out["id"].(string)).Status != "success" {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("long snapshot blocked ordinary task")
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
if readSnapshotTask(t, f, task.ID).Status != "running" {
|
||||
t.Fatal("snapshot unexpectedly completed")
|
||||
}
|
||||
}
|
||||
func TestSnapshotConfigHash(t *testing.T) {
|
||||
a := map[string]any{"cores": 2, "memory": 2048, "vmgenid": "one", "digest": "first", "parent": "old"}
|
||||
b := map[string]any{"cores": "2", "memory": "2048", "vmgenid": "two", "digest": "second", "parent": "new", "snaptime": 4}
|
||||
if snapshotConfigHash(a) != snapshotConfigHash(b) {
|
||||
t.Fatal("transient fields or scalar representation affect hash")
|
||||
}
|
||||
b["memory"] = 4096
|
||||
if snapshotConfigHash(a) == snapshotConfigHash(b) {
|
||||
t.Fatal("material config change ignored")
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"strings"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/model"
|
||||
@@ -49,64 +51,142 @@ func (s *Server) register(c *gin.Context) {
|
||||
}
|
||||
token := store.ID("device_")
|
||||
var id string
|
||||
err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret))
|
||||
err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE enabled AND sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret))
|
||||
if err != nil {
|
||||
s.fail(c, 401, "invalid_activation", "激活信息无效或已使用,请联系管理员")
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"terminalId": id, "accessToken": token, "heartbeatInterval": 30})
|
||||
}
|
||||
|
||||
// Keep identity validation and device work under the same row lock. A revoked
|
||||
// token cannot resume an in-flight request after reactivation changes the row.
|
||||
func (s *Server) deviceAuth(c *gin.Context) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
var id string
|
||||
if token == "" || s.DB.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1", store.Hash(token)) != nil {
|
||||
if token == "" {
|
||||
s.fail(c, 401, "device_unauthorized", "终端身份无效")
|
||||
return
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(c.Request.Context(), nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if strings.HasSuffix(c.FullPath(), "/connect-ticket") || strings.HasSuffix(c.FullPath(), "/connect-ticket/consume") {
|
||||
if !s.lockAuthorization(c, tx) {
|
||||
return
|
||||
}
|
||||
}
|
||||
var id string
|
||||
if tx.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1 AND enabled FOR UPDATE", store.Hash(token)) != nil {
|
||||
s.fail(c, 401, "device_unauthorized", "终端身份无效或已停用")
|
||||
return
|
||||
}
|
||||
c.Set("terminalId", id)
|
||||
c.Set("deviceTx", tx)
|
||||
c.Next()
|
||||
}
|
||||
func deviceTx(c *gin.Context) *sqlx.Tx { return c.MustGet("deviceTx").(*sqlx.Tx) }
|
||||
func (s *Server) commitDevice(c *gin.Context) bool {
|
||||
if err := deviceTx(c).Commit(); err != nil {
|
||||
s.dbError(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Server) deviceJSON(c *gin.Context, status int, value any) {
|
||||
if s.commitDevice(c) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
}
|
||||
func (s *Server) heartbeat(c *gin.Context) {
|
||||
var in struct {
|
||||
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
|
||||
FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"`
|
||||
RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"`
|
||||
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
|
||||
PolicyProtocol int `json:"policyProtocol" binding:"min=0,max=1"`
|
||||
FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"`
|
||||
RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"`
|
||||
SystemMetrics *terminalSystemMetrics `json:"systemMetrics"`
|
||||
}
|
||||
if !s.decode(c, &in) {
|
||||
return
|
||||
}
|
||||
_, err := s.DB.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId"))
|
||||
_, err := deviceTx(c).ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"accepted": true, "serverTime": time.Now().UTC()})
|
||||
if err = saveTerminalMetrics(c.Request.Context(), deviceTx(c), c.GetString("terminalId"), in.SystemMetrics); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = deviceTx(c).ExecContext(c.Request.Context(), "INSERT INTO terminal_policy_capabilities(terminal_id,protocol) VALUES($1,$2) ON CONFLICT(terminal_id) DO UPDATE SET protocol=EXCLUDED.protocol", c.GetString("terminalId"), in.PolicyProtocol); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if _, err = deviceTx(c).ExecContext(c.Request.Context(), "UPDATE session_history SET last_reported_at=now() WHERE terminal_id=$1 AND ended_at IS NULL", c.GetString("terminalId")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
// An upgraded or temporarily unreachable device may already have a ready
|
||||
// session. Start a new observed segment without inventing its original start.
|
||||
if _, err = deviceTx(c).ExecContext(c.Request.Context(), `INSERT INTO session_history(id,session_id,terminal_id,terminal_name,desktop_id,desktop_name,username,display_name,state,source,connected_at)
|
||||
SELECT $1,t.session_id,t.id,t.name,d.id,d.name,COALESCE(u.username,''),COALESCE(u.display_name,''),'connected','observed',now()
|
||||
FROM terminals t JOIN desktops d ON d.id=t.session_desktop_id LEFT JOIN users u ON u.terminal_id=t.id
|
||||
WHERE t.id=$2 AND t.session_state='connected' AND t.session_id!=''
|
||||
AND NOT EXISTS(SELECT 1 FROM session_history h WHERE h.terminal_id=t.id AND h.ended_at IS NULL)`, store.ID("history_"), c.GetString("terminalId")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.deviceJSON(c, 200, gin.H{"accepted": true, "serverTime": time.Now().UTC()})
|
||||
}
|
||||
func (s *Server) deviceConfig(c *gin.Context) {
|
||||
var version int
|
||||
if err := s.DB.GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil {
|
||||
if err := deviceTx(c).GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"configVersion": version, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": 30})
|
||||
var item struct {
|
||||
DeploymentID string `db:"deployment_id"`
|
||||
HeartbeatInterval int `db:"heartbeat_interval"`
|
||||
CommandPollInterval int `db:"command_poll_interval"`
|
||||
DesktopRefreshInterval int `db:"desktop_refresh_interval"`
|
||||
}
|
||||
err := deviceTx(c).GetContext(c.Request.Context(), &item, `SELECT r.deployment_id,p.heartbeat_interval,p.command_poll_interval,p.desktop_refresh_interval
|
||||
FROM terminal_policy_deliveries r JOIN terminal_policy_deployments d ON d.id=r.deployment_id JOIN terminal_policy_templates p ON p.id=d.template_id
|
||||
WHERE r.terminal_id=$1 AND r.config_version=$2`, c.GetString("terminalId"), version)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
item.HeartbeatInterval = 30
|
||||
item.CommandPollInterval = 3
|
||||
item.DesktopRefreshInterval = 15
|
||||
}
|
||||
s.deviceJSON(c, 200, gin.H{"configVersion": version, "deploymentId": item.DeploymentID, "policyProtocol": 1, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": item.HeartbeatInterval, "commandPollInterval": item.CommandPollInterval, "desktopRefreshInterval": item.DesktopRefreshInterval})
|
||||
}
|
||||
func (s *Server) deviceDesktops(c *gin.Context) {
|
||||
items := []model.Desktop{}
|
||||
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId"))
|
||||
err := deviceTx(c).SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
if s.commitDevice(c) {
|
||||
list(c, items)
|
||||
}
|
||||
}
|
||||
func (s *Server) deviceCommands(c *gin.Context) {
|
||||
items := []model.Task{}
|
||||
err := s.DB.SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId"))
|
||||
err := deviceTx(c).SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
list(c, items)
|
||||
if s.commitDevice(c) {
|
||||
list(c, items)
|
||||
}
|
||||
}
|
||||
func (s *Server) deviceAck(c *gin.Context) {
|
||||
var in struct {
|
||||
@@ -118,13 +198,13 @@ func (s *Server) deviceAck(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var task model.Task
|
||||
err := s.DB.GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId"))
|
||||
err := deviceTx(c).GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if task.Status == in.Status {
|
||||
c.JSON(200, gin.H{"accepted": true})
|
||||
s.deviceJSON(c, 200, gin.H{"accepted": true})
|
||||
return
|
||||
}
|
||||
if task.Status != "running" {
|
||||
@@ -141,17 +221,17 @@ func (s *Server) deviceAck(c *gin.Context) {
|
||||
s.dbError(c, marshalErr)
|
||||
return
|
||||
}
|
||||
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil {
|
||||
if _, err = deviceTx(c).ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Agent text can contain sensitive device details. Keep the public audit message fixed.
|
||||
if err = s.complete(c.Request.Context(), task, in.Status, "已收到终端执行回执"); err != nil {
|
||||
if err = s.completeTx(c.Request.Context(), deviceTx(c), task, in.Status, "已收到终端执行回执"); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"accepted": true})
|
||||
s.deviceJSON(c, 200, gin.H{"accepted": true})
|
||||
}
|
||||
func (s *Server) createTicket(c *gin.Context) {
|
||||
var in struct {
|
||||
@@ -161,7 +241,7 @@ func (s *Server) createTicket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var allowed bool
|
||||
err := s.DB.GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId"))
|
||||
err := deviceTx(c).GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
@@ -171,22 +251,25 @@ func (s *Server) createTicket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var d model.Desktop
|
||||
if err = s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil {
|
||||
if err = deviceTx(c).GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
if d.PowerState != "running" || d.Endpoint == "" {
|
||||
if !s.hardwareAllowsConnection(c, d.ID) {
|
||||
return
|
||||
}
|
||||
if d.SetupRequired || d.PowerState != "running" || d.Endpoint == "" {
|
||||
s.fail(c, 409, "desktop_not_ready", "桌面未开机或尚未配置连接地址")
|
||||
return
|
||||
}
|
||||
token := store.ID("ticket_")
|
||||
expires := time.Now().Add(45 * time.Second)
|
||||
_, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires)
|
||||
_, err = deviceTx(c).ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"})
|
||||
s.deviceJSON(c, 201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"})
|
||||
}
|
||||
func (s *Server) consumeTicket(c *gin.Context) {
|
||||
var in struct {
|
||||
@@ -196,12 +279,8 @@ func (s *Server) consumeTicket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
tx := deviceTx(c)
|
||||
var err error
|
||||
var row struct {
|
||||
TokenHash string `db:"token_hash"`
|
||||
TerminalID string `db:"terminal_id"`
|
||||
@@ -227,7 +306,10 @@ func (s *Server) consumeTicket(c *gin.Context) {
|
||||
s.fail(c, 403, "desktop_forbidden", "桌面授权已撤销")
|
||||
return
|
||||
}
|
||||
if d.PowerState != "running" || d.Endpoint == "" {
|
||||
if !s.hardwareAllowsConnection(c, d.ID) {
|
||||
return
|
||||
}
|
||||
if d.SetupRequired || d.PowerState != "running" || d.Endpoint == "" {
|
||||
s.fail(c, 409, "desktop_not_ready", "桌面当前不可连接")
|
||||
return
|
||||
}
|
||||
@@ -246,13 +328,16 @@ func (s *Server) consumeTicket(c *gin.Context) {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE terminals SET session_desktop_id=$1,last_seen=now(),session_id=$3,session_state='connecting' WHERE id=$2", d.ID, row.TerminalID, sessionID)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
err = closeSessionRecords(ctx, tx, row.TerminalID, "replaced")
|
||||
}
|
||||
if err == nil {
|
||||
err = startSessionRecord(ctx, tx, row.TerminalID, sessionID, d.ID, "connecting", "ticket")
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"})
|
||||
s.deviceJSON(c, 200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"})
|
||||
}
|
||||
|
||||
func (s *Server) deviceSession(c *gin.Context) {
|
||||
@@ -267,7 +352,7 @@ func (s *Server) deviceSession(c *gin.Context) {
|
||||
if in.State == "ended" {
|
||||
query = "UPDATE terminals SET session_state='idle',session_id='',session_desktop_id=NULL WHERE id=$1 AND session_id=$2"
|
||||
}
|
||||
result, err := s.DB.ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID)
|
||||
result, err := deviceTx(c).ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
@@ -277,7 +362,30 @@ func (s *Server) deviceSession(c *gin.Context) {
|
||||
s.fail(c, 409, "stale_session", "连接预约已结束或已被新会话替代")
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"accepted": true})
|
||||
tx := deviceTx(c)
|
||||
ctx := c.Request.Context()
|
||||
if in.State == "ended" {
|
||||
_, err = tx.ExecContext(ctx, "UPDATE session_history SET state='ended',ended_at=now(),last_reported_at=now(),end_reason='client_ended' WHERE terminal_id=$1 AND session_id=$2 AND ended_at IS NULL", c.GetString("terminalId"), in.SessionID)
|
||||
} else {
|
||||
var result sql.Result
|
||||
result, err = tx.ExecContext(ctx, "UPDATE session_history SET state='connected',connected_at=COALESCE(connected_at,now()),last_reported_at=now() WHERE terminal_id=$1 AND session_id=$2 AND ended_at IS NULL", c.GetString("terminalId"), in.SessionID)
|
||||
if err == nil {
|
||||
var count int64
|
||||
count, err = result.RowsAffected()
|
||||
if err == nil && count == 0 {
|
||||
var desktop string
|
||||
err = tx.GetContext(ctx, &desktop, "SELECT session_desktop_id FROM terminals WHERE id=$1", c.GetString("terminalId"))
|
||||
if err == nil {
|
||||
err = startSessionRecord(ctx, tx, c.GetString("terminalId"), in.SessionID, desktop, "connected", "observed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
s.deviceJSON(c, 200, gin.H{"accepted": true})
|
||||
}
|
||||
|
||||
func (s *Server) taskDiagnostics(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Values are optional: unavailable sensors are null, never fabricated zeroes.
|
||||
type terminalSystemMetrics struct {
|
||||
CPUPercent *float64 `json:"cpuPercent" db:"cpu_percent" binding:"omitempty,min=0,max=100"`
|
||||
MemoryPercent *float64 `json:"memoryPercent" db:"memory_percent" binding:"omitempty,min=0,max=100"`
|
||||
SoCTemperatureC *float64 `json:"socTemperatureC" db:"soc_temperature_c" binding:"omitempty,min=-40,max=150"`
|
||||
UptimeSeconds *float64 `json:"uptimeSeconds" db:"uptime_seconds" binding:"omitempty,min=0,max=3155760000"`
|
||||
}
|
||||
|
||||
func saveTerminalMetrics(ctx context.Context, tx *sqlx.Tx, id string, input *terminalSystemMetrics) error {
|
||||
metrics := terminalSystemMetrics{}
|
||||
if input != nil {
|
||||
metrics = *input
|
||||
}
|
||||
// Device auth already holds the terminal row lock, serializing concurrent heartbeats.
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO terminal_metrics_current(terminal_id,sampled_at,supported,cpu_percent,memory_percent,soc_temperature_c,uptime_seconds)
|
||||
VALUES($1,now(),$2,$3,$4,$5,$6) ON CONFLICT(terminal_id) DO UPDATE SET
|
||||
sampled_at=EXCLUDED.sampled_at,supported=EXCLUDED.supported,cpu_percent=EXCLUDED.cpu_percent,
|
||||
memory_percent=EXCLUDED.memory_percent,soc_temperature_c=EXCLUDED.soc_temperature_c,uptime_seconds=EXCLUDED.uptime_seconds`,
|
||||
id, input != nil, metrics.CPUPercent, metrics.MemoryPercent, metrics.SoCTemperatureC, metrics.UptimeSeconds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Bound history growth even if an agent retries aggressively. Preserve null samples on downgrade.
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO terminal_metric_samples(terminal_id,sampled_at,cpu_percent,memory_percent,soc_temperature_c,uptime_seconds)
|
||||
SELECT $1,now(),$2,$3,$4,$5 WHERE NOT EXISTS(SELECT 1 FROM terminal_metric_samples
|
||||
WHERE terminal_id=$1 AND sampled_at>now()-interval '10 seconds')`,
|
||||
id, metrics.CPUPercent, metrics.MemoryPercent, metrics.SoCTemperatureC, metrics.UptimeSeconds)
|
||||
return err
|
||||
}
|
||||
|
||||
type terminalMetricPoint struct {
|
||||
terminalSystemMetrics
|
||||
Time time.Time `json:"time" db:"time"`
|
||||
Samples int `json:"samples" db:"samples"`
|
||||
}
|
||||
|
||||
func (s *Server) terminalMetrics(c *gin.Context) {
|
||||
from, to, ok := timeRange(c, 7)
|
||||
if !ok {
|
||||
s.fail(c, 400, "invalid_range", "请选择有效时间范围,最长 7 天")
|
||||
return
|
||||
}
|
||||
var latest struct {
|
||||
terminalSystemMetrics
|
||||
SampledAt *time.Time `json:"sampledAt" db:"sampled_at"`
|
||||
Supported bool `json:"supported" db:"supported"`
|
||||
Online bool `json:"online" db:"online"`
|
||||
Fresh bool `json:"fresh" db:"fresh"`
|
||||
}
|
||||
// Query through the terminal identity so unknown devices return 404. A stopped,
|
||||
// revoked or stale device keeps history but cannot present an old value as live.
|
||||
err := s.DB.GetContext(c.Request.Context(), &latest, `SELECT m.sampled_at,COALESCE(m.supported,false) supported,
|
||||
COALESCE(t.enabled AND t.last_seen>now()-interval '90 seconds',false) online,
|
||||
COALESCE(t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds',false) fresh,
|
||||
CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.cpu_percent END cpu_percent,
|
||||
CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.memory_percent END memory_percent,
|
||||
CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.soc_temperature_c END soc_temperature_c,
|
||||
CASE WHEN t.enabled AND t.last_seen>now()-interval '90 seconds' AND m.sampled_at>now()-interval '90 seconds' THEN m.uptime_seconds END uptime_seconds
|
||||
FROM terminals t LEFT JOIN terminal_metrics_current m ON m.terminal_id=t.id WHERE t.id=$1`, c.Param("id"))
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
bucket := 60
|
||||
if to.Sub(from) > 6*time.Hour {
|
||||
bucket = 300
|
||||
}
|
||||
if to.Sub(from) > 24*time.Hour {
|
||||
bucket = 1800
|
||||
}
|
||||
points := []terminalMetricPoint{}
|
||||
// Explicit empty buckets break the chart across missing heartbeats.
|
||||
err = s.DB.SelectContext(c.Request.Context(), &points, `WITH data AS (
|
||||
SELECT date_bin(make_interval(secs=>$4),sampled_at,'2000-01-01'::timestamptz) time,
|
||||
avg(cpu_percent) cpu_percent,avg(memory_percent) memory_percent,avg(soc_temperature_c) soc_temperature_c,
|
||||
max(uptime_seconds) uptime_seconds,count(*) samples FROM terminal_metric_samples
|
||||
WHERE terminal_id=$1 AND sampled_at>=$2 AND sampled_at<=$3 GROUP BY 1)
|
||||
SELECT b.time,d.cpu_percent,d.memory_percent,d.soc_temperature_c,d.uptime_seconds,COALESCE(d.samples,0) samples
|
||||
FROM generate_series(date_bin(make_interval(secs=>$4),$2::timestamptz,'2000-01-01'::timestamptz),
|
||||
date_bin(make_interval(secs=>$4),$3::timestamptz,'2000-01-01'::timestamptz),make_interval(secs=>$4)) b(time)
|
||||
LEFT JOIN data d ON d.time=b.time ORDER BY b.time`, c.Param("id"), from, to, bucket)
|
||||
if err != nil {
|
||||
s.dbError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"latest": latest, "items": points, "from": from, "to": to, "bucketSeconds": bucket, "retentionDays": 30})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
"vdi-cloud/platform/internal/store"
|
||||
)
|
||||
|
||||
func TestTerminalMetricsIdentityValidationAndCompatibility(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
viewer := f.login("viewer")
|
||||
for _, id := range []string{"metric-a", "metric-b"} {
|
||||
_, err := f.db.Exec(`INSERT INTO terminals(id,sn,name,token_hash,bootstrap_hash,last_seen,status) VALUES($1,$1,$1,$2,'',now(),'online')`, id, store.Hash(id+"-token"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
body := map[string]any{"agentVersion": "qt-platform/0.4.0", "terminalId": "metric-b", "systemMetrics": map[string]any{"cpuPercent": 0, "memoryPercent": 30, "socTemperatureC": 44.37, "uptimeSeconds": 120}}
|
||||
status, out := f.request("POST", "/terminal/heartbeat", "metric-a-token", body, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
latest := out["latest"].(map[string]any)
|
||||
if latest["supported"] != true || latest["fresh"] != true || latest["cpuPercent"] != float64(0) || latest["socTemperatureC"] != 44.37 {
|
||||
t.Fatal(latest)
|
||||
}
|
||||
status, out = f.request("GET", "/terminals/metric-b/metrics", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["latest"].(map[string]any)["supported"] != false {
|
||||
t.Fatal("forged terminal identity accepted", out)
|
||||
}
|
||||
// Repeated uploads update current state but cannot grow history without limit.
|
||||
status, out = f.request("POST", "/terminal/heartbeat", "metric-a-token", body, "")
|
||||
expect(t, 200, status, out)
|
||||
var count int
|
||||
f.db.Get(&count, "SELECT count(*) FROM terminal_metric_samples WHERE terminal_id='metric-a'")
|
||||
if count != 1 {
|
||||
t.Fatal("unbounded duplicate samples", count)
|
||||
}
|
||||
// Session reports also advance last_seen; they must not hide a recent metric.
|
||||
f.db.Exec("UPDATE terminals SET last_seen=now() WHERE id='metric-a'")
|
||||
status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["latest"].(map[string]any)["fresh"] != true {
|
||||
t.Fatal("session report hid metrics", out)
|
||||
}
|
||||
for _, invalid := range []map[string]any{{"cpuPercent": 101}, {"memoryPercent": -1}, {"socTemperatureC": 151}, {"uptimeSeconds": -0.1}, {"cpuPercent": "nan"}} {
|
||||
status, out = f.request("POST", "/terminal/heartbeat", "metric-a-token", map[string]any{"agentVersion": "test", "systemMetrics": invalid}, "")
|
||||
expect(t, 400, status, out)
|
||||
}
|
||||
f.db.Exec("UPDATE terminals SET last_seen=now()-interval '91 seconds' WHERE id='metric-a'")
|
||||
status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
latest = out["latest"].(map[string]any)
|
||||
if latest["fresh"] != false || latest["online"] != false || latest["cpuPercent"] != nil || latest["socTemperatureC"] != nil {
|
||||
t.Fatal("stale live value", latest)
|
||||
}
|
||||
status, out = f.request("POST", "/terminal/heartbeat", "metric-a-token", map[string]any{"agentVersion": "legacy"}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
latest = out["latest"].(map[string]any)
|
||||
if latest["supported"] != false || latest["cpuPercent"] != nil {
|
||||
t.Fatal("legacy values retained", latest)
|
||||
}
|
||||
admin := f.login("admin")
|
||||
status, out = f.request("POST", "/terminals/metric-a/disable", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("POST", "/terminal/heartbeat", "metric-a-token", body, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("POST", "/terminals/metric-a/reactivate", admin, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
secret := out["activationSecret"]
|
||||
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": "metric-a", "activationSecret": secret, "agentVersion": "test"}, "")
|
||||
expect(t, 200, status, out)
|
||||
status, out = f.request("GET", "/terminals/metric-a/metrics", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["latest"].(map[string]any)["supported"] != false || out["latest"].(map[string]any)["fresh"] != false {
|
||||
t.Fatal("old identity metrics retained", out)
|
||||
}
|
||||
status, out = f.request("GET", "/terminals/missing/metrics", viewer, nil, "")
|
||||
expect(t, 404, status, out)
|
||||
status, out = f.request("GET", "/terminals/metric-a/metrics", "metric-b-token", nil, "")
|
||||
expect(t, 401, status, out)
|
||||
status, out = f.request("GET", "/terminals/metric-a/metrics", "", nil, "")
|
||||
expect(t, 401, status, out)
|
||||
// A never-registered terminal has null last_seen and no current metrics.
|
||||
f.db.Exec("INSERT INTO terminals(id,sn,name,bootstrap_hash) VALUES('metric-new','metric-new','new','')")
|
||||
status, out = f.request("GET", "/terminals/metric-new/metrics", viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
if out["latest"].(map[string]any)["online"] != false {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalMetricsAggregationGapsAndRetention(t *testing.T) {
|
||||
f := newFixtureWorker(t, false)
|
||||
viewer := f.login("viewer")
|
||||
if _, err := f.db.Exec("INSERT INTO terminals(id,sn,name,bootstrap_hash) VALUES('metrics-series','metrics-series','series','')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now().UTC().Add(-time.Hour).Truncate(time.Minute)
|
||||
for _, p := range []struct{ seconds, cpu int }{{10, 20}, {30, 40}, {130, 60}} {
|
||||
if _, err := f.db.Exec(`INSERT INTO terminal_metric_samples(terminal_id,sampled_at,cpu_percent) VALUES('metrics-series',$1,$2)`, start.Add(time.Duration(p.seconds)*time.Second), p.cpu); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
path := fmt.Sprintf("/terminals/metrics-series/metrics?from=%s&to=%s", start.Format(time.RFC3339), start.Add(3*time.Minute).Format(time.RFC3339))
|
||||
status, out := f.request("GET", path, viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
points := out["items"].([]any)
|
||||
if len(points) != 4 {
|
||||
t.Fatal(out)
|
||||
}
|
||||
first, gap, third := points[0].(map[string]any), points[1].(map[string]any), points[2].(map[string]any)
|
||||
if first["cpuPercent"] != float64(30) || first["samples"] != float64(2) || first["memoryPercent"] != nil || gap["cpuPercent"] != nil || gap["samples"] != float64(0) || third["cpuPercent"] != float64(60) {
|
||||
t.Fatal(points)
|
||||
}
|
||||
status, out = f.request("GET", "/terminals/metrics-series/metrics?from=2026-01-01T00:00:00Z&to=2026-01-09T00:00:00Z", viewer, nil, "")
|
||||
expect(t, 400, status, out)
|
||||
status, out = f.request("GET", "/terminals/metrics-series/metrics?from=bad", viewer, nil, "")
|
||||
expect(t, 400, status, out)
|
||||
for _, hours := range []int{24, 168} {
|
||||
status, out = f.request("GET", fmt.Sprintf("/terminals/metrics-series/metrics?from=%s&to=%s", start.Add(-time.Duration(hours)*time.Hour).Format(time.RFC3339), start.Format(time.RFC3339)), viewer, nil, "")
|
||||
expect(t, 200, status, out)
|
||||
want := float64(300)
|
||||
if hours == 168 {
|
||||
want = 1800
|
||||
}
|
||||
if out["bucketSeconds"] != want || len(out["items"].([]any)) > 400 {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
f.db.Exec("INSERT INTO terminal_metric_samples(terminal_id,sampled_at) VALUES('metrics-series',now()-interval '31 days')")
|
||||
if err := f.server.observe(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var old int
|
||||
f.db.Get(&old, "SELECT count(*) FROM terminal_metric_samples WHERE sampled_at<now()-interval '30 days'")
|
||||
if old != 0 {
|
||||
t.Fatal("metrics retention not applied")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user