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:
parent
8ae83f6c50
commit
77e37cb21b
4 changed files with 115 additions and 10 deletions
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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue