thermograph/frontend/Dockerfile
emi 92e74c585a
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
frontend: rewrite the SSR content service in Go (#28)
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No
climate math, no DB, no auth -- every route fetches from the backend's
/content/* API.

Verified with a golden-HTML diff, not just unit tests: both the Python
original and the Go rewrite were run against the same committed fixtures
and every route compared byte-for-byte, confirmed programmatically. That
process caught defects unit tests alone missed, since map[string]any has
no compile-time field check:

- Render-context keys were snake_case throughout while the templates read
  PascalCase fields. A missing map key doesn't error, it silently renders
  empty -- title, meta description, canonical URL, OpenGraph tags, and the
  homepage's entire ranked list were blank on every page despite every
  route returning 200. Fixed by renaming every key to match each
  template's own documented field contract, and passing API structs
  straight through wherever their fields already matched (removes a whole
  layer of future drift risk).
- Three pages 500'd: ToolHref needed a composed href, not a bare
  "lat,lon" fragment; the records table needed the raw API struct.
- JSON-LD was double-encoded: <script type="application/ld+json"> is
  JAVASCRIPT context to html/template's escaper regardless of the
  script's type attribute, so template.HTML gets re-escaped as a quoted
  JS string. Needed template.JS. The glossary term page's JSON-LD was
  never built at all -- added.
- html/template silently strips literal HTML and JS comments from parsed
  output (verified in isolation) -- both need a FuncMap function
  returning template.HTML/template.JS to survive.

Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the
Swarm stack's entrypoint override with no explicit command drops the
image's CMD entirely (every deploy would have exited 127), and
COPY --chown by name fails under the classic Docker builder on Alpine.
Both fixed.

go build/vet/test -race clean; docker build passes its embedded test step
under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00

91 lines
4.2 KiB
Docker

# Thermograph frontend: server-rendered content pages, the interactive tool's
# SPA shells, and every static asset. Split from the monorepo (repo-split
# Stage 7), rewritten as a Go service (server/). No migrations, no DB, no
# pre-boot logic -- a plain exec-form CMD is enough (unlike backend, no
# separate entrypoint script needed).
#
# Multi-stage: the golang builder runs vet + the full Go test suite before
# building, so every published image provably passed the hermetic tier with
# the exact toolchain that compiled the shipping binary (this replaces the
# old in-image pytest step in .forgejo/workflows/build.yml -- the runtime
# image carries no toolchain to test with). The final stage is Alpine, not
# distroless: the Swarm stack (infra/deploy/stack/thermograph-stack.yml)
# bind-mounts a bash entrypoint shim (env-entrypoint.sh) over this image's
# entrypoint, so bash must exist inside the container; curl serves the
# HEALTHCHECK, same line as ever.
FROM golang:1.26 AS builder
WORKDIR /src
# Module graph first so the download layer caches across source-only changes.
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
# internal/content's tests read two directories the same three-levels-up
# relative path away from the test file's own package dir (go test always
# runs with cwd set there): the committed golden fixtures (frontend/tests/
# fixtures/*.json — the same set the Python golden-diff comparison used) and
# the SSR copy (frontend/content/*.yaml, content_loader.go's LoadGlossary
# etc.). This stage only copies server/ into /src (so /src has no "frontend/"
# parent to climb to), which is why both land at container-root paths here
# instead — same three-levels-up relationship the tests' relative paths
# expect, just anchored differently.
COPY tests/fixtures /tests/fixtures
COPY content /content
RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./...
# Static binary: CGO off (no libc dependency on Alpine), -trimpath for
# reproducible paths, -s -w to strip debug info the container never uses.
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
-o /out/thermograph-frontend .
FROM alpine:3.22
# bash: required by the Swarm stack's env-entrypoint.sh shim (see above).
# curl: the HEALTHCHECK below (pulls in ca-certificates as a dependency).
RUN apk add --no-cache bash curl
# Same uid as the Python image: 10001 is the uid infra provisions readable
# secrets for (deploy-stack.sh installs /etc/thermograph/stack.env
# uid-10001-readable) -- do not change it. Explicit group (Alpine's `adduser
# -S` with no -G falls back to an existing system group, not a same-named
# one -- a bare `--chown=thermograph` below then has no "thermograph" group
# to resolve, which the classic (non-BuildKit) builder rejects outright).
RUN addgroup -S -g 10001 thermograph \
&& adduser -S -u 10001 -G thermograph -h /home/thermograph thermograph
COPY --from=builder /out/thermograph-frontend /usr/local/bin/thermograph-frontend
# The binary embeds its HTML templates (server/internal/render); static/ and
# content/ stay on disk, resolved relative to the working directory (see
# server/internal/config: StaticDir="static", ContentDir="content"), so /app
# mirrors the repo layout the config expects. Read-only at runtime -- the
# service is stateless and holds no data of its own.
#
# Numeric --chown, not the name: needs no /etc/passwd|group lookup at COPY
# time, so it works identically under BuildKit and the classic builder (the
# CI runner installs plain `docker.io`, no buildx plugin, so a build there
# silently uses the classic builder unless BuildKit is forced).
COPY --chown=10001:10001 static/ /app/static/
COPY --chown=10001:10001 content/ /app/content/
USER thermograph
WORKDIR /app
# No WORKERS knob anymore: uvicorn needed a process count, the Go server
# handles concurrency in one process. (The stack/compose files never set it
# for frontend, so nothing references it.)
ENV PORT=8080 \
THERMOGRAPH_BASE=/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://127.0.0.1:${PORT}/healthz || exit 1
# Exec form, no shell wrapper: the Swarm shim receives this CMD as $@ and
# execs the binary directly; PID 1 gets SIGTERM and shuts down gracefully.
CMD ["/usr/local/bin/thermograph-frontend"]