diff --git a/server/internal/handler/delivery.go b/server/internal/handler/delivery.go new file mode 100644 index 0000000..f4f2a7d --- /dev/null +++ b/server/internal/handler/delivery.go @@ -0,0 +1,289 @@ +package handler + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/1024XEngineer/xinfra/server/internal/model" + "github.com/1024XEngineer/xinfra/server/internal/service" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type DeliveryHandler struct{ service *service.DeliveryService } + +func NewDeliveryHandler(s *service.DeliveryService) *DeliveryHandler { + return &DeliveryHandler{service: s} +} + +type ExecutionHandler struct { + service *service.DeliveryService + token string +} + +func NewExecutionHandler(s *service.DeliveryService, token string) *ExecutionHandler { + return &ExecutionHandler{service: s, token: strings.TrimSpace(token)} +} + +type executionPayload struct { + TaskID string `json:"task_id" binding:"required"` + PayloadHash string `json:"payload_hash" binding:"required"` + IdempotencyKey string `json:"idempotency_key" binding:"required"` +} + +func (h *ExecutionHandler) Create(c *gin.Context) { + provided := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") + if h.token == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(h.token)) != 1 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid service credential"}) + return + } + var req executionPayload + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + job, replay, err := h.service.CreateExecution(c.Request.Context(), req.TaskID, req.PayloadHash, req.IdempotencyKey) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + status := http.StatusCreated + if replay { + status = http.StatusOK + } + c.JSON(status, gin.H{"execution": job, "idempotent_replay": replay}) +} + +// CreateMySQL 提交 MySQL 交付请求 +// @Summary 提交 MySQL 一键交付 +// @Description 创建一个 MySQL 交付任务,调度器会自动分配主机、调用 AWX 执行部署 +// @Tags delivery +// @Accept json +// @Produce json +// @Param Idempotency-Key header string true "幂等键(防重复提交,最长 128 字符)" +// @Param body body service.MySQLDeliveryInput true "交付参数" +// @Success 202 {object} map[string]any "任务已创建" +// @Success 200 {object} map[string]any "幂等重放(相同 Idempotency-Key 已存在)" +// @Failure 400 {object} map[string]any "参数错误" +// @Failure 401 {object} map[string]any "未授权" +// @Router /auth/api/v1/delivery/mysql [post] +// @Security BearerAuth +func (h *DeliveryHandler) CreateMySQL(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + var req service.MySQLDeliveryInput + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + task, existing, err := h.service.CreateTask(c.Request.Context(), claims.UserID, claims.IsAdmin, c.GetHeader("Idempotency-Key"), req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + status := http.StatusAccepted + if existing { + status = http.StatusOK + } + c.JSON(status, gin.H{"task": task, "idempotent_replay": existing}) +} + +// List 获取交付任务列表 +// @Summary 获取交付任务列表 +// @Description 返回当前用户可见的交付任务列表(管理员可见全部) +// @Tags delivery +// @Produce json +// @Param business_line_id query uint64 false "业务线 ID 过滤" +// @Success 200 {object} map[string]any "items: 任务数组" +// @Failure 401 {object} map[string]any "未授权" +// @Router /auth/api/v1/delivery/tasks [get] +// @Security BearerAuth +func (h *DeliveryHandler) List(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + var businessLineID uint64 + if raw := c.Query("business_line_id"); raw != "" { + businessLineID, _ = strconv.ParseUint(raw, 10, 64) + } + items, err := h.service.ListTasks(c.Request.Context(), claims.UserID, claims.IsAdmin, businessLineID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +// Get 获取交付任务详情 +// @Summary 获取交付任务详情 +// @Description 返回指定任务的详细信息及事件流 +// @Tags delivery +// @Produce json +// @Param id path string true "任务 ID" +// @Success 200 {object} map[string]any "task + events" +// @Failure 401 {object} map[string]any "未授权" +// @Failure 404 {object} map[string]any "任务不存在" +// @Router /auth/api/v1/delivery/tasks/{id} [get] +// @Security BearerAuth +func (h *DeliveryHandler) Get(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + task, events, err := h.service.GetTask(c.Request.Context(), c.Param("id"), claims.UserID, claims.IsAdmin) + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"task": task, "events": events}) +} + +// Cancel 取消交付任务 +// @Summary 取消交付任务 +// @Description 取消一个正在执行或等待中的交付任务 +// @Tags delivery +// @Produce json +// @Param id path string true "任务 ID" +// @Success 200 {object} map[string]any "ok: true" +// @Failure 401 {object} map[string]any "未授权" +// @Failure 409 {object} map[string]any "无法取消(状态冲突)" +// @Router /auth/api/v1/delivery/tasks/{id}/cancel [post] +// @Security BearerAuth +func (h *DeliveryHandler) Cancel(c *gin.Context) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + if err := h.service.Cancel(c.Request.Context(), c.Param("id"), claims.UserID, claims.IsAdmin); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// Targets 获取可用部署目标 +// @Summary 获取可用部署目标 +// @Description 返回所有已启用的部署目标(如 k8s 集群、主机池) +// @Tags delivery +// @Produce json +// @Success 200 {object} map[string]any "items: 部署目标数组" +// @Failure 500 {object} map[string]any "内部错误" +// @Router /auth/api/v1/delivery/targets [get] +// @Security BearerAuth +func (h *DeliveryHandler) Targets(c *gin.Context) { + var items []model.DeploymentTarget + if err := h.service.DB().WithContext(c.Request.Context()).Where("enabled = ?", true).Order("id ASC").Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +type targetPayload struct { + Name string `json:"name" binding:"required"` + TargetType string `json:"target_type" binding:"required"` + AWXInventoryID uint64 `json:"awx_inventory_id" binding:"required"` + AWXTemplateID uint64 `json:"awx_template_id" binding:"required"` + Metadata map[string]any `json:"metadata"` +} + +// CreateTarget 创建部署目标 +// @Summary 创建部署目标 +// @Description 管理员创建新的部署目标(目前仅支持 k8s 类型) +// @Tags delivery +// @Accept json +// @Produce json +// @Param body body targetPayload true "目标配置" +// @Success 201 {object} model.DeploymentTarget "目标已创建" +// @Failure 400 {object} map[string]any "参数错误" +// @Failure 401 {object} map[string]any "未授权" +// @Failure 409 {object} map[string]any "名称冲突" +// @Router /auth/api/v1/delivery/targets [post] +// @Security BearerAuth +func (h *DeliveryHandler) CreateTarget(c *gin.Context) { + if !requirePlatformAdmin(c) { + return + } + var req targetPayload + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.TargetType != "k8s" { + c.JSON(http.StatusBadRequest, gin.H{"error": "only k8s targets are supported"}) + return + } + raw, _ := json.Marshal(req.Metadata) + item := model.DeploymentTarget{Name: req.Name, TargetType: req.TargetType, AWXInventoryID: req.AWXInventoryID, AWXTemplateID: req.AWXTemplateID, Enabled: true, Metadata: string(raw)} + if err := h.service.DB().Create(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, item) +} + +type quotaPayload struct { + BusinessLineID uint64 `json:"business_line_id" binding:"required"` + TargetID uint64 `json:"target_id" binding:"required"` + CPUMilli int64 `json:"cpu_milli" binding:"required"` + MemoryMi int64 `json:"memory_mi" binding:"required"` + StorageGi int64 `json:"storage_gi" binding:"required"` + InstanceLimit int64 `json:"instance_limit" binding:"required"` +} + +// UpsertQuota 创建或更新资源配额 +// @Summary 创建或更新资源配额 +// @Description 管理员为指定业务线 + 部署目标设置资源配额 +// @Tags delivery +// @Accept json +// @Produce json +// @Param body body quotaPayload true "配额参数" +// @Success 200 {object} model.ResourceQuota "配额已更新" +// @Failure 400 {object} map[string]any "参数错误" +// @Failure 401 {object} map[string]any "未授权" +// @Router /auth/api/v1/delivery/quotas [put] +// @Security BearerAuth +func (h *DeliveryHandler) UpsertQuota(c *gin.Context) { + if !requirePlatformAdmin(c) { + return + } + var req quotaPayload + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var item model.ResourceQuota + err := h.service.DB().Where("business_line_id = ? AND target_id = ?", req.BusinessLineID, req.TargetID).First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + item = model.ResourceQuota{BusinessLineID: req.BusinessLineID, TargetID: req.TargetID} + err = nil + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + item.CPUMilli = req.CPUMilli + item.MemoryMi = req.MemoryMi + item.StorageGi = req.StorageGi + item.InstanceLimit = req.InstanceLimit + if err := h.service.DB().Save(&item).Error; err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, item) +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index f4e1bae..b838d78 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -1,6 +1,7 @@ package router import ( + "context" "github.com/1024XEngineer/xinfra/server/internal/config" "github.com/1024XEngineer/xinfra/server/internal/handler" "github.com/1024XEngineer/xinfra/server/internal/service" @@ -71,6 +72,10 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { wayenService := service.NewWayenService(deps.Config, deps.DB) wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config, deps.DB) deploymentService := service.NewDeploymentService(deps.Config, deps.DB) + deliveryService := service.NewDeliveryService(deps.Config, deps.DB, auditService) + if deps.Config.DeliverySchedulerEnabled { + go deliveryService.Run(context.Background()) + } healthHandler := handler.NewHealthHandler(deps.DB) authHandler := handler.NewAuthHandler(deps.Config, authService) @@ -83,9 +88,12 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { samlHandler := handler.NewSAMLHandler(deps.Config, authService) oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService) deploymentHandler := handler.NewDeploymentHandler(deps.Config, deps.DB, deploymentService) + deliveryHandler := handler.NewDeliveryHandler(deliveryService) + executionHandler := handler.NewExecutionHandler(deliveryService, deps.Config.DeliveryServiceToken) r.GET("/healthz", healthHandler.Healthz) r.GET("/readyz", healthHandler.Readyz) + r.POST("/api/v1/executions", executionHandler.Create) r.GET("/auth/.well-known/openid-configuration", oauthHandler.Discovery) r.GET("/auth/oauth/authorize", oauthHandler.Authorize) r.POST("/auth/oauth/token", oauthHandler.Token) @@ -139,5 +147,12 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { protected.GET("/deployments/:id/events", deploymentHandler.Events) protected.POST("/deployments/:id/cancel", deploymentHandler.Cancel) protected.GET("/clouddm/login", clouddmHandler.Login) + protected.GET("/delivery/targets", deliveryHandler.Targets) + protected.POST("/delivery/targets", deliveryHandler.CreateTarget) + protected.PUT("/delivery/quotas", deliveryHandler.UpsertQuota) + protected.POST("/delivery/mysql", deliveryHandler.CreateMySQL) + protected.GET("/delivery/tasks", deliveryHandler.List) + protected.GET("/delivery/tasks/:id", deliveryHandler.Get) + protected.POST("/delivery/tasks/:id/cancel", deliveryHandler.Cancel) } }