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:
175
internal/database/database.go
Normal file
175
internal/database/database.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Package database provides SQLite database access with logging.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
)
|
||||
|
||||
// dataDirPermissions is the file permission for the data directory.
|
||||
const dataDirPermissions = 0o750
|
||||
|
||||
// Params contains dependencies for Database.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Database wraps sql.DB with logging and helper methods.
|
||||
type Database struct {
|
||||
database *sql.DB
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
}
|
||||
|
||||
// New creates a new Database instance.
|
||||
func New(lifecycle fx.Lifecycle, params Params) (*Database, error) {
|
||||
database := &Database{
|
||||
log: params.Logger.Get(),
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
// For testing, if lifecycle is nil, connect immediately
|
||||
if lifecycle == nil {
|
||||
err := database.connect(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
return database.connect(ctx)
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
return database.close()
|
||||
},
|
||||
})
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
// DB returns the underlying sql.DB for direct access.
|
||||
func (d *Database) DB() *sql.DB {
|
||||
return d.database
|
||||
}
|
||||
|
||||
// Exec executes a query with logging.
|
||||
func (d *Database) Exec(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
args ...any,
|
||||
) (sql.Result, error) {
|
||||
d.log.Debug("database exec", "query", query, "args", args)
|
||||
|
||||
result, err := d.database.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec failed: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// QueryRow executes a query that returns a single row.
|
||||
func (d *Database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row {
|
||||
d.log.Debug("database query row", "query", query, "args", args)
|
||||
|
||||
return d.database.QueryRowContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns multiple rows.
|
||||
func (d *Database) Query(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
args ...any,
|
||||
) (*sql.Rows, error) {
|
||||
d.log.Debug("database query", "query", query, "args", args)
|
||||
|
||||
rows, err := d.database.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// BeginTx starts a new transaction.
|
||||
func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
|
||||
d.log.Debug("database begin transaction")
|
||||
|
||||
transaction, err := d.database.BeginTx(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin transaction failed: %w", err)
|
||||
}
|
||||
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
// Path returns the database file path.
|
||||
func (d *Database) Path() string {
|
||||
return d.params.Config.DatabasePath()
|
||||
}
|
||||
|
||||
func (d *Database) connect(ctx context.Context) error {
|
||||
dbPath := d.params.Config.DatabasePath()
|
||||
|
||||
// Ensure data directory exists
|
||||
dir := filepath.Dir(dbPath)
|
||||
|
||||
err := os.MkdirAll(dir, dataDirPermissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
// Open database with WAL mode and foreign keys
|
||||
dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on"
|
||||
|
||||
database, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
err = database.PingContext(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
d.database = database
|
||||
d.log.Info("database connected", "path", dbPath)
|
||||
|
||||
// Run migrations
|
||||
err = d.migrate(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) close() error {
|
||||
if d.database != nil {
|
||||
d.log.Info("closing database connection")
|
||||
|
||||
err := d.database.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
122
internal/database/migrations.go
Normal file
122
internal/database/migrations.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func (d *Database) migrate(ctx context.Context) error {
|
||||
// Create migrations table if not exists
|
||||
_, err := d.database.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
||||
}
|
||||
|
||||
// Get list of migration files
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migrations directory: %w", err)
|
||||
}
|
||||
|
||||
// Sort migrations by name
|
||||
migrations := make([]string, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||
migrations = append(migrations, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(migrations)
|
||||
|
||||
// Apply each migration
|
||||
for _, migration := range migrations {
|
||||
applied, err := d.isMigrationApplied(ctx, migration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check migration %s: %w", migration, err)
|
||||
}
|
||||
|
||||
if applied {
|
||||
d.log.Debug("migration already applied", "migration", migration)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
err = d.applyMigration(ctx, migration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
||||
}
|
||||
|
||||
d.log.Info("migration applied", "migration", migration)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) isMigrationApplied(ctx context.Context, version string) (bool, error) {
|
||||
var count int
|
||||
|
||||
err := d.database.QueryRowContext(
|
||||
ctx,
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||
version,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to query migration status: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (d *Database) applyMigration(ctx context.Context, filename string) error {
|
||||
content, err := migrationsFS.ReadFile("migrations/" + filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration file: %w", err)
|
||||
}
|
||||
|
||||
transaction, err := d.database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = transaction.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Execute migration
|
||||
_, err = transaction.ExecContext(ctx, string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute migration: %w", err)
|
||||
}
|
||||
|
||||
// Record migration
|
||||
_, err = transaction.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||
filename,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration: %w", err)
|
||||
}
|
||||
|
||||
commitErr := transaction.Commit()
|
||||
if commitErr != nil {
|
||||
return fmt.Errorf("failed to commit migration: %w", commitErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
94
internal/database/migrations/001_initial.sql
Normal file
94
internal/database/migrations/001_initial.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- Initial schema for upaas
|
||||
|
||||
-- Users table (single admin user)
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Apps table
|
||||
CREATE TABLE apps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
repo_url TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
dockerfile_path TEXT DEFAULT 'Dockerfile',
|
||||
webhook_secret TEXT NOT NULL,
|
||||
ssh_private_key TEXT NOT NULL,
|
||||
ssh_public_key TEXT NOT NULL,
|
||||
container_id TEXT,
|
||||
image_id TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
docker_network TEXT,
|
||||
ntfy_topic TEXT,
|
||||
slack_webhook TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- App environment variables
|
||||
CREATE TABLE app_env_vars (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
UNIQUE(app_id, key)
|
||||
);
|
||||
|
||||
-- App labels
|
||||
CREATE TABLE app_labels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
UNIQUE(app_id, key)
|
||||
);
|
||||
|
||||
-- App volume mounts
|
||||
CREATE TABLE app_volumes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
host_path TEXT NOT NULL,
|
||||
container_path TEXT NOT NULL,
|
||||
readonly INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Webhook events log
|
||||
CREATE TABLE webhook_events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
commit_sha TEXT,
|
||||
payload TEXT,
|
||||
matched INTEGER NOT NULL,
|
||||
processed INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Deployments log
|
||||
CREATE TABLE deployments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
webhook_event_id INTEGER REFERENCES webhook_events(id),
|
||||
commit_sha TEXT,
|
||||
image_id TEXT,
|
||||
container_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
logs TEXT,
|
||||
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at DATETIME
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_apps_status ON apps(status);
|
||||
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);
|
||||
CREATE INDEX idx_app_env_vars_app_id ON app_env_vars(app_id);
|
||||
CREATE INDEX idx_app_labels_app_id ON app_labels(app_id);
|
||||
CREATE INDEX idx_app_volumes_app_id ON app_volumes(app_id);
|
||||
CREATE INDEX idx_webhook_events_app_id ON webhook_events(app_id);
|
||||
CREATE INDEX idx_webhook_events_created_at ON webhook_events(created_at);
|
||||
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
||||
CREATE INDEX idx_deployments_started_at ON deployments(started_at);
|
||||
Reference in New Issue
Block a user