From 0cd5273e019a04b2b3a4c6b299b4d48a014b72a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 10 May 2026 19:40:22 +0000 Subject: [PATCH 1/5] feat(frontend): Go web server skeleton with /healthz + Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First container-buildable code for the frontend. Mirrors the backend's shape (chi router with /healthz, multi-stage Dockerfile, OCI labels, non-root UID 1000, configurable PORT, multi-arch buildable) so the docker-publish workflow can push both images side-by-side at the next release. Go skeleton (cmd/server/main.go): - chi router with RequestID + RealIP + Logger + Recoverer middleware - /healthz returns the same JSON shape as the backend: status, version, revision, build_date, repository — so orchestrator probe configs work for both containers uniformly - BuildInfo populated from HUB_* env vars baked in by Dockerfile; safe defaults when running uncontained (local dev, unit tests) - Graceful shutdown on SIGTERM/SIGINT with 15s context timeout - ReadHeaderTimeout / ReadTimeout / WriteTimeout / IdleTimeout all set to sane defaults (mitigates slowloris-style attacks) Tests (8 unit tests): - /healthz: 200, correct JSON shape, Content-Type, no auth required - /healthz body does not leak secret-ish substrings (parity with backend test_does_not_expose_secrets) - envDefault: fallback on unset / empty, env value when set Dockerfile (multi-stage): - Stage 1 (builder, golang:1.23-alpine): go mod download cached separately, then CGO_ENABLED=0 static binary with -trimpath -ldflags '-s -w' for smallest reproducible output - Stage 2 (runtime, alpine:3.20): tini + curl (HEALTHCHECK) + ca-certificates (HTTPS to backend for proxied requests later) + non-root UID 1000 user matching the backend container - ARGs VERSION / REVISION / BUILD_DATE flow into OCI labels (12 of them, identical schema to backend) AND runtime ENV vars (HUB_VERSION, HUB_REVISION, HUB_BUILD_DATE, HUB_REPO_URL) so the running app surfaces them through /healthz - Shell-form HEALTHCHECK expands ${PORT:-8080} - ENTRYPOINT tini -- /usr/local/bin/server (no shell wrapper needed; Go binary reads $PORT itself) .dockerignore minimal: ignores build output, IDE, secrets, .git, node_modules (Tailwind toolchain lands later). frontend/README.md: subdirectory README pointing at the repo root, documenting env vars and the local dev loop. Verified locally with --build-arg + docker run + curl /healthz on default port (8080) and custom port (PORT=7777). This is intentionally a SKELETON — Tailwind, HTMX, PWA manifest, service worker, OpenAPI-generated backend client, and the actual UI routes land in follow-up PRs once the backend exposes real endpoints. The point of this PR is to make 'docker compose up' work end-to-end with both containers from the next release. Refs: ADR 0001 (two-container), ADR 0003 (Go + Tailwind + HTMX + PWA), ADR 0007 (multi-arch tag scheme) --- frontend/.dockerignore | 40 ++++++++++ frontend/Dockerfile | 104 ++++++++++++++++++++++++ frontend/README.md | 34 ++++++++ frontend/cmd/server/main.go | 131 +++++++++++++++++++++++++++++++ frontend/cmd/server/main_test.go | 117 +++++++++++++++++++++++++++ frontend/go.mod | 7 ++ frontend/go.sum | 2 + 7 files changed, 435 insertions(+) create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/cmd/server/main.go create mode 100644 frontend/cmd/server/main_test.go create mode 100644 frontend/go.mod create mode 100644 frontend/go.sum diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6164b23 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,40 @@ +# Keep build context minimal. + +# Build output +/bin/ +/dist/ +/out/ +*.exe +*.test +*.prof + +# Test artefacts +coverage.out + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Local dev +.env +.env.local +*.log + +# Secrets — never copy +*.pem +*.key +*.crt +secrets/ + +# Git +.git/ +.gitignore + +# Node toolchain (Tailwind build cache — comes in a later PR) +node_modules/ +.npm/ +.pnpm-store/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..fe35f9b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,104 @@ +# ============================================================================= +# Label Printer Hub — frontend container +# +# Multi-stage build: +# 1) builder — Go toolchain compiles a static binary +# 2) runtime — alpine + tini + curl + non-root UID 1000 +# +# Build context: the `frontend/` directory. +# +# Tailwind, HTMX, PWA assets and the OpenAPI-generated backend client will +# land in follow-up commits — this skeleton produces a deployable container +# that serves /healthz so the docker-publish workflow has something real to +# push at the next release. +# +# References: +# docs/decisions/0001-two-container-architecture.md (two-container split) +# docs/decisions/0003-go-tailwind-htmx-pwa-frontend.md +# docs/decisions/0007-docker-image-tag-scheme.md (multi-arch publish) +# CLAUDE.md hard rules (non-root UID 1000) +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: builder +# ----------------------------------------------------------------------------- +FROM golang:1.23-alpine AS builder + +WORKDIR /build + +# Stage A: dependency download — cached separately from source. Subsequent +# code edits don't re-resolve modules unless go.mod / go.sum change. +COPY go.mod go.sum ./ +RUN go mod download + +# Stage B: compile. Static binary so we don't need libc in the runtime image. +COPY . ./ +# CGO_ENABLED=0 produces a fully static binary. -ldflags strip debug info +# and shrink the binary (~30-40% smaller). +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/server \ + ./cmd/server + +# ----------------------------------------------------------------------------- +# Stage 2: runtime +# ----------------------------------------------------------------------------- +FROM alpine:3.20 AS runtime + +# Build-time arguments — set by the CI workflow (.github/workflows/docker-publish.yml). +# Local builds without --build-arg get the placeholder defaults below. +ARG VERSION=0.0.0-dev +ARG REVISION=unknown +ARG BUILD_DATE=1970-01-01T00:00:00Z + +# OCI image labels — visible via `docker inspect`. Keep in sync with the +# backend Dockerfile so both images describe themselves identically. +LABEL org.opencontainers.image.title="label-printer-hub-frontend" \ + org.opencontainers.image.description="Self-hosted label printer hub for Brother PT/QL series — frontend container (Go + Tailwind + HTMX + PWA)" \ + org.opencontainers.image.url="https://github.com/strausmann/label-printer-hub" \ + org.opencontainers.image.source="https://github.com/strausmann/label-printer-hub" \ + org.opencontainers.image.documentation="https://github.com/strausmann/label-printer-hub/tree/main/docs" \ + org.opencontainers.image.authors="Björn Strausmann " \ + org.opencontainers.image.vendor="strausmann (independent open-source project; not affiliated with Brother Industries, Ltd.)" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.base.name="docker.io/library/alpine:3.20" + +ENV PORT=8080 \ + BACKEND_URL=http://backend:8000 \ + HUB_VERSION="${VERSION}" \ + HUB_REVISION="${REVISION}" \ + HUB_BUILD_DATE="${BUILD_DATE}" \ + HUB_REPO_URL="https://github.com/strausmann/label-printer-hub" + +# Runtime essentials: +# tini — PID 1 signal handler so SIGTERM propagates cleanly +# curl — HEALTHCHECK probe +# ca-certificates — outbound HTTPS to the backend (when proxying SSE/API) +# Then create the non-root user (UID 1000, matches the backend container). +RUN apk add --no-cache tini curl ca-certificates \ + && addgroup -S -g 1000 hub \ + && adduser -S -u 1000 -G hub -s /sbin/nologin -h /home/hub hub + +# Copy the compiled binary from the builder. No source code, no Go toolchain +# in the runtime image. +COPY --from=builder --chown=hub:hub /out/server /usr/local/bin/server + +USER hub +WORKDIR /home/hub + +# EXPOSE documents the DEFAULT port — overridden by the PORT env var at +# runtime. Adjust the `docker run -p` mapping or compose `ports` to match +# the value of PORT if you change it. +EXPOSE 8080 + +# Shell-form HEALTHCHECK so $PORT is expanded by the shell. +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl --fail --silent --show-error "http://localhost:${PORT:-8080}/healthz" || exit 1 + +# tini reaps zombies and propagates SIGTERM cleanly. The Go server reads $PORT +# from the environment itself, so we don't need a shell wrapper around it. +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/server"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..907d8c1 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,34 @@ +# label-printer-hub — frontend + +Go web server for the [Label Printer Hub](https://github.com/strausmann/label-printer-hub). Serves the user-facing UI and proxies API + SSE requests to the Python backend (see [ADR 0001](../docs/decisions/0001-two-container-architecture.md)). + +Stack: Go + chi router. Tailwind CSS, HTMX, PWA assets, and the OpenAPI-generated backend client land in follow-up commits — this skeleton is the buildable container baseline. + +## Local development + +```bash +go mod download +go test ./... +go run ./cmd/server +``` + +The server listens on `:8080` by default (override with `PORT=…`). `/healthz` returns the same JSON shape as the backend's `/healthz` so orchestrator probe configs work for both. + +## Configuration + +| Env var | Default | Purpose | +|---|---|---| +| `PORT` | `8080` | Internal HTTP port | +| `BACKEND_URL` | `http://backend:8000` | Base URL of the Python backend | +| `HUB_VERSION` | `0.0.0-dev` | Baked in by the Dockerfile from the release tag | +| `HUB_REVISION` | `unknown` | Baked in by the Dockerfile from the git SHA | +| `HUB_BUILD_DATE` | `1970-01-01T00:00:00Z` | Baked in by the Dockerfile at build time | +| `HUB_REPO_URL` | `https://github.com/strausmann/label-printer-hub` | Baked in by the Dockerfile | + +## Container + +The `Dockerfile` produces `ghcr.io/strausmann/label-printer-hub-frontend` — see the [tag scheme ADR](../docs/decisions/0007-docker-image-tag-scheme.md) for which tags every release publishes. + +## License + +MIT — see [LICENSE](../LICENSE) in the repository root. diff --git a/frontend/cmd/server/main.go b/frontend/cmd/server/main.go new file mode 100644 index 0000000..fde4481 --- /dev/null +++ b/frontend/cmd/server/main.go @@ -0,0 +1,131 @@ +// Package main is the Label Printer Hub frontend entry point. +// +// The frontend container serves the user-facing UI and proxies API + SSE +// requests to the backend (see ADR 0001). This skeleton boots a chi router +// with a single /healthz endpoint so the container becomes deployable and +// release-publishable. Tailwind/HTMX/PWA assets, OpenAPI-generated client, +// and the actual UI routes land in follow-up PRs. +// +// Environment variables (all optional, with safe defaults): +// +// PORT internal HTTP port (default: 8080) +// BACKEND_URL base URL of the Python backend (default: http://backend:8000) +// HUB_VERSION release version — baked in by Dockerfile build arg +// HUB_REVISION git commit SHA — baked in by Dockerfile build arg +// HUB_BUILD_DATE ISO-8601 UTC — baked in by Dockerfile build arg +// HUB_REPO_URL project repo URL — baked in by Dockerfile build arg +package main + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +const defaultRepoURL = "https://github.com/strausmann/label-printer-hub" + +// BuildInfo is the JSON body returned by /healthz. +// +// It mirrors the shape of the backend's /healthz response so callers can +// treat both endpoints uniformly. Fields are populated from environment +// variables set by the Dockerfile (HUB_VERSION, HUB_REVISION, HUB_BUILD_DATE, +// HUB_REPO_URL); when running outside a built image they fall back to +// placeholders. +type BuildInfo struct { + Status string `json:"status"` + Version string `json:"version"` + Revision string `json:"revision"` + BuildDate string `json:"build_date"` + Repository string `json:"repository"` +} + +// healthzHandler returns 200 with the BuildInfo struct. It performs no +// authentication, has no external dependencies, and never blocks — matching +// the contract of the backend's /healthz so the same orchestrator probe +// configuration works against both containers. +func healthzHandler(w http.ResponseWriter, _ *http.Request) { + info := BuildInfo{ + Status: "ok", + Version: envDefault("HUB_VERSION", "0.0.0-dev"), + Revision: envDefault("HUB_REVISION", "unknown"), + BuildDate: envDefault("HUB_BUILD_DATE", "1970-01-01T00:00:00Z"), + Repository: envDefault("HUB_REPO_URL", defaultRepoURL), + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(info); err != nil { + // Encoding a fixed-shape struct should never fail; if it does the + // connection is dead anyway. Log and return — no further writes. + slog.Error("failed to encode healthz response", "err", err) + } +} + +// newRouter builds the chi router. Kept as a separate function so tests can +// exercise it without spinning up an actual HTTP server. +func newRouter() *chi.Mux { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(middleware.Logger) + + r.Get("/healthz", healthzHandler) + return r +} + +func envDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func main() { + port := envDefault("PORT", "8080") + addr := ":" + port + + r := newRouter() + srv := &http.Server{ + Addr: addr, + Handler: r, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // Graceful shutdown on SIGTERM/SIGINT — important because docker stop + // sends SIGTERM and we don't want to drop in-flight requests. + idleConnsClosed := make(chan struct{}) + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + <-sigCh + slog.Info("shutdown signal received") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + slog.Error("graceful shutdown failed", "err", err) + } + close(idleConnsClosed) + }() + + slog.Info("starting frontend", + "addr", addr, + "version", envDefault("HUB_VERSION", "0.0.0-dev"), + "revision", envDefault("HUB_REVISION", "unknown")) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server stopped with error", "err", err) + os.Exit(1) + } + <-idleConnsClosed + slog.Info("server stopped cleanly") +} diff --git a/frontend/cmd/server/main_test.go b/frontend/cmd/server/main_test.go new file mode 100644 index 0000000..8b567b9 --- /dev/null +++ b/frontend/cmd/server/main_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHealthz_ReturnsOK(t *testing.T) { + t.Parallel() + r := newRouter() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } +} + +func TestHealthz_BodyShape(t *testing.T) { + t.Parallel() + r := newRouter() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var body BuildInfo + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Status != "ok" { + t.Errorf("status = %q, want %q", body.Status, "ok") + } + if body.Version == "" { + t.Error("version must not be empty") + } + if body.Revision == "" { + t.Error("revision must not be empty") + } + if body.BuildDate == "" { + t.Error("build_date must not be empty") + } + if !strings.Contains(body.Repository, "github.com/strausmann/label-printer-hub") { + t.Errorf("repository = %q, must point at the project repo", body.Repository) + } +} + +func TestHealthz_ContentTypeJSON(t *testing.T) { + t.Parallel() + r := newRouter() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + ct := w.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json prefix", ct) + } +} + +func TestHealthz_NoAuthRequired(t *testing.T) { + t.Parallel() + r := newRouter() + // No Authorization header — container orchestrators probe healthz without credentials. + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 even without auth", w.Code) + } +} + +func TestHealthz_DoesNotLeakSecrets(t *testing.T) { + t.Parallel() + r := newRouter() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + body := strings.ToLower(w.Body.String()) + for _, needle := range []string{"password", "token", "api_key", "secret", "snipeit", "grocy"} { + if strings.Contains(body, needle) { + t.Errorf("healthz body must not contain %q (potential secret leak)", needle) + } + } +} + +func TestEnvDefault_UsesFallbackWhenUnset(t *testing.T) { + t.Parallel() + // Use a key extremely unlikely to be set so this test is hermetic. + got := envDefault("DEFINITELY_NOT_A_REAL_ENVVAR_FOR_FRONTEND_TESTS", "fallback") + if got != "fallback" { + t.Errorf("envDefault returned %q, want fallback", got) + } +} + +func TestEnvDefault_UsesEnvWhenSet(t *testing.T) { + t.Parallel() + t.Setenv("FRONTEND_TEST_KEY", "expected") + got := envDefault("FRONTEND_TEST_KEY", "fallback") + if got != "expected" { + t.Errorf("envDefault returned %q, want expected", got) + } +} + +func TestEnvDefault_UsesFallbackWhenEmpty(t *testing.T) { + t.Parallel() + t.Setenv("FRONTEND_EMPTY_KEY", "") + got := envDefault("FRONTEND_EMPTY_KEY", "fallback") + if got != "fallback" { + t.Errorf("envDefault with empty env returned %q, want fallback", got) + } +} diff --git a/frontend/go.mod b/frontend/go.mod new file mode 100644 index 0000000..76831f3 --- /dev/null +++ b/frontend/go.mod @@ -0,0 +1,7 @@ +module github.com/strausmann/label-printer-hub/frontend + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.1.0 +) diff --git a/frontend/go.sum b/frontend/go.sum new file mode 100644 index 0000000..823cdbb --- /dev/null +++ b/frontend/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= From ff9afaf219abf96c759eb62745c075d68faaec39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 10 May 2026 19:48:13 +0000 Subject: [PATCH 2/5] fix(frontend): remove t.Parallel() from tests using t.Setenv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's testing package panics when a test uses both t.Parallel() and t.Setenv (or t.Chdir, or cryptotest.SetGlobalRandom). The setenv calls mutate process-wide state and can't safely interleave with parallel tests. The two affected tests in main_test.go set FRONTEND_TEST_KEY and FRONTEND_EMPTY_KEY via t.Setenv — they now run serially, while the six other tests still use t.Parallel(). Caught by the Go CI job on PR #35. Adding this pattern to docs/learnings/code-review-patterns.md in a follow-up commit if it likely recurs — Go contributors may not know the t.Setenv/t.Parallel incompatibility off the top of their head. --- frontend/cmd/server/main_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/cmd/server/main_test.go b/frontend/cmd/server/main_test.go index 8b567b9..b9407a4 100644 --- a/frontend/cmd/server/main_test.go +++ b/frontend/cmd/server/main_test.go @@ -99,7 +99,8 @@ func TestEnvDefault_UsesFallbackWhenUnset(t *testing.T) { } func TestEnvDefault_UsesEnvWhenSet(t *testing.T) { - t.Parallel() + // t.Setenv is incompatible with t.Parallel — Go's testing package + // panics if both are used on the same test. t.Setenv("FRONTEND_TEST_KEY", "expected") got := envDefault("FRONTEND_TEST_KEY", "fallback") if got != "expected" { @@ -108,7 +109,6 @@ func TestEnvDefault_UsesEnvWhenSet(t *testing.T) { } func TestEnvDefault_UsesFallbackWhenEmpty(t *testing.T) { - t.Parallel() t.Setenv("FRONTEND_EMPTY_KEY", "") got := envDefault("FRONTEND_EMPTY_KEY", "fallback") if got != "fallback" { From c60900efffd2f3944e538aade20ec22c2829f602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 10 May 2026 19:55:16 +0000 Subject: [PATCH 3/5] fix(frontend): cache build-info, slog request logger, SSE-safe WriteTimeout Addresses Gemini + Copilot review findings on PR #35: - Cache HUB_* env vars once at startup into `buildInfo` instead of reading os.Getenv on every /healthz request (Gemini): the values are baked into the image and never change at runtime, so per-request syscalls were waste. - Replace chi's `middleware.Logger` with a small custom slog-based request logger (Gemini): chi's logger writes through the stdlib `log` package and bypasses our slog handler, so request lines would not honour the configured log level/format/destination. - Register signal.Notify BEFORE launching the shutdown goroutine (Gemini): a SIGTERM arriving in the scheduling window between `go func` and the channel registration would otherwise terminate the process by default instead of triggering graceful shutdown. - Set `WriteTimeout: 0` with explanatory comment (Copilot): the frontend will proxy Server-Sent Events, and any non-zero WriteTimeout would tear down long-lived SSE responses mid-stream. Per-route timeouts will be applied to non-SSE routes when they are added. Tests: - New TestLoadBuildInfo_AppliesEnvOverrides verifies the startup-cache path. - New TestLoadBuildInfo_UsesDefaultsWhenUnset verifies the fallback path. - Existing tests now call initBuildInfoForTests so /healthz sees populated values (main() is what loads the cache in production, and that does not run during `go test`). --- frontend/cmd/server/main.go | 77 +++++++++++++++++++++++++------- frontend/cmd/server/main_test.go | 54 ++++++++++++++++++++++ 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/frontend/cmd/server/main.go b/frontend/cmd/server/main.go index fde4481..f737580 100644 --- a/frontend/cmd/server/main.go +++ b/frontend/cmd/server/main.go @@ -48,26 +48,61 @@ type BuildInfo struct { Repository string `json:"repository"` } -// healthzHandler returns 200 with the BuildInfo struct. It performs no -// authentication, has no external dependencies, and never blocks — matching -// the contract of the backend's /healthz so the same orchestrator probe -// configuration works against both containers. -func healthzHandler(w http.ResponseWriter, _ *http.Request) { - info := BuildInfo{ +// buildInfo is captured once at startup so /healthz does not hit os.Getenv +// on every request. The HUB_* values are baked into the image by the +// Dockerfile and never change for a running container — caching them +// removes per-request syscalls on what is meant to be a cheap probe. +var buildInfo BuildInfo + +// loadBuildInfo reads HUB_* env vars once and returns the BuildInfo. Kept +// as a separate function so tests can call it explicitly after t.Setenv +// without depending on package-load ordering. +func loadBuildInfo() BuildInfo { + return BuildInfo{ Status: "ok", Version: envDefault("HUB_VERSION", "0.0.0-dev"), Revision: envDefault("HUB_REVISION", "unknown"), BuildDate: envDefault("HUB_BUILD_DATE", "1970-01-01T00:00:00Z"), Repository: envDefault("HUB_REPO_URL", defaultRepoURL), } +} + +// healthzHandler returns 200 with the cached BuildInfo struct. It performs +// no authentication, has no external dependencies, and never blocks — +// matching the contract of the backend's /healthz so the same orchestrator +// probe configuration works against both containers. +func healthzHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(info); err != nil { + if err := json.NewEncoder(w).Encode(buildInfo); err != nil { // Encoding a fixed-shape struct should never fail; if it does the // connection is dead anyway. Log and return — no further writes. slog.Error("failed to encode healthz response", "err", err) } } +// slogRequestLogger returns a chi middleware that emits one structured log +// line per request using the global slog logger. chi's bundled +// middleware.Logger writes to the legacy stdlib `log` package which bypasses +// our slog handler — that means request lines would not honour the log +// level, format, or destination configured elsewhere. We keep the +// implementation small on purpose; it can grow when we add real routes. +func slogRequestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "bytes", ww.BytesWritten(), + "duration_ms", time.Since(start).Milliseconds(), + "request_id", middleware.GetReqID(r.Context()), + "remote_ip", r.RemoteAddr, + ) + }) +} + // newRouter builds the chi router. Kept as a separate function so tests can // exercise it without spinning up an actual HTTP server. func newRouter() *chi.Mux { @@ -75,7 +110,7 @@ func newRouter() *chi.Mux { r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) - r.Use(middleware.Logger) + r.Use(slogRequestLogger) r.Get("/healthz", healthzHandler) return r @@ -89,6 +124,8 @@ func envDefault(key, fallback string) string { } func main() { + buildInfo = loadBuildInfo() + port := envDefault("PORT", "8080") addr := ":" + port @@ -98,16 +135,24 @@ func main() { Handler: r, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 120 * time.Second, + // WriteTimeout is intentionally 0 (no deadline). The frontend will + // proxy Server-Sent Events from the backend — a single SSE response + // can stay open for minutes or hours, and any non-zero WriteTimeout + // would tear it down mid-stream. Per-route timeouts will be applied + // to non-SSE routes when they are added. + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, } - // Graceful shutdown on SIGTERM/SIGINT — important because docker stop - // sends SIGTERM and we don't want to drop in-flight requests. + // Register signal handler BEFORE starting the listener. If we waited + // until after `go func()` returned its first scheduling slice, a SIGTERM + // arriving during that window would terminate the process by default + // instead of triggering graceful shutdown. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + idleConnsClosed := make(chan struct{}) go func() { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) <-sigCh slog.Info("shutdown signal received") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -120,8 +165,8 @@ func main() { slog.Info("starting frontend", "addr", addr, - "version", envDefault("HUB_VERSION", "0.0.0-dev"), - "revision", envDefault("HUB_REVISION", "unknown")) + "version", buildInfo.Version, + "revision", buildInfo.Revision) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server stopped with error", "err", err) os.Exit(1) diff --git a/frontend/cmd/server/main_test.go b/frontend/cmd/server/main_test.go index b9407a4..2656ea0 100644 --- a/frontend/cmd/server/main_test.go +++ b/frontend/cmd/server/main_test.go @@ -8,8 +8,17 @@ import ( "testing" ) +// initBuildInfoForTests loads the package-level buildInfo from the current +// env. Tests that exercise /healthz must call this — main() is what loads +// buildInfo in production, and that does not run during `go test`. +func initBuildInfoForTests(t *testing.T) { + t.Helper() + buildInfo = loadBuildInfo() +} + func TestHealthz_ReturnsOK(t *testing.T) { t.Parallel() + initBuildInfoForTests(t) r := newRouter() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() @@ -22,6 +31,7 @@ func TestHealthz_ReturnsOK(t *testing.T) { func TestHealthz_BodyShape(t *testing.T) { t.Parallel() + initBuildInfoForTests(t) r := newRouter() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() @@ -50,6 +60,7 @@ func TestHealthz_BodyShape(t *testing.T) { func TestHealthz_ContentTypeJSON(t *testing.T) { t.Parallel() + initBuildInfoForTests(t) r := newRouter() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() @@ -63,6 +74,7 @@ func TestHealthz_ContentTypeJSON(t *testing.T) { func TestHealthz_NoAuthRequired(t *testing.T) { t.Parallel() + initBuildInfoForTests(t) r := newRouter() // No Authorization header — container orchestrators probe healthz without credentials. req := httptest.NewRequest(http.MethodGet, "/healthz", nil) @@ -76,6 +88,7 @@ func TestHealthz_NoAuthRequired(t *testing.T) { func TestHealthz_DoesNotLeakSecrets(t *testing.T) { t.Parallel() + initBuildInfoForTests(t) r := newRouter() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() @@ -115,3 +128,44 @@ func TestEnvDefault_UsesFallbackWhenEmpty(t *testing.T) { t.Errorf("envDefault with empty env returned %q, want fallback", got) } } + +func TestLoadBuildInfo_AppliesEnvOverrides(t *testing.T) { + // Confirms the startup-cache path actually reads env. t.Setenv prevents + // parallel execution, which is fine — this is a small synchronous check. + t.Setenv("HUB_VERSION", "9.9.9") + t.Setenv("HUB_REVISION", "deadbeef") + t.Setenv("HUB_BUILD_DATE", "2099-12-31T23:59:59Z") + t.Setenv("HUB_REPO_URL", "https://example.invalid/fork") + + got := loadBuildInfo() + if got.Version != "9.9.9" { + t.Errorf("Version = %q, want %q", got.Version, "9.9.9") + } + if got.Revision != "deadbeef" { + t.Errorf("Revision = %q, want %q", got.Revision, "deadbeef") + } + if got.BuildDate != "2099-12-31T23:59:59Z" { + t.Errorf("BuildDate = %q, want %q", got.BuildDate, "2099-12-31T23:59:59Z") + } + if got.Repository != "https://example.invalid/fork" { + t.Errorf("Repository = %q, want override URL", got.Repository) + } +} + +func TestLoadBuildInfo_UsesDefaultsWhenUnset(t *testing.T) { + t.Setenv("HUB_VERSION", "") + t.Setenv("HUB_REVISION", "") + t.Setenv("HUB_BUILD_DATE", "") + t.Setenv("HUB_REPO_URL", "") + + got := loadBuildInfo() + if got.Status != "ok" { + t.Errorf("Status = %q, want %q", got.Status, "ok") + } + if got.Version != "0.0.0-dev" { + t.Errorf("Version = %q, want default %q", got.Version, "0.0.0-dev") + } + if got.Repository != defaultRepoURL { + t.Errorf("Repository = %q, want default %q", got.Repository, defaultRepoURL) + } +} From 5bfba551b3302446bc002a576f85ee4c95c8b87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 10 May 2026 19:58:31 +0000 Subject: [PATCH 4/5] fix(frontend): use sync.Once in test build-info init to fix race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go test -race` flagged a race on the global `buildInfo` var: every healthz test calls `initBuildInfoForTests`, and with `t.Parallel()` those calls run concurrently — multiple goroutines wrote to the same variable. Wrapping the write in `sync.Once` makes the initialization race-free: the first caller populates `buildInfo`, every subsequent caller is a no-op. Test correctness is preserved because `loadBuildInfo()` is pure with respect to the env it reads — once it has produced a value, calling it again with the same env would produce the same value. --- frontend/cmd/server/main_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/cmd/server/main_test.go b/frontend/cmd/server/main_test.go index 2656ea0..793fef3 100644 --- a/frontend/cmd/server/main_test.go +++ b/frontend/cmd/server/main_test.go @@ -5,15 +5,24 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" ) // initBuildInfoForTests loads the package-level buildInfo from the current // env. Tests that exercise /healthz must call this — main() is what loads // buildInfo in production, and that does not run during `go test`. +// +// sync.Once makes this safe for `t.Parallel()` tests: many goroutines may +// call it concurrently, but the write to the global `buildInfo` only +// happens once. Without this, `go test -race` (which CI runs) fails. +var initBuildInfoOnce sync.Once + func initBuildInfoForTests(t *testing.T) { t.Helper() - buildInfo = loadBuildInfo() + initBuildInfoOnce.Do(func() { + buildInfo = loadBuildInfo() + }) } func TestHealthz_ReturnsOK(t *testing.T) { From b19866925cd87162bfe5ee2277c9725c0bfea2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 10 May 2026 20:09:27 +0000 Subject: [PATCH 5/5] fix(ci): repair docker-publish.yml startup failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow has been failing with "workflow file issue" (0 jobs, 0s) on every push, release, and workflow_dispatch trigger since the initial commit — no container images have ever been published. Two root causes: 1. `matrix.service` was referenced in a job-level `if:` expression: if: hashFiles(format('{0}/Dockerfile', matrix.service)) != '' The matrix context is not available before the matrix expands, and the job-level `if:` evaluates BEFORE expansion. GitHub treats this as a startup failure and reports zero jobs. Replaced with a step-level `test -f` check after checkout, which has matrix.service available. 2. `secrets.*` was referenced in a step-level `if:` expression: if: secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' Since the 2024 Actions hardening, `secrets` is no longer a recognised named-value in step `if:` conditions ("Unrecognized named-value: 'secrets'"). Surfacing the secrets into the step's env first and then conditioning on env.* is the documented workaround. After these two fixes the workflow parses cleanly, the matrix expands into [backend, frontend], and both images can be built and pushed to GHCR (Docker Hub is still optional and gated on the env-var check). --- .github/workflows/docker-publish.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 05ed5fb..e0ab2e6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -21,7 +21,6 @@ jobs: publish: name: Build and push (${{ matrix.service }}) runs-on: ubuntu-24.04 - if: hashFiles(format('{0}/Dockerfile', matrix.service)) != '' strategy: fail-fast: true # if backend fails, don't push frontend mismatched matrix: @@ -29,6 +28,9 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Verify Dockerfile exists for ${{ matrix.service }} + run: test -f "${{ matrix.service }}/Dockerfile" + - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -42,8 +44,15 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # `secrets.*` is not allowed in step-level `if:` expressions — Actions + # rejects it as "Unrecognized named-value: 'secrets'". We surface the + # value into the step env first, then condition on env.* which IS + # allowed. - name: Log in to Docker Hub - if: secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }}