package transaction import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) const backendService = "backend" // BackendContainerDeployment is the last container slot committed for backend. // Runtime inspection remains authoritative for the current external state; this // record distinguishes a fresh installation from loss or drift after deployment. type BackendContainerDeployment struct { ActivePort int ContainerName string ImageDigest string ContainerID string TransactionID string UpdatedAt time.Time } // BackendContainerDeployment returns the single committed backend container state. func (s *Store) BackendContainerDeployment(ctx context.Context) (BackendContainerDeployment, error) { return scanBackendContainerDeployment(s.db.QueryRowContext(ctx, ` SELECT active_port, container_name, image_digest, container_id, transaction_id, updated_at FROM backend_container_deployment WHERE singleton_id = 1`)) } // HasCommittedBackendContainerTransactionHistory reports whether this store // contains a committed container backend transaction. This is the legacy // deployment evidence used when a database predates the deployment table. // Failed and rolled-back first-install attempts do not mark a machine deployed. func (s *Store) HasCommittedBackendContainerTransactionHistory(ctx context.Context) (bool, error) { var found int if err := s.db.QueryRowContext(ctx, ` SELECT EXISTS ( SELECT 1 FROM transactions WHERE service = ? AND idempotency_key GLOB 'backend:container:*' AND state = ? )`, backendService, StateCommitted).Scan(&found); err != nil { return false, fmt.Errorf("query committed backend container transaction history: %w", err) } return found == 1, nil } // CommitBackendContainerDeployment atomically records the active container and // commits its transaction. A crash cannot leave COMMITTED without the matching // deployment row, or publish a deployment row for an unfinished transaction. func (s *Store) CommitBackendContainerDeployment( ctx context.Context, transactionID string, deployment BackendContainerDeployment, message string, ) (Transaction, error) { if transactionID == "" || strings.TrimSpace(transactionID) != transactionID { return Transaction{}, errors.New("exact transaction ID is required") } if err := validateBackendContainerDeployment(deployment); err != nil { return Transaction{}, err } tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) if err != nil { return Transaction{}, fmt.Errorf("begin backend container deployment commit: %w", err) } defer tx.Rollback() record, err := getTransactionByID(ctx, tx, transactionID) if err != nil { return Transaction{}, err } if record.Service != backendService { return Transaction{}, fmt.Errorf("transaction %s service must be %s", transactionID, backendService) } if record.State != StateDraining { return Transaction{}, &TransitionError{From: record.State, To: StateCommitted} } now := s.now().UTC() _, err = tx.ExecContext(ctx, ` INSERT INTO backend_container_deployment ( singleton_id, active_port, container_name, image_digest, container_id, transaction_id, updated_at ) VALUES (1, ?, ?, ?, ?, ?, ?) ON CONFLICT(singleton_id) DO UPDATE SET active_port = excluded.active_port, container_name = excluded.container_name, image_digest = excluded.image_digest, container_id = excluded.container_id, transaction_id = excluded.transaction_id, updated_at = excluded.updated_at`, deployment.ActivePort, deployment.ContainerName, deployment.ImageDigest, deployment.ContainerID, transactionID, formatTime(now), ) if err != nil { return Transaction{}, fmt.Errorf("write backend container deployment: %w", err) } result, err := tx.ExecContext(ctx, ` UPDATE transactions SET state = ?, version = version + 1, updated_at = ? WHERE id = ? AND version = ? AND state = ?`, StateCommitted, formatTime(now), transactionID, record.Version, StateDraining, ) if err != nil { return Transaction{}, fmt.Errorf("commit backend container transaction state: %w", err) } rows, err := result.RowsAffected() if err != nil { return Transaction{}, fmt.Errorf("read committed backend container transaction rows: %w", err) } if rows != 1 { return Transaction{}, errors.New("backend container transaction changed concurrently") } if err := insertEvent(ctx, tx, transactionID, "", "TRANSACTION_STATE_CHANGED", record.State, StateCommitted, message, now); err != nil { return Transaction{}, err } if err := tx.Commit(); err != nil { return Transaction{}, fmt.Errorf("commit backend container deployment: %w", err) } record.State = StateCommitted record.Version++ record.UpdatedAt = now return record, nil } func validateBackendContainerDeployment(deployment BackendContainerDeployment) error { if deployment.ActivePort != 8080 && deployment.ActivePort != 8081 { return fmt.Errorf("backend container deployment port must be 8080 or 8081: %d", deployment.ActivePort) } for _, field := range []struct { name string value string }{ {"container name", deployment.ContainerName}, {"image digest", deployment.ImageDigest}, {"container ID", deployment.ContainerID}, } { if field.value == "" || strings.TrimSpace(field.value) != field.value { return fmt.Errorf("exact backend container %s is required", field.name) } } return nil } func scanBackendContainerDeployment(row rowScanner) (BackendContainerDeployment, error) { var deployment BackendContainerDeployment var updatedAt string if err := row.Scan( &deployment.ActivePort, &deployment.ContainerName, &deployment.ImageDigest, &deployment.ContainerID, &deployment.TransactionID, &updatedAt, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return BackendContainerDeployment{}, ErrNotFound } return BackendContainerDeployment{}, fmt.Errorf("scan backend container deployment: %w", err) } parsed, err := parseTime(updatedAt) if err != nil { return BackendContainerDeployment{}, fmt.Errorf("parse backend container deployment update time: %w", err) } deployment.UpdatedAt = parsed return deployment, nil }