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 }} 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..f737580 --- /dev/null +++ b/frontend/cmd/server/main.go @@ -0,0 +1,176 @@ +// 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"` +} + +// 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(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 { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(slogRequestLogger) + + r.Get("/healthz", healthzHandler) + return r +} + +func envDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func main() { + buildInfo = loadBuildInfo() + + port := envDefault("PORT", "8080") + addr := ":" + port + + r := newRouter() + srv := &http.Server{ + Addr: addr, + Handler: r, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * 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, + } + + // 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 + 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", 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) + } + <-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..793fef3 --- /dev/null +++ b/frontend/cmd/server/main_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "encoding/json" + "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() + initBuildInfoOnce.Do(func() { + buildInfo = loadBuildInfo() + }) +} + +func TestHealthz_ReturnsOK(t *testing.T) { + t.Parallel() + initBuildInfoForTests(t) + 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() + initBuildInfoForTests(t) + 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() + initBuildInfoForTests(t) + 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() + initBuildInfoForTests(t) + 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() + initBuildInfoForTests(t) + 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.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" { + t.Errorf("envDefault returned %q, want expected", got) + } +} + +func TestEnvDefault_UsesFallbackWhenEmpty(t *testing.T) { + 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) + } +} + +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) + } +} 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=