package service import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net" "regexp" "strconv" "strings" "time" "github.com/1024XEngineer/xinfra/server/internal/config" "github.com/1024XEngineer/xinfra/server/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( postgresqlPortPoolStart = 15432 postgresqlPortPoolEnd = 15999 postgresqlServiceType = "postgresql" ) var supportedPostgreSQLVersions = map[string]bool{"15": true, "16": true} var supportedPostgreSQLTopologies = map[string]bool{"standalone": true, "primary_replica": true} var postgresqlNamePattern = regexp.MustCompile(`^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$`) var errPostgreSQLTaskHandled = errors.New("postgresql task validation completed without dispatch") type PostgreSQLDeliveryInput struct { BusinessLineID uint64 `json:"business_line_id" binding:"required"` TargetID uint64 `json:"target_id" binding:"required"` Namespace string `json:"namespace" binding:"required"` ClusterName string `json:"cluster_name" binding:"required"` VersionMajor string `json:"version_major"` PostgreSQLVersion string `json:"postgresql_version,omitempty"` Topology string `json:"topology"` ReplicaCount int `json:"replica_count"` TargetHosts []string `json:"target_hosts,omitempty"` CPUMilli int64 `json:"cpu_milli" binding:"required"` MemoryMi int64 `json:"memory_mi" binding:"required"` StorageGi int64 `json:"storage_gi" binding:"required"` DataRoot string `json:"data_root"` MaxConnections int `json:"max_connections"` } type postgresqlDeliveryPayload struct { PostgreSQLDeliveryInput TargetType string `json:"target_type"` } func validatePostgreSQLDeliveryInput(input PostgreSQLDeliveryInput, dataDisks []string) error { _ = dataDisks if input.VersionMajor == "" { input.VersionMajor = input.PostgreSQLVersion } if input.PostgreSQLVersion != "" && input.VersionMajor != "" && input.PostgreSQLVersion != input.VersionMajor { return fmt.Errorf("version_major and postgresql_version must match") } if len(input.Namespace) > 63 || !dnsLabelPattern.MatchString(input.Namespace) { return fmt.Errorf("namespace must be a valid Kubernetes DNS label") } if len(input.ClusterName) > 63 || !postgresqlNamePattern.MatchString(input.ClusterName) { return fmt.Errorf("cluster_name must be a valid DNS label") } if !supportedPostgreSQLVersions[input.VersionMajor] { return fmt.Errorf("unsupported version_major %q, supported: 15, 16", input.VersionMajor) } topology := input.Topology if topology == "" { topology = "standalone" } if !supportedPostgreSQLTopologies[topology] { return fmt.Errorf("unsupported topology %q, supported: standalone, primary_replica", topology) } if topology == "standalone" && input.ReplicaCount != 0 { return fmt.Errorf("standalone topology cannot have replicas") } if topology == "primary_replica" && (input.ReplicaCount < 1 || input.ReplicaCount > 7) { return fmt.Errorf("replica_count must be between 1 and 7 for primary_replica") } 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)") } nodes := 1 if topology == "primary_replica" { nodes += input.ReplicaCount } if len(input.TargetHosts) > 0 && len(input.TargetHosts) != nodes { return fmt.Errorf("target_hosts must contain exactly %d distinct hosts", nodes) } seen := map[string]bool{} for _, host := range input.TargetHosts { if len(host) > 253 || !hostNamePattern.MatchString(host) || seen[host] { return fmt.Errorf("target_hosts must contain unique valid inventory host names") } seen[host] = true } if input.DataRoot != "" { if input.DataRoot != "/data/postgresql" { return fmt.Errorf("data_root is fixed to /data/postgresql in the first release") } } if input.MaxConnections < 0 || input.MaxConnections > 10000 { return fmt.Errorf("max_connections must be between 0 and 10000") } return nil } func allocatePostgreSQLPort(requested int, used []int) (int, error) { taken := make(map[int]bool, len(used)) for _, port := range used { taken[port] = true } if requested != 0 { if requested < postgresqlPortPoolStart || requested > postgresqlPortPoolEnd { return 0, fmt.Errorf("postgresql_port must be within %d-%d", postgresqlPortPoolStart, postgresqlPortPoolEnd) } if taken[requested] { return 0, fmt.Errorf("postgresql port %d is already allocated on the target host", requested) } return requested, nil } for port := postgresqlPortPoolStart; port <= postgresqlPortPoolEnd; port++ { if !taken[port] { return port, nil } } return 0, fmt.Errorf("postgresql port pool %d-%d is exhausted on the target host", postgresqlPortPoolStart, postgresqlPortPoolEnd) } type postgresqlPortProbe func(context.Context, string, int) bool func allocateReachablePostgreSQLPort(ctx context.Context, host string, used []int, inUse postgresqlPortProbe) (int, error) { occupied := append([]int(nil), used...) for { port, err := allocatePostgreSQLPort(0, occupied) if err != nil { return 0, err } if !inUse(ctx, host, port) { return port, nil } occupied = append(occupied, port) } } func postgresqlPortInUse(ctx context.Context, host string, port int) bool { probeCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() conn, err := (&net.Dialer{}).DialContext(probeCtx, "tcp", net.JoinHostPort(host, strconv.Itoa(port))) if err != nil { return false } _ = conn.Close() return true } type PostgreSQLDeliveryService struct { db *gorm.DB cfg config.Config awx *AWXClient common *DeliveryService } func NewPostgreSQLDeliveryService(cfg config.Config, db *gorm.DB, common *DeliveryService) *PostgreSQLDeliveryService { return &PostgreSQLDeliveryService{db: db, cfg: cfg, awx: NewAWXClient(cfg.AWXBaseURL, cfg.AWXToken, cfg.AWXUsername, cfg.AWXPassword), common: common} } func (s *PostgreSQLDeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin bool, idempotencyKey string, input PostgreSQLDeliveryInput) (*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") } if err := validatePostgreSQLDeliveryInput(input, s.cfg.DeliveryDataDisks); err != nil { return nil, false, err } if input.VersionMajor == "" { input.VersionMajor = input.PostgreSQLVersion } var existing model.DeliveryTask if err := s.db.WithContext(ctx).Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; err == nil { if existing.RequestedBy != userID || existing.ServiceType != postgresqlServiceType { return nil, false, fmt.Errorf("idempotency key is already in use") } return &existing, true, nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, err } 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.Topology == "" { input.Topology = "standalone" } if input.DataRoot == "" { input.DataRoot = "/data/postgresql" } target, err := getPostgreSQLTarget(ctx, s.awx, input.TargetID) if err != nil { return nil, false, err } payload := postgresqlDeliveryPayload{PostgreSQLDeliveryInput: 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: postgresqlServiceType, TargetType: target.TargetType, ServiceType: postgresqlServiceType, TargetID: input.TargetID, Namespace: input.Namespace, InstanceName: input.ClusterName, 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 } _ = s.db.WithContext(ctx).Create(&model.TaskEvent{TaskID: task.ID, ToState: model.TaskPending, Message: "postgresql delivery task created"}).Error return &task, false, nil } func getPostgreSQLTarget(ctx context.Context, awx *AWXClient, templateID uint64) (DeliveryTarget, error) { template, err := awx.GetJobTemplate(ctx, templateID) if err != nil { return DeliveryTarget{}, fmt.Errorf("deployment target is unavailable: %w", err) } templateText := strings.ToLower(template.Name + " " + template.Description) if !strings.Contains(templateText, "postgresql") || strings.Contains(templateText, "rollback") { return DeliveryTarget{}, fmt.Errorf("AWX job template %d is not a PostgreSQL target", templateID) } hosts, err := awx.ListInventoryHosts(ctx, template.Inventory) if err != nil { return DeliveryTarget{}, err } meta := targetMetadata{Topology: "standalone"} for _, host := range hosts { if host.Enabled { 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: "host_pool", AWXInventoryID: template.Inventory, AWXTemplateID: template.ID, Enabled: true, Metadata: string(raw)}, nil } func selectPostgreSQLHosts(hosts []targetHost, requested []string, count int) ([]targetHost, error) { if len(requested) == 0 { if len(hosts) < count { return nil, fmt.Errorf("deployment target has %d hosts, but %d PostgreSQL instances are required", len(hosts), count) } return append([]targetHost(nil), hosts[:count]...), nil } byName := map[string]targetHost{} for _, host := range hosts { byName[host.Name] = host } selected := make([]targetHost, 0, len(requested)) for _, name := range requested { host, ok := byName[name] if !ok { return nil, fmt.Errorf("target host %q is not in the candidate host pool", name) } selected = append(selected, host) } return selected, nil } func (s *PostgreSQLDeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryTask, error) { var task model.DeliveryTask var payload postgresqlDeliveryPayload err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status = ? AND service_type = ?", model.TaskPending, postgresqlServiceType).Order("created_at ASC").First(&task).Error; err != nil { return err } if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil { return s.common.transitionTx(tx, &task, model.TaskValidationFailed, "stored PostgreSQL deployment payload is invalid", "stored PostgreSQL deployment payload is invalid") } activeStates := []string{model.TaskValidating, model.TaskDispatching, model.TaskRunning, model.TaskCanceling} checks := []struct { query string args []any limit int }{ {"status IN ?", []any{activeStates}, s.cfg.DeliveryGlobalLimit}, {"status IN ? AND target_id = ?", []any{activeStates, task.TargetID}, s.cfg.DeliveryTargetLimit}, {"status IN ? AND business_line_id = ?", []any{activeStates, task.BusinessLineID}, s.cfg.DeliveryBusinessLimit}, } for _, check := range checks { if check.limit <= 0 { continue } var count int64 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: delivery concurrency limit reached") } } target, err := getPostgreSQLTarget(ctx, s.awx, task.TargetID) if err != nil { return s.common.transitionTx(tx, &task, model.TaskValidationFailed, err.Error(), err.Error()) } meta := parseTargetMetadata(target.Metadata) nodes := 1 if payload.Topology == "primary_replica" { nodes += payload.ReplicaCount } selected, err := selectPostgreSQLHosts(meta.Hosts, payload.TargetHosts, nodes) if err != nil { return s.common.transitionTx(tx, &task, model.TaskValidationFailed, err.Error(), err.Error()) } quotaOK, err := checkPostgreSQLResourceQuota(tx, task.BusinessLineID, task.TargetID, payload, int64(nodes)) if err != nil { return err } if !quotaOK { return s.common.transitionTx(tx, &task, model.TaskValidationFailed, "resource quota is insufficient", "resource quota is insufficient") } var existingCluster model.PostgreSQLCluster if err := tx.Where("name = ?", payload.ClusterName).First(&existingCluster).Error; err == nil { return s.common.transitionTx(tx, &task, model.TaskValidationFailed, "PostgreSQL cluster name is already in use", "PostgreSQL cluster name is already in use") } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } cluster := model.PostgreSQLCluster{TaskID: task.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, Name: payload.ClusterName, VersionMajor: payload.VersionMajor, Topology: payload.Topology, ReplicationMode: "async", FailoverMode: "manual", Status: "provisioning", BackupStatus: "not_configured", MonitoringStatus: "not_configured"} if err := tx.Create(&cluster).Error; err != nil { return err } primaryPort := 0 for index, host := range selected { var usedPorts []int if err := tx.Model(&model.PostgreSQLInstance{}).Where("hostname = ? AND status IN ?", host.Name, []string{"provisioning", "active", "quarantined"}).Pluck("port", &usedPorts).Error; err != nil { return err } port, err := allocateReachablePostgreSQLPort(ctx, host.IP, usedPorts, postgresqlPortInUse) if err != nil { if cleanupErr := tx.Where("task_id = ?", task.ID).Delete(&model.PostgreSQLInstance{}).Error; cleanupErr != nil { return cleanupErr } if cleanupErr := tx.Delete(&cluster).Error; cleanupErr != nil { return cleanupErr } return s.common.transitionTx(tx, &task, model.TaskValidationFailed, err.Error(), err.Error()) } role := "replica" instanceID := fmt.Sprintf("%s-replica-%d", payload.ClusterName, index) upstream := payload.ClusterName + "-primary" slot := fmt.Sprintf("xinfra_%s_replica_%d", strings.ReplaceAll(payload.ClusterName, "-", "_"), index) if index == 0 { role = "primary" instanceID = payload.ClusterName + "-primary" upstream = "" slot = "" primaryPort = port } if payload.Topology == "standalone" { role = "standalone" instanceID = payload.ClusterName upstream = "" slot = "" } root := strings.TrimRight(payload.DataRoot, "/") + "/" + instanceID instance := model.PostgreSQLInstance{TaskID: task.ID, ClusterID: cluster.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, InstanceID: instanceID, Hostname: host.Name, HostIP: host.IP, Port: port, DataDir: root + "/data", ConfigDir: root + "/conf", LogDir: root + "/log", SystemdUnit: "postgresql-xinfra@" + instanceID + ".service", VersionMajor: payload.VersionMajor, Role: role, HAComponentRole: "database", UpstreamInstanceID: upstream, ReplicationSlotName: slot, Status: "provisioning", BackupStatus: "not_configured", MonitoringStatus: "not_configured"} if err := tx.Create(&instance).Error; err != nil { return err } if index == 0 { cluster.PrimaryInstanceID = instance.ID } } if err := tx.Save(&cluster).Error; err != nil { return err } reservation := model.ResourceReservation{TaskID: task.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, CPUMilli: payload.CPUMilli * int64(nodes), MemoryMi: payload.MemoryMi * int64(nodes), StorageGi: payload.StorageGi * int64(nodes), InstanceCount: int64(nodes), Status: "reserved", ExpiresAt: time.Now().Add(time.Duration(s.cfg.ReservationTTLMinutes) * time.Minute)} if err := tx.Create(&reservation).Error; err != nil { return err } hostNames := make([]string, 0, len(selected)) for _, host := range selected { hostNames = append(hostNames, host.Name) } task.TargetHost = strings.Join(hostNames, ",") task.TargetHostIP = selected[0].IP task.PostgreSQLPort = primaryPort if err := tx.Model(&model.DeliveryTask{}).Where("id = ?", task.ID).Updates(map[string]any{"target_host": task.TargetHost, "target_host_ip": task.TargetHostIP, "postgresql_port": primaryPort}).Error; err != nil { return err } return s.common.transitionTx(tx, &task, model.TaskDispatching, "PostgreSQL resources, ports and directories reserved", "") }) if err == nil && task.Status != model.TaskDispatching { return nil, errPostgreSQLTaskHandled } return &task, err } func checkPostgreSQLResourceQuota(tx *gorm.DB, businessLineID, targetID uint64, payload postgresqlDeliveryPayload, nodes int64) (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 mysqlUsed, postgresUsed, 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(&mysqlUsed).Error; err != nil { return false, err } if err := tx.Model(&model.PostgreSQLResourceUsage{}).Select("COALESCE(SUM(cpu_milli),0) cpu, COALESCE(SUM(memory_mi),0) memory, COALESCE(SUM(storage_gi),0) storage, COUNT(*) instances").Where("business_line_id = ? AND target_id = ? AND status = ?", businessLineID, targetID, "active").Scan(&postgresUsed).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 = ? AND expires_at > ?", businessLineID, targetID, "reserved", time.Now()).Scan(&reserved).Error; err != nil { return false, err } requestedCPU := payload.CPUMilli * nodes requestedMemory := payload.MemoryMi * nodes requestedStorage := payload.StorageGi * nodes return mysqlUsed.CPU+postgresUsed.CPU+reserved.CPU+requestedCPU <= quota.CPUMilli && mysqlUsed.Memory+postgresUsed.Memory+reserved.Memory+requestedMemory <= quota.MemoryMi && mysqlUsed.Storage+postgresUsed.Storage+reserved.Storage+requestedStorage <= quota.StorageGi && mysqlUsed.Instances+postgresUsed.Instances+reserved.Instances+nodes <= quota.InstanceLimit, nil } func (s *PostgreSQLDeliveryService) CreateExecution(ctx context.Context, task *model.DeliveryTask) error { var payload postgresqlDeliveryPayload if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil { return err } var instances []model.PostgreSQLInstance if err := s.db.WithContext(ctx).Where("task_id = ?", task.ID).Order("id ASC").Find(&instances).Error; err != nil { return err } instanceVars := make(map[string]any, len(instances)) for _, instance := range instances { instanceVars[instance.Hostname] = map[string]any{"instance_id": instance.InstanceID, "port": instance.Port, "data_dir": instance.DataDir, "config_dir": instance.ConfigDir, "log_dir": instance.LogDir, "role": instance.Role, "replication_slot": instance.ReplicationSlotName} } now := time.Now() execution := model.ExecutionJob{TaskID: task.ID, IdempotencyKey: task.IdempotencyKey, ExecutorJobID: "pending", Status: "launching", StartedAt: &now} if err := s.db.WithContext(ctx).Create(&execution).Error; err != nil { return err } target, err := getPostgreSQLTarget(ctx, s.awx, task.TargetID) if err != nil { return err } extraVars := map[string]any{"task_id": task.ID, "payload_hash": task.PayloadHash, "target_hosts": task.TargetHost, "cluster_name": payload.ClusterName, "postgresql_version": payload.VersionMajor, "postgresql_port": task.PostgreSQLPort, "topology": payload.Topology, "replica_count": payload.ReplicaCount, "postgresql_instances": instanceVars, "memory_mb": payload.MemoryMi, "storage_gb": payload.StorageGi} if payload.MaxConnections > 0 { extraVars["max_connections"] = payload.MaxConnections } 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 err } return 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.common.transitionTx(tx, task, model.TaskRunning, "PostgreSQL AWX job started", "") }) } func (s *PostgreSQLDeliveryService) DispatchOnce(ctx context.Context) error { task, err := s.claimAndReserve(ctx) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errPostgreSQLTaskHandled) || strings.HasPrefix(err.Error(), "defer:") { return nil } return err } if err := s.CreateExecution(ctx, task); err != nil { return s.beginRollback(ctx, task.ID, "PostgreSQL deployment could not be started: "+err.Error()) } return nil } func (s *PostgreSQLDeliveryService) PollOnce(ctx context.Context) error { var jobs []model.ExecutionJob if err := s.db.WithContext(ctx).Joins("JOIN delivery_tasks ON delivery_tasks.id = execution_jobs.task_id").Where("execution_jobs.status = ? AND delivery_tasks.service_type = ?", "running", postgresqlServiceType).Find(&jobs).Error; err != nil { return err } for _, execution := range jobs { job, err := s.awx.GetJob(ctx, execution.ExecutorJobID) if err != nil { // A temporary AWX failure does not mean the deployment failed. Retry on // the next scheduler tick while the execution remains running. continue } switch strings.ToLower(job.Status) { case "pending", "waiting", "running", "new": continue case "canceled": _ = s.beginRollback(ctx, execution.TaskID, "PostgreSQL AWX job was canceled") case "successful": if err := s.complete(ctx, execution.TaskID); err != nil { _ = s.beginRollback(ctx, execution.TaskID, err.Error()) } default: _ = s.beginRollback(ctx, execution.TaskID, "PostgreSQL AWX job finished with status "+job.Status) } } return nil } func (s *PostgreSQLDeliveryService) complete(ctx context.Context, taskID string) error { var task model.DeliveryTask if err := s.db.WithContext(ctx).First(&task, "id = ? AND service_type = ?", taskID, postgresqlServiceType).Error; err != nil { return err } var payload postgresqlDeliveryPayload if err := json.Unmarshal([]byte(task.ImmutablePayload), &payload); err != nil { return err } var instances []model.PostgreSQLInstance if err := s.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id ASC").Find(&instances).Error; err != nil { return err } if len(instances) == 0 { return fmt.Errorf("PostgreSQL task has no planned instances") } for _, instance := range instances { if err := postgresReady(ctx, net.JoinHostPort(instance.HostIP, fmt.Sprint(instance.Port))); err != nil { return fmt.Errorf("postgresql health check failed for %s: %w", instance.InstanceID, err) } } now := time.Now() if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.common.transitionTx(tx, &task, model.TaskRegistering, "PostgreSQL health checks passed", ""); err != nil { return err } for _, instance := range instances { if err := tx.Model(&instance).Updates(map[string]any{"status": "active", "version_full": instance.VersionMajor}).Error; err != nil { return err } if err := tx.Create(&model.PostgreSQLResourceUsage{TaskID: task.ID, InstanceID: instance.ID, ClusterID: instance.ClusterID, BusinessLineID: instance.BusinessLineID, TargetID: instance.TargetID, CPUMilli: payload.CPUMilli, MemoryMi: payload.MemoryMi, StorageGi: payload.StorageGi, Port: instance.Port, DataDir: instance.DataDir, 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.PostgreSQLCluster{}).Where("task_id = ?", task.ID).Update("status", "active").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("PostgreSQL delivered; CloudDM registration failed: %v", err) if transitionErr := s.common.transition(ctx, &task, model.TaskRegisterFailed, "PostgreSQL delivered; CloudDM registration failed and can be retried", message); transitionErr != nil { return fmt.Errorf("%s; cannot record register_failed: %w", message, transitionErr) } return nil } message := "PostgreSQL delivery completed; CloudDM registration was skipped because no PostgreSQL endpoint is configured" if strings.TrimSpace(s.cfg.CloudDMPostgreSQLRegisterURL) != "" { message = "PostgreSQL delivery completed, registered and recorded in the resource ledger" } return s.common.transition(ctx, &task, model.TaskFinished, message, "") } func postgresReady(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 *PostgreSQLDeliveryService) Run(ctx context.Context) { interval := time.Duration(s.cfg.DeliveryPollSeconds) * 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) } } }