Files
xinfra/server/internal/handler/delivery.go
T

496 lines
17 KiB
Go

package handler
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"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
postgresqlService *service.PostgreSQLDeliveryService
}
type PostgreSQLDeliveryHandler struct {
service *service.PostgreSQLDeliveryService
}
func NewDeliveryHandler(s *service.DeliveryService, postgresqlService *service.PostgreSQLDeliveryService) *DeliveryHandler {
return &DeliveryHandler{service: s, postgresqlService: postgresqlService}
}
func NewPostgreSQLDeliveryHandler(s *service.PostgreSQLDeliveryService) *PostgreSQLDeliveryHandler {
return &PostgreSQLDeliveryHandler{service: s}
}
type DeliveryCallbackHandler struct {
service *service.DeliveryService
token string
}
func NewDeliveryCallbackHandler(s *service.DeliveryService, token string) *DeliveryCallbackHandler {
return &DeliveryCallbackHandler{service: s, token: strings.TrimSpace(token)}
}
func (h *DeliveryCallbackHandler) authorize(c *gin.Context) bool {
provided := strings.TrimSpace(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 false
}
return true
}
func (h *DeliveryCallbackHandler) StageEvent(c *gin.Context) {
if !h.authorize(c) {
return
}
var req service.DeliveryStageEventInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.HandleStageEvent(c.Request.Context(), c.Param("id"), req); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"ok": true})
}
func (h *DeliveryCallbackHandler) AWXJobEvent(c *gin.Context) {
if !h.authorize(c) {
return
}
var req service.AWXJobNotificationInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid AWX webhook JSON: " + err.Error()})
return
}
if err := h.service.HandleAWXJobNotification(c.Request.Context(), req); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"ok": true})
}
// 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})
}
// CreatePostgreSQL submits a standalone or primary/replica PostgreSQL delivery.
func (h *PostgreSQLDeliveryHandler) CreatePostgreSQL(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
var req service.PostgreSQLDeliveryInput
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, service.DeliveryTaskListFilter{
BusinessLineID: businessLineID,
Component: c.Query("component"),
ActiveOnly: strings.EqualFold(c.Query("active"), "true"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *DeliveryHandler) MySQLServiceLedger(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
businessLineID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || businessLineID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid business line id"})
return
}
items, err := h.service.ListMySQLServiceLedger(c.Request.Context(), claims.UserID, claims.IsAdmin, businessLineID)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "business line not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *DeliveryHandler) SyncMySQLServiceLedger(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
businessLineID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || businessLineID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid business line id"})
return
}
items, err := h.service.SyncMySQLInstanceStatuses(c.Request.Context(), claims.UserID, claims.IsAdmin, businessLineID)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "business line not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *DeliveryHandler) RevealCredentials(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
items, err := h.service.RevealDeploymentCredentials(c.Request.Context(), c.Param("id"), claims.UserID, claims.IsAdmin)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "credentials not found or already claimed"})
return
}
if err != nil {
c.JSON(http.StatusConflict, 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})
}
func (h *DeliveryHandler) Stream(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
taskID := c.Param("id")
task, events, err := h.service.GetTask(c.Request.Context(), taskID, 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
}
updates, cancel := h.service.SubscribeTask(taskID)
defer cancel()
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
writeSSE(c.Writer, "snapshot", service.DeliveryTaskSnapshot{Task: task, Events: events})
c.Writer.Flush()
for {
select {
case <-c.Request.Context().Done():
return
case snapshot, ok := <-updates:
if !ok {
return
}
writeSSE(c.Writer, "snapshot", snapshot)
c.Writer.Flush()
}
}
}
func writeSSE(w http.ResponseWriter, event string, payload any) {
raw, err := json.Marshal(payload)
if err != nil {
return
}
_, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, raw)
}
// 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})
}
// RetryRollback retries the compensating AWX job after a previous cleanup failure.
func (h *DeliveryHandler) RetryRollback(c *gin.Context) {
if !requirePlatformAdmin(c) {
return
}
taskID := c.Param("id")
task, _, err := h.service.GetTask(c.Request.Context(), taskID, 0, true)
if err == nil && task.ServiceType == "postgresql" && h.postgresqlService != nil {
err = h.postgresqlService.RetryRollback(c.Request.Context(), taskID)
} else if err == nil {
err = h.service.RetryRollback(c.Request.Context(), taskID)
}
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"ok": true, "status": model.TaskRollbackPending})
}
// AcknowledgeRollbackRelease releases bookkeeping after an administrator has
// independently verified that no instance artifacts remain on the target host.
func (h *DeliveryHandler) AcknowledgeRollbackRelease(c *gin.Context) {
if !requirePlatformAdmin(c) {
return
}
taskID := c.Param("id")
task, _, err := h.service.GetTask(c.Request.Context(), taskID, 0, true)
if err == nil && task.ServiceType == "postgresql" && h.postgresqlService != nil {
err = h.postgresqlService.AcknowledgeRollbackRelease(c.Request.Context(), taskID)
} else if err == nil {
err = h.service.AcknowledgeRollbackRelease(c.Request.Context(), taskID)
}
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "status": model.TaskRollbackAck})
}
// RetryCloudDMRegistration retries only the CloudDM registration step for an
// already healthy and accounted-for MySQL instance.
func (h *DeliveryHandler) RetryCloudDMRegistration(c *gin.Context) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
taskID := c.Param("id")
task, _, err := h.service.GetTask(c.Request.Context(), taskID, claims.UserID, claims.IsAdmin)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if task.ServiceType == "postgresql" && h.postgresqlService != nil {
err = h.postgresqlService.RetryCloudDMRegistration(c.Request.Context(), taskID)
} else {
err = h.service.RetryCloudDMRegistration(c.Request.Context(), taskID)
}
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"ok": true, "status": model.TaskFinished})
}
// Targets 获取可用部署目标
// @Summary 获取可用部署目标
// @Description 从 AWX 动态返回可用 Job Template 及其 Inventory hosts
// @Tags delivery
// @Produce json
// @Param component query string false "组件过滤,例如 mysql"
// @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) {
items, err := h.service.ListTargets(c.Request.Context(), c.Query("component"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *DeliveryHandler) TargetHostMountPaths(c *gin.Context) {
targetID, err := strconv.ParseUint(c.Param("target_id"), 10, 64)
if err != nil || targetID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_id"})
return
}
items, err := h.service.ListHostMountPaths(c.Request.Context(), targetID, c.Param("host"), c.Query("prefix"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
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)
}