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
242 lines
5.7 KiB
Go
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
|
|
}
|