package models import ( "context" "database/sql" "errors" "fmt" "time" "git.eeqj.de/sneak/upaas/internal/database" ) // DeploymentStatus represents the status of a deployment. type DeploymentStatus string // Deployment status constants. const ( DeploymentStatusBuilding DeploymentStatus = "building" DeploymentStatusDeploying DeploymentStatus = "deploying" DeploymentStatusSuccess DeploymentStatus = "success" DeploymentStatusFailed DeploymentStatus = "failed" ) // Display constants. const ( // secondsPerMinute is used for duration formatting. secondsPerMinute = 60 // shortCommitLength is the number of characters to show for commit SHA. shortCommitLength = 12 ) // Deployment represents a deployment attempt for an app. type Deployment struct { db *database.Database ID int64 AppID string WebhookEventID sql.NullInt64 CommitSHA sql.NullString CommitURL sql.NullString ImageID sql.NullString ContainerID sql.NullString Status DeploymentStatus Logs sql.NullString StartedAt time.Time FinishedAt sql.NullTime } // NewDeployment creates a new Deployment with a database reference. func NewDeployment(db *database.Database) *Deployment { return &Deployment{ db: db, Status: DeploymentStatusBuilding, } } // Save inserts or updates the deployment in the database. func (d *Deployment) Save(ctx context.Context) error { if d.ID == 0 { return d.insert(ctx) } return d.update(ctx) } // Reload refreshes the deployment from the database. func (d *Deployment) Reload(ctx context.Context) error { query := ` SELECT id, app_id, webhook_event_id, commit_sha, commit_url, image_id, container_id, status, logs, started_at, finished_at FROM deployments WHERE id = ?` row := d.db.QueryRow(ctx, query, d.ID) return d.scan(row) } // AppendLog appends a log line to the deployment logs. func (d *Deployment) AppendLog(ctx context.Context, line string) error { var currentLogs string if d.Logs.Valid { currentLogs = d.Logs.String } d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true} return d.Save(ctx) } // MarkFinished marks the deployment as finished with the given status. func (d *Deployment) MarkFinished( ctx context.Context, status DeploymentStatus, ) error { d.Status = status d.FinishedAt = sql.NullTime{Time: time.Now(), Valid: true} return d.Save(ctx) } // Duration returns the duration of the deployment as a formatted string. // Returns empty string if deployment is not finished. func (d *Deployment) Duration() string { if !d.FinishedAt.Valid { return "" } duration := d.FinishedAt.Time.Sub(d.StartedAt) // Format as minutes and seconds minutes := int(duration.Minutes()) seconds := int(duration.Seconds()) % secondsPerMinute if minutes > 0 { return fmt.Sprintf("%dm %ds", minutes, seconds) } return fmt.Sprintf("%ds", seconds) } // ShortCommit returns a truncated commit SHA for display. // Returns "-" if no commit SHA is set. func (d *Deployment) ShortCommit() string { if !d.CommitSHA.Valid || d.CommitSHA.String == "" { return "-" } sha := d.CommitSHA.String if len(sha) > shortCommitLength { return sha[:shortCommitLength] } return sha } // FinishedAtISO returns the finished time in ISO format for JavaScript parsing. // Returns empty string if not finished yet. func (d *Deployment) FinishedAtISO() string { if d.FinishedAt.Valid { return d.FinishedAt.Time.Format(time.RFC3339) } return "" } // FinishedAtFormatted returns the finished time formatted for display. // Returns empty string if not finished yet. func (d *Deployment) FinishedAtFormatted() string { if d.FinishedAt.Valid { return d.FinishedAt.Time.Format("2006-01-02 15:04:05") } return "" } func (d *Deployment) insert(ctx context.Context) error { query := ` INSERT INTO deployments ( app_id, webhook_event_id, commit_sha, commit_url, image_id, container_id, status, logs ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` result, err := d.db.Exec(ctx, query, d.AppID, d.WebhookEventID, d.CommitSHA, d.CommitURL, d.ImageID, d.ContainerID, d.Status, d.Logs, ) if err != nil { return err } insertID, err := result.LastInsertId() if err != nil { return fmt.Errorf("getting last insert id: %w", err) } d.ID = insertID return d.Reload(ctx) } func (d *Deployment) update(ctx context.Context) error { query := ` UPDATE deployments SET image_id = ?, container_id = ?, status = ?, logs = ?, finished_at = ? WHERE id = ?` _, err := d.db.Exec(ctx, query, d.ImageID, d.ContainerID, d.Status, d.Logs, d.FinishedAt, d.ID, ) return err } func (d *Deployment) scan(row *sql.Row) error { return row.Scan( &d.ID, &d.AppID, &d.WebhookEventID, &d.CommitSHA, &d.CommitURL, &d.ImageID, &d.ContainerID, &d.Status, &d.Logs, &d.StartedAt, &d.FinishedAt, ) } // FindDeployment finds a deployment by ID. // //nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record func FindDeployment( ctx context.Context, deployDB *database.Database, deployID int64, ) (*Deployment, error) { deploy := NewDeployment(deployDB) deploy.ID = deployID row := deployDB.QueryRow(ctx, ` SELECT id, app_id, webhook_event_id, commit_sha, commit_url, image_id, container_id, status, logs, started_at, finished_at FROM deployments WHERE id = ?`, deployID, ) err := deploy.scan(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("scanning deployment: %w", err) } return deploy, nil } // FindDeploymentsByAppID finds recent deployments for an app. func FindDeploymentsByAppID( ctx context.Context, deployDB *database.Database, appID string, limit int, ) ([]*Deployment, error) { query := ` SELECT id, app_id, webhook_event_id, commit_sha, commit_url, image_id, container_id, status, logs, started_at, finished_at FROM deployments WHERE app_id = ? ORDER BY started_at DESC, id DESC LIMIT ?` rows, err := deployDB.Query(ctx, query, appID, limit) if err != nil { return nil, fmt.Errorf("querying deployments by app: %w", err) } defer func() { _ = rows.Close() }() var deployments []*Deployment for rows.Next() { deploy := NewDeployment(deployDB) scanErr := rows.Scan( &deploy.ID, &deploy.AppID, &deploy.WebhookEventID, &deploy.CommitSHA, &deploy.CommitURL, &deploy.ImageID, &deploy.ContainerID, &deploy.Status, &deploy.Logs, &deploy.StartedAt, &deploy.FinishedAt, ) if scanErr != nil { return nil, fmt.Errorf("scanning deployment row: %w", scanErr) } deployments = append(deployments, deploy) } rowsErr := rows.Err() if rowsErr != nil { return nil, fmt.Errorf("iterating deployment rows: %w", rowsErr) } return deployments, nil } // LatestDeploymentForApp finds the most recent deployment for an app. // //nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record func LatestDeploymentForApp( ctx context.Context, deployDB *database.Database, appID string, ) (*Deployment, error) { deploy := NewDeployment(deployDB) row := deployDB.QueryRow(ctx, ` SELECT id, app_id, webhook_event_id, commit_sha, commit_url, image_id, container_id, status, logs, started_at, finished_at FROM deployments WHERE app_id = ? ORDER BY started_at DESC, id DESC LIMIT 1`, appID, ) err := deploy.scan(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("scanning latest deployment: %w", err) } return deploy, nil } // CountDeploymentsByAppID returns the total number of deployments for an app. func CountDeploymentsByAppID( ctx context.Context, deployDB *database.Database, appID string, ) (int, error) { var count int row := deployDB.QueryRow(ctx, "SELECT COUNT(*) FROM deployments WHERE app_id = ?", appID, ) err := row.Scan(&count) if err != nil { return 0, fmt.Errorf("counting deployments: %w", err) } return count, nil }