docs(07): create phase plan
This commit is contained in:
parent
dbe9d493be
commit
8ae83f6c50
7 changed files with 637 additions and 52 deletions
|
|
@ -177,8 +177,13 @@ Plans:
|
|||
|
||||
**Plans:** 3 plans
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [ ] 07-01-PLAN.md — Wave 1: go:embed assets + goose RunMigrations + /healthz liveness split + /readyz readiness (DEPLOY-03, DEPLOY-04)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 07-02-PLAN.md — Wave 2: multi-stage Dockerfile (assets + builder + distroless) + .env.example update (DEPLOY-01, DEPLOY-02)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 07-03-PLAN.md — Wave 3: docker-compose.prod.yaml + deploy/Caddyfile + README runbook (DEPLOY-02..DEPLOY-05)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ gsd_state_version: 1.0
|
|||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: ready_to_plan
|
||||
last_updated: "2026-05-15T14:38:52.742Z"
|
||||
last_updated: "2026-05-15T16:06:01.313Z"
|
||||
progress:
|
||||
total_phases: 7
|
||||
completed_phases: 5
|
||||
total_plans: 25
|
||||
completed_plans: 24
|
||||
percent: 96
|
||||
completed_phases: 6
|
||||
total_plans: 28
|
||||
completed_plans: 25
|
||||
percent: 89
|
||||
---
|
||||
|
||||
# STATE
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ type: execute
|
|||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- backend/assets/assets.go
|
||||
- backend/embed.go
|
||||
- backend/internal/db/migrate.go
|
||||
- backend/internal/web/handlers.go
|
||||
- backend/internal/web/handlers_test.go
|
||||
|
|
@ -18,13 +18,13 @@ requirements:
|
|||
|
||||
must_haves:
|
||||
truths:
|
||||
- "GET /healthz returns 200 with no DB connection (pure liveness)"
|
||||
- "GET /readyz returns 200 when DB is reachable and 503 when DB is down"
|
||||
- "Static assets (tailwind.css, htmx.min.js, sortable.min.js) are served from embedded FS, not disk"
|
||||
- "goose.Up() is called in cmd/web/main.go startup, before router construction, using embedded migrations"
|
||||
- "D-12: GET /healthz returns 200 with no DB connection (pure liveness)"
|
||||
- "D-13: GET /readyz returns 200 when DB is reachable and 503 when DB is down"
|
||||
- "D-09: Static assets (tailwind.css, htmx.min.js, sortable.min.js) are served from embedded FS, not disk"
|
||||
- "D-10: goose.Up() is called in cmd/web/main.go startup, before router construction, using embedded migrations"
|
||||
- "Full Go test suite passes: go test ./..."
|
||||
artifacts:
|
||||
- path: "backend/assets/assets.go"
|
||||
- path: "backend/embed.go"
|
||||
provides: "go:embed declarations for static/ and migrations/ directories"
|
||||
contains: "//go:embed"
|
||||
- path: "backend/internal/db/migrate.go"
|
||||
|
|
@ -42,8 +42,8 @@ must_haves:
|
|||
via: "db.RunMigrations(ctx, pool, assets.Migrations)"
|
||||
pattern: "RunMigrations"
|
||||
- from: "backend/cmd/web/main.go"
|
||||
to: "backend/assets/assets.go"
|
||||
via: "assets.Static passed to web.NewRouter"
|
||||
to: "backend/embed.go"
|
||||
via: "assets.Static passed to web.NewRouter (import assets \"backend\")"
|
||||
pattern: "assets\\.Static"
|
||||
- from: "backend/internal/web/router.go"
|
||||
to: "ReadyzHandler"
|
||||
|
|
@ -58,7 +58,7 @@ must_haves:
|
|||
<objective>
|
||||
Deliver all Go code changes required before the Dockerfile can be built successfully:
|
||||
|
||||
1. Create `backend/assets/assets.go` with `//go:embed` for both `static/` and `migrations/` directories — this is the embed anchor for the entire phase (D-09, D-10).
|
||||
1. Create `backend/embed.go` with `//go:embed` for both `static/` and `migrations/` directories — this is the embed anchor for the entire phase (D-09, D-10).
|
||||
2. Create `backend/internal/db/migrate.go` with `RunMigrations(ctx, pool, migrationsFS)` using the pgx/v5/stdlib bridge to goose.Up() (D-10, DEPLOY-03).
|
||||
3. Refactor `backend/internal/web/handlers.go`: rename existing `HealthzHandler` → `ReadyzHandler`; add new no-pinger `HealthzHandler` (D-12, D-13, DEPLOY-04).
|
||||
4. Update `backend/internal/web/router.go`: change `staticDir string` → `staticFS fs.FS`, register `/readyz` route with `ReadyzHandler`, keep `/healthz` with new liveness `HealthzHandler` (D-09, D-12, D-13).
|
||||
|
|
@ -125,7 +125,7 @@ for _, v := range res.Versions {
|
|||
<task type="auto" tdd="true">
|
||||
<name>Task 1: go:embed anchor, goose RunMigrations, and /healthz + /readyz handler split</name>
|
||||
<files>
|
||||
backend/assets/assets.go
|
||||
backend/embed.go
|
||||
backend/internal/db/migrate.go
|
||||
backend/internal/web/handlers.go
|
||||
backend/internal/web/handlers_test.go
|
||||
|
|
@ -146,29 +146,24 @@ for _, v := range res.Versions {
|
|||
- TestReadyz_Down: `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` returns 503 with body containing `"status":"degraded"` and `"db":"down"`
|
||||
</behavior>
|
||||
<action>
|
||||
Create `backend/assets/assets.go` in package `assets`. The file must contain two `//go:embed` declarations: one for `static` (var Static embed.FS) and one for `migrations` (var Migrations embed.FS). IMPORTANT: the `//go:embed` path is relative to the .go file. Since `backend/assets/assets.go` is at `backend/assets/` and `static/` and `migrations/` are at `backend/`, the paths `../static` and `../migrations` would be invalid (go:embed cannot use `..`). Instead, create the file at `backend/embed.go` in package `main` — no, this conflicts with cmd/web. The correct approach per PATTERNS.md "Alternative (embed.go at module root)" is: create `backend/embed.go` as a standalone file in a new `package assets` would require a separate directory. The cleanest verified approach: place `assets.go` at `backend/assets/assets.go` and rely on the Go toolchain's embed path resolution from that file location. The `static/` directory is at `backend/static/`. From `backend/assets/assets.go`, the embed directive `//go:embed ../static` is invalid. Therefore use the RESEARCH-recommended alternative: create `backend/embed.go` (a file at the backend module root) in a new package. Since `backend/cmd/web/main.go` is in `package main`, `backend/embed.go` cannot also be `package main`. Create `backend/assets/assets.go` and symlink `static` and `migrations` inside `backend/assets/` — no symlinks in Docker builds.
|
||||
**Verified approach (no ambiguity — read RESEARCH.md Open Questions (RESOLVED) before starting):**
|
||||
|
||||
**Correct approach (verified per RESEARCH Pitfall 1 and PATTERNS.md "Path verification required"):** Place the embed file at `backend/internal/assets/assets.go` — this does NOT help with `..` restriction either. The only valid approach without moving directories: place the `//go:embed` directive in a file that IS in the `backend/` directory. Create `backend/assets.go` as a file directly at the `backend/` module root, in a package named `assets`. But Go requires all .go files in the same directory to share a package name — and `backend/cmd/web/main.go` is in a subdirectory, not in `backend/` directly. Files in `backend/` like `backend/go.mod` don't constrain the package name. Check if any .go files exist directly in `backend/`; if not, a new package at `backend/assets/assets.go` with `//go:embed static migrations` is valid IF `static` and `migrations` are moved inside `backend/assets/` — which we cannot do without changing many paths.
|
||||
Create `backend/embed.go` in `package assets`. This file lives at the module root (`backend/`), making `static/` and `migrations/` siblings — so `//go:embed` directives resolve without any `..` paths. Module is `module backend` (verified in `go.mod`). No other .go files exist at `backend/` root, so no package name conflict. Declare two exported vars:
|
||||
- `//go:embed all:static` → `var Static embed.FS` (use `all:` prefix because `backend/static/.gitkeep` exists)
|
||||
- `//go:embed migrations` → `var Migrations embed.FS` (no hidden files in migrations/)
|
||||
Import in `cmd/web/main.go` as `import assets "backend"` — aliasing the import path to the declared package name `assets`.
|
||||
|
||||
**Final resolution:** Create the embed file as `backend/embed.go` in a dedicated package. Scan what `.go` files exist directly in `backend/` with `ls backend/*.go`. If none exist, create `backend/embed.go` with `package assets` — this is a new Go package at the module root. Import it in `cmd/web/main.go` as `"backend/assets"` (where the module is `module backend` per go.mod). This works because `backend/embed.go` is a sibling of `backend/static/` and `backend/migrations/`, so `//go:embed static` and `//go:embed migrations` resolve correctly.
|
||||
Create `backend/internal/db/migrate.go` in `package db`. Implement `RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error` that: (1) extracts DSN via `pool.Config().ConnConfig.ConnString()`, (2) opens `sql.Open("pgx/v5", dsn)` + `defer sqlDB.Close()`, (3) calls `goose.SetBaseFS(migrationsFS)`, (4) calls `goose.SetDialect("postgres")`, (5) calls `goose.Up(sqlDB, "migrations")` and returns the error. Required imports: `database/sql`, `_ "github.com/jackc/pgx/v5/stdlib"`, `"github.com/pressly/goose/v3"`, `"github.com/jackc/pgx/v5/pgxpool"`, `"embed"`, `"context"`. Do NOT set a custom table name — default `goose_db_version` is correct.
|
||||
|
||||
Actually re-read RESEARCH: "the embed directive file must also be at `backend/` — either as `backend/embed.go`". Confirm module name by reading `backend/go.mod` first line. Create `backend/embed.go` with `package assets` containing:
|
||||
- `//go:embed static` → `var Static embed.FS`
|
||||
- `//go:embed migrations` → `var Migrations embed.FS`
|
||||
In `backend/internal/web/handlers.go`: rename existing `HealthzHandler(pinger Pinger) http.HandlerFunc` → `ReadyzHandler(pinger Pinger) http.HandlerFunc` (logic unchanged: 2s context timeout, `db.Ping()`, 503 + `{"status":"degraded","db":"down"}` on error, 200 + `{"status":"ok","db":"ok"}` on success). Add new `HealthzHandler() http.HandlerFunc` that returns 200 + `{"status":"ok"}` immediately with no DB ping and no pinger argument.
|
||||
|
||||
If `backend/static/` contains files starting with `.` (check with `ls -la backend/static/`), use `//go:embed all:static` and `//go:embed all:migrations`.
|
||||
|
||||
Create `backend/internal/db/migrate.go` in `package db`. Import `database/sql`, `_ "github.com/jackc/pgx/v5/stdlib"`, `"github.com/pressly/goose/v3"`, `"github.com/jackc/pgx/v5/pgxpool"`, and `"embed"`. Implement `RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error` that: (1) extracts DSN via `pool.Config().ConnConfig.ConnString()`, (2) opens `sql.Open("pgx/v5", dsn)` + `defer db.Close()`, (3) calls `goose.SetBaseFS(migrationsFS)`, (4) calls `goose.SetDialect("postgres")`, (5) calls `goose.Up(db, "migrations")` and returns the error. Do NOT call `goose.SetTableName` — the production table uses the default `goose_db_version`.
|
||||
|
||||
In `backend/internal/web/handlers.go`: rename current `HealthzHandler(pinger Pinger) http.HandlerFunc` → `ReadyzHandler(pinger Pinger) http.HandlerFunc` (logic unchanged: 2s timeout, DB ping, 503 on error with `{"status":"degraded","db":"down"}`, 200 with `{"status":"ok","db":"ok"}`). Add new `HealthzHandler() http.HandlerFunc` that returns 200 immediately with `{"status":"ok"}` only (no DB ping, no context, no time import needed in this handler — but keep the imports that ReadyzHandler still needs).
|
||||
|
||||
In `backend/internal/web/handlers_test.go`: update `TestHealthz_OK` to call `HealthzHandler()` with no args; remove the `"db":"ok"` assertion (new liveness handler does not return db field); delete `TestHealthz_Down`; add `TestReadyz_OK` that calls `ReadyzHandler(stubPinger{err: nil})` and asserts 200 + `"status":"ok"` + `"db":"ok"`; add `TestReadyz_Down` that calls `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` and asserts 503 + `"status":"degraded"` + `"db":"down"`. The `stubPinger` struct at the top of the file is unchanged.
|
||||
In `backend/internal/web/handlers_test.go`: update `TestHealthz_OK` to call `HealthzHandler()` with no args and assert 200 + body contains `"status":"ok"` but NOT `"db":"ok"`; delete `TestHealthz_Down`; add `TestReadyz_OK` calling `ReadyzHandler(stubPinger{err: nil})` → 200 + `"status":"ok"` + `"db":"ok"`; add `TestReadyz_Down` calling `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` → 503 + `"status":"degraded"` + `"db":"down"`. The `stubPinger` struct is unchanged.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/... -run "TestHealthz|TestReadyz" -v -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
TestHealthz_OK passes (200, no pinger arg, body has "status":"ok" but NOT "db":"ok"). TestReadyz_OK passes (200, DB ok, body has both "status":"ok" and "db":"ok"). TestReadyz_Down passes (503, body has "status":"degraded" and "db":"down"). TestHealthz_Down is deleted. backend/embed.go exists with //go:embed directives for both static and migrations. backend/internal/db/migrate.go exports RunMigrations.
|
||||
TestHealthz_OK passes (200, no pinger arg, body has "status":"ok" but NOT "db":"ok"). TestReadyz_OK passes (200, DB ok, body has both "status":"ok" and "db":"ok"). TestReadyz_Down passes (503, body has "status":"degraded" and "db":"down"). TestHealthz_Down is deleted. `backend/embed.go` exists in `package assets` with `//go:embed all:static` and `//go:embed migrations`. backend/internal/db/migrate.go exports RunMigrations.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
|
|
@ -190,9 +185,9 @@ for _, v := range res.Versions {
|
|||
<action>
|
||||
In `backend/internal/web/router.go`: change the `NewRouter` function signature — replace the `staticDir string` parameter with `staticFS fs.FS` (add `"io/fs"` to imports, remove no-longer-needed static path logic). Replace the static file serving lines (currently `fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))` and `r.Get("/static/*", fs.ServeHTTP)`) with the embed.FS pattern: `sub, err := fs.Sub(staticFS, "static"); if err != nil { panic(...) }; fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(sub))); r.Get("/static/*", fileServer.ServeHTTP)`. Change the `/healthz` route from `r.Get("/healthz", HealthzHandler(pinger))` to `r.Get("/healthz", HealthzHandler())` (liveness, no pinger, per D-12). Add `r.Get("/readyz", ReadyzHandler(pinger))` immediately after (per D-13). Variable name conflict: the local variable `fs` used for the file server will conflict with the `"io/fs"` package import alias. Rename the local variable (e.g., `fileHandler`) to avoid the shadowing.
|
||||
|
||||
In `backend/cmd/web/main.go`: add import for the embed package containing `assets.Static` and `assets.Migrations` — check the package path from `backend/go.mod` module declaration (likely `backend` module, so the import is `"backend"` if `embed.go` uses `package assets` but lives in the `backend/` directory... re-check: Go package = the `package` declaration in the .go file, not the directory. If `backend/embed.go` has `package assets`, then `cmd/web/main.go` imports `"backend"` with an alias — no, the import path is the directory path relative to module root. The `backend/embed.go` file lives in the directory `backend/`, and the module is `module backend` (check go.mod). So the import path for files in `backend/` directly is `"backend"`. But the package name would be `assets` (or whatever `embed.go` declares). You would import: `import assets "backend"` or `import "backend"` and refer to `backend.Static`. To avoid ambiguity, use `package main` for `backend/embed.go` is not possible (cmd/web is also main). The cleanest solution: name the package in `backend/embed.go` as `assets` and import it in cmd/web as `import "backend"` with local name `assets`. Actually: `import assets "backend"` does NOT work — you cannot rename a package import to differ from the last element of the path like that conventionally. The actual import path for a file in the `backend/` directory with `module backend` is just `"backend"`, and the package name is whatever the file declares. So `import "backend"` makes the identifier `assets.Static` available as `backend.Static` (using the package declaration name, not the import path). To make this ergonomic: declare `package assets` in `backend/embed.go` and import with alias: `import assets "backend"` — this IS valid Go; you can alias any import path. Then use `assets.Static` and `assets.Migrations` in main.go.
|
||||
In `backend/cmd/web/main.go`: add `import assets "backend"` — this aliases the import path `"backend"` (module root files) to the local identifier `assets`, which matches the `package assets` declaration in `backend/embed.go`. This is valid Go aliased import syntax.
|
||||
|
||||
Add to `cmd/web/main.go` after `pool, err := db.NewPool(ctx, dsn)` (after the nil-check block at line 64-69) and before `q := sqlc.New(pool)` (line 71): call `db.RunMigrations(ctx, pool, assets.Migrations)` with the same slog.Error + os.Exit(1) pattern used for other startup failures. Also log each applied migration with `slog.Info("goose migration applied")` if you want to mirror the worker, but the minimum is just error handling. Change `web.NewRouter(pool, "./static", ...)` to `web.NewRouter(pool, assets.Static, ...)`.
|
||||
After `pool, err := db.NewPool(ctx, dsn)` and its nil-check block, before `q := sqlc.New(pool)`: add a call to `db.RunMigrations(ctx, pool, assets.Migrations)`. Use the same `slog.Error(...) + os.Exit(1)` error-handling pattern already used for `db.NewPool`. Change `web.NewRouter(pool, "./static", ...)` to `web.NewRouter(pool, assets.Static, ...)`.
|
||||
|
||||
In `backend/internal/web/handlers_test.go`: find all call sites that pass `"./static"` as the second argument to `NewRouter` (lines 69, 84, 107 based on file inspection) and change them to `os.DirFS("./static")`. Add `"os"` to the test imports if not already present.
|
||||
</action>
|
||||
|
|
@ -243,7 +238,7 @@ cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go
|
|||
2. `cd backend && go build ./cmd/web/...` exits 0 (binary compiles with embedded assets and migrations)
|
||||
3. `grep -r "ReadyzHandler" backend/internal/web/router.go` returns a match
|
||||
4. `grep -r "RunMigrations" backend/cmd/web/main.go` returns a match
|
||||
5. `backend/embed.go` exists and contains `//go:embed static` and `//go:embed migrations`
|
||||
5. `backend/embed.go` exists in `package assets`, contains `//go:embed all:static` and `//go:embed migrations`
|
||||
6. `backend/internal/db/migrate.go` exists and exports `RunMigrations`
|
||||
7. TestHealthz_OK passes without a pinger argument; TestHealthz_Down is deleted; TestReadyz_OK and TestReadyz_Down exist and pass
|
||||
</success_criteria>
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ requirements:
|
|||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A single Dockerfile builds both /app/web and /app/worker binaries in one image"
|
||||
- "D-07: A single Dockerfile builds both /app/web and /app/worker binaries in one image"
|
||||
- "The builder stage runs templ generate before go build (gitignored *_templ.go files)"
|
||||
- "The final runtime image is distroless/static-debian12:nonroot with CGO_ENABLED=0 binaries"
|
||||
- "The image has no CMD — docker-compose overrides command: per service"
|
||||
- ".env.example documents all production env vars including S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB"
|
||||
- "D-08: The image has no CMD — docker-compose overrides command: per service"
|
||||
- "D-05/D-06: .env.example documents all production env vars including S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB"
|
||||
artifacts:
|
||||
- path: "backend/Dockerfile"
|
||||
provides: "3-stage multi-stage Docker build: assets, builder, runtime"
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ requirements:
|
|||
|
||||
must_haves:
|
||||
truths:
|
||||
- "docker-compose.prod.yaml defines postgres, web, worker, and caddy services using the single image"
|
||||
- "The web service has command: /app/web and depends_on postgres with service_healthy condition"
|
||||
- "D-01/D-02/D-03: docker-compose.prod.yaml defines postgres, web, worker, and caddy services for Hetzner VM"
|
||||
- "D-08: The web service has command: /app/web and depends_on postgres with service_healthy condition"
|
||||
- "The worker service has command: /app/worker with the same depends_on condition"
|
||||
- "Caddy service mounts deploy/Caddyfile and has persistent caddy_data and caddy_config named volumes"
|
||||
- "D-04: Caddy service mounts deploy/Caddyfile and has persistent caddy_data and caddy_config named volumes"
|
||||
- "Postgres service has no ports: directive (internal-only, per RESEARCH Pitfall 5)"
|
||||
- "backend/README.md documents first-time deploy, routine deploy, rollback, and incident triage sections"
|
||||
- "D-11: backend/README.md documents first-time deploy, routine deploy, rollback, and incident triage sections"
|
||||
- "backend/deploy/Caddyfile uses {$DOMAIN} env var interpolation for the site block"
|
||||
artifacts:
|
||||
- path: "backend/docker-compose.prod.yaml"
|
||||
|
|
|
|||
590
.planning/phases/07-deploy-v1/07-PATTERNS.md
Normal file
590
.planning/phases/07-deploy-v1/07-PATTERNS.md
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
# Phase 7: Deploy v1 - Pattern Map
|
||||
|
||||
**Mapped:** 2026-05-15
|
||||
**Files analyzed:** 8
|
||||
**Analogs found:** 6 / 8
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `backend/Dockerfile` | config | batch (build pipeline) | `apps/api/Dockerfile` (TS monorepo) | partial — different language, same multi-stage concept |
|
||||
| `backend/docker-compose.prod.yaml` | config | request-response | `backend/compose.yaml` | exact — same compose v2 syntax, same postgres service |
|
||||
| `backend/deploy/Caddyfile` | config | request-response | none in codebase | no analog |
|
||||
| `backend/cmd/web/main.go` (modify) | config | request-response | `backend/cmd/worker/main.go` | exact — rivermigrate startup is the model for goose.Up() |
|
||||
| `backend/internal/web/handlers.go` (modify) | handler | request-response | `backend/internal/web/handlers.go` itself | self — refactor existing HealthzHandler, add ReadyzHandler |
|
||||
| `backend/internal/web/handlers_test.go` (modify) | test | request-response | `backend/internal/web/handlers_test.go` itself | self — extend existing TestHealthz_* tests, add TestReadyz_* |
|
||||
| `backend/internal/web/router.go` (modify) | config | request-response | `backend/internal/web/router.go` itself | self — add /readyz route, update /healthz, change staticDir to fs.FS |
|
||||
| `backend/assets/assets.go` (new) | utility | batch (embed) | none in codebase | no analog — first go:embed usage in this codebase |
|
||||
| `backend/internal/db/migrate.go` (new) | utility | batch (migration) | `backend/cmd/worker/main.go` lines 54–68 | role-match — rivermigrate startup pattern |
|
||||
| `backend/README.md` (modify) | config | — | `backend/README.md` itself | self — extend existing structure |
|
||||
| `backend/.env.example` (modify) | config | — | `backend/.env.example` itself | self — add R2/prod vars |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
---
|
||||
|
||||
### `backend/docker-compose.prod.yaml` (config, request-response)
|
||||
|
||||
**Analog:** `backend/compose.yaml`
|
||||
|
||||
**Postgres service pattern** (compose.yaml lines 1–18):
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: xtablo-backend-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: xtablo
|
||||
POSTGRES_USER: xtablo
|
||||
POSTGRES_PASSWORD: xtablo
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
```
|
||||
|
||||
**Key differences in prod compose vs dev compose:**
|
||||
- No `ports:` on postgres (internal network only — Pitfall 5 in RESEARCH)
|
||||
- No minio / minio-init services (replaced by R2 env vars)
|
||||
- Add `web` service: `image: ${IMAGE}:${TAG}`, `command: /app/web`, `env_file: .env.prod`, `depends_on: postgres: condition: service_healthy`, `expose: ["8080"]`
|
||||
- Add `worker` service: same image, `command: /app/worker`, same env_file and depends_on
|
||||
- Add `caddy` service: `image: caddy:2-alpine`, `ports: ["80:80","443:443","443:443/udp"]`, volumes for Caddyfile bind-mount + named caddy_data + caddy_config
|
||||
- Volumes block: `postgres_data`, `caddy_data`, `caddy_config` (minio_data removed)
|
||||
- Healthcheck interval: change to `interval: 10s`, `retries: 10` (slower startup tolerance in prod vs dev's 5s)
|
||||
|
||||
---
|
||||
|
||||
### `backend/cmd/web/main.go` — add goose.Up() (modify, batch/startup)
|
||||
|
||||
**Analog:** `backend/cmd/worker/main.go` lines 54–68 — rivermigrate startup pattern
|
||||
|
||||
**rivermigrate pattern to mirror** (cmd/worker/main.go lines 54–68):
|
||||
```go
|
||||
// Step 2: Run river migrations before constructing the client (D-02).
|
||||
// rivermigrate is idempotent — already-applied versions are skipped.
|
||||
migrator, err := rivermigrate.New(riverpgxv5.New(pool), nil)
|
||||
if err != nil {
|
||||
slog.Error("rivermigrate init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
res, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil)
|
||||
if err != nil {
|
||||
slog.Error("river migration failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, v := range res.Versions {
|
||||
slog.Info("river migration applied", "version", v.Version)
|
||||
}
|
||||
```
|
||||
|
||||
**goose.Up() equivalent to insert after pool creation** (after cmd/web/main.go line 64 `pool, err := db.NewPool`):
|
||||
```go
|
||||
// Step N: Run goose migrations at startup (D-10). db.RunMigrations is
|
||||
// idempotent — already-applied versions are skipped via goose_db_version table.
|
||||
if err := db.RunMigrations(ctx, pool); err != nil {
|
||||
slog.Error("migrations failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
**Ordering constraint:** Insert the goose.Up() call AFTER `pool, err := db.NewPool(ctx, dsn)` (line 64) and BEFORE `q := sqlc.New(pool)` (line 71). Matches the worker's pattern of running migrations before constructing any higher-level clients.
|
||||
|
||||
**staticDir → fs.FS change** (cmd/web/main.go line 116):
|
||||
```go
|
||||
// BEFORE:
|
||||
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
|
||||
|
||||
// AFTER (once assets package exists):
|
||||
import "backend/assets"
|
||||
router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `backend/internal/db/migrate.go` (new, utility/batch)
|
||||
|
||||
**Analog:** `backend/cmd/worker/main.go` — rivermigrate pattern (role-match: different library, same idiom of programmatic migration before server start)
|
||||
|
||||
**Full file pattern** (new file, no existing analog — use RESEARCH Pattern 2 exactly):
|
||||
```go
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // registers "pgx/v5" driver with database/sql
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// RunMigrations opens a *sql.DB from the pool's connection string and runs all
|
||||
// pending goose migrations embedded via the assets package. Idempotent:
|
||||
// already-applied versions are skipped via the goose_db_version table.
|
||||
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error {
|
||||
dsn := pool.Config().ConnConfig.ConnString()
|
||||
db, err := sql.Open("pgx/v5", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return err
|
||||
}
|
||||
return goose.Up(db, "migrations")
|
||||
}
|
||||
```
|
||||
|
||||
**Embed path note (RESEARCH Open Question 1):** The `//go:embed migrations` directive cannot live in `backend/internal/db/migrate.go` because `backend/migrations/` is 2 levels up and `go:embed` cannot use `..`. The embed directive MUST live in `backend/assets/assets.go` (or `cmd/web/main.go`). Pass the `embed.FS` into `RunMigrations` as a parameter, or expose it via the assets package.
|
||||
|
||||
**Preferred signature** (accepts embed.FS as parameter so the function itself does not carry an embed directive):
|
||||
```go
|
||||
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error
|
||||
```
|
||||
|
||||
Called from `cmd/web/main.go` as:
|
||||
```go
|
||||
db.RunMigrations(ctx, pool, assets.Migrations)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `backend/assets/assets.go` (new, utility/embed)
|
||||
|
||||
**Analog:** None in this codebase — first `go:embed` usage. Pattern sourced from RESEARCH Pattern 1.
|
||||
|
||||
**Full file pattern:**
|
||||
```go
|
||||
// Package assets embeds the static web assets and SQL migration files into the
|
||||
// binary at build time (D-09, D-10). No runtime file dependencies.
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
|
||||
// Static holds the compiled CSS, HTMX JS, and Sortable.js served at /static/*.
|
||||
// The embed directive must be relative to this file; backend/static/ is a sibling
|
||||
// of backend/assets/ at the module root level.
|
||||
//go:embed static
|
||||
var Static embed.FS
|
||||
|
||||
// Migrations holds the goose SQL migration files embedded for programmatic
|
||||
// execution at startup (D-10).
|
||||
//go:embed migrations
|
||||
var Migrations embed.FS
|
||||
```
|
||||
|
||||
**Path verification required:** This file sits at `backend/assets/assets.go`. The `//go:embed static` path resolves to `backend/assets/static` — but `static/` is at `backend/static/`. The directory must either be moved inside `backend/assets/`, symlinked, or the embed directive must be placed in a file at `backend/` itself (e.g., `backend/embed.go`). See RESEARCH Pitfall 1 and Open Question 1.
|
||||
|
||||
**Alternative (embed.go at module root):**
|
||||
```go
|
||||
// backend/embed.go
|
||||
package main // or a dedicated package
|
||||
|
||||
//go:embed static
|
||||
var Static embed.FS
|
||||
|
||||
//go:embed migrations
|
||||
var Migrations embed.FS
|
||||
```
|
||||
|
||||
This works because `backend/embed.go` is a sibling of `backend/static/` and `backend/migrations/`. The planner should prefer this approach over an `assets/` package if the path constraint cannot be resolved cleanly.
|
||||
|
||||
---
|
||||
|
||||
### `backend/internal/web/handlers.go` (modify, handler/request-response)
|
||||
|
||||
**Analog:** `backend/internal/web/handlers.go` itself — refactor existing HealthzHandler
|
||||
|
||||
**Existing HealthzHandler pattern** (handlers.go lines 16–35) — this becomes ReadyzHandler:
|
||||
```go
|
||||
func HealthzHandler(pinger Pinger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := pinger.Ping(ctx); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "degraded",
|
||||
"db": "down",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"db": "ok",
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Required changes:**
|
||||
1. Rename existing `HealthzHandler(pinger Pinger)` → `ReadyzHandler(pinger Pinger)` (keep all logic identical)
|
||||
2. Add new `HealthzHandler()` with no pinger dependency:
|
||||
```go
|
||||
// HealthzHandler is a pure liveness check (D-12). Returns 200 immediately
|
||||
// as long as the server process is up. No DB ping.
|
||||
func HealthzHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Imports:** Remove `"context"` and `"time"` from `HealthzHandler` imports (they move to `ReadyzHandler`). Both handlers remain in `handlers.go`.
|
||||
|
||||
---
|
||||
|
||||
### `backend/internal/web/router.go` (modify, config/request-response)
|
||||
|
||||
**Analog:** `backend/internal/web/router.go` itself
|
||||
|
||||
**Existing static file serving pattern** (router.go line 120–121):
|
||||
```go
|
||||
fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
|
||||
r.Get("/static/*", fs.ServeHTTP)
|
||||
```
|
||||
|
||||
**Replacement pattern using embed.FS** (RESEARCH Pattern 1 / Code Examples):
|
||||
```go
|
||||
import "io/fs"
|
||||
|
||||
// In NewRouter signature: change `staticDir string` → `staticFS fs.FS`
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic("static embed sub failed: " + err.Error())
|
||||
}
|
||||
fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
||||
r.Get("/static/*", fileServer.ServeHTTP)
|
||||
```
|
||||
|
||||
**Signature change** (router.go line 47):
|
||||
```go
|
||||
// BEFORE:
|
||||
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, ...) http.Handler
|
||||
|
||||
// AFTER:
|
||||
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, ...) http.Handler
|
||||
```
|
||||
|
||||
**Route changes** (router.go line 117):
|
||||
```go
|
||||
// BEFORE:
|
||||
r.Get("/healthz", HealthzHandler(pinger))
|
||||
|
||||
// AFTER:
|
||||
r.Get("/healthz", HealthzHandler()) // D-12: liveness, no pinger
|
||||
r.Get("/readyz", ReadyzHandler(pinger)) // D-13: readiness, DB ping
|
||||
```
|
||||
|
||||
**Test compatibility note:** All existing tests that call `NewRouter(stubPinger{}, "./static", ...)` must change their second argument. Tests can pass `os.DirFS("./static")` to avoid embedding during unit test runs.
|
||||
|
||||
---
|
||||
|
||||
### `backend/internal/web/handlers_test.go` (modify, test/request-response)
|
||||
|
||||
**Analog:** `backend/internal/web/handlers_test.go` itself
|
||||
|
||||
**Existing tests to update** (handlers_test.go lines 24–61):
|
||||
|
||||
`TestHealthz_OK` (lines 24–43) — change to test the new no-pinger liveness handler:
|
||||
```go
|
||||
func TestHealthz_OK(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
|
||||
// BEFORE: HealthzHandler(stubPinger{err: nil})
|
||||
// AFTER: HealthzHandler() — no pinger dependency
|
||||
HealthzHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; want 200", rec.Code)
|
||||
}
|
||||
// Remove db:ok assertion — new liveness handler only returns {"status":"ok"}
|
||||
if !strings.Contains(rec.Body.String(), `"status":"ok"`) {
|
||||
t.Errorf("body missing status:ok; got: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`TestHealthz_Down` (lines 45–61) — delete this test (the new HealthzHandler has no failure mode).
|
||||
|
||||
**New tests to add** (mirror old TestHealthz structure, now for ReadyzHandler):
|
||||
```go
|
||||
func TestReadyz_OK(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
|
||||
ReadyzHandler(stubPinger{err: nil}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"status":"ok"`) {
|
||||
t.Errorf("body missing status:ok; got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"db":"ok"`) {
|
||||
t.Errorf("body missing db:ok; got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyz_Down(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
|
||||
ReadyzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d; want 503", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"status":"degraded"`) {
|
||||
t.Errorf("body missing status:degraded; got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"db":"down"`) {
|
||||
t.Errorf("body missing db:down; got: %s", body)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**stubPinger reuse:** The existing `stubPinger` struct (handlers_test.go lines 18–22) is unchanged — it satisfies `Pinger` and is used by the new ReadyzHandler tests identically.
|
||||
|
||||
**TestIndex_UnauthRedirects and other router-level tests** (handlers_test.go lines 68–121) — all pass `"./static"` as second arg to `NewRouter`. Change to `os.DirFS("./static")` after the signature changes. Requires adding `"os"` to imports.
|
||||
|
||||
---
|
||||
|
||||
### `backend/Dockerfile` (new, config/batch)
|
||||
|
||||
**Analog:** No Go Dockerfile in this codebase. Pattern sourced from RESEARCH Pattern 3.
|
||||
|
||||
**Key decisions from justfile** (justfile lines 20–81 — pinned versions to match):
|
||||
- `templ_version := "v0.3.1020"` — use this exact version in `go install github.com/a-h/templ/cmd/templ@v0.3.1020`
|
||||
- `tailwind_version := "v4.0.0"` — Tailwind standalone CLI download
|
||||
- `htmx_version := "2"` — unpkg.com HTMX download
|
||||
- `sortable_version := "1.15.7"` — CDN Sortable.js download
|
||||
- Builder base: `golang:1.26-alpine` (matches go.mod `go 1.26.1`)
|
||||
- Runtime base: `gcr.io/distroless/static-debian12:nonroot`
|
||||
|
||||
**Asset generation step** (mirrors justfile `bootstrap` + `generate` recipes):
|
||||
```dockerfile
|
||||
# Stage 1: Generate static assets (CSS + JS)
|
||||
FROM node:20-alpine AS assets
|
||||
WORKDIR /build
|
||||
RUN apk add --no-cache curl
|
||||
# Download Tailwind standalone CLI (pinned version from justfile)
|
||||
RUN curl -sSL -o /usr/local/bin/tailwindcss \
|
||||
"https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.0/tailwindcss-linux-x64" \
|
||||
&& chmod +x /usr/local/bin/tailwindcss
|
||||
# Download HTMX and Sortable.js (versions pinned in justfile)
|
||||
RUN curl -sSL -o static/htmx.min.js "https://unpkg.com/htmx.org@2/dist/htmx.min.js" \
|
||||
&& curl -sSL -o static/sortable.min.js \
|
||||
"https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js"
|
||||
COPY tailwind.input.css .
|
||||
COPY templates/ templates/
|
||||
RUN tailwindcss -i tailwind.input.css -o static/tailwind.css --minify
|
||||
```
|
||||
|
||||
**Go build stage** (CGO_ENABLED=0 is mandatory for distroless/static — RESEARCH Pitfall 7):
|
||||
```dockerfile
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=assets /build/static ./static
|
||||
|
||||
# templ generate BEFORE go build — *_templ.go files are gitignored (RESEARCH Pitfall 6)
|
||||
RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux \
|
||||
go build -ldflags="-s -w" -trimpath -o /app/web ./cmd/web
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux \
|
||||
go build -ldflags="-s -w" -trimpath -o /app/worker ./cmd/worker
|
||||
```
|
||||
|
||||
**Runtime stage** (D-08: no CMD — compose overrides with `command: /app/web` or `/app/worker`):
|
||||
```dockerfile
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=builder /app/web /app/web
|
||||
COPY --from=builder /app/worker /app/worker
|
||||
EXPOSE 8080
|
||||
# No CMD — docker-compose.prod.yaml sets `command:` per service (D-08)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `backend/deploy/Caddyfile` (new, config/request-response)
|
||||
|
||||
**Analog:** None in codebase.
|
||||
|
||||
**Pattern** (RESEARCH Pattern 6 — simple single-site reverse proxy):
|
||||
```caddyfile
|
||||
{$DOMAIN} {
|
||||
reverse_proxy web:8080
|
||||
}
|
||||
```
|
||||
|
||||
`{$DOMAIN}` is Caddy's env var interpolation syntax. The host `.env.prod` must set `DOMAIN=app.yourdomain.com`. Caddy automatically handles HTTP→HTTPS redirect and Let's Encrypt certificate issuance when a domain (not IP) is specified.
|
||||
|
||||
**Persistent volume requirement:** The caddy service in docker-compose.prod.yaml MUST mount named volumes at `/data` and `/config` — without them, certificates are re-issued on every restart and hit Let's Encrypt rate limits (RESEARCH Pitfall 4).
|
||||
|
||||
---
|
||||
|
||||
### `backend/.env.example` (modify, config)
|
||||
|
||||
**Analog:** `backend/.env.example` itself
|
||||
|
||||
**Existing vars** (current .env.example):
|
||||
```bash
|
||||
DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
|
||||
TEST_DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
|
||||
SESSION_SECRET=
|
||||
PORT=8080
|
||||
ENV=development
|
||||
```
|
||||
|
||||
**Add these vars** (R2 / production vars per D-05, D-06):
|
||||
```bash
|
||||
# S3-compatible object storage (Cloudflare R2 in prod; MinIO in local dev via compose.yaml)
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_BUCKET=xtablo-dev
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_USE_PATH_STYLE=true # true for MinIO; false for R2
|
||||
|
||||
# Maximum upload size in megabytes (default: 25)
|
||||
MAX_UPLOAD_SIZE_MB=25
|
||||
|
||||
# Production domain for Caddy TLS (only needed in docker-compose.prod.yaml context)
|
||||
# DOMAIN=app.yourdomain.com
|
||||
```
|
||||
|
||||
**Note on TEST_DATABASE_URL:** The comment in CONTEXT.md and RESEARCH.md says to remove the `TEST_DATABASE_URL` note. Keep the var but update the comment to clarify it's dev/test-only and should not be set in the prod `.env.prod` file.
|
||||
|
||||
---
|
||||
|
||||
### `backend/README.md` (modify, documentation)
|
||||
|
||||
**Analog:** `backend/README.md` itself (lines 1–240 exist; add sections after line 240)
|
||||
|
||||
**Sections to add** (append to existing README after the "What Phase 1 ships" section):
|
||||
|
||||
The existing README structure uses H2 headers (`##`) for top-level sections. New sections follow the same pattern:
|
||||
|
||||
```markdown
|
||||
## Deploy
|
||||
|
||||
Production runs on a Hetzner VM via plain Docker Compose. No PaaS, no Kubernetes (D-01, D-02).
|
||||
|
||||
### First-time setup
|
||||
[SSH, docker install, image build, .env.prod creation, docker compose up steps]
|
||||
|
||||
### Deploying a new version
|
||||
[docker build, tag, compose pull/up steps]
|
||||
|
||||
## Rollback
|
||||
|
||||
Normal rollback = change image tag in .env.prod + `docker compose up -d` (D-11).
|
||||
[exact commands]
|
||||
|
||||
## Incident runbook
|
||||
|
||||
### /readyz returns 503
|
||||
### Database unreachable
|
||||
### Caddy TLS certificate errors
|
||||
[break-glass steps including goose down documentation]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### slog structured logging
|
||||
**Source:** `backend/cmd/web/main.go` lines 41–41 and `backend/internal/web/slog.go`
|
||||
**Apply to:** All new Go files that log at startup or on error
|
||||
```go
|
||||
slog.SetDefault(slog.New(web.NewSlogHandler(env, os.Stdout)))
|
||||
// Then use slog.Error / slog.Info / slog.Warn directly
|
||||
slog.Error("migrations failed", "err", err)
|
||||
os.Exit(1)
|
||||
```
|
||||
|
||||
### os.Exit(1) on startup failure
|
||||
**Source:** `backend/cmd/web/main.go` lines 43–47, `backend/cmd/worker/main.go` lines 33–36
|
||||
**Apply to:** `db.RunMigrations` call in `cmd/web/main.go`
|
||||
```go
|
||||
if err := someStartupStep(...); err != nil {
|
||||
slog.Error("step failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
### httptest handler testing
|
||||
**Source:** `backend/internal/web/handlers_test.go` lines 24–43
|
||||
**Apply to:** TestReadyz_OK, TestReadyz_Down, updated TestHealthz_OK
|
||||
```go
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/route", nil)
|
||||
MyHandler(args...).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK { t.Fatalf(...) }
|
||||
```
|
||||
|
||||
### pgxpool DSN extraction for sql.DB bridge
|
||||
**Source:** RESEARCH Pattern 2 (no existing codebase usage — first occurrence)
|
||||
**Apply to:** `backend/internal/db/migrate.go`
|
||||
```go
|
||||
dsn := pool.Config().ConnConfig.ConnString()
|
||||
db, err := sql.Open("pgx/v5", dsn)
|
||||
// Import _ "github.com/jackc/pgx/v5/stdlib" to register the driver
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No Analog Found
|
||||
|
||||
Files with no close match in the codebase (planner should use RESEARCH.md patterns instead):
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| `backend/deploy/Caddyfile` | config | request-response | No reverse proxy config exists in codebase; use RESEARCH Pattern 6 exactly |
|
||||
| `backend/assets/assets.go` | utility | embed | No `go:embed` usage exists anywhere in this codebase; use RESEARCH Pattern 1; verify embed path resolution before committing |
|
||||
| `backend/Dockerfile` | config | batch | No Go Dockerfile in this repo; TS `apps/api/Dockerfile` exists but is Node-based and not a useful pattern source; use RESEARCH Pattern 3 |
|
||||
|
||||
---
|
||||
|
||||
## Critical Implementation Warnings
|
||||
|
||||
1. **go:embed path constraint (RESEARCH Pitfall 1):** The `//go:embed` path must be relative to the .go file and cannot use `..`. Since `static/` and `migrations/` are at `backend/`, the embed directive file must also be at `backend/` — either as `backend/embed.go` (a file in `package main` or a new package) or inside `backend/assets/` if the directories are moved there. Verify before implementing.
|
||||
|
||||
2. **templ generate in Docker (RESEARCH Pitfall 6):** `*_templ.go` files are gitignored. The Dockerfile builder stage MUST run `templ generate` at the pinned version (`v0.3.1020` from justfile) before `go build`, or the build will fail with undefined template functions.
|
||||
|
||||
3. **goose needs *sql.DB not *pgxpool.Pool (RESEARCH Pitfall 2):** Must import `_ "github.com/jackc/pgx/v5/stdlib"` and bridge via `sql.Open("pgx/v5", connStr)`. Close the `*sql.DB` with `defer db.Close()` after migrations — the pool remains open for app use.
|
||||
|
||||
4. **Postgres ports in prod compose:** Do NOT add `ports:` to the postgres service in `docker-compose.prod.yaml`. The postgres service is internal-only. The dev `compose.yaml` exposes `5432:5432` for local tooling — this must not be replicated in prod.
|
||||
|
||||
5. **caddy_data volume:** Must be a named volume in `docker-compose.prod.yaml`. Without it, Caddy re-issues certificates on every restart and will hit Let's Encrypt rate limits (RESEARCH Pitfall 4).
|
||||
|
||||
6. **web service depends_on with health condition:** `depends_on: postgres: condition: service_healthy` is required so `goose.Up()` does not race Postgres startup. The dev `compose.yaml` postgres service already has a `healthcheck:` — mirror it in the prod compose.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `backend/cmd/`, `backend/internal/`, `backend/compose.yaml`, `backend/.env.example`, `backend/README.md`, `backend/justfile`
|
||||
**Files scanned:** 14
|
||||
**Pattern extraction date:** 2026-05-15
|
||||
|
|
@ -546,22 +546,17 @@ func RunMigrations(pool *pgxpool.Pool, migrationsFS embed.FS) error {
|
|||
| A3 | Tailwind standalone binary download in Docker builder is reliable during CI/CD | Pattern 3 (Dockerfile) | If the external download is flaky, bake the Tailwind binary into the builder image or add it to the repo as a committed artifact |
|
||||
| A4 | `pool.Config().ConnConfig.ConnString()` reconstructs a DSN compatible with `sql.Open("pgx/v5", ...)` | Pattern 2 (goose bridge) | If ConnString() omits sslmode or other params, pass the original DATABASE_URL env var directly to sql.Open instead |
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **embed.FS path for migrations/ in internal/db/migrate.go**
|
||||
- What we know: `//go:embed` paths are relative to the Go source file and cannot use `..` to go above the module root.
|
||||
- What's unclear: Whether `backend/internal/db/migrate.go` can embed `../../migrations/*.sql` — the `migrations/` directory is at `backend/migrations/`, and `internal/db/` is 2 levels deep.
|
||||
- Recommendation: Test during Wave 1 implementation. If `../../migrations` is rejected, move the embed directive to an `assets` package at `backend/assets/` or to `cmd/web/main.go` itself and pass the `embed.FS` into `RunMigrations`.
|
||||
1. **embed.FS path for migrations/ — RESOLVED: use `backend/embed.go` at module root**
|
||||
- Resolution: No `.go` files exist in `backend/` root (verified: `ls backend/*.go` → no matches). Module is `module backend` (verified: `backend/go.mod` line 1). Create `backend/embed.go` with `package assets` — the embed directives `//go:embed all:static` and `//go:embed migrations` resolve correctly because `static/` and `migrations/` are siblings of the file. Import in `cmd/web/main.go` as `import assets "backend"`.
|
||||
- The `../../migrations` path from `internal/db/migrate.go` is invalid (go:embed cannot use `..`). The `assets` package approach is the correct solution.
|
||||
|
||||
2. **templ generate in Docker build**
|
||||
- What we know: `*_templ.go` files are gitignored (STATE.md); the build fails without them.
|
||||
- What's unclear: Whether `RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate` in the builder stage is fast enough or needs caching.
|
||||
- Recommendation: Use `--mount=type=cache,target=/root/.cache/go-build` on the `go build` steps; the templ generate step is fast (it's pure Go → Go codegen, no compilation).
|
||||
2. **templ generate in Docker build — RESOLVED: add to builder stage with go build cache**
|
||||
- Resolution: `RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate` runs in the builder stage before `go build`. Use `--mount=type=cache,target=/root/.cache/go-build` on `go build` steps to keep the build fast. templ generate is pure Go codegen — fast and deterministic.
|
||||
|
||||
3. **go:embed and files starting with `.` or `_`**
|
||||
- What we know: By default, `//go:embed` excludes files/dirs starting with `.` or `_`.
|
||||
- What's unclear: Whether `static/` contains any such files (e.g., `.gitkeep`).
|
||||
- Recommendation: Check `ls -la backend/static/` during implementation. If such files exist, use `//go:embed all:static`.
|
||||
3. **go:embed and `.gitkeep` in static/ — RESOLVED: use `//go:embed all:static`**
|
||||
- Resolution: `backend/static/` contains `.gitkeep` (verified: `ls -la backend/static/` confirms `.gitkeep` exists). The `all:` prefix is required so `.gitkeep` is included. Use `//go:embed all:static` for Static and `//go:embed migrations` for Migrations (migrations/ has no hidden files).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue