功能:扩展云管资源运维、账号授权及监控管理

This commit is contained in:
songyiming committed 2026-11-10 17:52:52 +08:00
1 parent eef485306f
commit 6134d49c16
87 files changed
+29498 -126

No files matched your search

+597
View File
@@ -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, &current, 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)
}