upaas/internal/models/app.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

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
}