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
291 lines
7.0 KiB
Go
291 lines
7.0 KiB
Go
package models
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/database"
|
|
)
|
|
|
|
// AppStatus represents the status of an app.
|
|
type AppStatus string
|
|
|
|
// App status constants.
|
|
const (
|
|
AppStatusPending AppStatus = "pending"
|
|
AppStatusBuilding AppStatus = "building"
|
|
AppStatusRunning AppStatus = "running"
|
|
AppStatusStopped AppStatus = "stopped"
|
|
AppStatusError AppStatus = "error"
|
|
)
|
|
|
|
// App represents an application managed by upaas.
|
|
type App struct {
|
|
db *database.Database
|
|
|
|
ID string
|
|
Name string
|
|
RepoURL string
|
|
Branch string
|
|
DockerfilePath string
|
|
WebhookSecret string
|
|
SSHPrivateKey string
|
|
SSHPublicKey string
|
|
ContainerID sql.NullString
|
|
ImageID sql.NullString
|
|
Status AppStatus
|
|
DockerNetwork sql.NullString
|
|
NtfyTopic sql.NullString
|
|
SlackWebhook sql.NullString
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// NewApp creates a new App with a database reference.
|
|
func NewApp(db *database.Database) *App {
|
|
return &App{
|
|
db: db,
|
|
Status: AppStatusPending,
|
|
Branch: "main",
|
|
}
|
|
}
|
|
|
|
// Save inserts or updates the app in the database.
|
|
func (a *App) Save(ctx context.Context) error {
|
|
if a.exists(ctx) {
|
|
return a.update(ctx)
|
|
}
|
|
|
|
return a.insert(ctx)
|
|
}
|
|
|
|
// Delete removes the app from the database.
|
|
func (a *App) Delete(ctx context.Context) error {
|
|
_, err := a.db.Exec(ctx, "DELETE FROM apps WHERE id = ?", a.ID)
|
|
|
|
return err
|
|
}
|
|
|
|
// Reload refreshes the app from the database.
|
|
func (a *App) Reload(ctx context.Context) error {
|
|
row := a.db.QueryRow(ctx, `
|
|
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
|
ssh_private_key, ssh_public_key, container_id, image_id, status,
|
|
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
FROM apps WHERE id = ?`,
|
|
a.ID,
|
|
)
|
|
|
|
return a.scan(row)
|
|
}
|
|
|
|
// GetEnvVars returns all environment variables for this app.
|
|
func (a *App) GetEnvVars(ctx context.Context) ([]*EnvVar, error) {
|
|
return FindEnvVarsByAppID(ctx, a.db, a.ID)
|
|
}
|
|
|
|
// GetLabels returns all labels for this app.
|
|
func (a *App) GetLabels(ctx context.Context) ([]*Label, error) {
|
|
return FindLabelsByAppID(ctx, a.db, a.ID)
|
|
}
|
|
|
|
// GetVolumes returns all volume mounts for this app.
|
|
func (a *App) GetVolumes(ctx context.Context) ([]*Volume, error) {
|
|
return FindVolumesByAppID(ctx, a.db, a.ID)
|
|
}
|
|
|
|
// GetDeployments returns recent deployments for this app.
|
|
func (a *App) GetDeployments(ctx context.Context, limit int) ([]*Deployment, error) {
|
|
return FindDeploymentsByAppID(ctx, a.db, a.ID, limit)
|
|
}
|
|
|
|
// GetWebhookEvents returns recent webhook events for this app.
|
|
func (a *App) GetWebhookEvents(
|
|
ctx context.Context,
|
|
limit int,
|
|
) ([]*WebhookEvent, error) {
|
|
return FindWebhookEventsByAppID(ctx, a.db, a.ID, limit)
|
|
}
|
|
|
|
func (a *App) exists(ctx context.Context) bool {
|
|
if a.ID == "" {
|
|
return false
|
|
}
|
|
|
|
var count int
|
|
|
|
row := a.db.QueryRow(ctx, "SELECT COUNT(*) FROM apps WHERE id = ?", a.ID)
|
|
|
|
err := row.Scan(&count)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return count > 0
|
|
}
|
|
|
|
func (a *App) insert(ctx context.Context) error {
|
|
query := `
|
|
INSERT INTO apps (
|
|
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
|
ssh_private_key, ssh_public_key, container_id, image_id, status,
|
|
docker_network, ntfy_topic, slack_webhook
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
|
|
_, err := a.db.Exec(ctx, query,
|
|
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
|
a.SSHPrivateKey, a.SSHPublicKey, a.ContainerID, a.ImageID, a.Status,
|
|
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return a.Reload(ctx)
|
|
}
|
|
|
|
func (a *App) update(ctx context.Context) error {
|
|
query := `
|
|
UPDATE apps SET
|
|
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
|
|
container_id = ?, image_id = ?, status = ?,
|
|
docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?`
|
|
|
|
_, err := a.db.Exec(ctx, query,
|
|
a.Name, a.RepoURL, a.Branch, a.DockerfilePath,
|
|
a.ContainerID, a.ImageID, a.Status,
|
|
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
|
a.ID,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
func (a *App) scan(row *sql.Row) error {
|
|
return row.Scan(
|
|
&a.ID, &a.Name, &a.RepoURL, &a.Branch,
|
|
&a.DockerfilePath, &a.WebhookSecret,
|
|
&a.SSHPrivateKey, &a.SSHPublicKey,
|
|
&a.ContainerID, &a.ImageID, &a.Status,
|
|
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
|
&a.CreatedAt, &a.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
|
|
var apps []*App
|
|
|
|
for rows.Next() {
|
|
app := NewApp(appDB)
|
|
|
|
scanErr := rows.Scan(
|
|
&app.ID, &app.Name, &app.RepoURL, &app.Branch,
|
|
&app.DockerfilePath, &app.WebhookSecret,
|
|
&app.SSHPrivateKey, &app.SSHPublicKey,
|
|
&app.ContainerID, &app.ImageID, &app.Status,
|
|
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
|
&app.CreatedAt, &app.UpdatedAt,
|
|
)
|
|
if scanErr != nil {
|
|
return nil, fmt.Errorf("scanning app row: %w", scanErr)
|
|
}
|
|
|
|
apps = append(apps, app)
|
|
}
|
|
|
|
rowsErr := rows.Err()
|
|
if rowsErr != nil {
|
|
return nil, fmt.Errorf("iterating app rows: %w", rowsErr)
|
|
}
|
|
|
|
return apps, nil
|
|
}
|
|
|
|
// FindApp finds an app by ID.
|
|
//
|
|
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
|
|
func FindApp(
|
|
ctx context.Context,
|
|
appDB *database.Database,
|
|
appID string,
|
|
) (*App, error) {
|
|
app := NewApp(appDB)
|
|
app.ID = appID
|
|
|
|
row := appDB.QueryRow(ctx, `
|
|
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
|
ssh_private_key, ssh_public_key, container_id, image_id, status,
|
|
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
FROM apps WHERE id = ?`,
|
|
appID,
|
|
)
|
|
|
|
err := app.scan(row)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("scanning app: %w", err)
|
|
}
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// FindAppByWebhookSecret finds an app by webhook secret.
|
|
//
|
|
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
|
|
func FindAppByWebhookSecret(
|
|
ctx context.Context,
|
|
appDB *database.Database,
|
|
secret string,
|
|
) (*App, error) {
|
|
app := NewApp(appDB)
|
|
|
|
row := appDB.QueryRow(ctx, `
|
|
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
|
ssh_private_key, ssh_public_key, container_id, image_id, status,
|
|
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
FROM apps WHERE webhook_secret = ?`,
|
|
secret,
|
|
)
|
|
|
|
err := app.scan(row)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("scanning app by webhook secret: %w", err)
|
|
}
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// AllApps returns all apps ordered by name.
|
|
func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) {
|
|
rows, err := appDB.Query(ctx, `
|
|
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
|
ssh_private_key, ssh_public_key, container_id, image_id, status,
|
|
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
|
FROM apps ORDER BY name`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying all apps: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
result, scanErr := scanApps(appDB, rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
|
|
return result, nil
|
|
}
|