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
This commit is contained in:
241
internal/models/deployment.go
Normal file
241
internal/models/deployment.go
Normal file
@@ -0,0 +1,241 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user