Check / check (pull_request) Skipped
Builds are tagged upaas-<app>:<short hash>, git's own short form of the commit checked out, instead of the deployment number. A redeploy of a commit gives the tag to the new image. The cleanup after a deploy now also finds the app's untagged images among those its deployments recorded, and removes them by ID once the app neither runs them nor would roll back to them. It keeps every image any app uses, since apps that build the same commit can share one. The clone reads the commits from git's output after removing Docker's log headers, which had hidden the COMMIT: line; commit_sha is now saved on update so manual deploys keep the commit they recorded. Model: opus-5-5
388 lines
9.3 KiB
Go
388 lines
9.3 KiB
Go
package models
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/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"
|
|
DeploymentStatusCancelled DeploymentStatus = "cancelled"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// maxLogSize is the maximum size of deployment logs stored in the database (1MB).
|
|
const maxLogSize = 1 << 20
|
|
|
|
// AppendLog appends a log line to the deployment logs.
|
|
// If the total log size exceeds maxLogSize, the oldest lines are truncated.
|
|
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
|
|
var currentLogs string
|
|
|
|
if d.Logs.Valid {
|
|
currentLogs = d.Logs.String
|
|
}
|
|
|
|
newLogs := currentLogs + line + "\n"
|
|
|
|
if len(newLogs) > maxLogSize {
|
|
// Keep the most recent logs that fit within the limit.
|
|
// Find a newline after the truncation point to avoid partial lines.
|
|
truncateAt := len(newLogs) - maxLogSize
|
|
idx := strings.Index(newLogs[truncateAt:], "\n")
|
|
|
|
if idx >= 0 {
|
|
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt+idx+1:]
|
|
} else {
|
|
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt:]
|
|
}
|
|
}
|
|
|
|
d.Logs = sql.NullString{String: newLogs, 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
|
|
commit_sha = ?, image_id = ?, container_id = ?, status = ?, logs = ?,
|
|
finished_at = ?
|
|
WHERE id = ?`
|
|
|
|
_, err := d.db.Exec(ctx, query,
|
|
d.CommitSHA, 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
|
|
}
|
|
|
|
// FindDeploymentImageIDs returns the IDs of the images an app's
|
|
// deployments built or rolled back to.
|
|
func FindDeploymentImageIDs(
|
|
ctx context.Context,
|
|
deployDB *database.Database,
|
|
appID string,
|
|
) ([]string, error) {
|
|
rows, err := deployDB.Query(ctx, `
|
|
SELECT DISTINCT image_id FROM deployments
|
|
WHERE app_id = ? AND image_id IS NOT NULL`,
|
|
appID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying deployment image IDs: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var imageIDs []string
|
|
|
|
for rows.Next() {
|
|
var imageID string
|
|
|
|
scanErr := rows.Scan(&imageID)
|
|
if scanErr != nil {
|
|
return nil, fmt.Errorf("scanning deployment image ID: %w", scanErr)
|
|
}
|
|
|
|
imageIDs = append(imageIDs, imageID)
|
|
}
|
|
|
|
rowsErr := rows.Err()
|
|
if rowsErr != nil {
|
|
return nil, fmt.Errorf("iterating deployment image IDs: %w", rowsErr)
|
|
}
|
|
|
|
return imageIDs, 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
|
|
}
|