chore: merge executor worktree (worktree-agent-a1df44c5ba4be47de)
This commit is contained in:
commit
5550befffc
12 changed files with 272 additions and 26 deletions
142
.planning/phases/07-deploy-v1/07-01-SUMMARY.md
Normal file
142
.planning/phases/07-deploy-v1/07-01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
---
|
||||
phase: 07-deploy-v1
|
||||
plan: "01"
|
||||
subsystem: backend-deploy
|
||||
tags: [go, embed, goose, health-endpoints, deploy]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- 06-02 (cmd/worker wiring — same pool/db patterns mirrored)
|
||||
provides:
|
||||
- embed.FS anchor for static/ and migrations/ (consumed by Dockerfile in plan 07-02)
|
||||
- RunMigrations for use in cmd/web startup (D-10)
|
||||
- /healthz liveness and /readyz readiness endpoints (D-12, D-13)
|
||||
affects:
|
||||
- backend/cmd/web/main.go (startup sequence)
|
||||
- backend/internal/web/router.go (NewRouter signature change)
|
||||
tech_stack:
|
||||
added:
|
||||
- go:embed (stdlib) — static asset and migration embedding
|
||||
- github.com/pressly/goose/v3 — SQL migration runner via embedded FS
|
||||
- github.com/jackc/pgx/v5/stdlib — pgx/v5 database/sql bridge for goose
|
||||
patterns:
|
||||
- embed.FS anchor in package root with //go:embed directives
|
||||
- fs.FS parameter for static serving (replaces string path)
|
||||
- goose.SetBaseFS + goose.Up for idempotent embedded migrations
|
||||
key_files:
|
||||
created:
|
||||
- backend/embed.go
|
||||
- backend/internal/db/migrate.go
|
||||
modified:
|
||||
- backend/internal/web/handlers.go
|
||||
- backend/internal/web/handlers_test.go
|
||||
- backend/internal/web/router.go
|
||||
- backend/cmd/web/main.go
|
||||
- backend/internal/web/csrf_test.go
|
||||
- backend/internal/web/handlers_auth_test.go
|
||||
- backend/internal/web/handlers_files_test.go
|
||||
- backend/internal/web/handlers_tablos_test.go
|
||||
- backend/internal/web/handlers_tasks_test.go
|
||||
decisions:
|
||||
- "embed.go in package assets at backend/ root — module root makes static/ and migrations/ siblings, no ../ paths needed"
|
||||
- "import assets \"backend\" aliased import — package name assets matches package declaration in embed.go"
|
||||
- "pool.Config().ConnConfig.ConnString() for DSN extraction — avoids storing DSN separately"
|
||||
- "goose default table name goose_db_version — no custom table needed (T-07-02 accepted)"
|
||||
- "fs.Sub() strips static/ prefix from embedded FS — avoids double-prefix in served URLs"
|
||||
- "Variable renamed fileHandler (not fs) — prevents shadowing io/fs import in router.go"
|
||||
metrics:
|
||||
duration: "~6 minutes"
|
||||
completed: "2026-05-15"
|
||||
tasks: 2
|
||||
files: 11
|
||||
---
|
||||
|
||||
# Phase 7 Plan 1: Go embed anchor, goose RunMigrations, and /healthz+/readyz handler split Summary
|
||||
|
||||
## What Was Built
|
||||
|
||||
go:embed anchor for zero-runtime-file-dependency binary, goose RunMigrations using pgx/v5/stdlib bridge, and liveness/readiness health endpoint split (HealthzHandler no-arg + ReadyzHandler with pinger).
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | go:embed anchor, goose RunMigrations, handler split | 77e37cb | backend/embed.go, backend/internal/db/migrate.go, backend/internal/web/handlers.go, backend/internal/web/handlers_test.go |
|
||||
| 2 | Wire embed.FS into NewRouter and RunMigrations into main.go | bdd3cba | backend/internal/web/router.go, backend/cmd/web/main.go, 5 test files |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
1. `embed.go` placed at `backend/` root in `package assets` — the module root makes `static/` and `migrations/` natural siblings so `//go:embed` directives resolve without `../` paths. Module is `module backend`; consumers import with `import assets "backend"` which aliases the import path to the package identifier.
|
||||
|
||||
2. `pool.Config().ConnConfig.ConnString()` used to extract DSN for goose's database/sql bridge — avoids threading DSN as a separate parameter through the call stack.
|
||||
|
||||
3. `goose.SetBaseFS` + `goose.Up(sqlDB, "migrations")` — the embedded FS subdirectory path `"migrations"` matches the actual directory name in the module root; no path remapping needed.
|
||||
|
||||
4. `fs.Sub(staticFS, "static")` strips the `static/` prefix from the embedded FS before passing to `http.FileServer(http.FS(...))` — this ensures `/static/tailwind.css` maps to the correct embedded entry without double-prefix.
|
||||
|
||||
5. Local variable renamed `fileHandler` (was `fs`) in `router.go` to prevent shadowing the `"io/fs"` package import.
|
||||
|
||||
6. All 5 test helper files updated from `"./static"` string to `os.DirFS("./static")` to match the new `fs.FS` parameter type in `NewRouter`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] router.go and all *_test.go call sites updated in Task 1**
|
||||
- **Found during:** Task 1 GREEN phase (handlers.go updated but router.go still called HealthzHandler(pinger))
|
||||
- **Issue:** Changing HealthzHandler's signature from `(pinger Pinger)` to `()` caused compilation failure in router.go which prevented the handler tests from compiling at all.
|
||||
- **Fix:** Updated router.go, cmd/web/main.go, and all 5 test helper files as part of the GREEN implementation. These are all interdependent changes that cannot compile in isolation. Task 2 changes were merged into a single GREEN commit after the RED test commit.
|
||||
- **Files modified:** backend/internal/web/router.go, backend/cmd/web/main.go, 5 *_test.go files
|
||||
- **Commit:** bdd3cba
|
||||
|
||||
**2. [Rule 3 - Blocking] Generated files needed in worktree (sqlc + templ)**
|
||||
- **Found during:** First test run in worktree context
|
||||
- **Issue:** The worktree starts clean — sqlc-generated .go files and templ-generated *_templ.go files are gitignored and must be regenerated per DEVELOPMENT.md.
|
||||
- **Fix:** Ran `templ generate` (via `just generate` partial) and `sqlc generate` in the worktree backend directory before running tests.
|
||||
- **Files modified:** None (generated files are gitignored; not committed)
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
cd backend && go test ./... -count=1
|
||||
```
|
||||
|
||||
All packages pass:
|
||||
- `backend/internal/auth` — ok
|
||||
- `backend/internal/db` — ok
|
||||
- `backend/internal/files` — ok
|
||||
- `backend/internal/jobs` — ok
|
||||
- `backend/internal/web` — ok (TestHealthz_OK, TestReadyz_OK, TestReadyz_Down all pass)
|
||||
- `backend/internal/web/ui` — ok
|
||||
- `backend/templates` — ok
|
||||
|
||||
```
|
||||
go build -o /tmp/xtablo-web ./cmd/web && ls -la /tmp/xtablo-web
|
||||
```
|
||||
Binary compiles at ~22.7 MB with embedded assets and migrations.
|
||||
|
||||
## Success Criteria Check
|
||||
|
||||
1. `go test ./... -count=1` exits 0 — PASS
|
||||
2. `go build ./cmd/web/...` exits 0 — PASS
|
||||
3. `grep ReadyzHandler backend/internal/web/router.go` — PASS (r.Get("/readyz", ReadyzHandler(pinger)))
|
||||
4. `grep RunMigrations backend/cmd/web/main.go` — PASS (db.RunMigrations(ctx, pool, assets.Migrations))
|
||||
5. `backend/embed.go` in `package assets` with `//go:embed all:static` and `//go:embed migrations` — PASS
|
||||
6. `backend/internal/db/migrate.go` exports `RunMigrations` — PASS
|
||||
7. TestHealthz_OK passes without pinger; TestHealthz_Down deleted; TestReadyz_OK and TestReadyz_Down pass — PASS
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new threat surface introduced beyond plan's threat_model. Health endpoints return only `{"status":"ok"}` or `{"status":"degraded","db":"down"}` — no version strings, stack traces, or DSN fragments (T-07-01 mitigated).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- backend/embed.go: EXISTS
|
||||
- backend/internal/db/migrate.go: EXISTS
|
||||
- backend/internal/web/handlers.go: EXISTS (contains ReadyzHandler)
|
||||
- backend/internal/web/router.go: EXISTS (contains /readyz route)
|
||||
- backend/cmd/web/main.go: EXISTS (contains RunMigrations call)
|
||||
- Commits 77e37cb and bdd3cba: VERIFIED in git log
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Command web is the Phase 1 walking-skeleton HTTP server. It loads env,
|
||||
// builds a slog handler, opens a pgxpool, mounts the chi router, and serves
|
||||
// /, /healthz, /demo/time, and /static/* with graceful shutdown on
|
||||
// SIGINT/SIGTERM (CONTEXT D-19).
|
||||
// builds a slog handler, opens a pgxpool, runs goose migrations, mounts the
|
||||
// chi router, and serves /, /healthz, /readyz, /demo/time, and /static/* with
|
||||
// graceful shutdown on SIGINT/SIGTERM (CONTEXT D-19, D-10, DEPLOY-03/04).
|
||||
//
|
||||
// No .env parser lives here — `.env` is exported into the process
|
||||
// environment by `just dev`; production injects real env vars (D-15).
|
||||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
assets "backend"
|
||||
"backend/internal/auth"
|
||||
"backend/internal/db"
|
||||
"backend/internal/db/sqlc"
|
||||
|
|
@ -68,6 +69,13 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// D-10: run goose migrations from the embedded FS before constructing the
|
||||
// router. goose.Up is idempotent — already-applied migrations are skipped.
|
||||
if err := db.RunMigrations(ctx, pool, assets.Migrations); err != nil {
|
||||
slog.Error("migrations failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
q := sqlc.New(pool)
|
||||
store := auth.NewStore(q)
|
||||
secure := env != "development" && env != "dev"
|
||||
|
|
@ -113,7 +121,8 @@ func main() {
|
|||
|
||||
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
|
||||
|
||||
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
|
||||
// D-09: pass the embedded static FS — binary has zero runtime file dependencies.
|
||||
router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
|
|
|
|||
22
backend/embed.go
Normal file
22
backend/embed.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Package assets exposes the embedded static assets and SQL migrations that are
|
||||
// baked into the binary at build time. Importing packages reference these via:
|
||||
//
|
||||
// import assets "backend"
|
||||
//
|
||||
// and then use assets.Static (for file serving) and assets.Migrations (for goose).
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
|
||||
// Static holds all files under the static/ directory, embedded at build time.
|
||||
// The all: prefix includes files whose names begin with . or _ (e.g. .gitkeep).
|
||||
//
|
||||
//go:embed all:static
|
||||
var Static embed.FS
|
||||
|
||||
// Migrations holds all .sql files under the migrations/ directory, embedded at
|
||||
// build time. goose.SetBaseFS(Migrations) + goose.Up(sqlDB, "migrations") uses
|
||||
// this FS so the binary has zero runtime file dependencies.
|
||||
//
|
||||
//go:embed migrations
|
||||
var Migrations embed.FS
|
||||
44
backend/internal/db/migrate.go
Normal file
44
backend/internal/db/migrate.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // pgx/v5 driver for database/sql
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// RunMigrations applies all pending goose migrations from the embedded FS.
|
||||
// It uses the pgx/v5/stdlib bridge so that goose's database/sql interface
|
||||
// can speak to the same Postgres as the rest of the application.
|
||||
//
|
||||
// Call RunMigrations once at startup, before constructing the router.
|
||||
// goose.Up is idempotent — already-applied migrations are skipped.
|
||||
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error {
|
||||
// Extract the DSN from the pool's connection config.
|
||||
connConfig := pool.Config().ConnConfig
|
||||
dsn := connConfig.ConnString()
|
||||
|
||||
// Open a database/sql connection for goose. The pgx/v5 stdlib driver is
|
||||
// registered by the blank import above.
|
||||
sqlDB, err := sql.Open("pgx/v5", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: open sql.DB: %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
// Point goose at the embedded FS so no on-disk files are needed.
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return fmt.Errorf("migrate: set dialect: %w", err)
|
||||
}
|
||||
|
||||
if err := goose.Up(sqlDB, "migrations"); err != nil {
|
||||
return fmt.Errorf("migrate: goose up: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler {
|
|||
csrfKey[i] = byte(i + 1)
|
||||
}
|
||||
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost")
|
||||
return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost")
|
||||
}
|
||||
|
||||
// extractCSRFToken performs a GET request and extracts the _csrf token from the
|
||||
|
|
|
|||
|
|
@ -9,11 +9,24 @@ import (
|
|||
"backend/templates"
|
||||
)
|
||||
|
||||
// HealthzHandler returns an HTTP handler that probes the supplied Pinger
|
||||
// with a 2-second timeout and responds per CONTEXT D-20: 200 + JSON
|
||||
// HealthzHandler returns a pure liveness probe handler (D-12). It responds
|
||||
// immediately with 200 + `{"status":"ok"}` and never contacts the database.
|
||||
// Use /readyz for a DB-aware readiness probe.
|
||||
func HealthzHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ReadyzHandler returns an HTTP handler that probes the supplied Pinger with a
|
||||
// 2-second timeout and responds per CONTEXT D-13: 200 + JSON
|
||||
// `{"status":"ok","db":"ok"}` when reachable, 503 + JSON
|
||||
// `{"status":"degraded","db":"down"}` otherwise.
|
||||
func HealthzHandler(pinger Pinger) http.HandlerFunc {
|
||||
func ReadyzHandler(pinger Pinger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -33,14 +34,14 @@ var testCSRFKey = func() []byte {
|
|||
// Referer header are accepted.
|
||||
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||
deps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
}
|
||||
|
||||
// newTestRouterWithLimiter builds a router with an injected LimiterStore,
|
||||
// enabling rate-limit tests to use a fake clock.
|
||||
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
|
||||
deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl}
|
||||
return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
}
|
||||
|
||||
// getCSRFToken performs a GET request to path and extracts the CSRF token
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS
|
|||
tabloDeps := TablosDeps{Queries: q}
|
||||
taskDeps := TasksDeps{Queries: q}
|
||||
fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25}
|
||||
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
|
||||
return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
|
||||
}
|
||||
|
||||
// ---- TestFileUpload (FILE-01, FILE-02) ----
|
||||
|
|
@ -164,7 +165,7 @@ func TestFileUploadTooLarge(t *testing.T) {
|
|||
tabloDeps := TablosDeps{Queries: q}
|
||||
taskDeps := TasksDeps{Queries: q}
|
||||
fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1}
|
||||
router := NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
|
||||
router := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
|
||||
|
||||
user := preInsertUser(t, ctx, q, "filelarge@example.com", "correct-horse-12")
|
||||
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ import (
|
|||
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
||||
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||
tabloDeps := TablosDeps{Queries: q}
|
||||
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
}
|
||||
|
||||
// loginUser signs up a user and returns the session cookie set after signup.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
|
|||
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
|
||||
tabloDeps := TablosDeps{Queries: q}
|
||||
taskDeps := TasksDeps{Queries: q}
|
||||
return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
|
||||
}
|
||||
|
||||
// ---- TestTasksKanbanRenders (TASK-01) ----
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -38,7 +39,7 @@ func TestHealthz_OK(t *testing.T) {
|
|||
if !strings.Contains(body, `"status":"ok"`) {
|
||||
t.Errorf("body missing status:ok; got: %s", body)
|
||||
}
|
||||
// Liveness endpoint must NOT expose db field.
|
||||
// Liveness endpoint must NOT expose db field (T-07-01: no internal info in responses).
|
||||
if strings.Contains(body, `"db"`) {
|
||||
t.Errorf("liveness body must not contain db field; got: %s", body)
|
||||
}
|
||||
|
|
@ -91,7 +92,7 @@ func TestReadyz_Down(t *testing.T) {
|
|||
// was public. The HTMX demo content is tested by
|
||||
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
|
||||
func TestIndex_UnauthRedirects(t *testing.T) {
|
||||
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||
router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
|
|
@ -106,7 +107,7 @@ func TestIndex_UnauthRedirects(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDemoTime_Fragment(t *testing.T) {
|
||||
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||
router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/demo/time", nil)
|
||||
|
||||
|
|
@ -129,7 +130,7 @@ func TestDemoTime_Fragment(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRequestID_HeaderSet(t *testing.T) {
|
||||
router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||
router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package web
|
|||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
|
@ -12,7 +13,7 @@ import (
|
|||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// Pinger is the contract /healthz uses to probe the data plane. *pgxpool.Pool
|
||||
// Pinger is the contract /readyz uses to probe the data plane. *pgxpool.Pool
|
||||
// satisfies this interface out of the box, which is why cmd/web passes the
|
||||
// pool directly to NewRouter (no adapter required).
|
||||
type Pinger interface {
|
||||
|
|
@ -29,10 +30,10 @@ type Pinger interface {
|
|||
// 5. auth.ResolveSession (reads session cookie, attaches user to context) — D-24
|
||||
// 6. auth.Mount (gorilla/csrf — MUST come after ResolveSession, before routes) — D-24, Pitfall 7
|
||||
//
|
||||
// Routes: GET / · GET /healthz · GET /demo/time · GET /static/*
|
||||
// Routes: GET / · GET /healthz (liveness) · GET /readyz (readiness) · GET /demo/time · GET /static/*
|
||||
// GET /signup (auth pages, behind RedirectIfAuthed) · POST /signup.
|
||||
// staticDir is the on-disk path served at /static/*; path traversal is
|
||||
// blocked by http.Dir's default behavior (T-01-08).
|
||||
// staticFS is the embedded FS (or os.DirFS in tests) served at /static/*; the
|
||||
// embedded FS pattern blocks path traversal at the http.FS layer (T-01-08).
|
||||
//
|
||||
// deps.Store may be nil during unit tests for Phase 1 routes (those routes
|
||||
// never exercise session resolution). ResolveSession guards against nil Store.
|
||||
|
|
@ -44,7 +45,7 @@ type Pinger interface {
|
|||
// trustedOrigins is an optional list of additional origins for the CSRF
|
||||
// referer check (used in integration tests to allow localhost requests without
|
||||
// a Referer header). In production, pass no extra args — leave empty.
|
||||
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
|
||||
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(RequestIDMiddleware)
|
||||
r.Use(chimw.RealIP)
|
||||
|
|
@ -114,11 +115,21 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD
|
|||
r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps))
|
||||
})
|
||||
|
||||
r.Get("/healthz", HealthzHandler(pinger))
|
||||
// Liveness probe (D-12): always 200, no DB contact.
|
||||
r.Get("/healthz", HealthzHandler())
|
||||
// Readiness probe (D-13): probes DB; 200 when ready, 503 when degraded.
|
||||
r.Get("/readyz", ReadyzHandler(pinger))
|
||||
r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() }))
|
||||
|
||||
fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
|
||||
r.Get("/static/*", fs.ServeHTTP)
|
||||
// Serve embedded static assets. Sub to strip the "static/" prefix from the
|
||||
// embedded path so /static/tailwind.css maps to static/tailwind.css inside
|
||||
// the FS. The "fs" local name is avoided to prevent shadowing the "io/fs" import.
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
panic("router: failed to sub static FS: " + err.Error())
|
||||
}
|
||||
fileHandler := http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
||||
r.Get("/static/*", fileHandler.ServeHTTP)
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue