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:
290
internal/models/app.go
Normal file
290
internal/models/app.go
Normal file
@@ -0,0 +1,290 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user