feat(07-01): embed.go + RunMigrations + HealthzHandler()/ReadyzHandler() split

- backend/embed.go: package assets with //go:embed all:static and //go:embed migrations
- backend/internal/db/migrate.go: RunMigrations using pgx/v5/stdlib bridge to goose.Up()
- backend/internal/web/handlers.go: HealthzHandler() no-arg liveness + ReadyzHandler(pinger) readiness
- backend/internal/web/handlers_test.go: TestHealthz_OK (no pinger), TestReadyz_OK, TestReadyz_Down added; TestHealthz_Down deleted
This commit is contained in:
Arthur Belleville 2026-05-15 18:14:26 +02:00
parent 8ae83f6c50
commit 77e37cb21b
No known key found for this signature in database
4 changed files with 115 additions and 10 deletions

22
backend/embed.go Normal file
View 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

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

View file

@ -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()

View file

@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"
@ -25,7 +26,32 @@ func TestHealthz_OK(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
HealthzHandler(stubPinger{err: nil}).ServeHTTP(rec, req)
// HealthzHandler takes no args — pure liveness, no DB ping (D-12).
HealthzHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q; want application/json", ct)
}
body := rec.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Errorf("body missing status:ok; got: %s", body)
}
// 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)
}
}
// TestHealthz_Down is deleted — new HealthzHandler has no failure mode (D-12).
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)
@ -42,11 +68,11 @@ func TestHealthz_OK(t *testing.T) {
}
}
func TestHealthz_Down(t *testing.T) {
func TestReadyz_Down(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
HealthzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req)
ReadyzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d; want 503", rec.Code)
@ -66,7 +92,7 @@ func TestHealthz_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)
@ -81,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)
@ -104,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)