61eeb0c8ac
- 修复 Wayne 任务状态映射,将数字状态统一为字符串状态 - 添加分页支持,支持 page/page_size 参数 - 添加 AWXJobStdout 内存缓存,减少重复 API 调用 - 修复 SubscribeTask 内存泄漏,自动清理 closed channel - 统一 Stream 端点,AWX 使用 pub/sub,Wayne 使用轮询 背景:task log 接口存在多个潜在问题,包括状态判断错误导致无限轮询、 缺少分页、重复 API 调用性能问题、内存泄漏风险等 关联 commit:fix/logs 分支
2084 lines
73 KiB
Go
2084 lines
73 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/1024XEngineer/xinfra/server/internal/config"
|
|
"github.com/1024XEngineer/xinfra/server/internal/model"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
var dnsLabelPattern = regexp.MustCompile(`^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$`)
|
|
|
|
// AWX inventory 主机名:允许字母数字、中划线与点(FQDN)。
|
|
var hostNamePattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[-a-zA-Z0-9.]*[a-zA-Z0-9])?$`)
|
|
|
|
type MySQLDeliveryInput struct {
|
|
BusinessLineID uint64 `json:"business_line_id" binding:"required"`
|
|
TargetID uint64 `json:"target_id" binding:"required"`
|
|
Namespace string `json:"namespace" binding:"required"`
|
|
InstanceName string `json:"instance_name" binding:"required"`
|
|
MySQLVersion string `json:"mysql_version"`
|
|
Topology string `json:"topology"`
|
|
MySQLPort int `json:"mysql_port"`
|
|
DataDisk string `json:"data_disk"`
|
|
// 调度控制(选填):点名候选池内主机跳过自动选机,端口/配额/实机守卫照常执行
|
|
TargetHost string `json:"target_host"`
|
|
CPUMilli int64 `json:"cpu_milli" binding:"required"`
|
|
MemoryMi int64 `json:"memory_mi" binding:"required"`
|
|
StorageGi int64 `json:"storage_gi" binding:"required"`
|
|
CPUCores int64 `json:"cpu_cores,omitempty"`
|
|
MemoryGB int64 `json:"memory_gb,omitempty"`
|
|
StorageGB int64 `json:"storage_gb,omitempty"`
|
|
// 数据库配置(选填,缺省由 playbook 基线兜底)
|
|
Timezone string `json:"timezone"`
|
|
LowerCaseTableNames *int `json:"lower_case_table_names"`
|
|
CharacterSet string `json:"character_set"`
|
|
Collation string `json:"collation"`
|
|
// 高级参数(选填,零值视为未设置)
|
|
MaxConnections string `json:"max_connections"`
|
|
InnodbRedoLogCapacity string `json:"innodb_redo_log_capacity"`
|
|
InnodbFlushLogAtTrxCommit *int `json:"innodb_flush_log_at_trx_commit"`
|
|
SyncBinlog *int `json:"sync_binlog"`
|
|
InnodbIOCapacity int `json:"innodb_io_capacity"`
|
|
LongQueryTime float64 `json:"long_query_time"`
|
|
BinlogExpireLogsSeconds int64 `json:"binlog_expire_logs_seconds"`
|
|
MaxBinlogSize string `json:"max_binlog_size"`
|
|
MySQLRootPassword string `json:"mysql_root_password"`
|
|
MySQLAdminPassword string `json:"mysql_admin_password"`
|
|
}
|
|
|
|
type deliveryPayload struct {
|
|
MySQLDeliveryInput
|
|
TargetType string `json:"target_type"`
|
|
}
|
|
|
|
type DeliveryTarget struct {
|
|
ID uint64 `json:"id"`
|
|
Name string `json:"name"`
|
|
TargetType string `json:"target_type"`
|
|
AWXInventoryID uint64 `json:"awx_inventory_id"`
|
|
AWXTemplateID uint64 `json:"awx_template_id"`
|
|
Enabled bool `json:"enabled"`
|
|
Metadata string `json:"metadata"`
|
|
}
|
|
|
|
type DeliveryMountPath struct {
|
|
Path string `json:"path"`
|
|
AvailableGi int64 `json:"available_gi"`
|
|
FSType string `json:"fstype,omitempty"`
|
|
}
|
|
|
|
type DeliveryStageEventInput struct {
|
|
Stage string `json:"stage" binding:"required"`
|
|
Status string `json:"status" binding:"required"`
|
|
Message string `json:"message"`
|
|
AWXJobID string `json:"awx_job_id"`
|
|
}
|
|
|
|
type AWXJobNotificationInput struct {
|
|
ID uint64 `json:"id"`
|
|
Status string `json:"status"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Traceback string `json:"traceback"`
|
|
ExtraVars any `json:"extra_vars"`
|
|
}
|
|
|
|
type DeliveryTaskListFilter struct {
|
|
BusinessLineID uint64
|
|
Component string
|
|
ActiveOnly bool
|
|
}
|
|
|
|
type DeliveryTaskSnapshot struct {
|
|
Task *model.DeliveryTask `json:"task"`
|
|
Events []model.TaskEvent `json:"events"`
|
|
}
|
|
|
|
type MySQLServiceLedgerItem struct {
|
|
Name string `json:"name"`
|
|
Datacenter string `json:"datacenter"`
|
|
BusinessTag string `json:"business_tag"`
|
|
Instances int `json:"instances"`
|
|
Healthy int `json:"healthy"`
|
|
Address string `json:"address"`
|
|
Status string `json:"status"`
|
|
StatusClass string `json:"status_class"`
|
|
Version string `json:"version"`
|
|
Namespace string `json:"namespace"`
|
|
}
|
|
|
|
type DeploymentCredentialView struct {
|
|
Username string `json:"username"`
|
|
Host string `json:"host"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// targetMetadata describes the native VM候选节点池以及部署形态,由 AWX inventory hosts 动态组装。
|
|
type targetMetadata struct {
|
|
Topology string `json:"topology"`
|
|
MySQLPort int `json:"mysql_port"`
|
|
Hosts []targetHost `json:"hosts"`
|
|
}
|
|
|
|
type targetHost struct {
|
|
Name string `json:"name"`
|
|
IP string `json:"ip"`
|
|
}
|
|
|
|
func parseTargetMetadata(raw string) targetMetadata {
|
|
meta := targetMetadata{}
|
|
if strings.TrimSpace(raw) != "" {
|
|
_ = json.Unmarshal([]byte(raw), &meta)
|
|
}
|
|
if meta.Topology == "" {
|
|
meta.Topology = "standalone"
|
|
}
|
|
if meta.MySQLPort == 0 {
|
|
meta.MySQLPort = 3307
|
|
}
|
|
return meta
|
|
}
|
|
|
|
func mountPathsFromFacts(facts map[string]any) []DeliveryMountPath {
|
|
rawMounts, ok := facts["ansible_mounts"].([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
items := make([]DeliveryMountPath, 0, len(rawMounts))
|
|
seen := map[string]struct{}{}
|
|
for _, raw := range rawMounts {
|
|
mount, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
path := strings.TrimSpace(stringValue(mount["mount"]))
|
|
if path == "" || !strings.HasPrefix(path, "/") {
|
|
continue
|
|
}
|
|
if _, exists := seen[path]; exists {
|
|
continue
|
|
}
|
|
seen[path] = struct{}{}
|
|
items = append(items, DeliveryMountPath{
|
|
Path: path,
|
|
AvailableGi: bytesToGi(int64Value(mount["size_available"])),
|
|
FSType: strings.TrimSpace(stringValue(mount["fstype"])),
|
|
})
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].Path == "/" {
|
|
return false
|
|
}
|
|
if items[j].Path == "/" {
|
|
return true
|
|
}
|
|
return items[i].Path < items[j].Path
|
|
})
|
|
return items
|
|
}
|
|
|
|
func stringValue(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
switch v := value.(type) {
|
|
case string:
|
|
return v
|
|
case fmt.Stringer:
|
|
return v.String()
|
|
default:
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
}
|
|
|
|
func int64Value(value any) int64 {
|
|
switch v := value.(type) {
|
|
case int:
|
|
return int64(v)
|
|
case int64:
|
|
return v
|
|
case float64:
|
|
return int64(v)
|
|
case json.Number:
|
|
n, _ := v.Int64()
|
|
return n
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func bytesToGi(bytes int64) int64 {
|
|
if bytes <= 0 {
|
|
return 0
|
|
}
|
|
return bytes / 1073741824
|
|
}
|
|
|
|
func credentialKey(secret string) []byte {
|
|
sum := sha256.Sum256([]byte(strings.TrimSpace(secret)))
|
|
return sum[:]
|
|
}
|
|
|
|
func encryptCredential(secret, plain string) (string, string, error) {
|
|
block, err := aes.NewCipher(credentialKey(secret))
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", "", err
|
|
}
|
|
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
|
|
return base64.StdEncoding.EncodeToString(ciphertext), base64.StdEncoding.EncodeToString(nonce), nil
|
|
}
|
|
|
|
func decryptCredential(secret, ciphertextValue, nonceValue string) (string, error) {
|
|
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextValue)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
nonce, err := base64.StdEncoding.DecodeString(nonceValue)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
block, err := aes.NewCipher(credentialKey(secret))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
// firstFreeHost 返回候选池中非失败任务数未达单机实例上限的第一个节点;
|
|
// limit < 1 时按 1 兜底(退化为旧的一机一实例语义)。顺序遍历天然形成"先摊开、摊满一轮再叠加"。
|
|
func firstFreeHost(hosts []targetHost, occupied []string, limit int) *targetHost {
|
|
if limit < 1 {
|
|
limit = 1
|
|
}
|
|
for i := range hosts {
|
|
if hostTaskCount(occupied, hosts[i].Name) < limit {
|
|
return &hosts[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hostTaskCount 统计某主机在占用清单(非失败任务的 target_host 列表,含重复)中的出现次数。
|
|
func hostTaskCount(occupied []string, name string) int {
|
|
count := 0
|
|
for _, h := range occupied {
|
|
if h == name {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// 端口池 13306-13999:混合分配模型,用户留空时自动分配,可覆盖为池内指定端口。
|
|
const (
|
|
mysqlPortPoolStart = 13306
|
|
mysqlPortPoolEnd = 13999
|
|
)
|
|
|
|
// allocatePort 在目标主机已占用端口集上做混合分配:指定端口验冲突,未指定则取池内首个空闲端口。
|
|
func allocatePort(requested int, used []int) (int, error) {
|
|
taken := make(map[int]bool, len(used))
|
|
for _, p := range used {
|
|
taken[p] = true
|
|
}
|
|
if requested != 0 {
|
|
if taken[requested] {
|
|
return 0, fmt.Errorf("mysql_port %d is already allocated on the target host", requested)
|
|
}
|
|
return requested, nil
|
|
}
|
|
for p := mysqlPortPoolStart; p <= mysqlPortPoolEnd; p++ {
|
|
if !taken[p] {
|
|
return p, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("mysql port pool %d-%d is exhausted on the target host", mysqlPortPoolStart, mysqlPortPoolEnd)
|
|
}
|
|
|
|
// stdoutCacheItem 缓存 AWX Job stdout 的结果
|
|
type stdoutCacheItem struct {
|
|
stdout string
|
|
createdAt time.Time
|
|
}
|
|
|
|
type DeliveryService struct {
|
|
db *gorm.DB
|
|
cfg config.Config
|
|
awx *AWXClient
|
|
audit *AuditService
|
|
executionMu sync.Mutex
|
|
streamMu sync.Mutex
|
|
streams map[string]map[chan DeliveryTaskSnapshot]struct{}
|
|
stdoutCache map[string]*stdoutCacheItem
|
|
cacheMu sync.RWMutex
|
|
}
|
|
|
|
func (s *DeliveryService) DB() *gorm.DB { return s.db }
|
|
|
|
func NewDeliveryService(cfg config.Config, db *gorm.DB, audit *AuditService) *DeliveryService {
|
|
return &DeliveryService{
|
|
db: db,
|
|
cfg: cfg,
|
|
awx: NewAWXClient(cfg.AWXBaseURL, cfg.AWXToken, cfg.AWXUsername, cfg.AWXPassword),
|
|
audit: audit,
|
|
streams: make(map[string]map[chan DeliveryTaskSnapshot]struct{}),
|
|
stdoutCache: make(map[string]*stdoutCacheItem),
|
|
}
|
|
}
|
|
|
|
func (s *DeliveryService) ListTargets(ctx context.Context, component string) ([]DeliveryTarget, error) {
|
|
templates, err := s.awx.ListJobTemplates(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
component = strings.ToLower(strings.TrimSpace(component))
|
|
var targets []DeliveryTarget
|
|
for _, template := range templates {
|
|
if template.Inventory == 0 {
|
|
continue
|
|
}
|
|
if component != "" && component != "all" {
|
|
text := strings.ToLower(template.Name + " " + template.Description)
|
|
if !strings.Contains(text, component) {
|
|
continue
|
|
}
|
|
}
|
|
target, err := s.awxDeliveryTarget(ctx, template)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
targets = append(targets, target)
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func (s *DeliveryService) getTarget(ctx context.Context, templateID uint64) (DeliveryTarget, error) {
|
|
template, err := s.awx.GetJobTemplate(ctx, templateID)
|
|
if err != nil {
|
|
return DeliveryTarget{}, fmt.Errorf("deployment target is unavailable: %w", err)
|
|
}
|
|
return s.awxDeliveryTarget(ctx, *template)
|
|
}
|
|
|
|
func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint64, hostName string) ([]DeliveryMountPath, error) {
|
|
if targetID == 0 {
|
|
return nil, fmt.Errorf("target_id is required")
|
|
}
|
|
if hostName == "" || len(hostName) > 253 || !hostNamePattern.MatchString(hostName) {
|
|
return nil, fmt.Errorf("host must be a valid inventory host name")
|
|
}
|
|
template, err := s.awx.GetJobTemplate(ctx, targetID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("deployment target is unavailable: %w", err)
|
|
}
|
|
hosts, err := s.awx.ListInventoryHosts(ctx, template.Inventory)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var matched *AWXInventoryHost
|
|
for i := range hosts {
|
|
if hosts[i].Enabled && hosts[i].Name == hostName {
|
|
matched = &hosts[i]
|
|
break
|
|
}
|
|
}
|
|
if matched == nil {
|
|
return nil, fmt.Errorf("host %q is not in the deployment target inventory", hostName)
|
|
}
|
|
if s.cfg.AWXFactsTemplateID != 0 {
|
|
if err := s.refreshHostFacts(ctx, hostName); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
facts, err := s.awx.GetHostFacts(ctx, matched.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := mountPathsFromFacts(facts)
|
|
if items == nil {
|
|
items = []DeliveryMountPath{}
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *DeliveryService) refreshHostFacts(ctx context.Context, hostName string) error {
|
|
job, err := s.awx.Launch(ctx, s.cfg.AWXFactsTemplateID, AWXLaunchRequest{
|
|
Limit: hostName,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("launch AWX facts job: %w", err)
|
|
}
|
|
done, err := s.awx.WaitJob(ctx, strconv.FormatUint(job.ID, 10), time.Duration(s.cfg.AWXFactsTimeoutSeconds)*time.Second)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if done.Status != "successful" || done.Failed {
|
|
return fmt.Errorf("AWX facts job %d finished with status %s", done.ID, done.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DeliveryService) awxDeliveryTarget(ctx context.Context, template AWXJobTemplate) (DeliveryTarget, error) {
|
|
hosts, err := s.awx.ListInventoryHosts(ctx, template.Inventory)
|
|
if err != nil {
|
|
return DeliveryTarget{}, err
|
|
}
|
|
meta := targetMetadata{Topology: "standalone", MySQLPort: 3307}
|
|
for _, host := range hosts {
|
|
if !host.Enabled {
|
|
continue
|
|
}
|
|
meta.Hosts = append(meta.Hosts, targetHost{Name: host.Name, IP: AWXHostIP(host)})
|
|
}
|
|
raw, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return DeliveryTarget{}, err
|
|
}
|
|
return DeliveryTarget{
|
|
ID: template.ID,
|
|
Name: template.Name,
|
|
TargetType: "k8s",
|
|
AWXInventoryID: template.Inventory,
|
|
AWXTemplateID: template.ID,
|
|
Enabled: true,
|
|
Metadata: string(raw),
|
|
}, nil
|
|
}
|
|
|
|
func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin bool, idempotencyKey string, input MySQLDeliveryInput) (*model.DeliveryTask, bool, error) {
|
|
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
|
if idempotencyKey == "" || len(idempotencyKey) > 128 {
|
|
return nil, false, fmt.Errorf("Idempotency-Key header is required and must not exceed 128 characters")
|
|
}
|
|
normalizeMySQLDeliveryInput(&input)
|
|
if err := validateDeliveryInput(input, s.cfg.DeliveryDataDisks); err != nil {
|
|
return nil, false, err
|
|
}
|
|
credentialInput := map[string]string{
|
|
"root@localhost": strings.TrimSpace(input.MySQLRootPassword),
|
|
"xinfra_admin@%": strings.TrimSpace(input.MySQLAdminPassword),
|
|
}
|
|
hasCredentialInput := credentialInput["root@localhost"] != "" || credentialInput["xinfra_admin@%"] != ""
|
|
if hasCredentialInput {
|
|
if credentialInput["root@localhost"] == "" || credentialInput["xinfra_admin@%"] == "" {
|
|
return nil, false, fmt.Errorf("mysql_root_password and mysql_admin_password must be provided together")
|
|
}
|
|
if len(credentialInput["root@localhost"]) < 16 || len(credentialInput["xinfra_admin@%"]) < 16 {
|
|
return nil, false, fmt.Errorf("mysql passwords must be at least 16 characters")
|
|
}
|
|
}
|
|
input.MySQLRootPassword = ""
|
|
input.MySQLAdminPassword = ""
|
|
|
|
var existing model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; err == nil {
|
|
if existing.RequestedBy != userID {
|
|
return nil, false, fmt.Errorf("idempotency key is already in use by another user")
|
|
}
|
|
return &existing, true, nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, false, err
|
|
}
|
|
|
|
target, err := s.getTarget(ctx, input.TargetID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if target.TargetType != "k8s" {
|
|
return nil, false, fmt.Errorf("target type %q is not supported in the MVP", target.TargetType)
|
|
}
|
|
if !isAdmin {
|
|
var count int64
|
|
if err := s.db.WithContext(ctx).Model(&model.BusinessLineUser{}).
|
|
Where("business_line_id = ? AND user_id = ?", input.BusinessLineID, userID).Count(&count).Error; err != nil {
|
|
return nil, false, err
|
|
}
|
|
if count == 0 {
|
|
return nil, false, fmt.Errorf("user is not authorized for this business line")
|
|
}
|
|
}
|
|
if input.MySQLVersion == "" {
|
|
input.MySQLVersion = "8.0"
|
|
}
|
|
if input.Topology == "" {
|
|
input.Topology = "standalone"
|
|
}
|
|
if input.DataDisk == "" {
|
|
if len(s.cfg.DeliveryDataDisks) > 0 {
|
|
input.DataDisk = s.cfg.DeliveryDataDisks[0]
|
|
} else {
|
|
input.DataDisk = "/data"
|
|
}
|
|
}
|
|
payload := deliveryPayload{MySQLDeliveryInput: input, TargetType: target.TargetType}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
digest := sha256.Sum256(raw)
|
|
task := model.DeliveryTask{
|
|
ID: randomUUID(),
|
|
BusinessLineID: input.BusinessLineID,
|
|
RequestedBy: userID,
|
|
Component: "mysql",
|
|
TargetType: target.TargetType,
|
|
TargetID: target.ID,
|
|
Namespace: input.Namespace,
|
|
InstanceName: input.InstanceName,
|
|
Status: model.TaskPending,
|
|
ImmutablePayload: string(raw),
|
|
PayloadHash: hex.EncodeToString(digest[:]),
|
|
IdempotencyKey: idempotencyKey,
|
|
}
|
|
if err := s.db.WithContext(ctx).Create(&task).Error; err != nil {
|
|
if lookupErr := s.db.WithContext(ctx).Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; lookupErr == nil {
|
|
return &existing, true, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
if hasCredentialInput {
|
|
if err := s.storeDeploymentCredentials(ctx, &task, credentialInput, "pending"); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
_ = s.db.WithContext(ctx).Create(&model.TaskEvent{TaskID: task.ID, ToState: model.TaskPending, Message: "delivery task created"}).Error
|
|
return &task, false, nil
|
|
}
|
|
|
|
// 版本白名单与 playbook 的 mysql_package_map 保持同步:
|
|
// 8.0 走 Ubuntu 自带源,8.4 走 MySQL 官方 APT 源;5.6/5.7 已 EOL 且无 noble 包,不支持。
|
|
var supportedMySQLVersions = map[string]bool{"8.0": true, "8.4": true}
|
|
|
|
// 拓扑白名单:playbook 已支持 primary_replica/mgr_3 的配置渲染,
|
|
// 但调度器仍是单主机模型且复制编排未自动化,本期仅放开 standalone。
|
|
var supportedTopologies = map[string]bool{"standalone": true}
|
|
|
|
const rollbackLaunchTimeout = 2 * time.Minute
|
|
|
|
var supportedCharsets = map[string]bool{"utf8mb4": true, "utf8": true, "gbk": true, "latin1": true}
|
|
|
|
// 高级参数档位白名单(与 docs/mysql-parameter-selection.md 保持一致)
|
|
var (
|
|
supportedMaxConnections = map[string]bool{"auto": true, "200": true, "500": true, "1000": true, "2000": true, "4000": true, "8000": true, "16000": true}
|
|
supportedLogSizes = map[string]bool{"128M": true, "256M": true, "512M": true, "1G": true}
|
|
supportedRedoLogSizes = map[string]bool{"auto": true, "128M": true, "256M": true, "512M": true, "1G": true}
|
|
supportedIOCapacities = map[int]bool{200: true, 2000: true, 5000: true}
|
|
supportedLongQueryTimes = map[float64]bool{0.5: true, 1: true, 2: true, 5: true, 10: true}
|
|
supportedBinlogExpireSecs = map[int64]bool{86400: true, 259200: true, 604800: true, 1209600: true}
|
|
)
|
|
|
|
// timezone 仅接受偏移量(±HH:MM)、SYSTEM 或命名时区(如 Asia/Shanghai)。
|
|
var timezonePattern = regexp.MustCompile(`^([+-](0\d|1[0-4]):[0-5]\d|SYSTEM|[A-Za-z]+(?:/[A-Za-z0-9_+-]+)+)$`)
|
|
|
|
func validateDeliveryInput(input MySQLDeliveryInput, _ []string) error {
|
|
if len(input.Namespace) > 63 || !dnsLabelPattern.MatchString(input.Namespace) {
|
|
return fmt.Errorf("namespace must be a valid Kubernetes DNS label")
|
|
}
|
|
if len(input.InstanceName) > 63 || !dnsLabelPattern.MatchString(input.InstanceName) {
|
|
return fmt.Errorf("instance_name must be a valid Kubernetes DNS label")
|
|
}
|
|
// 与文档目标态一致(memory 2048-65536 MiB / storage 20-2000 GiB),playbook assert 同步。
|
|
if input.CPUMilli < 100 || input.CPUMilli > 64000 || input.MemoryMi < 2048 || input.MemoryMi > 65536 || input.StorageGi < 20 || input.StorageGi > 2000 {
|
|
return fmt.Errorf("requested resources are outside the supported range (memory: 2048-65536 MiB, storage: 20-2000 GiB)")
|
|
}
|
|
if input.MySQLVersion != "" && !supportedMySQLVersions[input.MySQLVersion] {
|
|
return fmt.Errorf("unsupported mysql_version %q, supported: 8.0, 8.4 (5.6/5.7 are EOL and have no Ubuntu 24.04 packages)", input.MySQLVersion)
|
|
}
|
|
if input.Topology != "" && !supportedTopologies[input.Topology] {
|
|
return fmt.Errorf("unsupported topology %q, supported: standalone (primary_replica/mgr_3 pending scheduler support)", input.Topology)
|
|
}
|
|
if input.MySQLPort != 0 && (input.MySQLPort < mysqlPortPoolStart || input.MySQLPort > mysqlPortPoolEnd) {
|
|
return fmt.Errorf("mysql_port must be left empty for auto allocation or within the pool %d-%d", mysqlPortPoolStart, mysqlPortPoolEnd)
|
|
}
|
|
if input.TargetHost != "" && (len(input.TargetHost) > 253 || !hostNamePattern.MatchString(input.TargetHost)) {
|
|
return fmt.Errorf("target_host must be a valid inventory host name")
|
|
}
|
|
if input.Timezone != "" && !timezonePattern.MatchString(input.Timezone) {
|
|
return fmt.Errorf("timezone must be an offset like +08:00, SYSTEM, or a named zone like Asia/Shanghai")
|
|
}
|
|
if input.LowerCaseTableNames != nil && *input.LowerCaseTableNames != 0 && *input.LowerCaseTableNames != 1 {
|
|
return fmt.Errorf("lower_case_table_names must be 0 or 1")
|
|
}
|
|
if input.CharacterSet != "" && !supportedCharsets[input.CharacterSet] {
|
|
return fmt.Errorf("unsupported character_set %q, supported: utf8mb4, utf8, gbk, latin1", input.CharacterSet)
|
|
}
|
|
if input.Collation != "" {
|
|
charset := input.CharacterSet
|
|
if charset == "" {
|
|
charset = "utf8mb4"
|
|
}
|
|
if !strings.HasPrefix(input.Collation, charset+"_") {
|
|
return fmt.Errorf("collation %q does not match character_set %q", input.Collation, charset)
|
|
}
|
|
}
|
|
if input.MaxConnections != "" && !supportedMaxConnections[input.MaxConnections] {
|
|
return fmt.Errorf("max_connections must be one of auto, 200, 500, 1000, 2000, 4000, 8000, 16000")
|
|
}
|
|
if input.InnodbRedoLogCapacity != "" && !supportedRedoLogSizes[input.InnodbRedoLogCapacity] {
|
|
return fmt.Errorf("innodb_redo_log_capacity must be one of auto, 128M, 256M, 512M, 1G")
|
|
}
|
|
if input.InnodbFlushLogAtTrxCommit != nil && (*input.InnodbFlushLogAtTrxCommit < 0 || *input.InnodbFlushLogAtTrxCommit > 2) {
|
|
return fmt.Errorf("innodb_flush_log_at_trx_commit must be 0, 1 or 2")
|
|
}
|
|
if input.SyncBinlog != nil && *input.SyncBinlog != 0 && *input.SyncBinlog != 1 {
|
|
return fmt.Errorf("sync_binlog must be 0 or 1")
|
|
}
|
|
if input.InnodbIOCapacity != 0 && !supportedIOCapacities[input.InnodbIOCapacity] {
|
|
return fmt.Errorf("innodb_io_capacity must be one of 200, 2000, 5000")
|
|
}
|
|
if input.LongQueryTime != 0 && !supportedLongQueryTimes[input.LongQueryTime] {
|
|
return fmt.Errorf("long_query_time must be one of 0.5, 1, 2, 5, 10")
|
|
}
|
|
if input.BinlogExpireLogsSeconds != 0 && !supportedBinlogExpireSecs[input.BinlogExpireLogsSeconds] {
|
|
return fmt.Errorf("binlog_expire_logs_seconds must be one of 86400, 259200, 604800, 1209600")
|
|
}
|
|
if input.MaxBinlogSize != "" && !supportedLogSizes[input.MaxBinlogSize] {
|
|
return fmt.Errorf("max_binlog_size must be one of 128M, 256M, 512M, 1G")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeMySQLDeliveryInput(input *MySQLDeliveryInput) {
|
|
input.Namespace = normalizeDNSLabel(input.Namespace)
|
|
input.InstanceName = normalizeDNSLabel(input.InstanceName)
|
|
input.TargetHost = strings.TrimSpace(input.TargetHost)
|
|
input.DataDisk = strings.TrimSpace(input.DataDisk)
|
|
if input.MySQLVersion == "" {
|
|
input.MySQLVersion = "8.0"
|
|
}
|
|
if input.Topology == "" {
|
|
input.Topology = "standalone"
|
|
}
|
|
if input.CPUMilli == 0 && input.CPUCores != 0 {
|
|
input.CPUMilli = input.CPUCores * 1000
|
|
}
|
|
if input.MemoryMi == 0 && input.MemoryGB != 0 {
|
|
input.MemoryMi = input.MemoryGB * 1024
|
|
}
|
|
if input.StorageGi == 0 && input.StorageGB != 0 {
|
|
input.StorageGi = input.StorageGB
|
|
}
|
|
}
|
|
|
|
func normalizeDNSLabel(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
value = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(value, "-")
|
|
value = regexp.MustCompile(`-+`).ReplaceAllString(value, "-")
|
|
value = strings.Trim(value, "-")
|
|
if len(value) > 63 {
|
|
value = strings.Trim(value[:63], "-")
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (s *DeliveryService) ListTasks(ctx context.Context, userID uint64, isAdmin bool, filter DeliveryTaskListFilter) ([]model.DeliveryTask, error) {
|
|
query := s.db.WithContext(ctx).Order("created_at DESC")
|
|
if filter.BusinessLineID != 0 {
|
|
query = query.Where("business_line_id = ?", filter.BusinessLineID)
|
|
}
|
|
if filter.Component != "" {
|
|
query = query.Where("component = ?", filter.Component)
|
|
}
|
|
if filter.ActiveOnly {
|
|
query = query.Where("status NOT IN ?", terminalTaskStatuses())
|
|
}
|
|
if !isAdmin {
|
|
query = query.Where("business_line_id IN (?)", s.db.Model(&model.BusinessLineUser{}).Select("business_line_id").Where("user_id = ?", userID))
|
|
}
|
|
var tasks []model.DeliveryTask
|
|
return tasks, query.Find(&tasks).Error
|
|
}
|
|
|
|
func (s *DeliveryService) ListMySQLServiceLedger(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) ([]MySQLServiceLedgerItem, error) {
|
|
if businessLineID == 0 {
|
|
return nil, fmt.Errorf("business_line_id is required")
|
|
}
|
|
if !isAdmin {
|
|
var count int64
|
|
if err := s.db.WithContext(ctx).Model(&model.BusinessLineUser{}).
|
|
Where("business_line_id = ? AND user_id = ?", businessLineID, userID).
|
|
Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if count == 0 {
|
|
return nil, fmt.Errorf("user is not authorized for this business line")
|
|
}
|
|
}
|
|
var businessLine model.BusinessLine
|
|
if err := s.db.WithContext(ctx).First(&businessLine, "id = ?", businessLineID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var instances []model.DeploymentResult
|
|
if err := s.db.WithContext(ctx).
|
|
Where("business_line_id = ? AND component = ? AND service_type = ? AND status = ?", businessLineID, "mysql", "database", "active").
|
|
Order("created_at DESC").
|
|
Find(&instances).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
taskIDs := make([]string, 0, len(instances))
|
|
for _, instance := range instances {
|
|
taskIDs = append(taskIDs, instance.TaskID)
|
|
}
|
|
taskStatuses := map[string]string{}
|
|
if len(taskIDs) > 0 {
|
|
var tasks []model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).Select("id", "status").Where("id IN ?", taskIDs).Find(&tasks).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, task := range tasks {
|
|
taskStatuses[task.ID] = task.Status
|
|
}
|
|
}
|
|
items := make([]MySQLServiceLedgerItem, 0, len(instances))
|
|
for _, instance := range instances {
|
|
status := "健康"
|
|
statusClass := "ok"
|
|
healthy := 1
|
|
if taskStatuses[instance.TaskID] == model.TaskRegisterFailed {
|
|
status = "注册异常"
|
|
statusClass = "warn"
|
|
}
|
|
if instance.Host == "" || instance.Port == 0 {
|
|
status = "部分异常"
|
|
statusClass = "warn"
|
|
healthy = 0
|
|
}
|
|
items = append(items, MySQLServiceLedgerItem{
|
|
Name: instance.InstanceName,
|
|
Datacenter: instance.NodeName,
|
|
BusinessTag: businessLine.Name,
|
|
Instances: 1,
|
|
Healthy: healthy,
|
|
Address: fmt.Sprintf("%s:%d", instance.Host, instance.Port),
|
|
Status: status,
|
|
StatusClass: statusClass,
|
|
Version: instance.Version,
|
|
Namespace: instance.Namespace,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *DeliveryService) storeDeploymentCredentials(ctx context.Context, task *model.DeliveryTask, credentials map[string]string, status string) error {
|
|
for key, password := range credentials {
|
|
username, host, ok := strings.Cut(key, "@")
|
|
if !ok || strings.TrimSpace(username) == "" {
|
|
return fmt.Errorf("invalid credential account %q", key)
|
|
}
|
|
ciphertext, nonce, err := encryptCredential(s.cfg.DeliveryCredentialSecret, password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
item := model.DeploymentCredential{
|
|
TaskID: task.ID,
|
|
BusinessLineID: task.BusinessLineID,
|
|
Component: task.Component,
|
|
InstanceName: task.InstanceName,
|
|
Username: strings.TrimSpace(username),
|
|
AccountHost: strings.TrimSpace(host),
|
|
Ciphertext: ciphertext,
|
|
Nonce: nonce,
|
|
Status: status,
|
|
Source: "user_input",
|
|
}
|
|
if item.Component == "" {
|
|
item.Component = "mysql"
|
|
}
|
|
if err := s.db.WithContext(ctx).Create(&item).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DeliveryService) deploymentCredentialVars(ctx context.Context, taskID string) (map[string]string, error) {
|
|
var items []model.DeploymentCredential
|
|
if err := s.db.WithContext(ctx).
|
|
Where("task_id = ? AND status IN ?", taskID, []string{"pending", "available"}).
|
|
Find(&items).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return map[string]string{}, nil
|
|
}
|
|
values := map[string]string{}
|
|
for _, item := range items {
|
|
password, err := decryptCredential(s.cfg.DeliveryCredentialSecret, item.Ciphertext, item.Nonce)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch item.Username + "@" + item.AccountHost {
|
|
case "root@localhost":
|
|
values["mysql_root_password"] = password
|
|
case "xinfra_admin@%":
|
|
values["mysql_admin_password"] = password
|
|
}
|
|
}
|
|
if values["mysql_root_password"] == "" || values["mysql_admin_password"] == "" {
|
|
return nil, fmt.Errorf("deployment credentials are missing for task %s", taskID)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func (s *DeliveryService) GetTask(ctx context.Context, taskID string, userID uint64, isAdmin bool) (*model.DeliveryTask, []model.TaskEvent, error) {
|
|
query := s.db.WithContext(ctx).Where("id = ?", taskID)
|
|
if !isAdmin {
|
|
query = query.Where("business_line_id IN (?)", s.db.Model(&model.BusinessLineUser{}).Select("business_line_id").Where("user_id = ?", userID))
|
|
}
|
|
var task model.DeliveryTask
|
|
if err := query.First(&task).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var events []model.TaskEvent
|
|
if err := s.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id ASC").Find(&events).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &task, events, nil
|
|
}
|
|
|
|
func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskID string, userID uint64, isAdmin bool) ([]DeploymentCredentialView, error) {
|
|
task, _, err := s.GetTask(ctx, taskID, userID, isAdmin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if task.Status != model.TaskFinished && task.Status != model.TaskRegisterFailed {
|
|
return nil, fmt.Errorf("task credentials are available only after a successful deployment")
|
|
}
|
|
var out []DeploymentCredentialView
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var credentials []model.DeploymentCredential
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("task_id = ? AND status = ?", taskID, "available").
|
|
Order("id ASC").
|
|
Find(&credentials).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(credentials) == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
for _, credential := range credentials {
|
|
password, err := decryptCredential(s.cfg.DeliveryCredentialSecret, credential.Ciphertext, credential.Nonce)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out = append(out, DeploymentCredentialView{
|
|
Username: credential.Username,
|
|
Host: credential.AccountHost,
|
|
Password: password,
|
|
})
|
|
}
|
|
ids := make([]uint64, 0, len(credentials))
|
|
for _, credential := range credentials {
|
|
ids = append(ids, credential.ID)
|
|
}
|
|
now := time.Now()
|
|
return tx.Model(&model.DeploymentCredential{}).Where("id IN ?", ids).Updates(map[string]any{
|
|
"status": "viewed",
|
|
"viewed_by": userID,
|
|
"viewed_at": now,
|
|
"updated_at": now,
|
|
}).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *DeliveryService) SubscribeTask(taskID string) (<-chan DeliveryTaskSnapshot, func()) {
|
|
ch := make(chan DeliveryTaskSnapshot, 8)
|
|
s.streamMu.Lock()
|
|
if s.streams[taskID] == nil {
|
|
s.streams[taskID] = make(map[chan DeliveryTaskSnapshot]struct{})
|
|
}
|
|
s.streams[taskID][ch] = struct{}{}
|
|
s.streamMu.Unlock()
|
|
cancel := func() {
|
|
s.streamMu.Lock()
|
|
if subscribers := s.streams[taskID]; subscribers != nil {
|
|
delete(subscribers, ch)
|
|
if len(subscribers) == 0 {
|
|
delete(s.streams, taskID)
|
|
}
|
|
}
|
|
s.streamMu.Unlock()
|
|
close(ch)
|
|
}
|
|
return ch, cancel
|
|
}
|
|
|
|
func (s *DeliveryService) taskSnapshot(ctx context.Context, taskID string) (DeliveryTaskSnapshot, error) {
|
|
task, events, err := s.GetTask(ctx, taskID, 0, true)
|
|
if err != nil {
|
|
return DeliveryTaskSnapshot{}, err
|
|
}
|
|
return DeliveryTaskSnapshot{Task: task, Events: events}, nil
|
|
}
|
|
|
|
func (s *DeliveryService) broadcastTask(ctx context.Context, taskID string) {
|
|
snapshot, err := s.taskSnapshot(ctx, taskID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// 任务状态变化时清除 stdout 缓存
|
|
s.invalidateStdoutCacheForTask(taskID)
|
|
|
|
s.streamMu.Lock()
|
|
defer s.streamMu.Unlock()
|
|
|
|
// 收集需要清理的 closed channel
|
|
var closedChannels []chan DeliveryTaskSnapshot
|
|
|
|
for ch := range s.streams[taskID] {
|
|
// 使用 recover 捕获 send on closed channel 的错误
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
// channel 已关闭,标记需要清理
|
|
closedChannels = append(closedChannels, ch)
|
|
}
|
|
}()
|
|
select {
|
|
case ch <- snapshot:
|
|
default:
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 清理 closed channels
|
|
for _, ch := range closedChannels {
|
|
delete(s.streams[taskID], ch)
|
|
}
|
|
if len(s.streams[taskID]) == 0 {
|
|
delete(s.streams, taskID)
|
|
}
|
|
}
|
|
|
|
// invalidateStdoutCacheForTask 清除与任务相关的 stdout 缓存
|
|
func (s *DeliveryService) invalidateStdoutCacheForTask(taskID string) {
|
|
var execution model.ExecutionJob
|
|
var rollback model.RollbackJob
|
|
|
|
s.cacheMu.Lock()
|
|
defer s.cacheMu.Unlock()
|
|
|
|
// 清除 execution job 的缓存
|
|
if err := s.db.Where("task_id = ?", taskID).First(&execution).Error; err == nil && execution.ExecutorJobID != "" {
|
|
delete(s.stdoutCache, execution.ExecutorJobID)
|
|
}
|
|
|
|
// 清除 rollback job 的缓存
|
|
if err := s.db.Where("task_id = ?", taskID).First(&rollback).Error; err == nil && rollback.ExecutorJobID != "" {
|
|
delete(s.stdoutCache, rollback.ExecutorJobID)
|
|
}
|
|
}
|
|
|
|
const stdoutCacheTTL = 30 * time.Second
|
|
|
|
func (s *DeliveryService) AWXJobStdout(ctx context.Context, jobID string) (string, error) {
|
|
// 检查缓存
|
|
s.cacheMu.RLock()
|
|
if item, ok := s.stdoutCache[jobID]; ok {
|
|
if time.Since(item.createdAt) < stdoutCacheTTL {
|
|
s.cacheMu.RUnlock()
|
|
return item.stdout, nil
|
|
}
|
|
}
|
|
s.cacheMu.RUnlock()
|
|
|
|
// 缓存未命中或已过期,重新获取
|
|
stdout, err := s.awx.JobStdout(ctx, jobID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// 更新缓存
|
|
s.cacheMu.Lock()
|
|
s.stdoutCache[jobID] = &stdoutCacheItem{
|
|
stdout: stdout,
|
|
createdAt: time.Now(),
|
|
}
|
|
s.cacheMu.Unlock()
|
|
|
|
return stdout, nil
|
|
}
|
|
|
|
// InvalidateStdoutCache 清除指定 jobID 的 stdout 缓存
|
|
func (s *DeliveryService) InvalidateStdoutCache(jobID string) {
|
|
s.cacheMu.Lock()
|
|
delete(s.stdoutCache, jobID)
|
|
s.cacheMu.Unlock()
|
|
}
|
|
|
|
func (s *DeliveryService) Cancel(ctx context.Context, taskID string, userID uint64, isAdmin bool) error {
|
|
task, _, err := s.GetTask(ctx, taskID, userID, isAdmin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if task.Status == model.TaskPending {
|
|
return s.transition(ctx, task, model.TaskCanceled, "canceled before dispatch", "")
|
|
}
|
|
if task.Status != model.TaskRunning && task.Status != model.TaskDispatching {
|
|
return fmt.Errorf("task in state %q cannot be canceled", task.Status)
|
|
}
|
|
var job model.ExecutionJob
|
|
if err := s.db.WithContext(ctx).Where("task_id = ?", task.ID).First(&job).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := s.awx.Cancel(ctx, job.ExecutorJobID); err != nil {
|
|
return err
|
|
}
|
|
return s.transition(ctx, task, model.TaskCanceling, "cancel requested in AWX", "")
|
|
}
|
|
|
|
func (s *DeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryTask, error) {
|
|
var task model.DeliveryTask
|
|
var payload deliveryPayload
|
|
var target DeliveryTarget
|
|
dispatchable := false
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status = ?", model.TaskPending).Order("created_at ASC").First(&task).Error; err != nil {
|
|
return err
|
|
}
|
|
var targetErr error
|
|
target, targetErr = s.getTarget(ctx, task.TargetID)
|
|
if targetErr != nil {
|
|
return s.failInTransaction(tx, &task, model.TaskValidationFailed, targetErr.Error())
|
|
}
|
|
if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil {
|
|
return s.failInTransaction(tx, &task, model.TaskValidationFailed, "stored deployment payload is invalid")
|
|
}
|
|
activeStates := []string{model.TaskValidating, model.TaskDispatching, model.TaskRunning, model.TaskCanceling}
|
|
checks := []struct {
|
|
query string
|
|
args []any
|
|
limit int
|
|
message string
|
|
}{
|
|
{"status IN ?", []any{activeStates}, s.cfg.DeliveryGlobalLimit, "global concurrency limit reached"},
|
|
{"status IN ? AND target_id = ?", []any{activeStates, task.TargetID}, s.cfg.DeliveryTargetLimit, "target concurrency limit reached"},
|
|
{"status IN ? AND target_id = ? AND namespace = ?", []any{activeStates, task.TargetID, task.Namespace}, 1, "namespace already has an active MySQL delivery"},
|
|
}
|
|
for _, check := range checks {
|
|
var count int64
|
|
if check.limit > 0 {
|
|
if err := tx.Model(&model.DeliveryTask{}).Where(check.query, check.args...).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count >= int64(check.limit) {
|
|
return fmt.Errorf("defer: %s", check.message)
|
|
}
|
|
}
|
|
}
|
|
quotaOK, err := checkResourceQuota(tx, task.BusinessLineID, task.TargetID, payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !quotaOK {
|
|
return s.failInTransaction(tx, &task, model.TaskValidationFailed, "resource quota is insufficient")
|
|
}
|
|
meta := parseTargetMetadata(target.Metadata)
|
|
if len(meta.Hosts) == 0 {
|
|
return s.failInTransaction(tx, &task, model.TaskValidationFailed, "deployment target has no candidate hosts")
|
|
}
|
|
var occupied []string
|
|
occupiedExclude := []string{model.TaskExecutionFailed, model.TaskValidationFailed, model.TaskCanceled}
|
|
if err := tx.Model(&model.DeliveryTask{}).Where("target_id = ? AND target_host <> ? AND status NOT IN ?", task.TargetID, "", occupiedExclude).Pluck("target_host", &occupied).Error; err != nil {
|
|
return err
|
|
}
|
|
limit := s.cfg.DeliveryHostInstanceLimit
|
|
var host *targetHost
|
|
if payload.TargetHost != "" {
|
|
// 点名主机:仅校验池内存在性与单机实例上限,端口/配额/实机守卫照常执行。
|
|
for i := range meta.Hosts {
|
|
if meta.Hosts[i].Name == payload.TargetHost {
|
|
host = &meta.Hosts[i]
|
|
break
|
|
}
|
|
}
|
|
if host == nil {
|
|
return s.failInTransaction(tx, &task, model.TaskValidationFailed, fmt.Sprintf("target_host %q is not in the candidate host pool", payload.TargetHost))
|
|
}
|
|
if effective := max(limit, 1); hostTaskCount(occupied, host.Name) >= effective {
|
|
return fmt.Errorf("defer: pinned host %s reached the per-host instance limit %d", host.Name, effective)
|
|
}
|
|
} else {
|
|
host = firstFreeHost(meta.Hosts, occupied, limit)
|
|
}
|
|
if host == nil {
|
|
return fmt.Errorf("defer: no free host available on target")
|
|
}
|
|
// 端口池混合分配:同主机已占端口 = 非终态任务分配端口 ∪ 存量 active 实例端口。
|
|
var usedPorts []int
|
|
if err := tx.Model(&model.DeliveryTask{}).Where("target_host = ? AND status NOT IN ?", host.Name, occupiedExclude).Pluck("mysql_port", &usedPorts).Error; err != nil {
|
|
return err
|
|
}
|
|
var instancePorts []int
|
|
if err := tx.Model(&model.DeploymentResult{}).
|
|
Where("component = ? AND service_type = ? AND node_name = ? AND status = ?", "mysql", "database", host.Name, "active").
|
|
Pluck("port", &instancePorts).Error; err != nil {
|
|
return err
|
|
}
|
|
port, portErr := allocatePort(payload.MySQLPort, append(usedPorts, instancePorts...))
|
|
if portErr != nil {
|
|
return s.failInTransaction(tx, &task, model.TaskValidationFailed, portErr.Error())
|
|
}
|
|
reservation := model.ResourceReservation{TaskID: task.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, CPUMilli: payload.CPUMilli, MemoryMi: payload.MemoryMi, StorageGi: payload.StorageGi, InstanceCount: 1, Status: "reserved", ExpiresAt: time.Now().Add(time.Duration(s.cfg.ReservationTTLMinutes) * time.Minute)}
|
|
if err := tx.Create(&reservation).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.DeliveryTask{}).Where("id = ?", task.ID).Updates(map[string]any{"target_host": host.Name, "target_host_ip": host.IP, "mysql_port": port}).Error; err != nil {
|
|
return err
|
|
}
|
|
task.TargetHost = host.Name
|
|
task.TargetHostIP = host.IP
|
|
task.MySQLPort = port
|
|
if err := s.transitionTx(tx, &task, model.TaskDispatching, "resources reserved", ""); err != nil {
|
|
return err
|
|
}
|
|
dispatchable = true
|
|
return nil
|
|
})
|
|
if err == nil && !dispatchable {
|
|
err = gorm.ErrRecordNotFound
|
|
}
|
|
return &task, err
|
|
}
|
|
|
|
func checkResourceQuota(tx *gorm.DB, businessLineID, targetID uint64, payload deliveryPayload) (bool, error) {
|
|
var quota model.ResourceQuota
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("business_line_id = ? AND target_id = ?", businessLineID, targetID).First("a).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return true, nil
|
|
}
|
|
return false, err
|
|
}
|
|
type totals struct{ CPU, Memory, Storage, Instances int64 }
|
|
var used, reserved totals
|
|
if err := tx.Model(&model.ResourceUsage{}).Select("COALESCE(SUM(cpu_milli),0) cpu, COALESCE(SUM(memory_mi),0) memory, COALESCE(SUM(storage_gi),0) storage, COALESCE(SUM(instance_count),0) instances").Where("business_line_id = ? AND target_id = ? AND status = ?", businessLineID, targetID, "active").Scan(&used).Error; err != nil {
|
|
return false, err
|
|
}
|
|
if err := tx.Model(&model.ResourceReservation{}).Select("COALESCE(SUM(cpu_milli),0) cpu, COALESCE(SUM(memory_mi),0) memory, COALESCE(SUM(storage_gi),0) storage, COALESCE(SUM(instance_count),0) instances").Where("business_line_id = ? AND target_id = ? AND (status = ? OR (status = ? AND expires_at > ?))", businessLineID, targetID, "rollback", "reserved", time.Now()).Scan(&reserved).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return used.CPU+reserved.CPU+payload.CPUMilli <= quota.CPUMilli &&
|
|
used.Memory+reserved.Memory+payload.MemoryMi <= quota.MemoryMi &&
|
|
used.Storage+reserved.Storage+payload.StorageGi <= quota.StorageGi &&
|
|
used.Instances+reserved.Instances+1 <= quota.InstanceLimit, nil
|
|
}
|
|
|
|
func (s *DeliveryService) failInTransaction(tx *gorm.DB, task *model.DeliveryTask, status, message string) error {
|
|
if err := s.transitionTx(tx, task, status, message, message); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DeliveryService) transition(ctx context.Context, task *model.DeliveryTask, status, message, errorMessage string) error {
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.transitionTx(tx, task, status, message, errorMessage) })
|
|
}
|
|
|
|
func (s *DeliveryService) transitionTx(tx *gorm.DB, task *model.DeliveryTask, status, message, errorMessage string) error {
|
|
from := task.Status
|
|
updates := map[string]any{"status": status, "error_message": errorMessage}
|
|
now := time.Now()
|
|
if status == model.TaskRunning {
|
|
updates["started_at"] = now
|
|
}
|
|
if status == model.TaskFinished || status == model.TaskExecutionFailed || status == model.TaskValidationFailed || status == model.TaskCanceled || status == model.TaskRegisterFailed || status == model.TaskRolledBack || status == model.TaskRollbackFailed || status == model.TaskRollbackAck {
|
|
updates["finished_at"] = now
|
|
}
|
|
result := tx.Model(&model.DeliveryTask{}).Where("id = ? AND status = ?", task.ID, from).Updates(updates)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return fmt.Errorf("task %s changed concurrently", task.ID)
|
|
}
|
|
task.Status = status
|
|
task.ErrorMessage = errorMessage
|
|
return tx.Create(&model.TaskEvent{TaskID: task.ID, FromState: from, ToState: status, Message: message}).Error
|
|
}
|
|
|
|
func (s *DeliveryService) releaseReservation(tx *gorm.DB, taskID string) error {
|
|
return tx.Model(&model.ResourceReservation{}).Where("task_id = ? AND status = ?", taskID, "reserved").Update("status", "released").Error
|
|
}
|
|
|
|
func randomUUID() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|
|
|
|
func mustJSON(value any) []byte {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return []byte(`{}`)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func mysqlReady(ctx context.Context, address string) error {
|
|
dialer := net.Dialer{Timeout: 5 * time.Second}
|
|
conn, err := dialer.DialContext(ctx, "tcp", address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return conn.Close()
|
|
}
|
|
|
|
func (s *DeliveryService) DispatchOnce(ctx context.Context) error {
|
|
task, err := s.claimAndReserve(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) || strings.HasPrefix(err.Error(), "defer:") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
_, _, err = s.CreateExecution(ctx, task.ID, task.PayloadHash, task.IdempotencyKey)
|
|
if err != nil {
|
|
failErr := s.beginRollback(ctx, task.ID, "deployment could not be started: "+err.Error())
|
|
s.broadcastTask(ctx, task.ID)
|
|
return failErr
|
|
}
|
|
s.broadcastTask(ctx, task.ID)
|
|
return nil
|
|
}
|
|
|
|
func (s *DeliveryService) CreateExecution(ctx context.Context, taskID, payloadHash, idempotencyKey string) (*model.ExecutionJob, bool, error) {
|
|
s.executionMu.Lock()
|
|
defer s.executionMu.Unlock()
|
|
var existing model.ExecutionJob
|
|
if err := s.db.WithContext(ctx).Where("task_id = ? OR idempotency_key = ?", taskID, idempotencyKey).First(&existing).Error; err == nil {
|
|
if existing.TaskID != taskID || existing.IdempotencyKey != idempotencyKey {
|
|
return nil, false, fmt.Errorf("idempotency key is already bound to another task")
|
|
}
|
|
return &existing, true, nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, false, err
|
|
}
|
|
var task model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).First(&task, "id = ?", taskID).Error; err != nil {
|
|
return nil, false, err
|
|
}
|
|
if task.PayloadHash != payloadHash || task.IdempotencyKey != idempotencyKey {
|
|
return nil, false, fmt.Errorf("execution request does not match the immutable task payload")
|
|
}
|
|
if task.Status != model.TaskDispatching {
|
|
return nil, false, fmt.Errorf("task in state %q is not ready for execution", task.Status)
|
|
}
|
|
target, err := s.getTarget(ctx, task.TargetID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
var payload deliveryPayload
|
|
if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil {
|
|
return nil, false, err
|
|
}
|
|
meta := parseTargetMetadata(target.Metadata)
|
|
// Persist execution record BEFORE launching AWX to ensure crash recovery.
|
|
now := time.Now()
|
|
execution := model.ExecutionJob{TaskID: task.ID, IdempotencyKey: task.IdempotencyKey, ExecutorJobID: "pending-" + task.ID, Status: "launching", StartedAt: &now}
|
|
if err := s.db.WithContext(ctx).Create(&execution).Error; err != nil {
|
|
return nil, false, err
|
|
}
|
|
extraVars := deliveryExtraVars(&task, payload, meta)
|
|
credentialVars, err := s.deploymentCredentialVars(ctx, task.ID)
|
|
if err != nil {
|
|
_ = s.db.WithContext(ctx).Model(&execution).Updates(map[string]any{"status": "failed", "finished_at": time.Now()})
|
|
return nil, false, err
|
|
}
|
|
for key, value := range credentialVars {
|
|
extraVars[key] = value
|
|
}
|
|
extraVars["delivery_callback_url"] = s.deliveryCallbackURL(task.ID)
|
|
extraVars["delivery_callback_token"] = s.cfg.AWXWebhookToken
|
|
job, err := s.awx.Launch(ctx, target.AWXTemplateID, AWXLaunchRequest{InventoryID: target.AWXInventoryID, Limit: task.TargetHost, ExtraVars: extraVars})
|
|
if err != nil {
|
|
_ = s.db.WithContext(ctx).Model(&execution).Updates(map[string]any{"status": "failed", "finished_at": time.Now()})
|
|
return nil, false, err
|
|
}
|
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&execution).Updates(map[string]any{"executor_job_id": fmt.Sprint(job.ID), "status": "running"}).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.transitionTx(tx, &task, model.TaskRunning, "AWX job started", "")
|
|
}); err != nil {
|
|
return nil, false, err
|
|
}
|
|
return &execution, false, nil
|
|
}
|
|
|
|
func (s *DeliveryService) deliveryCallbackURL(taskID string) string {
|
|
base := strings.TrimRight(strings.TrimSpace(s.cfg.DeliveryCallbackBaseURL), "/")
|
|
if base == "" {
|
|
return ""
|
|
}
|
|
return base + "/auth/internal/delivery/tasks/" + taskID + "/events"
|
|
}
|
|
|
|
// deliveryExtraVars 组装传给 playbook 的变量:必传项固定注入,
|
|
// 选填项仅在用户显式设置时下发,未设置时由 playbook 默认基线兜底。
|
|
func deliveryExtraVars(task *model.DeliveryTask, payload deliveryPayload, meta targetMetadata) map[string]any {
|
|
topology := payload.Topology
|
|
if topology == "" {
|
|
// 存量任务的 ImmutablePayload 无 topology 字段,回退到 target metadata。
|
|
topology = meta.Topology
|
|
}
|
|
vars := map[string]any{
|
|
"task_id": task.ID, "payload_hash": task.PayloadHash,
|
|
"target_hosts": task.TargetHost, "topology": topology,
|
|
"instance_name": payload.InstanceName, "mysql_port": task.MySQLPort,
|
|
"memory_mb": payload.MemoryMi, "storage_gb": payload.StorageGi,
|
|
"mysql_version": payload.MySQLVersion,
|
|
}
|
|
if payload.DataDisk != "" {
|
|
vars["data_disk"] = payload.DataDisk
|
|
}
|
|
if payload.Timezone != "" {
|
|
vars["timezone"] = payload.Timezone
|
|
}
|
|
if payload.LowerCaseTableNames != nil {
|
|
vars["lower_case_table_names"] = *payload.LowerCaseTableNames
|
|
}
|
|
if payload.CharacterSet != "" {
|
|
vars["character_set"] = payload.CharacterSet
|
|
}
|
|
if payload.Collation != "" {
|
|
vars["collation"] = payload.Collation
|
|
}
|
|
if payload.MaxConnections != "" {
|
|
vars["max_connections"] = payload.MaxConnections
|
|
}
|
|
if payload.InnodbRedoLogCapacity != "" {
|
|
vars["innodb_redo_log_capacity"] = payload.InnodbRedoLogCapacity
|
|
}
|
|
if payload.InnodbFlushLogAtTrxCommit != nil {
|
|
vars["innodb_flush_log_at_trx_commit"] = *payload.InnodbFlushLogAtTrxCommit
|
|
}
|
|
if payload.SyncBinlog != nil {
|
|
vars["sync_binlog"] = *payload.SyncBinlog
|
|
}
|
|
if payload.InnodbIOCapacity != 0 {
|
|
vars["innodb_io_capacity"] = payload.InnodbIOCapacity
|
|
}
|
|
if payload.LongQueryTime != 0 {
|
|
vars["long_query_time"] = payload.LongQueryTime
|
|
}
|
|
if payload.BinlogExpireLogsSeconds != 0 {
|
|
vars["binlog_expire_logs_seconds"] = payload.BinlogExpireLogsSeconds
|
|
}
|
|
if payload.MaxBinlogSize != "" {
|
|
vars["max_binlog_size"] = payload.MaxBinlogSize
|
|
}
|
|
return vars
|
|
}
|
|
|
|
func (s *DeliveryService) HandleStageEvent(ctx context.Context, taskID string, input DeliveryStageEventInput) error {
|
|
stage := strings.ToLower(strings.TrimSpace(input.Stage))
|
|
status := strings.ToLower(strings.TrimSpace(input.Status))
|
|
if !validDeliveryStage(stage) {
|
|
return fmt.Errorf("invalid delivery stage %q", input.Stage)
|
|
}
|
|
if !validDeliveryStageStatus(status) {
|
|
return fmt.Errorf("invalid delivery stage status %q", input.Status)
|
|
}
|
|
message := strings.TrimSpace(input.Message)
|
|
if message == "" {
|
|
message = stage + " " + status
|
|
}
|
|
eventState := "stage_" + stage + "_" + status
|
|
var failedTaskID string
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var task model.DeliveryTask
|
|
if err := tx.First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
if input.AWXJobID != "" {
|
|
var execution model.ExecutionJob
|
|
if err := tx.Where("task_id = ?", task.ID).First(&execution).Error; err != nil {
|
|
return err
|
|
}
|
|
if execution.ExecutorJobID != strings.TrimSpace(input.AWXJobID) {
|
|
return fmt.Errorf("AWX job %s does not match task %s", input.AWXJobID, task.ID)
|
|
}
|
|
}
|
|
if isTerminalTaskStatus(task.Status) {
|
|
return nil
|
|
}
|
|
if err := tx.Create(&model.TaskEvent{
|
|
TaskID: task.ID,
|
|
FromState: task.Status,
|
|
ToState: eventState,
|
|
Stage: stage,
|
|
EventStatus: status,
|
|
Message: message,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
if status == "failed" {
|
|
now := time.Now()
|
|
if err := tx.Model(&model.ExecutionJob{}).Where("task_id = ?", task.ID).Updates(map[string]any{"status": "failed", "finished_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
failedTaskID = task.ID
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if failedTaskID != "" {
|
|
if rollbackErr := s.beginRollback(ctx, failedTaskID, message); rollbackErr != nil {
|
|
return rollbackErr
|
|
}
|
|
}
|
|
s.broadcastTask(ctx, taskID)
|
|
return nil
|
|
}
|
|
|
|
func (s *DeliveryService) HandleAWXJobNotification(ctx context.Context, input AWXJobNotificationInput) error {
|
|
status := strings.ToLower(strings.TrimSpace(input.Status))
|
|
if status == "" && input.ID == 0 && awxNotificationTaskID(input.ExtraVars) == "" {
|
|
return nil
|
|
}
|
|
if status == "" {
|
|
return fmt.Errorf("missing AWX job status")
|
|
}
|
|
execution, err := s.findExecutionForAWXNotification(ctx, input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
message := awxNotificationMessage(input)
|
|
defer s.broadcastTask(ctx, execution.TaskID)
|
|
switch status {
|
|
case "pending", "waiting", "running", "new":
|
|
return s.recordAWXEvent(ctx, execution.TaskID, status, message)
|
|
case "successful":
|
|
s.finishExecution(ctx, execution, "successful")
|
|
if err := s.completeTask(ctx, execution.TaskID); err != nil {
|
|
return s.beginRollback(ctx, execution.TaskID, err.Error())
|
|
}
|
|
return nil
|
|
case "canceled", "cancelled":
|
|
s.finishExecution(ctx, execution, "canceled")
|
|
return s.failTask(ctx, &model.DeliveryTask{ID: execution.TaskID, Status: model.TaskRunning}, model.TaskCanceled, message)
|
|
case "failed", "error":
|
|
s.finishExecution(ctx, execution, "failed")
|
|
return s.beginRollback(ctx, execution.TaskID, message)
|
|
default:
|
|
s.finishExecution(ctx, execution, "failed")
|
|
return s.beginRollback(ctx, execution.TaskID, "AWX job finished with status "+status)
|
|
}
|
|
}
|
|
|
|
func (s *DeliveryService) findExecutionForAWXNotification(ctx context.Context, input AWXJobNotificationInput) (*model.ExecutionJob, error) {
|
|
var execution model.ExecutionJob
|
|
if input.ID != 0 {
|
|
if err := s.db.WithContext(ctx).Where("executor_job_id = ?", fmt.Sprint(input.ID)).First(&execution).Error; err == nil {
|
|
return &execution, nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
}
|
|
taskID := awxNotificationTaskID(input.ExtraVars)
|
|
if taskID == "" {
|
|
return nil, fmt.Errorf("AWX notification did not include a known job id or task_id")
|
|
}
|
|
if err := s.db.WithContext(ctx).Where("task_id = ?", taskID).First(&execution).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &execution, nil
|
|
}
|
|
|
|
func (s *DeliveryService) recordAWXEvent(ctx context.Context, taskID, status, message string) error {
|
|
var task model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
if isTerminalTaskStatus(task.Status) {
|
|
return nil
|
|
}
|
|
return s.db.WithContext(ctx).Create(&model.TaskEvent{
|
|
TaskID: task.ID,
|
|
FromState: task.Status,
|
|
ToState: "awx_" + status,
|
|
Stage: "awx",
|
|
EventStatus: status,
|
|
Message: message,
|
|
}).Error
|
|
}
|
|
|
|
func validDeliveryStage(stage string) bool {
|
|
switch stage {
|
|
case "precheck", "install", "configure", "healthcheck", "register":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validDeliveryStageStatus(status string) bool {
|
|
switch status {
|
|
case "running", "success", "failed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isTerminalTaskStatus(status string) bool {
|
|
for _, item := range terminalTaskStatuses() {
|
|
if status == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func terminalTaskStatuses() []string {
|
|
return []string{
|
|
model.TaskFinished,
|
|
model.TaskExecutionFailed,
|
|
model.TaskValidationFailed,
|
|
model.TaskRegisterFailed,
|
|
model.TaskCanceled,
|
|
model.TaskRolledBack,
|
|
model.TaskRollbackFailed,
|
|
model.TaskRollbackAck,
|
|
}
|
|
}
|
|
|
|
func awxNotificationMessage(input AWXJobNotificationInput) string {
|
|
status := strings.TrimSpace(input.Status)
|
|
name := strings.TrimSpace(input.Name)
|
|
if strings.TrimSpace(input.Traceback) != "" {
|
|
return strings.TrimSpace(input.Traceback)
|
|
}
|
|
if name == "" {
|
|
return "AWX job " + status
|
|
}
|
|
return "AWX job " + name + " " + status
|
|
}
|
|
|
|
func awxNotificationTaskID(extraVars any) string {
|
|
var values map[string]any
|
|
switch v := extraVars.(type) {
|
|
case map[string]any:
|
|
values = v
|
|
case string:
|
|
if strings.TrimSpace(v) == "" {
|
|
return ""
|
|
}
|
|
_ = json.Unmarshal([]byte(v), &values)
|
|
default:
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
_ = json.Unmarshal(raw, &values)
|
|
}
|
|
if values == nil {
|
|
return ""
|
|
}
|
|
if taskID, ok := values["task_id"].(string); ok {
|
|
return strings.TrimSpace(taskID)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *DeliveryService) finishExecution(ctx context.Context, execution *model.ExecutionJob, status string) {
|
|
now := time.Now()
|
|
_ = s.db.WithContext(ctx).Model(execution).Updates(map[string]any{"status": status, "finished_at": now})
|
|
}
|
|
|
|
func (s *DeliveryService) PollOnce(ctx context.Context) error {
|
|
var jobs []model.ExecutionJob
|
|
if err := s.db.WithContext(ctx).Where("status = ?", "running").Find(&jobs).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, execution := range jobs {
|
|
job, err := s.awx.GetJob(ctx, execution.ExecutorJobID)
|
|
if err != nil {
|
|
// A transient AWX/API failure is not evidence that deployment failed.
|
|
// Keep the job running and retry on the next scheduler tick.
|
|
continue
|
|
}
|
|
switch strings.ToLower(job.Status) {
|
|
case "pending", "waiting", "running", "new":
|
|
continue
|
|
case "successful":
|
|
s.finishExecution(ctx, &execution, "successful")
|
|
if err := s.completeTask(ctx, execution.TaskID); err != nil {
|
|
_ = s.beginRollback(ctx, execution.TaskID, err.Error())
|
|
}
|
|
case "canceled":
|
|
s.finishExecution(ctx, &execution, "canceled")
|
|
_ = s.beginRollback(ctx, execution.TaskID, "AWX deployment job was canceled")
|
|
default:
|
|
s.finishExecution(ctx, &execution, "failed")
|
|
_ = s.beginRollback(ctx, execution.TaskID, "AWX job finished with status "+job.Status)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DeliveryService) completeTask(ctx context.Context, taskID string) error {
|
|
var task model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
var payload deliveryPayload
|
|
if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil {
|
|
return err
|
|
}
|
|
addr := fmt.Sprintf("%s:%d", task.TargetHostIP, task.MySQLPort)
|
|
if err := mysqlReady(ctx, addr); err != nil {
|
|
return fmt.Errorf("MySQL health check failed: %w", err)
|
|
}
|
|
now := time.Now()
|
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := s.transitionTx(tx, &task, model.TaskRegistering, "AWX succeeded and MySQL health check passed", ""); err != nil {
|
|
return err
|
|
}
|
|
result := model.DeploymentResult{
|
|
TaskID: task.ID,
|
|
BusinessLineID: task.BusinessLineID,
|
|
Component: task.Component,
|
|
ServiceType: "database",
|
|
InstanceName: payload.InstanceName,
|
|
Namespace: payload.Namespace,
|
|
TargetID: task.TargetID,
|
|
NodeName: task.TargetHost,
|
|
Host: task.TargetHostIP,
|
|
Port: task.MySQLPort,
|
|
Version: payload.MySQLVersion,
|
|
Status: "active",
|
|
Metadata: string(mustJSON(map[string]any{"data_disk": payload.DataDisk})),
|
|
}
|
|
if result.Component == "" {
|
|
result.Component = "mysql"
|
|
}
|
|
if err := tx.Create(&result).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.DeploymentCredential{}).Where("task_id = ? AND status = ?", task.ID, "pending").Updates(map[string]any{"deployment_result_id": result.ID, "status": "available"}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(&model.ResourceUsage{TaskID: task.ID, InstanceID: result.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, CPUMilli: payload.CPUMilli, MemoryMi: payload.MemoryMi, StorageGi: payload.StorageGi, InstanceCount: 1, Status: "active"}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.ResourceReservation{}).Where("task_id = ? AND status = ?", task.ID, "reserved").Update("status", "consumed").Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.ExecutionJob{}).Where("task_id = ?", task.ID).Updates(map[string]any{"status": "successful", "finished_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.RegisterCloudDM(ctx, task.ID); err != nil {
|
|
message := fmt.Sprintf("CloudDM registration failed: %v", err)
|
|
// The database is already healthy and accounted for at this point. A
|
|
// registration outage must not trigger destructive instance rollback.
|
|
if transitionErr := s.transition(ctx, &task, model.TaskRegisterFailed,
|
|
"MySQL delivered; CloudDM registration failed and can be retried", message); transitionErr != nil {
|
|
return fmt.Errorf("%s; cannot record register_failed: %w", message, transitionErr)
|
|
}
|
|
return nil
|
|
}
|
|
return s.transition(ctx, &task, model.TaskFinished, "MySQL delivery completed", "")
|
|
}
|
|
|
|
// RetryCloudDMRegistration retries only the external registration step. The
|
|
// instance and its resource usage remain active throughout the retry.
|
|
func (s *DeliveryService) RetryCloudDMRegistration(ctx context.Context, taskID string) error {
|
|
var task model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
if task.Status != model.TaskRegisterFailed {
|
|
return fmt.Errorf("task %s is in state %q and cannot retry CloudDM registration", taskID, task.Status)
|
|
}
|
|
if err := s.transition(ctx, &task, model.TaskRegistering, "CloudDM registration retry started", ""); err != nil {
|
|
return err
|
|
}
|
|
if err := s.RegisterCloudDM(ctx, taskID); err != nil {
|
|
message := fmt.Sprintf("CloudDM registration failed: %v", err)
|
|
if transitionErr := s.transition(ctx, &task, model.TaskRegisterFailed, "CloudDM registration retry failed; instance remains active", message); transitionErr != nil {
|
|
return fmt.Errorf("%s; cannot restore register_failed: %w", message, transitionErr)
|
|
}
|
|
return err
|
|
}
|
|
return s.transition(ctx, &task, model.TaskFinished, "CloudDM registration completed", "")
|
|
}
|
|
|
|
func (s *DeliveryService) RegisterCloudDM(ctx context.Context, taskID string) error {
|
|
if s.cfg.CloudDMRegisterURL == "" {
|
|
return nil
|
|
}
|
|
var instance model.DeploymentResult
|
|
if err := s.db.WithContext(ctx).Where("task_id = ? AND component = ? AND service_type = ?", taskID, "mysql", "database").First(&instance).Error; err != nil {
|
|
return err
|
|
}
|
|
body := map[string]any{"name": instance.InstanceName, "host": instance.Host, "port": instance.Port, "username": "root", "database_type": "mysql"}
|
|
raw, _ := json.Marshal(body)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.CloudDMRegisterURL, bytes.NewReader(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if s.cfg.CloudDMAPIToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+s.cfg.CloudDMAPIToken)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("CloudDM returned %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// beginRollback changes the task into the compensating workflow and launches
|
|
// the dedicated AWX rollback template. The deploy reservation is deliberately
|
|
// kept until rollback succeeds so a failed cleanup cannot be silently
|
|
// overwritten by a later delivery.
|
|
func (s *DeliveryService) beginRollback(ctx context.Context, taskID, reason string) error {
|
|
var task model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
if rollbackProtectedStatus(task.Status) {
|
|
return nil
|
|
}
|
|
|
|
if s.cfg.RollbackTemplateID == 0 {
|
|
if err := s.transition(ctx, &task, model.TaskRollbackFailed,
|
|
"automatic rollback is not configured; manual cleanup is required", reason); err != nil {
|
|
return err
|
|
}
|
|
return s.holdReservationForRollback(ctx, task.ID)
|
|
}
|
|
if err := s.transition(ctx, &task, model.TaskRollbackPending, "automatic rollback queued", reason); err != nil {
|
|
return err
|
|
}
|
|
if err := s.holdReservationForRollback(ctx, task.ID); err != nil {
|
|
return s.markRollbackFailed(ctx, task.ID, "cannot hold resources during rollback: "+err.Error())
|
|
}
|
|
return s.launchRollbackJob(ctx, &task, reason)
|
|
}
|
|
|
|
func rollbackProtectedStatus(status string) bool {
|
|
switch status {
|
|
case model.TaskRollbackPending, model.TaskRollingBack, model.TaskRolledBack, model.TaskRollbackFailed,
|
|
model.TaskRollbackAck, model.TaskRegisterFailed, model.TaskFinished, model.TaskCanceled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *DeliveryService) launchRollbackJob(ctx context.Context, task *model.DeliveryTask, reason string) error {
|
|
target, err := s.getTarget(ctx, task.TargetID)
|
|
if err != nil {
|
|
return s.markRollbackFailed(ctx, task.ID, "rollback target is unavailable: "+err.Error())
|
|
}
|
|
var payload deliveryPayload
|
|
if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil {
|
|
return s.markRollbackFailed(ctx, task.ID, "rollback payload is invalid: "+err.Error())
|
|
}
|
|
now := time.Now()
|
|
var rollback model.RollbackJob
|
|
if err := s.db.WithContext(ctx).Where("task_id = ?", task.ID).First(&rollback).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
|
rollback = model.RollbackJob{TaskID: task.ID}
|
|
} else if err != nil {
|
|
return s.markRollbackFailed(ctx, task.ID, "cannot load rollback job: "+err.Error())
|
|
}
|
|
rollback.ExecutorJobID = "pending-rollback-" + task.ID
|
|
rollback.Status = "launching"
|
|
rollback.Reason = reason
|
|
rollback.StartedAt = &now
|
|
rollback.FinishedAt = nil
|
|
if err := s.db.WithContext(ctx).Save(&rollback).Error; err != nil {
|
|
return s.markRollbackFailed(ctx, task.ID, "cannot persist rollback job: "+err.Error())
|
|
}
|
|
job, err := s.awx.Launch(ctx, s.cfg.RollbackTemplateID, AWXLaunchRequest{
|
|
InventoryID: target.AWXInventoryID,
|
|
Limit: task.TargetHost,
|
|
ExtraVars: rollbackExtraVars(task, payload),
|
|
})
|
|
if err != nil {
|
|
_ = s.db.WithContext(ctx).Model(&rollback).Updates(map[string]any{"status": "failed", "finished_at": time.Now()}).Error
|
|
return s.markRollbackFailed(ctx, task.ID, "cannot launch rollback AWX job: "+err.Error())
|
|
}
|
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var current model.DeliveryTask
|
|
if err := tx.First(¤t, "id = ?", task.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
if current.Status != model.TaskRollbackPending {
|
|
return fmt.Errorf("task %s is no longer pending rollback (state %q)", task.ID, current.Status)
|
|
}
|
|
if err := tx.Model(&rollback).Updates(map[string]any{"executor_job_id": fmt.Sprint(job.ID), "status": "running"}).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.transitionTx(tx, ¤t, model.TaskRollingBack, "rollback AWX job started", reason)
|
|
}); err != nil {
|
|
return s.markRollbackFailed(ctx, task.ID, "cannot record rollback AWX job: "+err.Error())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DeliveryService) RetryRollback(ctx context.Context, taskID string) error {
|
|
var task model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
if task.Status != model.TaskRollbackFailed {
|
|
return fmt.Errorf("task %s is in state %q and cannot retry rollback", taskID, task.Status)
|
|
}
|
|
if s.cfg.RollbackTemplateID == 0 {
|
|
return fmt.Errorf("automatic rollback is not configured")
|
|
}
|
|
reason := "manual rollback retry requested"
|
|
if err := s.transition(ctx, &task, model.TaskRollbackPending, "manual rollback retry queued", reason); err != nil {
|
|
return err
|
|
}
|
|
if err := s.holdReservationForRollback(ctx, task.ID); err != nil {
|
|
_ = s.markRollbackFailed(ctx, task.ID, "cannot hold resources during rollback retry: "+err.Error())
|
|
return err
|
|
}
|
|
return s.launchRollbackJob(ctx, &task, reason)
|
|
}
|
|
|
|
// AcknowledgeRollbackRelease is an explicit platform-admin escape hatch after
|
|
// the operator has verified the target host. It releases bookkeeping only; it
|
|
// does not claim that the automatic rollback succeeded.
|
|
func (s *DeliveryService) AcknowledgeRollbackRelease(ctx context.Context, taskID string) error {
|
|
now := time.Now()
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var task model.DeliveryTask
|
|
if err := tx.First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
if task.Status != model.TaskRollbackFailed {
|
|
return fmt.Errorf("task %s is in state %q and cannot acknowledge rollback release", taskID, task.Status)
|
|
}
|
|
if err := tx.Model(&model.DeploymentResult{}).Where("task_id = ? AND component = ? AND service_type = ? AND status = ?", taskID, "mysql", "database", "active").Updates(map[string]any{"status": "rollback_acknowledged", "updated_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.ResourceUsage{}).Where("task_id = ? AND status = ?", taskID, "active").Updates(map[string]any{"status": "released", "released_at": now, "updated_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.ResourceReservation{}).Where("task_id = ? AND status = ?", taskID, "rollback").Update("status", "released").Error; err != nil {
|
|
return err
|
|
}
|
|
return s.transitionTx(tx, &task, model.TaskRollbackAck, "rollback release acknowledged by platform admin", "")
|
|
})
|
|
}
|
|
|
|
func rollbackExtraVars(task *model.DeliveryTask, payload deliveryPayload) map[string]any {
|
|
return map[string]any{
|
|
"target_hosts": task.TargetHost,
|
|
"instance_name": payload.InstanceName,
|
|
"data_disk": payload.DataDisk,
|
|
"task_id": task.ID,
|
|
"rollback": true,
|
|
}
|
|
}
|
|
|
|
func (s *DeliveryService) holdReservationForRollback(ctx context.Context, taskID string) error {
|
|
return s.db.WithContext(ctx).Model(&model.ResourceReservation{}).
|
|
Where("task_id = ? AND status IN ?", taskID, []string{"reserved", "consumed"}).
|
|
Update("status", "rollback").Error
|
|
}
|
|
|
|
func (s *DeliveryService) markRollbackFailed(ctx context.Context, taskID, message string) error {
|
|
var task model.DeliveryTask
|
|
if err := s.db.WithContext(ctx).First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
if task.Status == model.TaskRolledBack || task.Status == model.TaskFinished || task.Status == model.TaskCanceled ||
|
|
task.Status == model.TaskRollbackFailed || task.Status == model.TaskRollbackAck || task.Status == model.TaskRegisterFailed {
|
|
return nil
|
|
}
|
|
return s.transition(ctx, &task, model.TaskRollbackFailed, "automatic rollback failed; manual cleanup is required", message)
|
|
}
|
|
|
|
func (s *DeliveryService) PollRollbackOnce(ctx context.Context) error {
|
|
var jobs []model.RollbackJob
|
|
if err := s.db.WithContext(ctx).Where("status IN ?", []string{"launching", "running"}).Find(&jobs).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, rollback := range jobs {
|
|
if rollback.Status == "launching" {
|
|
if !rollbackLaunchExpired(rollback, time.Now()) {
|
|
continue
|
|
}
|
|
// The AWX launch result is unknown. Do not blindly launch a second
|
|
// job; surface a recoverable failure for an explicit admin retry.
|
|
expired, err := s.expireRollbackLaunch(ctx, rollback.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !expired {
|
|
continue
|
|
}
|
|
_ = s.markRollbackFailed(ctx, rollback.TaskID, "rollback launch timed out before its AWX job ID was recorded; manual retry is required")
|
|
continue
|
|
}
|
|
job, err := s.awx.GetJob(ctx, rollback.ExecutorJobID)
|
|
if err != nil {
|
|
// Do not declare cleanup failed because AWX is temporarily unreachable.
|
|
continue
|
|
}
|
|
switch strings.ToLower(job.Status) {
|
|
case "pending", "waiting", "running", "new":
|
|
continue
|
|
case "successful":
|
|
_ = s.finishRollbackJob(ctx, &rollback, "successful")
|
|
if err := s.completeRollback(ctx, rollback.TaskID); err != nil {
|
|
_ = s.markRollbackFailed(ctx, rollback.TaskID, err.Error())
|
|
}
|
|
case "canceled":
|
|
_ = s.finishRollbackJob(ctx, &rollback, "canceled")
|
|
_ = s.markRollbackFailed(ctx, rollback.TaskID, "rollback AWX job was canceled")
|
|
default:
|
|
_ = s.finishRollbackJob(ctx, &rollback, "failed")
|
|
_ = s.markRollbackFailed(ctx, rollback.TaskID, "rollback AWX job finished with status "+job.Status)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rollbackLaunchExpired(rollback model.RollbackJob, now time.Time) bool {
|
|
if rollback.Status != "launching" {
|
|
return false
|
|
}
|
|
if rollback.StartedAt == nil {
|
|
return true
|
|
}
|
|
return !now.Before(rollback.StartedAt.Add(rollbackLaunchTimeout))
|
|
}
|
|
|
|
func (s *DeliveryService) expireRollbackLaunch(ctx context.Context, rollbackID uint64) (bool, error) {
|
|
result := s.db.WithContext(ctx).Model(&model.RollbackJob{}).
|
|
Where("id = ? AND status = ?", rollbackID, "launching").
|
|
Updates(map[string]any{"status": "launch_timeout", "finished_at": time.Now()})
|
|
return result.RowsAffected == 1, result.Error
|
|
}
|
|
|
|
func (s *DeliveryService) finishRollbackJob(ctx context.Context, rollback *model.RollbackJob, status string) error {
|
|
return s.db.WithContext(ctx).Model(rollback).Updates(map[string]any{"status": status, "finished_at": time.Now()}).Error
|
|
}
|
|
|
|
func (s *DeliveryService) completeRollback(ctx context.Context, taskID string) error {
|
|
now := time.Now()
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var task model.DeliveryTask
|
|
if err := tx.First(&task, "id = ?", taskID).Error; err != nil {
|
|
return err
|
|
}
|
|
if task.Status != model.TaskRollingBack {
|
|
return fmt.Errorf("task %s is in state %q, cannot complete rollback", taskID, task.Status)
|
|
}
|
|
if err := tx.Model(&model.DeploymentResult{}).Where("task_id = ?", taskID).Updates(map[string]any{"status": "rolled_back", "updated_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Unscoped().Where("task_id = ?", taskID).Delete(&model.DeploymentCredential{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.ResourceUsage{}).Where("task_id = ? AND status = ?", taskID, "active").Updates(map[string]any{"status": "released", "released_at": now, "updated_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.ResourceReservation{}).Where("task_id = ? AND status IN ?", taskID, []string{"reserved", "consumed", "rollback"}).Update("status", "released").Error; err != nil {
|
|
return err
|
|
}
|
|
return s.transitionTx(tx, &task, model.TaskRolledBack, "MySQL instance rollback completed", "")
|
|
})
|
|
}
|
|
|
|
func (s *DeliveryService) failTask(ctx context.Context, task *model.DeliveryTask, status, message string) error {
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var current model.DeliveryTask
|
|
if err := tx.First(¤t, "id = ?", task.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
if current.Status != model.TaskPending && current.Status != model.TaskDispatching && current.Status != model.TaskRunning && current.Status != model.TaskRegistering && current.Status != model.TaskCanceling {
|
|
return nil
|
|
}
|
|
if err := s.transitionTx(tx, ¤t, status, message, message); err != nil {
|
|
return err
|
|
}
|
|
if status == model.TaskExecutionFailed || status == model.TaskValidationFailed || status == model.TaskCanceled {
|
|
return s.releaseReservation(tx, current.ID)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *DeliveryService) Run(ctx context.Context) {
|
|
interval := time.Duration(s.cfg.DeliveryDispatchSeconds) * time.Second
|
|
if interval < time.Second {
|
|
interval = time.Second
|
|
}
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
_ = s.DispatchOnce(ctx)
|
|
_ = s.PollOnce(ctx)
|
|
_ = s.PollRollbackOnce(ctx)
|
|
}
|
|
}
|
|
}
|