upaas/internal/models/deployment.go
sneak 3f9d83c436 Initial commit with server startup infrastructure
Core infrastructure:
- Uber fx dependency injection
- Chi router with middleware stack
- SQLite database with embedded migrations
- Embedded templates and static assets
- Structured logging with slog

Features implemented:
- Authentication (login, logout, session management, argon2id hashing)
- App management (create, edit, delete, list)
- Deployment pipeline (clone, build, deploy, health check)
- Webhook processing for Gitea
- Notifications (ntfy, Slack)
- Environment variables, labels, volumes per app
- SSH key generation for deploy keys

Server startup:
- Server.Run() starts HTTP server on configured port
- Server.Shutdown() for graceful shutdown
- SetupRoutes() wires all handlers with chi router
2025-12-29 15:46:03 +07:00

242 lines
5.7 KiB
Go

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"
)
// Deployment represents a deployment attempt for an app.
type Deployment struct {
db *database.Database
ID int64
AppID string
WebhookEventID sql.NullInt64
CommitSHA 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, 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)
}
func (d *Deployment) insert(ctx context.Context) error {
query := `
INSERT INTO deployments (
app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs
) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := d.db.Exec(ctx, query,
d.AppID, d.WebhookEventID, d.CommitSHA, 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.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, 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, 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.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, 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
}