Backend Phase A: foundation, migrations, Docker setup

- Go backend skeleton: config (caarlos0/env), slog JSON logging,
  pgxpool store, HTTP server with graceful shutdown.
- httpapi: render helpers, Problem errors, middleware chain
  (requestID / logging / recover / CORS), /healthz and /readyz.
- Migrations: full initial schema (users, sessions, lists,
  list_members, items, op_log SOURCE OF TRUTH, item_names) +
  golang-migrate runner binary using source/iofs (embedded).
- Docker: multi-stage Dockerfile (Go 1.26 -> distroless nonroot),
  builds both server and migrate binaries.
- deploy: docker-compose (caddy + backend + migrate + postgres:16),
  Caddyfile (auto-HTTPS), .env.example, pg extensions init script.
- AGENTS.md: project context + roadmap for AI agents.

Verified: image builds, both binaries run in container (smoke test).
This commit is contained in:
Tronax 2026-08-05 15:14:37 +02:00
commit 2899eb205b
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
23 changed files with 1372 additions and 0 deletions

105
backend/cmd/migrate/main.go Normal file
View file

@ -0,0 +1,105 @@
// Package main is the migration runner. It applies embedded SQL migrations to the
// configured database. Intended to run as a one-shot container/job before the API.
//
// Usage: migrate [up|down|version|force <n>]
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/mitbringsl/backend/internal/config"
"github.com/mitbringsl/backend/internal/logging"
"github.com/mitbringsl/backend/migrations"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
logging.Init(cfg.LogLevel)
cmd := "up"
if len(os.Args) > 1 {
cmd = os.Args[1]
}
if err := run(cfg, cmd); err != nil {
slog.Error("migration failed", "cmd", cmd, "error", err)
os.Exit(1)
}
}
func run(cfg *config.Config, cmd string) error {
src, err := iofs.New(migrations.FS, ".")
if err != nil {
return fmt.Errorf("create source: %w", err)
}
// Use the configured DATABASE_URL; golang-migrate accepts the same DSN
// form pgx uses (postgres://...). Allow a short startup grace period.
dbURL := cfg.DatabaseURL
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var m *migrate.Migrate
var lastErr error
for attempt := 0; attempt < 30; attempt++ {
m, lastErr = migrate.NewWithSourceInstance("iofs", src, dbURL)
if lastErr == nil {
break
}
select {
case <-ctx.Done():
return fmt.Errorf("db not ready: %w", lastErr)
case <-time.After(time.Second):
}
}
if lastErr != nil {
return fmt.Errorf("create migrate instance: %w", lastErr)
}
defer m.Close()
switch cmd {
case "up":
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
slog.Info("migrations applied (up)")
case "down":
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
slog.Info("migrations applied (down)")
case "version":
v, dirty, err := m.Version()
if err != nil {
return err
}
fmt.Printf("version=%d dirty=%v\n", v, dirty)
case "force":
if len(os.Args) < 3 {
return errors.New("force requires a version argument")
}
var v int
fmt.Sscanf(os.Args[2], "%d", &v)
if err := m.Force(v); err != nil {
return err
}
slog.Info("forced migration version", "version", v)
default:
return fmt.Errorf("unknown command %q (use up|down|version|force)", cmd)
}
return nil
}