diff --git a/.env.example b/.env.example index 282c62b6..4c6ad738 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ ENV_APP_LOG_LEVEL=debug ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log" ENV_APP_LOGS_DATE_FORMAT="2006-01-02" ENV_APP_URL= +API_LOGS_PATH="./storage/logs/api" # --- The App master key for encryption. ENV_APP_MASTER_KEY= diff --git a/Makefile b/Makefile index 38608713..47e7f3f3 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,8 @@ help: @printf " $(BOLD)$(GREEN)watch-local$(NC) : Start the Docker local stack in the foreground.\n" @printf " $(BOLD)$(GREEN)build-ci$(NC) : Build the main application for the CI.\n" @printf " $(BOLD)$(GREEN)build-release$(NC) : Build a release version of the application.\n" - @printf " $(BOLD)$(GREEN)build-fresh$(NC) : Build a fresh development environment.\n\n" + @printf " $(BOLD)$(GREEN)build-fresh$(NC) : Build a fresh development environment.\n" + @printf " $(BOLD)$(GREEN)prewarm-cli-docker$(NC): Warm Docker CLI caches and build the reusable CLI binary.\n\n" @printf "$(BOLD)$(BLUE)Database Commands:$(NC)\n" @printf " $(BOLD)$(GREEN)db:local$(NC) : Set up or manage the local database environment.\n" diff --git a/docker-compose.yml b/docker-compose.yml index 6c3aa772..92091a37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ volumes: caddy_config: go_mod_cache: driver: local + go_build_cache: + driver: local prometheus_data_prod: driver: local prometheus_data_local: @@ -48,7 +50,8 @@ services: container_name: oullin_proxy_prod restart: unless-stopped depends_on: - - api + api: + condition: service_healthy # --- The 443:443/udp is required for HTTP/3 # NOTES: @@ -86,7 +89,8 @@ services: container_name: oullin_local_proxy restart: unless-stopped depends_on: - - api + api: + condition: service_healthy ports: - "18080:80" - "127.0.0.1:2019:2019" # Admin API - localhost only for debugging @@ -340,15 +344,19 @@ services: dockerfile: ./infra/docker/dockerfile-api target: builder args: - - BASE_IMAGE_VERSION=${BASE_IMAGE_VERSION:-1.26.1-alpine3.23-r2} + - BASE_IMAGE_VERSION=${BASE_IMAGE_VERSION:-1.26.1-alpine3.23-r3} volumes: - .:/app - go_mod_cache:/go/pkg/mod + - go_build_cache:/tmp/go-build - "${ENV_SPA_DIR}:${ENV_SPA_DIR}" - "${ENV_SPA_IMAGES_DIR}:${ENV_SPA_IMAGES_DIR}" working_dir: /app environment: CGO_ENABLED: 1 + GOPATH: /go + GOMODCACHE: /go/pkg/mod + GOCACHE: /tmp/go-build GOTOOLCHAIN: ${GO_LOCAL_TOOLCHAIN:-go1.26.1} ENV_DB_HOST: api-db ENV_SPA_DIR: ${ENV_SPA_DIR} @@ -375,18 +383,20 @@ services: volumes: - ./.env:/app/.env:ro - ./storage/fixture:/app/storage/fixture:ro + - ${API_LOGS_PATH:-./storage/logs/api}:/app/storage/logs environment: CGO_ENABLED: 1 GOTOOLCHAIN: ${GO_LOCAL_TOOLCHAIN:-go1.26.1} # --- This ensures the Go web server listens for connections from other # containers (like Caddy), not just from within itself. + ENV_APP_LOGS_DIR: /app/storage/logs/logs_%s.log ENV_DB_HOST: api-db ENV_HTTP_HOST: 0.0.0.0 build: context: . dockerfile: ./infra/docker/dockerfile-api args: - - BASE_IMAGE_VERSION=${BASE_IMAGE_VERSION:-1.26.1-alpine3.23-r2} + - BASE_IMAGE_VERSION=${BASE_IMAGE_VERSION:-1.26.1-alpine3.23-r3} - APP_VERSION=0.0.0.1 - APP_HOST_PORT=${ENV_HTTP_PORT} - APP_USER=${ENV_DOCKER_USER} @@ -405,6 +415,12 @@ services: # local: use Caddy on port 18080 (handles /api prefix like production) # prod : Caddy handles all traffic (no direct API access) - ${ENV_HTTP_PORT} + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:$${ENV_HTTP_PORT:-8080}/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s networks: oullin_net: {} caddy_net: diff --git a/docs/SETUP.md b/docs/SETUP.md index ae2ee111..047d9d54 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -21,6 +21,8 @@ Review the `.env` file and adjust the settings as needed. - `ENV_APP_NAME`: Name of the application. - `ENV_APP_ENV_TYPE`: Environment type (e.g., `local`, `production`). +- `ENV_APP_LOGS_DIR`: Application log filename pattern inside the API runtime. +- `API_LOGS_PATH`: Host path mounted to `/app/storage/logs` for persistent API logs. - `ENV_DB_*`: Database connection details. - `ENV_HTTP_PORT`: Port for the HTTP server (default: `8080`). @@ -55,7 +57,8 @@ The application uses a PostgreSQL database. You can manage it using the followin To run the application locally: -- **CLI Mode**: `make run-cli` +- **Optional first-run prewarm**: `make prewarm-cli-docker` warms the Docker CLI module cache, build cache, and reusable CLI binary. It does **not** start the database. +- **CLI Mode**: `make run-cli` reuses `oullin_db` when it is already healthy, starts `api-db` only when needed, and reuses a Docker-built CLI binary on warm runs. - **Metal (Dev) Mode**: `make run-metal` ### Monitoring @@ -67,6 +70,8 @@ The project includes a monitoring stack with Prometheus and Grafana. - Check Status: `make monitor-status` - Open Grafana: `make monitor-grafana` +For production outage investigation, use [Uptime Incident Log Runbook](UPTIME_LOGS.md). + ## Testing Run the test suite: diff --git a/go.mod b/go.mod index 92221ff3..0751c0e6 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/andybalholm/brotli v1.2.0 github.com/chai2010/webp v1.4.0 + github.com/felixge/httpsnoop v1.0.4 github.com/gen2brain/avif v0.4.4 github.com/getsentry/sentry-go v0.43.0 github.com/go-playground/validator/v10 v10.30.1 @@ -43,7 +44,6 @@ require ( github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/handler/health.go b/handler/health.go new file mode 100644 index 00000000..44d582a1 --- /dev/null +++ b/handler/health.go @@ -0,0 +1,31 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/oullin/handler/payload" + "github.com/oullin/pkg/endpoint" + "github.com/oullin/pkg/portal" +) + +type HealthHandler struct{} + +func NewHealthHandler() HealthHandler { + return HealthHandler{} +} + +func (h HealthHandler) Handle(w http.ResponseWriter, r *http.Request) *endpoint.ApiError { + resp := endpoint.NewNoCacheResponse(w, r) + + data := payload.KeepAliveResponse{ + Message: "ok", + DateTime: time.Now().UTC().Format(portal.DatesLayout), + } + + if err := resp.RespondOk(data); err != nil { + return endpoint.LogInternalError("could not encode health response", err) + } + + return nil +} diff --git a/handler/health_test.go b/handler/health_test.go new file mode 100644 index 00000000..4a7f261a --- /dev/null +++ b/handler/health_test.go @@ -0,0 +1,39 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/oullin/handler/payload" + "github.com/oullin/pkg/portal" +) + +func TestHealthHandler(t *testing.T) { + h := NewHealthHandler() + req := httptest.NewRequest("GET", "/health", nil) + rec := httptest.NewRecorder() + + if err := h.Handle(rec, req); err != nil { + t.Fatalf("handle err: %v", err) + } + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp payload.KeepAliveResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + if resp.Message != "ok" { + t.Fatalf("unexpected message: %s", resp.Message) + } + + if _, err := time.Parse(portal.DatesLayout, resp.DateTime); err != nil { + t.Fatalf("invalid datetime: %v", err) + } +} diff --git a/handler/keep_alive_db.go b/handler/keep_alive_db.go index 689dee19..9aab6a28 100644 --- a/handler/keep_alive_db.go +++ b/handler/keep_alive_db.go @@ -2,6 +2,7 @@ package handler import ( "fmt" + "log/slog" "net/http" "time" @@ -31,9 +32,32 @@ func (h KeepAliveDBHandler) Handle(w http.ResponseWriter, r *http.Request) *endp ) } + started := time.Now() if err := h.db.Ping(); err != nil { - return endpoint.LogInternalError("database ping failed", err) + slog.Error( + "database ping failed", + "duration_ms", time.Since(started).Milliseconds(), + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + "request_id", r.Header.Get(portal.RequestIDHeader), + "error", err, + ) + + return &endpoint.ApiError{ + Message: "Internal server error: database ping failed", + Status: http.StatusInternalServerError, + Err: err, + } } + slog.Info( + "database ping completed", + "duration_ms", time.Since(started).Milliseconds(), + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + "request_id", r.Header.Get(portal.RequestIDHeader), + ) resp := endpoint.NewNoCacheResponse(w, r) now := time.Now().UTC() diff --git a/handler/keep_alive_db_test.go b/handler/keep_alive_db_test.go index 73d5902b..1baefb99 100644 --- a/handler/keep_alive_db_test.go +++ b/handler/keep_alive_db_test.go @@ -1,9 +1,12 @@ package handler import ( + "bytes" "encoding/json" + "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -50,6 +53,13 @@ func TestKeepAliveDBHandler(t *testing.T) { }) t.Run("db ping failure", func(t *testing.T) { + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { + slog.SetDefault(previous) + }) + db.Close() req := httptest.NewRequest("GET", "/ping-db", nil) req.SetBasicAuth("user", "pass") @@ -57,5 +67,13 @@ func TestKeepAliveDBHandler(t *testing.T) { if err := h.Handle(rec, req); err == nil || err.Status != http.StatusInternalServerError { t.Fatalf("expected internal error, got %#v", err) } + + got := logs.String() + if count := strings.Count(got, "level=ERROR"); count != 1 { + t.Fatalf("expected one error log, got %d: %s", count, got) + } + if !strings.Contains(got, `msg="database ping failed"`) { + t.Fatalf("expected structured db ping failure log, got %s", got) + } }) } diff --git a/infra/caddy/caddyfile_prod_test.go b/infra/caddy/caddyfile_prod_test.go new file mode 100644 index 00000000..043f4d8d --- /dev/null +++ b/infra/caddy/caddyfile_prod_test.go @@ -0,0 +1,116 @@ +package caddy_test + +import ( + "os" + "regexp" + "strings" + "testing" +) + +func readProdCaddyfile(t *testing.T) string { + t.Helper() + + content, err := os.ReadFile("Caddyfile.prod") + if err != nil { + t.Fatalf("read production Caddyfile: %v", err) + } + + return string(content) +} + +func protectedPublicPaths(caddyfile string) map[string]bool { + paths := make(map[string]bool) + + for _, line := range strings.Split(caddyfile, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] != "@protected_public" || fields[1] != "path" { + continue + } + + for _, path := range fields[2:] { + paths[path] = true + } + + return paths + } + + return paths +} + +func stripCaddyComments(caddyfile string) string { + var lines []string + + for _, line := range strings.Split(caddyfile, "\n") { + beforeComment, _, _ := strings.Cut(line, "#") + lines = append(lines, beforeComment) + } + + return strings.Join(lines, "\n") +} + +func caddyBlock(caddyfile, name string) (string, bool) { + lines := strings.Split(caddyfile, "\n") + start := -1 + + for i, line := range lines { + if strings.TrimSpace(line) == name+" {" { + start = i + break + } + } + + if start == -1 { + return "", false + } + + depth := 0 + var block []string + for _, line := range lines[start:] { + block = append(block, line) + depth += strings.Count(line, "{") + depth -= strings.Count(line, "}") + + if depth == 0 { + return strings.Join(block, "\n"), true + } + } + + return "", false +} + +func TestProdCaddyfileBlocksPublicSignatureEndpoint(t *testing.T) { + caddyfile := readProdCaddyfile(t) + + protectedPaths := protectedPublicPaths(caddyfile) + for _, requiredPath := range []string{"/api/generate-signature*", "/api/metrics"} { + if !protectedPaths[requiredPath] { + t.Fatalf("expected public protected matcher to include %s", requiredPath) + } + } + + if !regexp.MustCompile(`handle\s+@protected_public\s*{\s*respond\s+403\s*}`).MatchString(caddyfile) { + t.Fatal("expected public protected matcher to respond with 403") + } +} + +func TestProdCaddyfileKeepsSignatureEndpointBehindMTLS(t *testing.T) { + caddyfile := readProdCaddyfile(t) + mtlsBlock, ok := caddyBlock(stripCaddyComments(caddyfile), ":8443") + if !ok { + t.Fatal("expected production Caddyfile to contain the :8443 mTLS listener") + } + + mtlsSignatureBlock := regexp.MustCompile(`(?s):8443\s*{\s*` + + `tls\s+/etc/caddy/mtls/server\.pem\s+/etc/caddy/mtls/server\.key\s*{.*?` + + `mode\s+require_and_verify\s+` + + `trust_pool\s+file\s+/etc/caddy/mtls/ca\.pem.*?` + + `@sig\s+path\s+/api/generate-signature\*.*?` + + `handle\s+@sig\s*{\s*` + + `uri\s+strip_prefix\s+/api\s+` + + `reverse_proxy\s+api:8080\s*` + + `}`) + + if !mtlsSignatureBlock.MatchString(mtlsBlock) { + t.Fatal("expected /api/generate-signature* to be handled by api:8080 inside the :8443 mTLS listener") + } +} diff --git a/infra/caddy/docker_compose_test.go b/infra/caddy/docker_compose_test.go new file mode 100644 index 00000000..52b4f007 --- /dev/null +++ b/infra/caddy/docker_compose_test.go @@ -0,0 +1,156 @@ +package caddy_test + +import ( + "errors" + "os" + "testing" + + "gopkg.in/yaml.v3" +) + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i < len(node.Content)-1; i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + + return nil +} + +func readComposeRoot(t *testing.T) *yaml.Node { + t.Helper() + + content, err := os.ReadFile("../../docker-compose.yml") + if err != nil { + t.Fatalf("read docker-compose.yml: %v", err) + } + + root, err := parseComposeRoot(content) + if err != nil { + t.Fatal(err) + } + + return root +} + +func parseComposeRoot(content []byte) (*yaml.Node, error) { + var compose yaml.Node + if err := yaml.Unmarshal(content, &compose); err != nil { + return nil, err + } + + if len(compose.Content) == 0 || compose.Content[0] == nil { + return nil, errors.New("expected docker-compose.yml to contain a YAML document") + } + + return compose.Content[0], nil +} + +func sequenceContains(node *yaml.Node, value string) bool { + if node == nil || node.Kind != yaml.SequenceNode { + return false + } + + for _, item := range node.Content { + if item.Value == value { + return true + } + } + + return false +} + +func TestParseComposeRootRejectsEmptyDocuments(t *testing.T) { + tests := []struct { + name string + content string + }{ + {name: "empty", content: ""}, + {name: "comments only", content: "# docker compose config\n# no document body\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root, err := parseComposeRoot([]byte(tt.content)) + if err == nil { + t.Fatalf("expected empty document error, got root %#v", root) + } + + if err.Error() != "expected docker-compose.yml to contain a YAML document" { + t.Fatalf("expected empty document error, got %q", err) + } + }) + } +} + +func TestComposeKeepsProxyAliasOnProdCaddy(t *testing.T) { + root := readComposeRoot(t) + services := mappingValue(root, "services") + caddyProd := mappingValue(services, "caddy_prod") + if caddyProd == nil { + t.Fatal("expected caddy_prod service to exist") + } + + networks := mappingValue(caddyProd, "networks") + caddyNet := mappingValue(networks, "caddy_net") + if caddyNet == nil { + t.Fatal("expected caddy_prod to be attached to caddy_net") + } + + aliases := mappingValue(caddyNet, "aliases") + if aliases == nil || aliases.Kind != yaml.SequenceNode { + t.Fatal("expected caddy_prod caddy_net aliases to be a sequence") + } + + for _, alias := range aliases.Content { + if alias.Value == "proxy" { + return + } + } + + t.Fatal("expected caddy_prod caddy_net aliases to include proxy") +} + +func TestComposeProdCaddyWaitsForHealthyAPI(t *testing.T) { + root := readComposeRoot(t) + services := mappingValue(root, "services") + caddyProd := mappingValue(services, "caddy_prod") + dependsOn := mappingValue(caddyProd, "depends_on") + api := mappingValue(dependsOn, "api") + condition := mappingValue(api, "condition") + + if condition == nil || condition.Value != "service_healthy" { + t.Fatalf("expected caddy_prod to wait for healthy api, got %#v", condition) + } +} + +func TestComposeAPIHasHealthcheckAndPersistentLogs(t *testing.T) { + root := readComposeRoot(t) + services := mappingValue(root, "services") + api := mappingValue(services, "api") + if api == nil { + t.Fatal("expected api service to exist") + } + + healthcheck := mappingValue(api, "healthcheck") + test := mappingValue(healthcheck, "test") + if !sequenceContains(test, "wget --no-verbose --tries=1 --spider http://localhost:$${ENV_HTTP_PORT:-8080}/health") { + t.Fatalf("expected api healthcheck to call /health") + } + + volumes := mappingValue(api, "volumes") + if !sequenceContains(volumes, "${API_LOGS_PATH:-./storage/logs/api}:/app/storage/logs") { + t.Fatalf("expected api logs to be persisted on the host") + } + + environment := mappingValue(api, "environment") + logsDir := mappingValue(environment, "ENV_APP_LOGS_DIR") + if logsDir == nil || logsDir.Value != "/app/storage/logs/logs_%s.log" { + t.Fatalf("expected api logs dir to target persisted mount, got %#v", logsDir) + } +} diff --git a/infra/docker/base-images/Dockerfile.builder b/infra/docker/base-images/Dockerfile.builder index 98404439..41a206de 100644 --- a/infra/docker/base-images/Dockerfile.builder +++ b/infra/docker/base-images/Dockerfile.builder @@ -7,9 +7,9 @@ # against committed SHA256 checksums, and installed via an ephemeral RSA-signed # APKINDEX so `apk add` never contacts the live Alpine package index. -ARG GO_VERSION -ARG GO_IMAGE_VARIANT -ARG GO_IMAGE_DIGEST +ARG GO_VERSION=1.26.1 +ARG GO_IMAGE_VARIANT=alpine3.23 +ARG GO_IMAGE_DIGEST=sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 FROM golang:${GO_VERSION}-${GO_IMAGE_VARIANT}@${GO_IMAGE_DIGEST} @@ -37,7 +37,7 @@ COPY checksums/ /tmp/checksums/ # fortify-headers separately (it must be added by path because its virtual # provider name conflicts with the musl-provided headers already present). # 6. Clean up all temporary artifacts. -RUN apk add --no-cache openssl=3.5.5-r0 && \ +RUN apk add --no-cache openssl=3.5.6-r0 && \ target_arch="${TARGETARCH}"; \ if [ -z "${target_arch}" ]; then \ case "$(apk --print-arch)" in \ @@ -75,8 +75,8 @@ RUN apk add --no-cache openssl=3.5.5-r0 && \ make-4.4.1-r3.apk \ mpc1-1.3.1-r1.apk \ mpfr4-4.2.2-r0.apk \ - musl-1.2.5-r21.apk \ - musl-dev-1.2.5-r21.apk \ + musl-1.2.5-r23.apk \ + musl-dev-1.2.5-r23.apk \ patch-2.8-r0.apk \ pkgconf-2.5.1-r0.apk \ zlib-1.3.2-r0.apk \ diff --git a/infra/docker/base-images/Dockerfile.runtime b/infra/docker/base-images/Dockerfile.runtime index c8ea4716..f5b67c76 100644 --- a/infra/docker/base-images/Dockerfile.runtime +++ b/infra/docker/base-images/Dockerfile.runtime @@ -30,7 +30,7 @@ COPY checksums/ /tmp/checksums/ # 4. Remove openssl — it is only needed for the signing step and should not # remain in the runtime image. # 5. Clean up all temporary artifacts. -RUN apk add --no-cache openssl=3.5.5-r0 && \ +RUN apk add --no-cache openssl=3.5.6-r0 && \ target_arch="${TARGETARCH}"; \ if [ -z "${target_arch}" ]; then \ case "$(apk --print-arch)" in \ @@ -47,8 +47,8 @@ RUN apk add --no-cache openssl=3.5.5-r0 && \ for apk_pkg in \ libsharpyuv-1.6.0-r0.apk \ libwebp-1.6.0-r0.apk \ - musl-1.2.5-r21.apk \ - tzdata-2026a-r0.apk \ + musl-1.2.5-r23.apk \ + tzdata-2026b-r0.apk \ zlib-1.3.2-r0.apk \ ; do \ wget -qO "/tmp/local-repo/${apk_arch}/${apk_pkg}" "${APK_BASE_URL}/${apk_arch}/${apk_pkg}" || exit 1; \ @@ -67,7 +67,7 @@ RUN apk add --no-cache openssl=3.5.5-r0 && \ | gzip -9 \ | cat - /tmp/APKINDEX.unsigned.tar.gz \ > "/tmp/local-repo/${apk_arch}/APKINDEX.tar.gz" && \ - apk add --no-cache --no-network --repositories-file /dev/null --repository /tmp/local-repo libwebp && \ - apk add --no-cache --no-network "/tmp/local-repo/${apk_arch}/tzdata-2026a-r0.apk" && \ + apk add --no-cache --no-network --repositories-file /dev/null --repository /tmp/local-repo musl=1.2.5-r23 libwebp && \ + apk add --no-cache --no-network "/tmp/local-repo/${apk_arch}/tzdata-2026b-r0.apk" && \ apk del --no-cache openssl && \ rm -rf /tmp/local-repo /tmp/checksums /tmp/apk-sign.rsa /tmp/sig /tmp/sig.tar /tmp/APKINDEX.unsigned.tar.gz /etc/apk/keys/apk-sign.rsa.pub diff --git a/infra/docker/base-images/checksums/builder-aarch64.sha256 b/infra/docker/base-images/checksums/builder-aarch64.sha256 index 150ac754..b908386d 100644 --- a/infra/docker/base-images/checksums/builder-aarch64.sha256 +++ b/infra/docker/base-images/checksums/builder-aarch64.sha256 @@ -21,8 +21,8 @@ e9a504ac218c00fa5c1fc83c7d5b94a3d26e2c2314404b3d101b22710bf752ec libwebpmux-1.6 e15217cadc367b7fc52871e3cc82fe7193bc82f75087db0598aa2e5e941f734c make-4.4.1-r3.apk 753858c93308a54fd2c578e67ce5f3947e4b7946052b1e66a892cd5ee7efd4a4 mpc1-1.3.1-r1.apk da8b196bad532b4a82ea62f0b1609fc4bbdadb8ac9fed7c7792a84be8084a04f mpfr4-4.2.2-r0.apk -c3214bd980cd06839ed7906f5b803d0f0aebbb762b739feed74fb17ae05dd79c musl-1.2.5-r21.apk -1bf44a0ff5d9a63acf14a53b86a4fa80aa999b0b52b755914042814c3bf1e8b0 musl-dev-1.2.5-r21.apk +6a3edd924ead1fad88a69e28c5775809af3026b322f58428001cd02fedc5299e musl-1.2.5-r23.apk +19032a762b8c6967d3789de1bf540754a0121e517402022eebd3ae6f1c9965a4 musl-dev-1.2.5-r23.apk ed4ec83a16470de9087e295f53d3b2e1a666a684cc117ea017adb969ba044c4b patch-2.8-r0.apk 68a8cbd9901184caf0c4cf86c54ae199eae6110c313457ad34adb8ce1d929816 pkgconf-2.5.1-r0.apk ecda4cc94fd18f90182f1d3a615889df5e0db9cf78926d11627dd23e06d2e6e8 zlib-1.3.2-r0.apk diff --git a/infra/docker/base-images/checksums/builder-x86_64.sha256 b/infra/docker/base-images/checksums/builder-x86_64.sha256 index e38a32a4..b1780726 100644 --- a/infra/docker/base-images/checksums/builder-x86_64.sha256 +++ b/infra/docker/base-images/checksums/builder-x86_64.sha256 @@ -21,8 +21,8 @@ ac19a7d33d4e7d4e14a6d6ca9481d3291eb7ef37759d9dc9226ccb47d3c752eb libwebpmux-1.6 db47019dc9ebbfec1c0224efb8c30b3e26f22728b6f433b492af57da622787d5 make-4.4.1-r3.apk 114dcb18a4e5c7739fdab19414e156295c2783d56cdd226bcee4b5afc6a88c76 mpc1-1.3.1-r1.apk 4361c9084ae95e3987cee87fed35bbaca10c75907eb1900365707196d250bdc8 mpfr4-4.2.2-r0.apk -fd5bb1158ec3092bc62922f701e245a647b497405d3b5c9ad9b46cb4bc492752 musl-1.2.5-r21.apk -a82753d912955e1861d5de27eb25185e725f72c6719106007522b2d1cd7b0693 musl-dev-1.2.5-r21.apk +4f3c4a7bf9f51d2c91007e333b17459362ffd881b4a343e8da07b6c50c4f4a0d musl-1.2.5-r23.apk +2ea566b665ebaea4f84cee1e4b7ef093b0aa67356006e84deb26d9bd7a716c6b musl-dev-1.2.5-r23.apk e7104a7dd5c4a8a0e4a1df2ff8ff0fd9a1587d8de791dffc18d8dae1c0e7ec3f patch-2.8-r0.apk 01244a680c1db46539f7e6160ba56476a18f53aefb86cc18354c8d283b6dda71 pkgconf-2.5.1-r0.apk f56dea63692059bd65854bb179b7551971a441000b0d98c5b031291ca0450b56 zlib-1.3.2-r0.apk diff --git a/infra/docker/base-images/checksums/runtime-aarch64.sha256 b/infra/docker/base-images/checksums/runtime-aarch64.sha256 index 05c0e148..984ad5ba 100644 --- a/infra/docker/base-images/checksums/runtime-aarch64.sha256 +++ b/infra/docker/base-images/checksums/runtime-aarch64.sha256 @@ -1,5 +1,5 @@ 5b5009c66c47bd7a49a1b0eb236b526a14b2ed6c0802cb82d41eb6f9ea852a59 libsharpyuv-1.6.0-r0.apk 808eac0eba50a5a7de86e280f4337281eace15a6b10301ee04de3d39f212cfb9 libwebp-1.6.0-r0.apk -c3214bd980cd06839ed7906f5b803d0f0aebbb762b739feed74fb17ae05dd79c musl-1.2.5-r21.apk -d9db7d6ddaf7d258a246165eadcad6ecf65e3690db8ebef29e0f43b9791186a6 tzdata-2026a-r0.apk +6a3edd924ead1fad88a69e28c5775809af3026b322f58428001cd02fedc5299e musl-1.2.5-r23.apk +3649d55ead19c80e66fd5c210f8fdd5b20ccaf007d5ec862e6345aa54a4b5b0f tzdata-2026b-r0.apk ecda4cc94fd18f90182f1d3a615889df5e0db9cf78926d11627dd23e06d2e6e8 zlib-1.3.2-r0.apk diff --git a/infra/docker/base-images/checksums/runtime-x86_64.sha256 b/infra/docker/base-images/checksums/runtime-x86_64.sha256 index e773c23f..6541e428 100644 --- a/infra/docker/base-images/checksums/runtime-x86_64.sha256 +++ b/infra/docker/base-images/checksums/runtime-x86_64.sha256 @@ -1,5 +1,5 @@ 68b1787f41c36e7b6d9def855c5663f2f1ab62fbcc599d38ce3487e75fc883ec libsharpyuv-1.6.0-r0.apk 4d5303958ee38a978501c330c23b62ef98993ae3c30afcd62444723f33da2477 libwebp-1.6.0-r0.apk -fd5bb1158ec3092bc62922f701e245a647b497405d3b5c9ad9b46cb4bc492752 musl-1.2.5-r21.apk -21db015c0fc2be7a6a23e399bcf190c23675370e8cf48a65a11d5498b330508e tzdata-2026a-r0.apk +4f3c4a7bf9f51d2c91007e333b17459362ffd881b4a343e8da07b6c50c4f4a0d musl-1.2.5-r23.apk +a172f7eedda93509d0dde2f892cf1e0c135969a30c0f19535deef7556aa910a0 tzdata-2026b-r0.apk f56dea63692059bd65854bb179b7551971a441000b0d98c5b031291ca0450b56 zlib-1.3.2-r0.apk diff --git a/infra/docker/base-images/generate-checksums.sh b/infra/docker/base-images/generate-checksums.sh index 48d15bf6..68c0884f 100755 --- a/infra/docker/base-images/generate-checksums.sh +++ b/infra/docker/base-images/generate-checksums.sh @@ -45,8 +45,8 @@ BUILDER_PACKAGES=( make-4.4.1-r3.apk mpc1-1.3.1-r1.apk mpfr4-4.2.2-r0.apk - musl-1.2.5-r21.apk - musl-dev-1.2.5-r21.apk + musl-1.2.5-r23.apk + musl-dev-1.2.5-r23.apk patch-2.8-r0.apk pkgconf-2.5.1-r0.apk zlib-1.3.2-r0.apk @@ -56,8 +56,8 @@ BUILDER_PACKAGES=( RUNTIME_PACKAGES=( libsharpyuv-1.6.0-r0.apk libwebp-1.6.0-r0.apk - musl-1.2.5-r21.apk - tzdata-2026a-r0.apk + musl-1.2.5-r23.apk + tzdata-2026b-r0.apk zlib-1.3.2-r0.apk ) diff --git a/infra/docker/dockerfile-api b/infra/docker/dockerfile-api index 43cac9d3..8173bbde 100644 --- a/infra/docker/dockerfile-api +++ b/infra/docker/dockerfile-api @@ -32,7 +32,7 @@ ARG BINARY_NAME=oullin_api # can work without registry access. CI/maintainers can override these args with # digest-pinned registry references after running `make push-base-images`. # -ARG BASE_IMAGE_VERSION=1.26.1-alpine3.23-r2 +ARG BASE_IMAGE_VERSION=1.26.1-alpine3.23-r3 ARG BUILDER_BASE_IMAGE=oullin-api-builder-base:${BASE_IMAGE_VERSION} ARG RUNTIME_BASE_IMAGE=oullin-api-runtime-base:${BASE_IMAGE_VERSION} ARG GO_TOOLCHAIN=go1.26.1 diff --git a/infra/makefile/app.mk b/infra/makefile/app.mk index 01789659..40d8131c 100644 --- a/infra/makefile/app.mk +++ b/infra/makefile/app.mk @@ -164,17 +164,52 @@ run-cli: esac`; \ printf " DB_SECRET_DBNAME=%s\n\n" "$$DB_SECRET_DBNAME_DISPLAY" @status=0; \ + compose_cmd=""; \ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then \ + compose_cmd="docker compose"; \ printf "Using docker compose to run the CLI.\n"; \ - docker compose run --rm api-runner go run ./metal/cli/main.go || status=$$?; \ elif command -v docker-compose >/dev/null 2>&1; then \ + compose_cmd="docker-compose"; \ printf "Using docker-compose to run the CLI.\n"; \ - docker-compose run --rm api-runner go run ./metal/cli/main.go || status=$$?; \ else \ printf "\n$(RED)❌ Neither 'docker compose' nor 'docker-compose' is available.$(NC)\n"; \ printf " Install Docker Compose or run the CLI locally without containers.\n\n"; \ exit 1; \ fi; \ + $(DB_DOCKER_STATE_FUNCS) \ + $(MAKE) --no-print-directory ensure-base-images || status=$$?; \ + if [ $$status -eq 0 ] && [ "$$(db_running)" = "true" ] && [ "$$(db_health)" = "healthy" ]; then \ + printf "Database container $(DB_DOCKER_CONTAINER_NAME) is already healthy.\n"; \ + elif [ $$status -eq 0 ]; then \ + printf "Database container $(DB_DOCKER_CONTAINER_NAME) is not ready. Starting $(DB_DOCKER_SERVICE_NAME)...\n"; \ + $(MAKE) --no-print-directory ensure-db-volume || status=$$?; \ + if [ $$status -eq 0 ]; then \ + $$compose_cmd up -d $(DB_DOCKER_SERVICE_NAME) || status=$$?; \ + fi; \ + if [ $$status -eq 0 ]; then \ + printf "Waiting for database to become healthy...\n"; \ + attempt=0; max_attempts=30; \ + while [ $$attempt -lt $$max_attempts ]; do \ + if [ "$$(db_running)" = "true" ] && [ "$$(db_health)" = "healthy" ]; then \ + printf "Database is healthy.\n"; \ + break; \ + fi; \ + attempt=$$((attempt + 1)); \ + if [ $$attempt -eq $$max_attempts ]; then \ + printf "\n$(RED)❌ Database failed to become healthy after 60 seconds.$(NC)\n"; \ + status=1; \ + break; \ + fi; \ + sleep 2; \ + done; \ + fi; \ + fi; \ + if [ $$status -eq 0 ]; then \ + $(MAKE) --no-print-directory build-cli-docker || status=$$?; \ + fi; \ + if [ $$status -eq 0 ]; then \ + $$compose_cmd run --rm --no-deps api-runner $(CLI_DOCKER_BINARY_CONTAINER) || status=$$?; \ + fi; \ if [ $$status -ne 0 ]; then \ printf "\n$(RED)❌ CLI exited with status $$status.$(NC)\n"; \ exit $$status; \ diff --git a/infra/makefile/build.mk b/infra/makefile/build.mk index d6b4e8d0..d9400d89 100644 --- a/infra/makefile/build.mk +++ b/infra/makefile/build.mk @@ -1,9 +1,9 @@ -.PHONY: build-local watch-local build-ci build-prod build-release build-deploy build-local-restart build-prod-force build-fresh ensure-caddy-net ensure-base-images ensure-builder-base-image ensure-runtime-base-image build-base-images push-base-images generate-apk-checksums +.PHONY: build-local watch-local build-ci build-prod build-release build-deploy build-local-restart build-prod-force build-fresh ensure-caddy-net ensure-base-images ensure-builder-base-image ensure-runtime-base-image build-base-images push-base-images generate-apk-checksums build-cli-docker prewarm-cli-docker BUILD_VERSION ?= latest BASE_GO_VERSION ?= 1.26.1 BASE_ALPINE_VERSION ?= 3.23 -BASE_IMAGE_REVISION ?= 2 +BASE_IMAGE_REVISION ?= 3 BASE_GO_IMAGE_VARIANT ?= alpine$(BASE_ALPINE_VERSION) BASE_GO_IMAGE_DIGEST ?= sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 BASE_ALPINE_IMAGE_DIGEST ?= sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 @@ -20,8 +20,11 @@ BUILD_BASE_BUILDER_ARGS := --build-arg GO_VERSION=$(BASE_GO_VERSION) --build-arg BUILD_BASE_RUNTIME_ARGS := --build-arg ALPINE_VERSION=$(BASE_ALPINE_VERSION) --build-arg ALPINE_IMAGE_DIGEST=$(BASE_ALPINE_IMAGE_DIGEST) --build-arg APK_BASE_URL=$(BASE_APK_BASE_URL) DB_INFRA_ROOT_PATH ?= $(ROOT_PATH)/database/infra DB_INFRA_SCRIPTS_PATH ?= $(DB_INFRA_ROOT_PATH)/scripts +CLI_DOCKER_BINARY_HOST := $(ROOT_PATH)/bin/metal-cli +CLI_DOCKER_BINARY_CONTAINER := /app/bin/metal-cli +CLI_DOCKER_BUILD_INPUTS := $(shell find "$(ROOT_PATH)" \( -name '.git' -o -name '.gopath' -o -name '.gocache' -o -name 'vendor' \) -prune -o \( -type f \( -name '*.go' -o -name 'go.mod' -o -name 'go.sum' \) \) -print 2>/dev/null) -build-local build-local-restart build-ci build-prod build-deploy run-cli run-cli-docker: export BASE_IMAGE_VERSION := $(BASE_IMAGE_VERSION) +build-local build-local-restart build-ci build-prod build-deploy run-cli run-cli-docker build-cli-docker prewarm-cli-docker: export BASE_IMAGE_VERSION := $(BASE_IMAGE_VERSION) build-prod build-deploy: export DB_SECRET_USERNAME := $(value DB_SECRET_USERNAME) build-prod build-deploy: export DB_SECRET_PASSWORD := $(value DB_SECRET_PASSWORD) build-prod build-deploy: export DB_SECRET_DBNAME := $(value DB_SECRET_DBNAME) @@ -56,6 +59,33 @@ build-base-images: -t "$(BUILD_BASE_RUNTIME_IMAGE)" \ "$(BUILD_BASE_IMAGES_DIR)" +build-cli-docker: $(CLI_DOCKER_BINARY_HOST) + @printf " $(CYAN)Docker CLI binary ready at %s.$(NC)\n" "$(CLI_DOCKER_BINARY_HOST)" + +prewarm-cli-docker: + @printf "\n$(CYAN)Warming Docker CLI caches and binary$(NC)\n" + @printf " This does not start or wait for the database.\n" + @$(MAKE) --no-print-directory build-cli-docker + +$(CLI_DOCKER_BINARY_HOST): $(CLI_DOCKER_BUILD_INPUTS) | ensure-base-images + @mkdir -p "$(dir $@)" + @status=0; \ + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then \ + printf "Building Docker CLI binary at $(CLI_DOCKER_BINARY_CONTAINER).\n"; \ + docker compose run --rm --no-deps api-runner sh -lc 'go build -o "$(CLI_DOCKER_BINARY_CONTAINER)" ./metal/cli/main.go' || status=$$?; \ + elif command -v docker-compose >/dev/null 2>&1; then \ + printf "Building Docker CLI binary at $(CLI_DOCKER_BINARY_CONTAINER).\n"; \ + docker-compose run --rm --no-deps api-runner sh -lc 'go build -o "$(CLI_DOCKER_BINARY_CONTAINER)" ./metal/cli/main.go' || status=$$?; \ + else \ + printf "\n$(RED)❌ Neither 'docker compose' nor 'docker-compose' is available.$(NC)\n"; \ + printf " Install Docker Compose or run the CLI locally without containers.\n\n"; \ + exit 1; \ + fi; \ + if [ $$status -ne 0 ]; then \ + printf "\n$(RED)❌ Failed to build the Docker CLI binary (status $$status).$(NC)\n"; \ + exit $$status; \ + fi + push-base-images: @$(MAKE) ensure-base-images @printf "\n$(CYAN)Tagging API base images for GitHub registry$(NC)\n" diff --git a/infra/makefile/db.mk b/infra/makefile/db.mk index 20d55be7..8c088e1f 100644 --- a/infra/makefile/db.mk +++ b/infra/makefile/db.mk @@ -7,6 +7,8 @@ DB_DOCKER_SERVICE_NAME := api-db DB_DOCKER_CONTAINER_NAME := oullin_db DB_MIGRATE_SERVICE_NAME := $(DB_DOCKER_SERVICE_NAME)-migrate DB_DOCKER_VOLUME_NAME := api_oullin_db_data +DB_DOCKER_STATE_FUNCS = db_running() { docker inspect --format '{{.State.Running}}' $(DB_DOCKER_CONTAINER_NAME) 2>/dev/null || true; }; \ + db_health() { docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' $(DB_DOCKER_CONTAINER_NAME) 2>/dev/null || true; }; # --- Paths # Define root paths for clarity. Assumes ROOT_PATH is exported or defined. diff --git a/metal/kernel/app.go b/metal/kernel/app.go index 9cd10ea2..91a710ff 100644 --- a/metal/kernel/app.go +++ b/metal/kernel/app.go @@ -87,6 +87,7 @@ func (a *App) Boot() { modem.KeepAlive() modem.KeepAliveDB() + modem.Health() modem.Metrics() modem.Profile() modem.Experience() diff --git a/metal/router/router.go b/metal/router/router.go index eb918538..bd673bc7 100644 --- a/metal/router/router.go +++ b/metal/router/router.go @@ -92,6 +92,16 @@ func (r *Router) KeepAliveDB() { r.Mux.HandleFunc("GET /ping-db", apiHandler) } +func (r *Router) Health() { + abstract := handler.NewHealthHandler() + + apiHandler := endpoint.NewApiHandler( + r.Pipeline.Chain(abstract.Handle), + ) + + r.Mux.HandleFunc("GET /health", apiHandler) +} + func (r *Router) Metrics() { metricsHandler := handler.NewMetricsHandler() diff --git a/metal/router/router_health_test.go b/metal/router/router_health_test.go new file mode 100644 index 00000000..33efb6ab --- /dev/null +++ b/metal/router/router_health_test.go @@ -0,0 +1,26 @@ +package router_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/oullin/metal/router" + "github.com/oullin/pkg/middleware" +) + +func TestHealthRoute(t *testing.T) { + r := router.Router{ + Mux: http.NewServeMux(), + Pipeline: middleware.Pipeline{PublicMiddleware: middleware.NewPublicMiddleware("", false)}, + } + r.Health() + + req := httptest.NewRequest("GET", "/health", nil) + rec := httptest.NewRecorder() + r.Mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } +} diff --git a/pkg/endpoint/server.go b/pkg/endpoint/server.go index c7630cc4..b24b7781 100644 --- a/pkg/endpoint/server.go +++ b/pkg/endpoint/server.go @@ -6,10 +6,16 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "os" "os/signal" + "strconv" "syscall" "time" + + "github.com/felixge/httpsnoop" + + "github.com/oullin/pkg/portal" ) // RunServer starts the provided HTTP server, listens for shutdown signals, and @@ -94,5 +100,58 @@ func NewServerHandler(cfg ServerHandlerConfig) http.Handler { handler = cfg.Wrap(handler) } - return handler + return requestLogHandler{next: handler} +} + +type requestLogHandler struct { + next http.Handler +} + +func (h requestLogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + started := time.Now() + metrics := httpsnoop.CaptureMetrics(h.next, w, r) + status := metrics.Code + + attrs := []any{ + "method", r.Method, + "path", r.URL.Path, + "status", status, + "duration_ms", time.Since(started).Milliseconds(), + "bytes", metrics.Written, + "remote_addr", r.RemoteAddr, + "request_id", r.Header.Get(portal.RequestIDHeader), + "user_agent", r.UserAgent(), + } + + if query := safeRequestQuery(r.URL.Query()); query != "" { + attrs = append(attrs, "query", query) + } + + if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" { + attrs = append(attrs, "forwarded_for", forwardedFor) + } + + if status >= http.StatusInternalServerError { + slog.Error("http request completed", append(attrs, "status_class", strconv.Itoa(status)[0:1]+"xx")...) + return + } + + if status >= http.StatusBadRequest { + slog.Warn("http request completed", append(attrs, "status_class", strconv.Itoa(status)[0:1]+"xx")...) + return + } + + slog.Info("http request completed", attrs...) +} + +func safeRequestQuery(values url.Values) string { + safe := url.Values{} + + for _, key := range []string{"limit", "page"} { + if v, ok := values[key]; ok { + safe[key] = v + } + } + + return safe.Encode() } diff --git a/pkg/endpoint/server_test.go b/pkg/endpoint/server_test.go new file mode 100644 index 00000000..e9146e57 --- /dev/null +++ b/pkg/endpoint/server_test.go @@ -0,0 +1,189 @@ +package endpoint_test + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/oullin/pkg/endpoint" + "github.com/oullin/pkg/portal" +) + +func TestNewServerHandlerLogsRequests(t *testing.T) { + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { + slog.SetDefault(previous) + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + handler := endpoint.NewServerHandler(endpoint.ServerHandlerConfig{Mux: mux}) + req := httptest.NewRequest("GET", "/health", nil) + req.Header.Set(portal.RequestIDHeader, "req-health") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code) + } + + got := logs.String() + for _, want := range []string{ + "msg=\"http request completed\"", + "method=GET", + "path=/health", + "status=204", + "request_id=req-health", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected request log to contain %q, got %q", want, got) + } + } +} + +func TestNewServerHandlerPreservesFlusherWhenUnderlyingWriterSupportsIt(t *testing.T) { + mux := http.NewServeMux() + var sawFlusher bool + + mux.HandleFunc("GET /stream", func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + sawFlusher = ok + if ok { + flusher.Flush() + } + }) + + handler := endpoint.NewServerHandler(endpoint.ServerHandlerConfig{Mux: mux}) + rec := newFlusherResponseWriter() + + handler.ServeHTTP(rec, httptest.NewRequest("GET", "/stream", nil)) + + if !sawFlusher { + t.Fatal("expected downstream handler to receive http.Flusher") + } + + if !rec.flushed { + t.Fatal("expected downstream flush to reach underlying writer") + } +} + +func TestNewServerHandlerDoesNotAddFlusherWhenUnderlyingWriterDoesNotSupportIt(t *testing.T) { + mux := http.NewServeMux() + var sawFlusher bool + + mux.HandleFunc("GET /plain", func(w http.ResponseWriter, r *http.Request) { + _, sawFlusher = w.(http.Flusher) + }) + + handler := endpoint.NewServerHandler(endpoint.ServerHandlerConfig{Mux: mux}) + rec := newPlainResponseWriter() + + handler.ServeHTTP(rec, httptest.NewRequest("GET", "/plain", nil)) + + if sawFlusher { + t.Fatal("expected downstream handler not to receive http.Flusher") + } +} + +type flusherResponseWriter struct { + *plainResponseWriter + flushed bool +} + +func newFlusherResponseWriter() *flusherResponseWriter { + return &flusherResponseWriter{plainResponseWriter: newPlainResponseWriter()} +} + +func (w *flusherResponseWriter) Flush() { + w.flushed = true +} + +type plainResponseWriter struct { + header http.Header + body bytes.Buffer + status int +} + +func newPlainResponseWriter() *plainResponseWriter { + return &plainResponseWriter{header: http.Header{}} +} + +func (w *plainResponseWriter) Header() http.Header { + return w.header +} + +func (w *plainResponseWriter) Write(body []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + + return w.body.Write(body) +} + +func (w *plainResponseWriter) WriteHeader(status int) { + if w.status != 0 { + return + } + + w.status = status +} + +var ( + _ http.ResponseWriter = (*plainResponseWriter)(nil) + _ http.Flusher = (*flusherResponseWriter)(nil) +) + +func TestNewServerHandlerLogsOnlySafeQueryValues(t *testing.T) { + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { + slog.SetDefault(previous) + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + handler := endpoint.NewServerHandler(endpoint.ServerHandlerConfig{Mux: mux}) + req := httptest.NewRequest("GET", "/health?page=2&limit=10&token=secret&email=a@example.com", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code) + } + + got := logs.String() + for _, want := range []string{ + "query=\"limit=10&page=2\"", + "limit=10", + "page=2", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected request log to contain %q, got %q", want, got) + } + } + + for _, unwanted := range []string{ + "token", + "secret", + "email", + "a@example.com", + } { + if strings.Contains(got, unwanted) { + t.Fatalf("expected request log not to contain %q, got %q", unwanted, got) + } + } +}