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:
2025-12-29 15:46:03 +07:00
commit 3f9d83c436
59 changed files with 11707 additions and 0 deletions

View 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: &params,
}
// 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
}

View 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
}

View 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);