From a4ecb5140136b954bef0921128e6634dcd378868 Mon Sep 17 00:00:00 2001 From: emi Date: Sat, 25 Jul 2026 04:13:47 +0000 Subject: [PATCH] web/worker: add a process-level liveness heartbeat (#80) --- .forgejo/workflows/build.yml | 32 +- backend/docker-compose.test.yml | 1 + backend/tests/conftest.py | 1 + backend/tests/data/test_climate.py | 43 + backend/tests/web/test_heartbeat.py | 91 ++ backend/web/app.py | 35 + frontend/.dockerignore | 29 + frontend/Dockerfile | 97 +- frontend/server/README.md | 59 ++ frontend/server/go.mod | 5 + frontend/server/go.sum | 4 + frontend/server/internal/config/config.go | 127 +++ .../server/internal/config/config_test.go | 137 +++ frontend/server/internal/content/content.go | 268 ++++++ .../server/internal/content/content_test.go | 892 ++++++++++++++++++ frontend/server/internal/content/funcmap.go | 177 ++++ frontend/server/internal/content/pages.go | 708 ++++++++++++++ frontend/server/internal/content/seo.go | 119 +++ frontend/server/internal/contentapi/client.go | 329 +++++++ .../server/internal/contentapi/client_test.go | 251 +++++ frontend/server/internal/contentapi/types.go | 281 ++++++ .../server/internal/contentdata/loader.go | 129 +++ .../internal/contentdata/loader_test.go | 111 +++ frontend/server/internal/format/bands.go | 71 ++ frontend/server/internal/format/bands_test.go | 138 +++ frontend/server/internal/format/format.go | 362 +++++++ .../server/internal/format/format_test.go | 257 +++++ frontend/server/internal/handlers/handlers.go | 137 +++ .../server/internal/handlers/handlers_test.go | 277 ++++++ frontend/server/internal/handlers/shells.go | 131 +++ frontend/server/internal/handlers/static.go | 62 ++ frontend/server/internal/render/render.go | 136 +++ .../server/internal/render/render_test.go | 102 ++ .../internal/render/templates/about.html.tmpl | 30 + .../internal/render/templates/base.html.tmpl | 169 ++++ .../internal/render/templates/city.html.tmpl | 101 ++ .../render/templates/glossary.html.tmpl | 10 + .../render/templates/glossary_term.html.tmpl | 21 + .../internal/render/templates/home.html.tmpl | 182 ++++ .../internal/render/templates/hub.html.tmpl | 80 ++ .../internal/render/templates/month.html.tmpl | 60 ++ .../render/templates/privacy.html.tmpl | 31 + .../render/templates/records.html.tmpl | 95 ++ frontend/server/main.go | 174 ++++ infra/deploy/deploy.sh | 14 +- infra/deploy/forgejo/README.md | 65 ++ infra/deploy/forgejo/ci-runner/Dockerfile | 46 + infra/deploy/forgejo/docker-stack.yml | 8 + infra/deploy/forgejo/register-lan-runner.sh | 14 +- infra/deploy/stack/env-entrypoint.sh | 11 +- infra/deploy/stack/thermograph-stack.yml | 6 + infra/docker-compose.yml | 10 +- 52 files changed, 6671 insertions(+), 55 deletions(-) create mode 100644 backend/tests/web/test_heartbeat.py create mode 100644 frontend/.dockerignore create mode 100644 frontend/server/README.md create mode 100644 frontend/server/go.mod create mode 100644 frontend/server/go.sum create mode 100644 frontend/server/internal/config/config.go create mode 100644 frontend/server/internal/config/config_test.go create mode 100644 frontend/server/internal/content/content.go create mode 100644 frontend/server/internal/content/content_test.go create mode 100644 frontend/server/internal/content/funcmap.go create mode 100644 frontend/server/internal/content/pages.go create mode 100644 frontend/server/internal/content/seo.go create mode 100644 frontend/server/internal/contentapi/client.go create mode 100644 frontend/server/internal/contentapi/client_test.go create mode 100644 frontend/server/internal/contentapi/types.go create mode 100644 frontend/server/internal/contentdata/loader.go create mode 100644 frontend/server/internal/contentdata/loader_test.go create mode 100644 frontend/server/internal/format/bands.go create mode 100644 frontend/server/internal/format/bands_test.go create mode 100644 frontend/server/internal/format/format.go create mode 100644 frontend/server/internal/format/format_test.go create mode 100644 frontend/server/internal/handlers/handlers.go create mode 100644 frontend/server/internal/handlers/handlers_test.go create mode 100644 frontend/server/internal/handlers/shells.go create mode 100644 frontend/server/internal/handlers/static.go create mode 100644 frontend/server/internal/render/render.go create mode 100644 frontend/server/internal/render/render_test.go create mode 100644 frontend/server/internal/render/templates/about.html.tmpl create mode 100644 frontend/server/internal/render/templates/base.html.tmpl create mode 100644 frontend/server/internal/render/templates/city.html.tmpl create mode 100644 frontend/server/internal/render/templates/glossary.html.tmpl create mode 100644 frontend/server/internal/render/templates/glossary_term.html.tmpl create mode 100644 frontend/server/internal/render/templates/home.html.tmpl create mode 100644 frontend/server/internal/render/templates/hub.html.tmpl create mode 100644 frontend/server/internal/render/templates/month.html.tmpl create mode 100644 frontend/server/internal/render/templates/privacy.html.tmpl create mode 100644 frontend/server/internal/render/templates/records.html.tmpl create mode 100644 frontend/server/main.go create mode 100644 infra/deploy/forgejo/ci-runner/Dockerfile diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 7b8e950..7ab6799 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -35,24 +35,24 @@ jobs: - name: Build run: docker build -t thermograph-${{ inputs.domain }}:ci ${{ inputs.domain }}/ - # Run the hermetic test tier INSIDE the image we just built (each image - # ships tests/ + every runtime dep via COPY . /app/): the exact + # Run the hermetic test tier INSIDE the image we just built (the backend + # image ships tests/ + every runtime dep via COPY . /app/): the exact # interpreter/deps that ship, none of the runner-environment quirks that - # sank the old host-side boot check. Backend runs its full (hermetic) - # suite; frontend runs only its unit tier -- its integration tier - # (frontend/tests/integration/, needs a live backend container) stays a - # local `make`/scripts concern, see frontend/scripts/backend-for-tests.sh. + # sank the old host-side boot check. Backend only: the frontend image is + # a Go binary with no interpreter or toolchain to test with -- its vet + + # test suite runs in frontend/Dockerfile's builder stage instead, so the + # Build step above already fails when the Go tests do; its integration + # tier (needs a live backend container) stays a local `make`/scripts + # concern, see frontend/scripts/backend-for-tests.sh. # requirements-dev only layers pytest on top, so the install is tiny. - # -u 0: the images' default user can't write to site-packages. + # -u 0: the image's default user can't write to site-packages. # --entrypoint sh: backend's entrypoint is alembic-migrate + uvicorn -- # without the override the test command is swallowed as entrypoint args - # and the container just boots the server forever (harmless no-op for - # frontend, which has no entrypoint). unset THERMOGRAPH_BASE: the images - # bake the prod root-mount (/), which moves every route off the - # /thermograph prefix the tests are written against. - - name: Run tests in the built image + # and the container just boots the server forever. unset + # THERMOGRAPH_BASE: the image bakes the prod root-mount (/), which moves + # every route off the /thermograph prefix the tests are written against. + - name: Run tests in the built image (backend only) + if: inputs.domain == 'backend' run: | - target=tests - if [ "${{ inputs.domain }}" = "frontend" ]; then target=tests/unit; fi - docker run --rm -u 0 --entrypoint sh thermograph-${{ inputs.domain }}:ci \ - -c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest $target -q" + docker run --rm -u 0 --entrypoint sh thermograph-backend:ci \ + -c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest tests -q" diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml index d110466..2b1addb 100644 --- a/backend/docker-compose.test.yml +++ b/backend/docker-compose.test.yml @@ -33,6 +33,7 @@ services: THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://127.0.0.1:1 THERMOGRAPH_BASE: "/" THERMOGRAPH_ENABLE_NOTIFIER: "0" + THERMOGRAPH_ENABLE_HEARTBEAT: "0" PORT: "8137" WORKERS: "1" ports: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fb76f4f..f4f92fb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,6 +28,7 @@ _TMP = tempfile.mkdtemp(prefix="thermograph-tests-") # start the background notifier thread during tests. Set before db is imported. os.environ.setdefault("THERMOGRAPH_ACCOUNTS_DB", os.path.join(_TMP, "accounts.sqlite")) os.environ.setdefault("THERMOGRAPH_ENABLE_NOTIFIER", "0") +os.environ.setdefault("THERMOGRAPH_ENABLE_HEARTBEAT", "0") # web/app.py (repo-split Stage 4) fails loud at import if this is unset -- tests # never actually hit a frontend-owned path through the live proxy (that's # frontend_ssr/tests' job), so an unreachable placeholder is fine here. diff --git a/backend/tests/data/test_climate.py b/backend/tests/data/test_climate.py index 9ca1932..edaae3f 100644 --- a/backend/tests/data/test_climate.py +++ b/backend/tests/data/test_climate.py @@ -258,6 +258,49 @@ def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch): assert df.height == climate._to_frame(_om_daily(3)).height +def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch): + """The Open-Meteo fallback must not grade the cell's in-progress local day: its + daily high/low is only a partial aggregate of the hours elapsed so far (a cool + morning would read as a record-low high). Past days and future forecast days + survive; today — per the UTC offset Open-Meteo reports for a timezone=auto + request — is dropped, matching the MET path's diurnal-coverage gate.""" + offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai incident) + local_today = (datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(seconds=offset)).date() + days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)] + daily = { + "time": [d.isoformat() for d in days], + "temperature_2m_max": [80.0, 82.0, 61.0, 84.0], # today's 61 is the partial value + "temperature_2m_min": [60.0, 61.0, 57.0, 62.0], + "precipitation_sum": [0.0, 0.0, 0.0, 0.0], + } + payload = {"utc_offset_seconds": offset, "daily": daily} + + class Resp: + def json(self): return payload + monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp()) + + got = climate._fetch_recent_forecast_om( + {"center_lat": 54.9, "center_lon": 23.8})["date"].to_list() + assert local_today not in got # partial today dropped + assert local_today - datetime.timedelta(days=1) in got # yesterday kept + assert local_today + datetime.timedelta(days=1) in got # tomorrow's forecast kept + assert len(got) == 3 + + +def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch): + """No UTC offset reported -> skip the in-progress-day guard rather than guess a + date, so the bundle passes through as before (only the usual null-day filter).""" + payload = {"daily": _om_daily(3)} # no utc_offset_seconds + + class Resp: + def json(self): return payload + monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp()) + + df = climate._fetch_recent_forecast_om({"center_lat": 1.0, "center_lon": 2.0}) + assert df.height == climate._to_frame(_om_daily(3)).height + + def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path): """With every source down (the NASA + MET Norway primary and the Open-Meteo fallback), an existing (stale) cache is served rather than failing — and it is NOT diff --git a/backend/tests/web/test_heartbeat.py b/backend/tests/web/test_heartbeat.py new file mode 100644 index 0000000..d65bd19 --- /dev/null +++ b/backend/tests/web/test_heartbeat.py @@ -0,0 +1,91 @@ +"""Process-level liveness heartbeat for web/worker (app.py _heartbeat_loop / +_start_heartbeat) — the fix for ProdWebContainerSilent / ProdWorkerContainerSilent +false-firing on container-stdout silence that isn't a real liveness signal for +either role.""" +import json +import time + +import pytest +from fastapi.testclient import TestClient + +from core import audit +from web import app as appmod + + +@pytest.fixture(autouse=True) +def _reset_heartbeat_thread(): + # _HEARTBEAT_THREAD is a module-level singleton guard (mirrors notify.py's own + # _STOP/_THREAD pattern) -- reset it around every test so one test starting the + # thread doesn't leave _start_heartbeat() a no-op for the next. + appmod._HEARTBEAT_STOP.clear() + appmod._HEARTBEAT_THREAD = None + yield + appmod._HEARTBEAT_STOP.set() + appmod._HEARTBEAT_THREAD = None + + +@pytest.mark.parametrize("role", ["web", "worker", "all"]) +def test_heartbeat_loop_tags_the_beat_with_role(monkeypatch, role): + monkeypatch.setattr(appmod, "ROLE", role) + calls = [] + monkeypatch.setattr(audit, "log_heartbeat", lambda daemon, interval_s: calls.append((daemon, interval_s))) + monkeypatch.setattr(appmod.metrics, "record_heartbeat", lambda daemon, interval_s: None) + + # Setting the stop event first means .wait() returns True immediately, so the + # loop beats exactly once (the pre-loop beat) and exits without blocking. + appmod._HEARTBEAT_STOP.set() + appmod._heartbeat_loop() + + assert calls == [(role, appmod.HEARTBEAT_INTERVAL)] + + +def test_start_heartbeat_respects_the_disable_flag(monkeypatch): + monkeypatch.setenv("THERMOGRAPH_ENABLE_HEARTBEAT", "0") + started = [] + monkeypatch.setattr(appmod.threading, "Thread", lambda **kw: started.append(kw)) + + appmod._start_heartbeat() + + assert appmod._HEARTBEAT_THREAD is None + assert started == [] + + +def test_start_heartbeat_is_idempotent(monkeypatch): + monkeypatch.delenv("THERMOGRAPH_ENABLE_HEARTBEAT", raising=False) + starts = [] + + class _FakeThread: + def __init__(self, **kw): + starts.append(kw) + + def start(self): + pass + + monkeypatch.setattr(appmod.threading, "Thread", _FakeThread) + + appmod._start_heartbeat() + appmod._start_heartbeat() + + # A second call is a no-op once a thread handle already exists -- mirrors + # notify.start()'s own idempotency, so a double-entry into the lifespan + # (or a stray manual call) can't spawn a second beat loop. + assert len(starts) == 1 + + +def test_app_boot_emits_a_heartbeat_within_one_interval(monkeypatch, tmp_path): + monkeypatch.setattr(appmod, "ROLE", "web") + monkeypatch.setattr(appmod, "HEARTBEAT_INTERVAL", 0.01) + monkeypatch.setattr(audit, "HEARTBEAT_DIR", str(tmp_path)) + monkeypatch.delenv("THERMOGRAPH_ENABLE_HEARTBEAT", raising=False) + + with TestClient(appmod.app): + # The pre-loop beat fires synchronously on thread start; a short sleep is + # just slack for the thread to actually get scheduled. The loop itself + # keeps running on HEARTBEAT_INTERVAL until lifespan shutdown sets the + # stop event, so there's nothing to join here. + time.sleep(0.2) + + files = list(tmp_path.glob("heartbeat-*.jsonl")) + assert files, "expected a heartbeat-*.jsonl file to be written during app lifespan" + lines = [json.loads(line) for line in files[0].read_text().splitlines() if line] + assert any(rec["daemon"] == "web" and rec["tag"] == "heartbeat" for rec in lines) diff --git a/backend/web/app.py b/backend/web/app.py index 5949033..cbec4ae 100644 --- a/backend/web/app.py +++ b/backend/web/app.py @@ -154,6 +154,39 @@ def _should_run_notifier() -> bool: return ROLE in ("worker", "all") and singleton.claim_leader() +_HEARTBEAT_STOP = threading.Event() +_HEARTBEAT_THREAD: threading.Thread | None = None +HEARTBEAT_INTERVAL = float(os.environ.get("THERMOGRAPH_HEARTBEAT_INTERVAL", "300")) # 5 min + + +def _heartbeat_loop() -> None: + metrics.record_heartbeat(ROLE, HEARTBEAT_INTERVAL) + audit.log_heartbeat(ROLE, HEARTBEAT_INTERVAL) + while not _HEARTBEAT_STOP.wait(HEARTBEAT_INTERVAL): + metrics.record_heartbeat(ROLE, HEARTBEAT_INTERVAL) + audit.log_heartbeat(ROLE, HEARTBEAT_INTERVAL) + + +def _start_heartbeat() -> None: + """Process-level liveness beat, tagged daemon=ROLE, independent of whether this + process also runs the notifier. web's and worker's own stdout is near-silent by + design (real logging goes to the app-json file source; worker's Docker/stdout + stream is dropped entirely by Alloy), so container-log-silence alerting can't + tell a live process from a dead one for either role — this heartbeat is the + signal observability actually alerts on instead. Deliberately unguarded across + every uvicorn worker process and every autoscaled replica: a beat is a single + local file append (no upstream quota, no DB, no lock), so duplicate beats are + harmless, and only the process actually being dead stops them — see + _should_run_notifier's leader election for the contrasting case where + duplication would be a real cost.""" + global _HEARTBEAT_THREAD + if os.environ.get("THERMOGRAPH_ENABLE_HEARTBEAT", "1") == "0" or _HEARTBEAT_THREAD is not None: + return + _HEARTBEAT_STOP.clear() + _HEARTBEAT_THREAD = threading.Thread(target=_heartbeat_loop, name=f"heartbeat-{ROLE}", daemon=True) + _HEARTBEAT_THREAD.start() + + @contextlib.asynccontextmanager async def _lifespan(app): # Warm the local place-name index (/suggest's typo tolerance) in the @@ -167,6 +200,7 @@ async def _lifespan(app): await db.create_db_and_tables() places.start_loading() threading.Thread(target=_neighbor_warmer, name="neighbor-warmer", daemon=True).start() + _start_heartbeat() # Background subscription evaluator. Its periodic sweep fetches upstream on a timer # (not per request), so with multiple workers — or multiple *hosts* under Swarm — # it must run in exactly one: elect a leader (see singleton.claim_leader, which @@ -189,6 +223,7 @@ async def _lifespan(app): yield audit.log_activity("app.stop", {"role": ROLE, "pid": os.getpid()}) notify.stop() + _HEARTBEAT_STOP.set() await _frontend_client.aclose() diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6da5f36 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,29 @@ +# The build only COPYs server/, static/ and content/ -- everything else just +# bloats the context upload. Venvs especially: an untracked .venv silently +# swallowed by a broad COPY tripled the backend image once (Stage 2); keep +# them excluded even though no COPY should reach them. +.git +.gitignore +.venv/ +.venv-test/ +__pycache__/ +**/__pycache__ +*.pyc +.pytest_cache/ +**/node_modules +tests/ +# ...except the golden fixtures the Go build stage copies in to run +# internal/content's tests (see the Dockerfile's COPY tests/fixtures step). +!tests/fixtures/ +!tests/fixtures/** +tools/ +templates/ +scripts/ +*.py +requirements.txt +requirements-dev.txt +docker-compose.test.yml +Dockerfile +.dockerignore +Makefile +*.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6270314..692ecb9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,40 +1,91 @@ # Thermograph frontend: server-rendered content pages, the interactive tool's # SPA shells, and every static asset. Split from the monorepo (repo-split -# Stage 7). No migrations, no DB, no pre-boot logic -- a plain shell-form CMD -# is enough (unlike backend, no separate entrypoint script needed). -FROM python:3.12-slim +# 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 -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* +WORKDIR /src -COPY requirements.txt /tmp/requirements.txt -RUN pip install --no-cache-dir -r /tmp/requirements.txt +# Module graph first so the download layer caches across source-only changes. +COPY server/go.mod server/go.sum ./ +RUN go mod download -COPY . /app/ +COPY server/ ./ -RUN useradd --system --create-home --uid 10001 thermograph \ - && chown -R thermograph:thermograph /app +# 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 \ - WORKERS=1 \ - THERMOGRAPH_BASE=/ \ - PYTHONUNBUFFERED=1 + 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 -# Worker count is env-driven (mirrors thermograph-backend's Dockerfile/ -# entrypoint WORKERS pattern) so infra can raise it later with no code -# change. Default of 1 preserves today's exact behavior (no --workers was -# ever passed before). api_client's TTL cache and register()'s IndexNow-key -# handling are both in-process only -- multiple workers just each keep their -# own independent cache, and the IndexNow key is fetched fresh FROM the -# backend by every worker's own boot, so there's no cross-worker state to -# diverge -- safe to raise whenever infra wants the throughput. -CMD uvicorn app:app --host 0.0.0.0 --port ${PORT} --workers ${WORKERS:-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"] diff --git a/frontend/server/README.md b/frontend/server/README.md new file mode 100644 index 0000000..9eb1425 --- /dev/null +++ b/frontend/server/README.md @@ -0,0 +1,59 @@ +# thermograph-frontend (Go) + +The SSR frontend service, ported from the Python implementation one directory +up (`app.py` / `content.py` / `api_client.py` / `format.py`): server-rendered +content pages, the interactive tool's SPA shells, and every static asset. It +is I/O-bound glue over the backend's `/content/*` JSON API — no climate maths, +no database, no auth. + +## Layout + + go.mod module thermograph/frontend (Go 1.26) + main.go config + mux + static + graceful shutdown + internal/config/ every env var the service reads (same names, + defaults and required/optional split as the + Python — see config.go's field docs) + internal/contentapi/ backend /content/* client: TTL cache, bounded + LRU, per-key single-flight, origin forwarding; + typed payloads in types.go + internal/render/ html/template engine over an embed.FS + + response ETag helpers (W/"sha1[:20]", + If-None-Match handling) + internal/render/templates/ the page templates (embedded; *.tmpl) + internal/contentdata/ glossary.yaml / pages.yaml loader (fail-loud + validation, file order preserved) + +Dependencies: stdlib plus `gopkg.in/yaml.v3` — the committed SSR copy in +`frontend/content/*.yaml` is shared with the rest of the repo and uses block/ +folded scalars, so a YAML parser is genuinely required. + +## Run locally + + cd frontend/server + go build -o thermograph-frontend . + cd .. # static/ and content/ resolve relative to the working dir + THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \ + THERMOGRAPH_BASE=/thermograph \ + ./server/thermograph-frontend + +`THERMOGRAPH_API_BASE_INTERNAL` is required — the boot fails loudly without it, +same as the Python raised at import. Other env vars (all optional): +`THERMOGRAPH_BASE` (default `/thermograph`; the image sets `/`), +`THERMOGRAPH_API_VERSION` (default `v2` — bump only per the API-version pinning +contract in `frontend/CLAUDE.md`), `THERMOGRAPH_API_BASE_PUBLIC`, +`THERMOGRAPH_SSR_CACHE_TTL` (seconds, default 600), +`THERMOGRAPH_GOOGLE_VERIFY` / `THERMOGRAPH_BING_VERIFY`, and `PORT` +(default 8080). + +The process expects `static/` and `content/` in its working directory +(`frontend/` locally, `/app` in the image). Templates are embedded in the +binary; static assets and the YAML copy are read from disk. + +## Test / verify + + cd frontend/server + go build ./... && go vet ./... && go test ./... + +The deployed binary is `/usr/local/bin/thermograph-frontend` inside the +`emi/thermograph/frontend` image; the image name, `frontend-*` CI workflows and +deploy path are unchanged from the Python service. diff --git a/frontend/server/go.mod b/frontend/server/go.mod new file mode 100644 index 0000000..ab8cdd4 --- /dev/null +++ b/frontend/server/go.mod @@ -0,0 +1,5 @@ +module thermograph/frontend + +go 1.26 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/frontend/server/go.sum b/frontend/server/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/frontend/server/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/frontend/server/internal/config/config.go b/frontend/server/internal/config/config.go new file mode 100644 index 0000000..cab29fc --- /dev/null +++ b/frontend/server/internal/config/config.go @@ -0,0 +1,127 @@ +// Package config reads every environment variable the SSR frontend consumes. +// +// The names, defaults and required/optional split mirror the Python service +// exactly (app.py, api_client.py, content.py, paths.py, and the Dockerfile / +// infra/docker-compose.yml PORT wiring). Do not invent new variable names — +// the deploy path (compose + /etc/thermograph.env) sets these and only these. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Config is the fully-resolved runtime configuration. +type Config struct { + // APIBaseInternal is THERMOGRAPH_API_BASE_INTERNAL — the backend's URL on + // the compose-internal network (e.g. http://backend:8137). REQUIRED: the + // Python client raised RuntimeError at import when unset ("a missing + // backend URL should break the boot, not silently 500 on the first + // request") — Load returns an error and main exits, same philosophy. + APIBaseInternal string + + // APIVersion is THERMOGRAPH_API_VERSION (default "v2") — the single pin + // point for the backend content-API version this service speaks. Bump only + // in lockstep with a verified backend /api/version check (see + // frontend CLAUDE.md, "API-version pinning contract"). + APIVersion string + + // Base is THERMOGRAPH_BASE normalized the way content.py normalized it: + // strip "/" from both ends, then "/"+rest, or "" when the variable is set + // to "/" (the deployed clean-root topology — the Dockerfile sets "/"). + // Default when unset: "/thermograph" (LAN dev under a sub-path). + Base string + + // AssetBase is THERMOGRAPH_API_BASE_PUBLIC with any trailing "/" removed, + // falling back to Base when empty — the browser-facing base for static + // asset / SPA-shell URLs in templates. Empty var = today's same-origin + // default, where those references stay relative (ASSET_BASE == BASE). + AssetBase string + + // SSRCacheTTL is THERMOGRAPH_SSR_CACHE_TTL in seconds (default 600) — the + // content-API response-cache TTL in the backend client. The Python parsed + // it with float(env or 600): an empty string means the default, a present + // but unparsable value crashed the boot — Load mirrors both. + SSRCacheTTL time.Duration + + // GoogleVerify / BingVerify are THERMOGRAPH_GOOGLE_VERIFY / + // THERMOGRAPH_BING_VERIFY, whitespace-trimmed; empty = no verification + // tag emitted. + GoogleVerify string + BingVerify string + + // Port is PORT (default "8080"). The Python process itself never read it — + // the Dockerfile's `uvicorn --port ${PORT}` did — but it is the one knob + // infra uses to move the listen port, so the Go binary reads the same name. + Port string + + // StaticDir / ContentDir are where static assets and the structured SSR + // copy (glossary.yaml / pages.yaml) live. The Python resolved these from + // its own source location (paths.py); a compiled binary has no source + // directory, so these default to "static" and "content" relative to the + // working directory — run from frontend/ locally, /app in the image (the + // Dockerfile must COPY static/ and content/ there and keep WORKDIR /app). + StaticDir string + ContentDir string +} + +// Load resolves the configuration from the process environment. +func Load() (Config, error) { + return load(os.LookupEnv) +} + +// load is Load with an injectable environment, for tests. +func load(getenv func(string) (string, bool)) (Config, error) { + get := func(name, dflt string) string { + if v, ok := getenv(name); ok { + return v + } + return dflt + } + + var cfg Config + + cfg.APIBaseInternal, _ = getenv("THERMOGRAPH_API_BASE_INTERNAL") + if cfg.APIBaseInternal == "" { + return cfg, fmt.Errorf("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)") + } + + cfg.APIVersion = get("THERMOGRAPH_API_VERSION", "v2") + + // content.py / api_client.py: os.environ.get("THERMOGRAPH_BASE", + // "/thermograph").strip("/"), then "/"+base if base else "". + base := strings.Trim(get("THERMOGRAPH_BASE", "/thermograph"), "/") + if base != "" { + cfg.Base = "/" + base + } + + // content.py: os.environ.get("THERMOGRAPH_API_BASE_PUBLIC", "").rstrip("/") or BASE. + cfg.AssetBase = strings.TrimRight(get("THERMOGRAPH_API_BASE_PUBLIC", ""), "/") + if cfg.AssetBase == "" { + cfg.AssetBase = cfg.Base + } + + // api_client.py: float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600). + ttlRaw := get("THERMOGRAPH_SSR_CACHE_TTL", "600") + if ttlRaw == "" { + ttlRaw = "600" + } + ttlSecs, err := strconv.ParseFloat(ttlRaw, 64) + if err != nil { + // The Python raised ValueError at import for a garbage value — fail + // loud here too rather than silently running with a wrong TTL. + return cfg, fmt.Errorf("THERMOGRAPH_SSR_CACHE_TTL: %w", err) + } + cfg.SSRCacheTTL = time.Duration(ttlSecs * float64(time.Second)) + + cfg.GoogleVerify = strings.TrimSpace(get("THERMOGRAPH_GOOGLE_VERIFY", "")) + cfg.BingVerify = strings.TrimSpace(get("THERMOGRAPH_BING_VERIFY", "")) + + cfg.Port = get("PORT", "8080") + cfg.StaticDir = "static" + cfg.ContentDir = "content" + return cfg, nil +} diff --git a/frontend/server/internal/config/config_test.go b/frontend/server/internal/config/config_test.go new file mode 100644 index 0000000..416caac --- /dev/null +++ b/frontend/server/internal/config/config_test.go @@ -0,0 +1,137 @@ +package config + +import ( + "testing" + "time" +) + +func envFrom(m map[string]string) func(string) (string, bool) { + return func(k string) (string, bool) { + v, ok := m[k] + return v, ok + } +} + +func TestDefaults(t *testing.T) { + cfg, err := load(envFrom(map[string]string{ + "THERMOGRAPH_API_BASE_INTERNAL": "http://backend:8137", + })) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.APIBaseInternal != "http://backend:8137" { + t.Errorf("APIBaseInternal = %q", cfg.APIBaseInternal) + } + if cfg.APIVersion != "v2" { + t.Errorf("APIVersion = %q, want v2", cfg.APIVersion) + } + if cfg.Base != "/thermograph" { + t.Errorf("Base = %q, want /thermograph", cfg.Base) + } + if cfg.AssetBase != "/thermograph" { + t.Errorf("AssetBase = %q, want /thermograph (falls back to Base)", cfg.AssetBase) + } + if cfg.SSRCacheTTL != 600*time.Second { + t.Errorf("SSRCacheTTL = %v, want 10m", cfg.SSRCacheTTL) + } + if cfg.GoogleVerify != "" || cfg.BingVerify != "" { + t.Errorf("verify tokens should default empty, got %q / %q", cfg.GoogleVerify, cfg.BingVerify) + } + if cfg.Port != "8080" { + t.Errorf("Port = %q, want 8080", cfg.Port) + } + if cfg.StaticDir != "static" || cfg.ContentDir != "content" { + t.Errorf("StaticDir/ContentDir = %q/%q", cfg.StaticDir, cfg.ContentDir) + } +} + +func TestAPIBaseInternalRequired(t *testing.T) { + if _, err := load(envFrom(nil)); err == nil { + t.Fatal("expected an error when THERMOGRAPH_API_BASE_INTERNAL is unset (fail-loud boot)") + } + if _, err := load(envFrom(map[string]string{"THERMOGRAPH_API_BASE_INTERNAL": ""})); err == nil { + t.Fatal("expected an error when THERMOGRAPH_API_BASE_INTERNAL is empty") + } +} + +func TestBaseNormalization(t *testing.T) { + cases := []struct{ raw, want string }{ + {"/", ""}, // deployed clean-root topology (Dockerfile sets "/") + {"", ""}, // set-but-empty strips to nothing too + {"/thermograph", "/thermograph"}, + {"thermograph/", "/thermograph"}, + {"/a/b/", "/a/b"}, + } + for _, c := range cases { + cfg, err := load(envFrom(map[string]string{ + "THERMOGRAPH_API_BASE_INTERNAL": "http://b", + "THERMOGRAPH_BASE": c.raw, + })) + if err != nil { + t.Fatalf("load(%q): %v", c.raw, err) + } + if cfg.Base != c.want { + t.Errorf("Base(%q) = %q, want %q", c.raw, cfg.Base, c.want) + } + } +} + +func TestAssetBase(t *testing.T) { + cfg, err := load(envFrom(map[string]string{ + "THERMOGRAPH_API_BASE_INTERNAL": "http://b", + "THERMOGRAPH_API_BASE_PUBLIC": "https://api.example.org/", + })) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.AssetBase != "https://api.example.org" { + t.Errorf("AssetBase = %q, want trailing slash stripped", cfg.AssetBase) + } +} + +func TestSSRCacheTTL(t *testing.T) { + // Empty string means the default (Python: float(env or 600)). + cfg, err := load(envFrom(map[string]string{ + "THERMOGRAPH_API_BASE_INTERNAL": "http://b", + "THERMOGRAPH_SSR_CACHE_TTL": "", + })) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.SSRCacheTTL != 600*time.Second { + t.Errorf("empty TTL = %v, want 10m", cfg.SSRCacheTTL) + } + + cfg, err = load(envFrom(map[string]string{ + "THERMOGRAPH_API_BASE_INTERNAL": "http://b", + "THERMOGRAPH_SSR_CACHE_TTL": "2.5", + })) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.SSRCacheTTL != 2500*time.Millisecond { + t.Errorf("TTL 2.5 = %v, want 2.5s", cfg.SSRCacheTTL) + } + + // Garbage crashed the Python boot (ValueError at import) — error here too. + if _, err = load(envFrom(map[string]string{ + "THERMOGRAPH_API_BASE_INTERNAL": "http://b", + "THERMOGRAPH_SSR_CACHE_TTL": "ten minutes", + })); err == nil { + t.Fatal("expected an error for an unparsable TTL") + } +} + +func TestVerifyTokensTrimmed(t *testing.T) { + cfg, err := load(envFrom(map[string]string{ + "THERMOGRAPH_API_BASE_INTERNAL": "http://b", + "THERMOGRAPH_GOOGLE_VERIFY": " g-token ", + "THERMOGRAPH_BING_VERIFY": "\tb-token\n", + })) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.GoogleVerify != "g-token" || cfg.BingVerify != "b-token" { + t.Errorf("tokens not trimmed: %q / %q", cfg.GoogleVerify, cfg.BingVerify) + } +} diff --git a/frontend/server/internal/content/content.go b/frontend/server/internal/content/content.go new file mode 100644 index 0000000..5f68392 --- /dev/null +++ b/frontend/server/internal/content/content.go @@ -0,0 +1,268 @@ +// Package content is the Go port of content.py: the server-rendered, +// crawlable content pages (climate hub / per-city / month / records / +// glossary / about / privacy / homepage) plus robots.txt, sitemap.xml and the +// IndexNow key file. Every route handler fetches its data from the backend's +// SSR content API (internal/contentapi) — page_title / page_description / +// canonical_path / breadcrumb / jsonld are all computed by the backend +// (backend/api/content_payloads.py), never here. +// +// Render-context convention: handlers hand templates a map[string]any whose +// keys are the EXPORTED-Go-style PascalCase names the templates (see +// internal/render/templates) access — e.g. content.py's snake_case +// `year_range` context key is `YearRange` here. This does NOT mirror the +// Jinja context names verbatim: html/template's field/map-key resolution is +// case-sensitive with no fallback, and a map has no compile-time check for a +// wrong or stale key, so a mismatch fails SILENTLY (a missing string key +// renders empty, not an error — only an index/range over the resulting nil +// can panic). PascalCase was chosen to read as ordinary Go field access in +// the templates ({{.Display}}, not {{.display}}), and every template's own +// header comment documents the exact field set it expects — treat that +// comment as the authoritative contract when adding or renaming a key here. +// Nested display objects are maps/slices with the same PascalCase discipline. +// +// The one Jinja construct with no Go analogue is dict iteration order: +// Python dicts preserve insertion order, Go maps do not, so anything the +// templates iterate (hub country groups, glossary terms) is a SLICE here, in +// the same order the Python dict would have yielded. +package content + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "time" + + "thermograph/frontend/internal/config" + "thermograph/frontend/internal/contentapi" + "thermograph/frontend/internal/contentdata" + "thermograph/frontend/internal/format" + "thermograph/frontend/internal/render" +) + +// API is the slice of contentapi.Client the handlers consume — an interface +// so tests can substitute fixture-backed fakes exactly the way the Python +// suite monkeypatched api_client (tests/conftest.py's `client` fixture). +// *contentapi.Client satisfies it. +type API interface { + Hub() (*contentapi.HubPayload, error) + Sitemap() ([]contentapi.SitemapEntry, error) + IndexNowKey() (string, error) + Home() (*contentapi.HomePayload, error) + City(slug, origin string) (*contentapi.CityPayload, error) + CityMonth(slug, month string) (*contentapi.MonthPayload, error) + CityRecords(slug, origin string) (*contentapi.RecordsPayload, error) +} + +// requiredPages are the pages.yaml keys the handlers read (content.py's +// PAGES[...] lookups). Python failed lazily — a KeyError 500 on first hit of +// the page — but a missing key is a content-file bug that should break the +// boot, same philosophy as the loader's own fail-loud validation. +var requiredPages = []string{"hub", "glossary_index", "about", "privacy"} + +// Handlers owns every content route. Construct with New, wire with Register. +type Handlers struct { + cfg config.Config + api API + engine *render.Engine + glossary *contentdata.Glossary + pages map[string]contentdata.Page + + // bootDate is a stable per-process "content last built" date — crawlers + // should see sitemap change on a real rebuild (a fresh + // process), not churn on every request (content.py's _BOOT_DATE). + bootDate string + + log *log.Logger +} + +// New builds the handler set. The engine must have been constructed with +// FuncMap(cfg) (the template helpers close over the same config). logger may +// be nil (falls back to the standard logger). +func New(cfg config.Config, api API, engine *render.Engine, glossary *contentdata.Glossary, pages map[string]contentdata.Page, logger *log.Logger) (*Handlers, error) { + for _, key := range requiredPages { + if _, ok := pages[key]; !ok { + return nil, fmt.Errorf("pages.yaml: missing required page %q", key) + } + } + if logger == nil { + logger = log.Default() + } + return &Handlers{ + cfg: cfg, + api: api, + engine: engine, + glossary: glossary, + pages: pages, + bootDate: time.Now().Format("2006-01-02"), + log: logger, + }, nil +} + +// Register attaches every content route (the port of content.register). +// static is the same handler main.go mounts for static assets: the lazy +// IndexNow fallback route has to claim the whole single-segment pattern +// ({BASE}/{token} — Go's mux cannot express the Python's "/{token}.txt" +// suffix wildcard) and must hand non-.txt requests (app.js, style.css, …) +// back to the file server, so explicit page routes still win on precedence +// and everything else keeps serving. Go 1.22 mux precedence (most-specific +// pattern wins) replaces Starlette's registration-order rule; "GET" patterns +// serve HEAD too, covering the Python's methods=["GET", "HEAD"] routes. +func (h *Handlers) Register(mux *http.ServeMux, static http.Handler) { + base := h.cfg.Base + mux.HandleFunc("GET "+base+"/robots.txt", h.RobotsTxt) + mux.HandleFunc("GET "+base+"/sitemap.xml", h.SitemapXML) + h.registerIndexNow(mux, static) + if base == "" { + mux.HandleFunc("GET /{$}", h.HomePage) + } else { + mux.HandleFunc("GET "+base+"/{$}", h.HomePage) + } + mux.HandleFunc("GET "+base+"/about", h.AboutPage) + mux.HandleFunc("GET "+base+"/privacy", h.PrivacyPage) + mux.HandleFunc("GET "+base+"/glossary", h.GlossaryIndex) + mux.HandleFunc("GET "+base+"/glossary/{term}", h.GlossaryTerm) + mux.HandleFunc("GET "+base+"/climate", h.HubPage) + mux.HandleFunc("GET "+base+"/climate/{slug}", h.CityPage) + mux.HandleFunc("GET "+base+"/climate/{slug}/records", h.RecordsPage) + mux.HandleFunc("GET "+base+"/climate/{slug}/{month}", h.MonthPage) +} + +// origin reconstructs the browser-facing origin of a request. +// x-forwarded-host takes precedence over Host: when reached via backend's +// internal proxy fallback (no Caddy in front — see _proxy_to_frontend in the +// backend's web/app.py), Host is the internal hop's own address, not the +// browser-facing one. +func origin(r *http.Request) string { + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host // covers both the Host header and the URL netloc + } + return proto + "://" + host +} + +// respondHTML renders a template and writes it with the weak-ETag / +// If-None-Match handling of content.py's _respond_html. It stamps the shared +// context keys every page gets (Base / AssetBase / AssetBaseURL / Origin / +// BaseURL / UnitDefault / Fmt) plus defaults for the keys base.html.j2 read +// through |default() or compared while possibly undefined (BrandTag, +// MainClass, Section) — Jinja tolerated undefined names, html/template's eq +// does not, so the defaults move here. +func (h *Handlers) respondHTML(w http.ResponseWriter, r *http.Request, tmpl string, unit format.Unit, ctx map[string]any) { + o := origin(r) + // og:image (an Open Graph tag, which the spec requires be absolute) is + // the one place a *static asset* reference needs an absolute URL always, + // not just when THERMOGRAPH_API_BASE_PUBLIC happens to be set — + // AssetBase is already absolute in that case, otherwise it's the same + // relative Base base_url itself is built from, so prefixing with this + // request's own origin recovers exactly today's behavior in the default + // topology. + assetBaseURL := h.cfg.AssetBase + if !isAbsoluteURL(assetBaseURL) { + assetBaseURL = o + assetBaseURL + } + ctx["Base"] = h.cfg.Base + ctx["AssetBase"] = h.cfg.AssetBase + ctx["AssetBaseURL"] = assetBaseURL + ctx["Origin"] = o + ctx["BaseURL"] = o + h.cfg.Base + // The request-scoped unit-bound formatter templates call as + // {{.Fmt.Temp v}} / {{.Fmt.TempBare v}} / {{.Fmt.TempClass v}} — set once + // here (not by each page's own ctx builder) since every page that embeds + // base.html.tmpl can reach it, and respondHTML already has the unit. + setDefault(ctx, "Fmt", format.NewFormatter(unit)) + setDefault(ctx, "UnitDefault", string(unit)) // "" -> no data-unit-default attribute + setDefault(ctx, "BrandTag", "h1") // base.html.j2: brand_tag|default('h1') + setDefault(ctx, "MainClass", "content") // base.html.j2: main_class|default('content') + setDefault(ctx, "Section", "") // base.html.j2 compares it; undefined is not a Go option + + body, err := h.engine.RenderBytes(tmpl, ctx) + if err != nil { + h.serverError(w, fmt.Errorf("render %s: %w", tmpl, err)) + return + } + render.WriteHTML(w, r, body) +} + +func setDefault(ctx map[string]any, key string, v any) { + if _, ok := ctx[key]; !ok { + ctx[key] = v + } +} + +func isAbsoluteURL(s string) bool { + return len(s) >= 7 && (s[:7] == "http://" || (len(s) >= 8 && s[:8] == "https://")) +} + +// crumb builds one locally-constructed breadcrumb element (the hub/glossary/ +// about pages assemble their own trails; href nil marks the terminal crumb). +// contentapi.Crumb's own Name/Href fields are exactly what the "breadcrumb" +// template reads ({{$c.Name}}/{{$c.Href}}), so API-sourced breadcrumbs +// (j.Breadcrumb) pass straight through with no wrapping at all — this helper +// exists only for the trails a page assembles itself. +func crumb(name, href string) contentapi.Crumb { + c := contentapi.Crumb{Name: name} + if href != "" { + c.Href = &href + } + return c +} + +// apiError is the port of content.py's _raise_api_error: the backend's 404 +// (unknown city/month) and 503 (warming) map straight through — same status +// codes the pre-split in-process code raised. Any other error never got a +// response at all — a backend blip or restart — so it becomes a 503 too, +// rather than surfacing as a raw 500. +func (h *Handlers) apiError(w http.ResponseWriter, err error) { + var se *contentapi.StatusError + if errors.As(err, &se) { + writeDetail(w, se.StatusCode, se.Body) + return + } + writeDetail(w, http.StatusServiceUnavailable, "backend unavailable") +} + +// writeDetail writes the {"detail": ...} JSON error body FastAPI's +// HTTPException produced, so error responses keep their shape across the +// rewrite (the backend's own error text rides through as the detail string, +// exactly like detail=e.response.text did). +func writeDetail(w http.ResponseWriter, status int, detail string) { + body, err := json.Marshal(map[string]string{"detail": detail}) + if err != nil { + body = []byte(`{"detail":"error"}`) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(body) +} + +// serverError is the unhandled-exception path (Jinja/template failures, +// malformed payloads): log it, answer a plain 500 like the Python framework +// did for an uncaught exception. +func (h *Handlers) serverError(w http.ResponseWriter, err error) { + h.log.Printf("content: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) +} + +// compactJSONLD re-emits the backend's jsonld bytes with insignificant +// whitespace removed — the Go analogue of json.dumps(j["jsonld"], +// ensure_ascii=False, separators=(",", ":")). Compacting the RAW bytes (not +// unmarshal + re-marshal) is what preserves the backend's key order; Go maps +// would sort keys and break golden-diff parity. +func compactJSONLD(raw json.RawMessage) (string, error) { + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + return "", fmt.Errorf("jsonld: %w", err) + } + return buf.String(), nil +} diff --git a/frontend/server/internal/content/content_test.go b/frontend/server/internal/content/content_test.go new file mode 100644 index 0000000..1d199ea --- /dev/null +++ b/frontend/server/internal/content/content_test.go @@ -0,0 +1,892 @@ +// Behaviour tests for the content.py port, driven by the same committed +// backend fixtures the Python suite used (frontend/tests/fixtures/*.json, +// captured by scripts/capture-fixtures.sh) and mirroring the assertions of +// frontend/tests/unit/test_rendering.py that belong to this layer. Full +// rendered-page assertions (200 + HTML structure) live with the template +// port / golden-diff phase — here the templates are placeholders, so these +// tests target the routes' status behavior and the exact render contexts the +// handlers build. +package content + +import ( + "encoding/json" + "errors" + "fmt" + "html/template" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "thermograph/frontend/internal/config" + "thermograph/frontend/internal/contentapi" + "thermograph/frontend/internal/contentdata" + "thermograph/frontend/internal/format" + "thermograph/frontend/internal/render" +) + +const ( + testSlug = "london-england-gb" // captured into tests/fixtures/ (GB -> Celsius) + testMonth = "july" + fakeKey = "test0000indexnow0000key000000000" +) + +// fixturesDir / contentDir reach the repo's committed test fixtures and SSR +// copy from this package directory. +var ( + fixturesDir = filepath.Join("..", "..", "..", "tests", "fixtures") + contentDir = filepath.Join("..", "..", "..", "content") +) + +func loadFixture[T any](t *testing.T, name string) *T { + t.Helper() + raw, err := os.ReadFile(filepath.Join(fixturesDir, name+".json")) + if err != nil { + t.Fatalf("fixture %s: %v", name, err) + } + out := new(T) + if err := json.Unmarshal(raw, out); err != nil { + t.Fatalf("fixture %s: %v", name, err) + } + return out +} + +// fakeAPI is the Go analogue of conftest.py's monkeypatched api_client. +type fakeAPI struct { + hub func() (*contentapi.HubPayload, error) + sitemap func() ([]contentapi.SitemapEntry, error) + indexNowKey func() (string, error) + home func() (*contentapi.HomePayload, error) + city func(slug, origin string) (*contentapi.CityPayload, error) + cityMonth func(slug, month string) (*contentapi.MonthPayload, error) + cityRecords func(slug, origin string) (*contentapi.RecordsPayload, error) +} + +func (f *fakeAPI) Hub() (*contentapi.HubPayload, error) { return f.hub() } +func (f *fakeAPI) Sitemap() ([]contentapi.SitemapEntry, error) { return f.sitemap() } +func (f *fakeAPI) IndexNowKey() (string, error) { return f.indexNowKey() } +func (f *fakeAPI) Home() (*contentapi.HomePayload, error) { return f.home() } +func (f *fakeAPI) City(slug, origin string) (*contentapi.CityPayload, error) { + return f.city(slug, origin) +} +func (f *fakeAPI) CityMonth(slug, month string) (*contentapi.MonthPayload, error) { + return f.cityMonth(slug, month) +} +func (f *fakeAPI) CityRecords(slug, origin string) (*contentapi.RecordsPayload, error) { + return f.cityRecords(slug, origin) +} + +func notFound(detail string) error { + return &contentapi.StatusError{ + StatusCode: 404, + Body: fmt.Sprintf(`{"detail":%q}`, detail), + URL: "http://backend.invalid/x", + } +} + +var errUnreachable = errors.New("connect: connection refused") + +// fixtureAPI wires every method to the committed fixtures, exactly like the +// Python `client` fixture's monkeypatching (unknown slug/month -> a backend +// 404 StatusError). +func fixtureAPI(t *testing.T) *fakeAPI { + t.Helper() + city := loadFixture[contentapi.CityPayload](t, "city") + month := loadFixture[contentapi.MonthPayload](t, "month") + records := loadFixture[contentapi.RecordsPayload](t, "records") + home := loadFixture[contentapi.HomePayload](t, "home") + hub := loadFixture[contentapi.HubPayload](t, "hub") + sitemap := loadFixture[[]contentapi.SitemapEntry](t, "sitemap") + return &fakeAPI{ + hub: func() (*contentapi.HubPayload, error) { return hub, nil }, + sitemap: func() ([]contentapi.SitemapEntry, error) { return *sitemap, nil }, + indexNowKey: func() (string, error) { return fakeKey, nil }, + home: func() (*contentapi.HomePayload, error) { return home, nil }, + city: func(slug, origin string) (*contentapi.CityPayload, error) { + if slug != testSlug { + return nil, notFound("Unknown city.") + } + return city, nil + }, + cityMonth: func(slug, m string) (*contentapi.MonthPayload, error) { + if slug != testSlug || m != testMonth { + return nil, notFound("Unknown month.") + } + return month, nil + }, + cityRecords: func(slug, origin string) (*contentapi.RecordsPayload, error) { + if slug != testSlug { + return nil, notFound("Unknown city.") + } + return records, nil + }, + } +} + +func testConfig() config.Config { + return config.Config{ + APIBaseInternal: "http://backend.invalid", + APIVersion: "v2", + Base: "/thermograph", // matches THERMOGRAPH_BASE in the Python conftest + AssetBase: "/thermograph", + GoogleVerify: "", + BingVerify: "", + } +} + +func newHandlers(t *testing.T, api API) *Handlers { + t.Helper() + cfg := testConfig() + engine, err := render.New(FuncMap(cfg)) + if err != nil { + t.Fatalf("render.New: %v", err) + } + glossary, err := contentdata.LoadGlossary(contentDir) + if err != nil { + t.Fatalf("LoadGlossary: %v", err) + } + pages, err := contentdata.LoadPages(contentDir) + if err != nil { + t.Fatalf("LoadPages: %v", err) + } + h, err := New(cfg, api, engine, glossary, pages, nil) + if err != nil { + t.Fatalf("New: %v", err) + } + return h +} + +func newMux(t *testing.T, api API) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + static := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("STATIC")) // stands in for main.go's file server + }) + h := newHandlers(t, api) + h.Register(mux, static) + // main.go ALSO registers a subtree static catch-all ("GET {base}/", via + // handlers.Server.Register) on the same mux — Register's own doc comment + // notes this. Only the LAZY IndexNow fallback claims the single-segment + // slot itself and forwards non-.txt requests to `static`; the EAGER path + // (key fetched successfully at boot) registers just the exact key-file + // route and relies on that separate subtree registration for everything + // else. Mirror both here, or the eager-route test exercises a mux with no + // handler for e.g. app.js at all — a gap in this test double, not in + // production routing. + mux.Handle("GET "+h.cfg.Base+"/", static) + return mux +} + +func get(mux *http.ServeMux, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", "http://testserver"+path, nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +// --- robots / sitemap -------------------------------------------------------- + +func TestRobotsTxt(t *testing.T) { + rec := get(newMux(t, fixtureAPI(t)), "/thermograph/robots.txt") + if rec.Code != 200 { + t.Fatalf("status %d", rec.Code) + } + want := "User-agent: *\n" + + "Allow: /\n" + + "Disallow: /thermograph/api/\n" + + "Disallow: /thermograph/alerts\n" + + "Sitemap: http://testserver/thermograph/sitemap.xml\n" + if rec.Body.String() != want { + t.Errorf("body:\n%s", rec.Body.String()) + } +} + +func TestSitemapListsCityURLs(t *testing.T) { + rec := get(newMux(t, fixtureAPI(t)), "/thermograph/sitemap.xml") + if rec.Code != 200 { + t.Fatalf("status %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "", + "/climate/" + testSlug + "/" + testMonth + "", + "/climate/" + testSlug + "/records", + } { + if !strings.Contains(body, frag) { + t.Errorf("missing %q", frag) + } + } + if ct := rec.Header().Get("Content-Type"); ct != "application/xml" { + t.Errorf("Content-Type %q", ct) + } + if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=300" { + t.Errorf("Cache-Control %q", cc) + } + // One full entry, byte-exact: loc + boot-date lastmod + changefreq + priority. + h := newHandlers(t, fixtureAPI(t)) + wantURL := fmt.Sprintf("http://testserver/thermograph/%s"+ + "daily1.0", h.bootDate) + if !strings.Contains(body, wantURL) { + t.Errorf("missing exact entry %q", wantURL) + } +} + +// --- error mapping ----------------------------------------------------------- + +func TestUnknownCityAndMonthAre404(t *testing.T) { + mux := newMux(t, fixtureAPI(t)) + for _, path := range []string{ + "/thermograph/climate/nope-not-a-city", + "/thermograph/climate/" + testSlug + "/nonemonth", + "/thermograph/climate/nope-not-a-city/records", + } { + if rec := get(mux, path); rec.Code != 404 { + t.Errorf("%s -> %d, want 404", path, rec.Code) + } + } +} + +func TestGlossaryUnknownTermIs404(t *testing.T) { + rec := get(newMux(t, fixtureAPI(t)), "/thermograph/glossary/not-a-term") + if rec.Code != 404 { + t.Fatalf("status %d", rec.Code) + } + if got := rec.Body.String(); got != `{"detail":"Unknown term."}` { + t.Errorf("body %q", got) + } +} + +// TestBackendUnreachableMapsTo503 is the port of the Python suite's +// backend-down resilience cases: a transport-level failure (no response at +// all) must become a clean 503, not a raw 500. +func TestBackendUnreachableMapsTo503(t *testing.T) { + api := fixtureAPI(t) + api.city = func(_, _ string) (*contentapi.CityPayload, error) { return nil, errUnreachable } + api.cityMonth = func(_, _ string) (*contentapi.MonthPayload, error) { return nil, errUnreachable } + api.cityRecords = func(_, _ string) (*contentapi.RecordsPayload, error) { return nil, errUnreachable } + api.hub = func() (*contentapi.HubPayload, error) { return nil, errUnreachable } + api.home = func() (*contentapi.HomePayload, error) { return nil, errUnreachable } + api.sitemap = func() ([]contentapi.SitemapEntry, error) { return nil, errUnreachable } + mux := newMux(t, api) + for _, path := range []string{ + "/thermograph/climate/" + testSlug, + "/thermograph/climate/" + testSlug + "/july", + "/thermograph/climate/" + testSlug + "/records", + "/thermograph/climate", + "/thermograph/", + "/thermograph/sitemap.xml", + } { + rec := get(mux, path) + if rec.Code != 503 { + t.Errorf("%s -> %d, want 503", path, rec.Code) + } + if body := rec.Body.String(); body != `{"detail":"backend unavailable"}` { + t.Errorf("%s body %q", path, body) + } + } +} + +// A backend 404/503 status passes straight through with its own detail text. +func TestBackendStatusPassesThrough(t *testing.T) { + rec := get(newMux(t, fixtureAPI(t)), "/thermograph/climate/nope-not-a-city") + if rec.Code != 404 { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Unknown city.") { + t.Errorf("detail not forwarded: %q", rec.Body.String()) + } +} + +// --- IndexNow ---------------------------------------------------------------- + +func TestIndexNowEagerRoute(t *testing.T) { + mux := newMux(t, fixtureAPI(t)) + rec := get(mux, "/thermograph/"+fakeKey+".txt") + if rec.Code != 200 || rec.Body.String() != fakeKey+"\n" { + t.Errorf("eager key file: %d %q", rec.Code, rec.Body.String()) + } + // With the eager route registered there is no {token} catch-all: other + // single-segment paths stay with the static file server. + if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" { + t.Errorf("static delegation broken: %q", rec.Body.String()) + } +} + +func TestIndexNowLazyFallback(t *testing.T) { + api := fixtureAPI(t) + down := true + api.indexNowKey = func() (string, error) { + if down { + return "", errUnreachable + } + return fakeKey, nil + } + mux := newMux(t, api) // Register's eager fetch fails -> lazy route + + // Backend still down: the key file answers 503 (never a wrong/empty key). + if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 503 { + t.Errorf("while down: %d", rec.Code) + } + // Backend back up: the correct key serves with no frontend restart. + down = false + if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 200 || rec.Body.String() != fakeKey+"\n" { + t.Errorf("after recovery: %d %q", rec.Code, rec.Body.String()) + } + // A wrong token is 404, and non-.txt single-segment paths fall through to + // the static handler the lazy route displaced. + if rec := get(mux, "/thermograph/wrongkey.txt"); rec.Code != 404 { + t.Errorf("wrong token: %d", rec.Code) + } + if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" { + t.Errorf("static delegation broken: %q", rec.Body.String()) + } + // Explicit page routes still beat the {token} pattern on specificity. + if rec := get(mux, "/thermograph/robots.txt"); !strings.HasPrefix(rec.Body.String(), "User-agent:") { + t.Errorf("robots displaced by token route: %q", rec.Body.String()) + } +} + +// --- ctx builders ------------------------------------------------------------ + +func TestCityCtx(t *testing.T) { + h := newHandlers(t, fixtureAPI(t)) + j := loadFixture[contentapi.CityPayload](t, "city") + ctx, unit, err := h.cityCtx(j) + if err != nil { + t.Fatal(err) + } + if unit != "C" { // GB city -> Celsius (test_city_page_renders_structure) + t.Errorf("unit %q", unit) + } + months := ctx["Months"].([]map[string]any) + if len(months) != 12 { + t.Fatalf("months: %d", len(months)) + } + // January high 44.5°F -> 7°C, formatted as the converter span. + if got := months[0]["High"].(template.HTML); got != `7°C` { + t.Errorf("january high: %q", got) + } + if bar := months[0]["Bar"]; bar == nil { + t.Error("january bar missing") + } + warmest := ctx["Warmest"].(map[string]any) + if warmest["Slug"] != "july" { + t.Errorf("warmest %v", warmest["Slug"]) + } + if ctx["Coldest"].(map[string]any)["Slug"] != "february" || + ctx["Wettest"].(map[string]any)["Slug"] != "january" { + t.Error("coldest/wettest lookup broken") + } + // Full href, base + "#lat,lon" — not just the bare "lat,lon" fragment, so + // the template never has to concatenate it (html/template's URL-context + // escaper would %-escape the comma if it did). + if ctx["ToolHref"] != "/thermograph/#51.50853,-0.12574" { + t.Errorf("ToolHref %v", ctx["ToolHref"]) + } + if ctx["CompareURL"] != "/thermograph/compare#loc=51.5085,-0.1257" { + t.Errorf("CompareURL %v", ctx["CompareURL"]) + } + // JSON-LD: compacted raw bytes, key order preserved (starts with @context). + jsonld := string(ctx["JSONLDStr"].(template.JS)) + if !strings.HasPrefix(jsonld, `{"@context":"https://schema.org"`) { + t.Errorf("JSONLDStr prefix: %.60s", jsonld) + } + if !strings.Contains(jsonld, `"@type":"Dataset"`) { + t.Error("JSONLDStr lost the Dataset block") // test_city_page_renders_structure + } + if strings.Contains(jsonld, ": ") { + t.Error("JSONLDStr not compact") + } + // Today cards: pre-formatted value; percentile stays raw for |ordinal. + today := ctx["Today"].(map[string]any) + card := today["Cards"].([]map[string]any)[0] + if card["Value"].(template.HTML) != `26°C` { + t.Errorf("today card value: %q", card["Value"]) + } + if format.PctOrdinal(card["Percentile"]) != "89th" { + t.Errorf("card percentile ordinal: %v", card["Percentile"]) + } + if ctx["Display"] != j.Display || ctx["Name"] != "London" { + t.Error("display/name broken") + } + // Breadcrumb: the raw []contentapi.Crumb straight off the payload (its own + // Name/Href fields already match what the "breadcrumb" template reads). + // Terminal crumb has nil Href ({{if $c.Href}} -> false). + bc := ctx["Breadcrumb"].([]contentapi.Crumb) + if len(bc) == 0 || bc[len(bc)-1].Href != nil { + t.Errorf("breadcrumb terminal href: %v", bc) + } +} + +func TestMonthCtx(t *testing.T) { + h := newHandlers(t, fixtureAPI(t)) + j := loadFixture[contentapi.MonthPayload](t, "month") + city := loadFixture[contentapi.CityPayload](t, "city").City + ctx, unit, err := h.monthCtx(j, city) + if err != nil { + t.Fatal(err) + } + if unit != "C" { + t.Errorf("unit %q", unit) + } + stats := ctx["Stats"].([]map[string]any) + labels := make([]string, len(stats)) + for i, s := range stats { + labels[i] = s["Label"].(string) + } + want := []string{"Average high", "Typical high range", "Average low", "Typical low range", "Average daily precipitation"} + if strings.Join(labels, "|") != strings.Join(want, "|") { + t.Errorf("stat labels: %v", labels) + } + // "X to Y" concatenation: temp(lo) + " to " + temp(hi). + rng := string(stats[1]["Value"].(template.HTML)) + if rng != `18°C to 27°C` { + t.Errorf("typical high range: %q", rng) + } + // display falls back to the city name when the payload has none + // (j.get("display", city["name"])). + if j.Display == nil && ctx["Display"] != "London" { + t.Errorf("display fallback: %v", ctx["Display"]) + } + // Record label -> metric dispatch: Humidity is a bare g/m³ figure. + recs := ctx["Records"].([]map[string]any) + var humid map[string]any + for _, r := range recs { + if r["Label"] == "Humidity" { + humid = r + } + } + if humid == nil || humid["High"].(template.HTML) != "16.0 g/m³" { + t.Errorf("humidity record: %v", humid) + } + if ctx["AvgHighCls"] != "warm" { // 71.1°F -> [70, 80) + t.Errorf("AvgHighCls %v", ctx["AvgHighCls"]) + } + // Prev/Next: the raw contentapi.MonthLink (dereferenced), not a map — its + // own Name/Slug fields already match what the template reads. + if prev := ctx["Prev"].(contentapi.MonthLink); prev.Slug != "june" { + t.Errorf("prev %v", prev) + } +} + +// --- fetchWithCity ----------------------------------------------------------- + +func TestFetchWithCityHappyPath(t *testing.T) { + api := &fakeAPI{ + city: func(slug, _ string) (*contentapi.CityPayload, error) { + return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug, Name: "Testville"}}, nil + }, + } + primary, city, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil }) + if err != nil { + t.Fatal(err) + } + if primary != "primary-value" { + t.Errorf("primary = %q", primary) + } + if city.Slug != "testville" || city.Name != "Testville" { + t.Errorf("city = %+v", city) + } +} + +// When only primary fails, its error must win even though City() also ran +// (and, here, succeeded) -- matching the old sequential code's behavior, +// where City() was never even called once primary had already failed. +func TestFetchWithCityPrimaryErrorWins(t *testing.T) { + primaryErr := notFound("no such month") + api := &fakeAPI{ + city: func(slug, _ string) (*contentapi.CityPayload, error) { + return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug}}, nil + }, + } + _, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr }) + if err != primaryErr { + t.Errorf("err = %v, want the primary error", err) + } +} + +// When primary succeeds but City() fails, City's error must surface -- same +// as the old sequential code (it called City() second and returned whatever +// that call did). +func TestFetchWithCityCityErrorSurfaces(t *testing.T) { + cityErr := notFound("no such city") + api := &fakeAPI{ + city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr }, + } + _, _, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil }) + if err != cityErr { + t.Errorf("err = %v, want the city error", err) + } +} + +// When BOTH fail, primary's error still wins -- the same priority as the +// single-failure case above, just with both goroutines erroring. +func TestFetchWithCityBothErrorPrimaryWins(t *testing.T) { + primaryErr, cityErr := notFound("primary"), notFound("city") + api := &fakeAPI{ + city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr }, + } + _, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr }) + if err != primaryErr { + t.Errorf("err = %v, want the primary error (city error must not win)", err) + } +} + +// Proves the two calls actually run CONCURRENTLY rather than sequentially -- +// deterministically, via a rendezvous, not by timing (which would be flaky +// under CI load). Each fake blocks until BOTH have started; if fetchWithCity +// ran them sequentially, the second would never start until the first +// returned, and this would deadlock and fail on the test's own timeout. +func TestFetchWithCityRunsConcurrently(t *testing.T) { + started := make(chan struct{}, 2) + release := make(chan struct{}) + rendezvous := func() { + started <- struct{}{} + <-release + } + + api := &fakeAPI{ + city: func(_, _ string) (*contentapi.CityPayload, error) { + rendezvous() + return &contentapi.CityPayload{}, nil + }, + } + + done := make(chan struct{}) + go func() { + fetchWithCity(api, "testville", func() (string, error) { + rendezvous() + return "", nil + }) + close(done) + }() + + // Both goroutines must reach the rendezvous before either can proceed -- + // only possible if they were launched concurrently. + for range 2 { + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for both calls to start -- they are not running concurrently") + } + } + close(release) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("fetchWithCity did not return after release") + } +} + +func TestMonthCtxUnknownLabelIsError(t *testing.T) { + h := newHandlers(t, fixtureAPI(t)) + j := loadFixture[contentapi.MonthPayload](t, "month") + city := loadFixture[contentapi.CityPayload](t, "city").City + j.Records[0].Label = "Bogus" + if _, _, err := h.monthCtx(j, city); err == nil { + t.Error("unknown record label must error (Python KeyError -> 500)") + } +} + +func TestRecordsCtx(t *testing.T) { + h := newHandlers(t, fixtureAPI(t)) + j := loadFixture[contentapi.RecordsPayload](t, "records") + city := loadFixture[contentapi.CityPayload](t, "city").City + ctx, unit, err := h.recordsCtx(j, city) + if err != nil { + t.Fatal(err) + } + if unit != "C" { + t.Errorf("unit %q", unit) + } + rows := ctx["Rows"].([]map[string]any) + var precip, high map[string]any + for _, r := range rows { + switch r["Label"] { + case "Precip": + precip = r + case "High": + high = r + } + } + // The precip low is the dry streak, not a formatted value. + if precip["Low"] != "33-day dry spell" { + t.Errorf("dry spell: %v", precip["Low"]) + } + if precip["LowDate"] != "1995-07-31" { + t.Errorf("dry spell date: %v", precip["LowDate"]) + } + // HighF/LowF only ride along for temperature metrics. + if precip["HighF"] != nil { + t.Errorf("precip HighF should be nil: %v", precip["HighF"]) + } + if high["HighF"] == nil { + t.Error("temp HighF missing") + } + // Monthly extremes carry the {Txt, Cls, Date} shape the extremes() macro + // reads; 57.9°F sits in the [45, 58) "cool" tier — boundary-adjacent. + warm := ctx["Monthly"].([]map[string]any)[0]["High"].(map[string]any)["Warm"].(map[string]any) + if warm["Cls"] != "cool" || warm["Date"] != "2022-01-01" { + t.Errorf("monthly warm extreme: %v", warm) + } + if warm["Txt"].(template.HTML) != `14°C` { + t.Errorf("monthly warm txt: %q", warm["Txt"]) + } + if ctx["Hemisphere"] != j.Hemisphere { + t.Error("hemisphere lost") + } + // ToolHref: the full href, same composition as city/month. + if ctx["ToolHref"] != fmt.Sprintf("/thermograph/#%.5f,%.5f", city.Lat, city.Lon) { + t.Errorf("ToolHref %v", ctx["ToolHref"]) + } +} + +func TestHubCtx(t *testing.T) { + h := newHandlers(t, fixtureAPI(t)) + hub := loadFixture[contentapi.HubPayload](t, "hub") + ctx := h.hubCtx(hub) + if ctx["NCities"] != 1000 || ctx["NCountries"] != 124 { + t.Errorf("counts: %v %v", ctx["NCities"], ctx["NCountries"]) + } + // Groups is the raw []contentapi.HubCountry slice, preserving the + // backend's country order (the Python dict was insertion-ordered; a Go + // map would shuffle every render) — HubCountry/HubCity's own fields + // already match what the template reads, so no rebuilding at all. + groups := ctx["Groups"].([]contentapi.HubCountry) + if groups[0].Country != "Afghanistan" { + t.Errorf("group order lost: %v", groups[0].Country) + } + first := groups[0].Cities[0] + if first.Slug != "kabul-af" || first.Name != "Kabul" { + t.Errorf("city entry: %v", first) + } + if ctx["PageTitle"] == "" || ctx["CanonicalPath"] != "/climate" { + t.Error("hub meta broken") + } +} + +func TestHomeCtxJSONLD(t *testing.T) { + h := newHandlers(t, fixtureAPI(t)) + hp := loadFixture[contentapi.HomePayload](t, "home") + ctx, err := h.homeCtx(hp, "http://example.test") + if err != nil { + t.Fatal(err) + } + // Byte-exact against Python's json.dumps({**_HOME_JSONLD, "url": ...}) + // (default separators — spaces preserved — and insertion order). + want := `{"@context": "https://schema.org", "@type": "WebApplication", "name": "Thermograph", ` + + `"applicationCategory": "WeatherApplication", "operatingSystem": "Web, iOS, Android", ` + + `"isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, ` + + `"description": "How unusual is your weather? Any day, anywhere on Earth, graded against 45 years ` + + `of that place's own history.", "url": "http://example.test/thermograph/"}` + if got := string(ctx["JSONLDStr"].(template.JS)); got != want { + t.Errorf("home jsonld:\n got %s\nwant %s", got, want) + } + if ctx["BrandTag"] != "p" || ctx["MainClass"] != "" || ctx["Section"] != "home" { + t.Error("home ctx flags broken") + } + // The captured fixture has no hero pick; nil must stay nil (template's + // {{if .Unusual}} falsiness), not a zero-valued struct. + if ctx["Unusual"] != nil { + t.Errorf("unusual: %v", ctx["Unusual"]) + } + // Cities is the raw []contentapi.HomeCity slice (Slug/Name already match). + if len(ctx["Cities"].([]contentapi.HomeCity)) != 12 { + t.Error("city chips lost") + } +} + +func TestHomeRankedPreservesLatLonText(t *testing.T) { + h := newHandlers(t, fixtureAPI(t)) + v := 100.4 + hp := &contentapi.HomePayload{Ranked: []contentapi.HomeRanked{{ + Cls: "rec-hot", Display: "Testville", Lat: "51.5074", Lon: "-0.1000", + Value: &v, GradeLabel: "Near Record", MetricLabel: "High", PctLabel: "99", + }}} + ctx, err := h.homeCtx(hp, "http://x") + if err != nil { + t.Fatal(err) + } + // Ranked is the raw []contentapi.HomeRanked slice (Cls/Display/Lat/Lon/ + // Value/... already match what the template reads). + r := ctx["Ranked"].([]contentapi.HomeRanked)[0] + // json.Number prints its raw text — "-0.1000" must not become "-0.1". + if fmt.Sprint(r.Lat) != "51.5074" || fmt.Sprint(r.Lon) != "-0.1000" { + t.Errorf("lat/lon text: %v %v", r.Lat, r.Lon) + } + if r.DateLabel != nil { + t.Errorf("DateLabel should be nil: %v", r.DateLabel) + } +} + +func TestGlossaryCtx(t *testing.T) { + h := newHandlers(t, fixtureAPI(t)) + first := h.glossary.Terms[0] + ctx, err := h.glossaryTermCtx(first.Slug, first, "http://testserver/thermograph") + if err != nil { + t.Fatal(err) + } + // {base} substitution happens per-request in the body, like the Python's + // _glossary_body. + body := string(ctx["Body"].(template.HTML)) + if strings.Contains(body, "{base}") { + t.Error("body {base} placeholder not substituted") + } + if strings.Contains(first.Body, "{base}") && !strings.Contains(body, "/thermograph") { + t.Error("body {base} not replaced with the configured base") + } + if ctx["PageTitle"] != first.Term+": what it means | Thermograph" { + t.Errorf("PageTitle: %v", ctx["PageTitle"]) + } + // Others is the raw []contentdata.GlossaryTerm slice (Slug/Term already + // match what the template reads). + others := ctx["Others"].([]contentdata.GlossaryTerm) + if len(others) != len(h.glossary.Terms)-1 { + t.Errorf("others: %d", len(others)) + } + for _, o := range others { + if o.Slug == first.Slug { + t.Error("others must exclude the current term") + } + } + // JSONLDStr: a raw DefinedTerm object, not a JSON-encoded STRING + // containing one (the template.HTML-vs-template.JS bug this guards + // against would wrap the whole thing in quotes with escaped internals). + jsonld := ctx["JSONLDStr"] + js, ok := jsonld.(template.JS) + if !ok { + t.Fatalf("JSONLDStr type = %T, want template.JS", jsonld) + } + want := `{"@context":"https://schema.org","@type":"DefinedTerm","name":"` + first.Term + + `","description":"` + first.Short + `","inDefinedTermSet":"http://testserver/thermograph/glossary"}` + if string(js) != want { + t.Errorf("JSONLDStr:\n got %s\nwant %s", js, want) + } +} + +// --- FuncMap helpers --------------------------------------------------------- + +// html/template strips literal comments while parsing (verified +// directly: a template of only `

a

b

` renders as +// `

a

b

`) -- comment must wrap its output template.HTML to insert +// it as trusted content rather than markup, so a visible comment written in a +// .tmpl file actually survives into what ships. Checked both as a bare +// function call and end to end through a real html/template parse+execute, +// since the function alone proves nothing about whether html/template's +// parser then strips it back out. +func TestCommentSurvivesParsing(t *testing.T) { + fm := FuncMap(testConfig()) + comment := fm["comment"].(func(string) template.HTML) + if got := comment("hello"); got != "" { + t.Errorf("comment(%q) = %q", "hello", got) + } + + tmpl, err := template.New("t").Funcs(fm).Parse(`

a

{{comment "x"}}

b

`) + if err != nil { + t.Fatal(err) + } + var buf strings.Builder + if err := tmpl.Execute(&buf, nil); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != `

a

b

` { + t.Errorf("comment through a real template = %q", got) + } +} + +// html/template ALSO strips JavaScript `//` line comments from ") + if err != nil { + t.Fatal(err) + } + var buf strings.Builder + if err := tmpl.Execute(&buf, nil); err != nil { + t.Fatal(err) + } + want := "" + if got := buf.String(); got != want { + t.Errorf("jscomment through a real template:\n got %q\nwant %q", got, want) + } +} + +func TestHeadVerifyHTML(t *testing.T) { + if headVerifyHTML("", "") != "" { + t.Error("no tokens -> empty") + } + got := headVerifyHTML("gtok", "btok") + want := "\n " + + "" + if string(got) != want { + t.Errorf("head_verify: %q", got) + } + // Attribute escaping matches markupsafe. + if !strings.Contains(string(headVerifyHTML(`a"b`, "")), "a"b") { + t.Error("token not attribute-escaped") + } +} + +func TestToJSON(t *testing.T) { + // u builds a backslash-u escape the way json.dumps/htmlsafe_json_dumps + // emit them (lowercase hex, four digits). + u := func(r rune) string { return fmt.Sprintf(`\u%04x`, r) } + cases := []struct { + in, want string + }{ + {"Feels-like temperature", `"Feels-like temperature"`}, + // Jinja's htmlsafe |tojson escapes the HTML-unsafe <, >, &, '. + {`a&'c`, `"a` + u('<') + `b` + u('>') + u('&') + u('\'') + `c"`}, + // ...and ensure_ascii turns non-ASCII into escapes. + {"50°F", `"50` + u('°') + `F"`}, + {"±7", `"` + u('±') + `7"`}, + } + for _, c := range cases { + got, err := toJSON(c.in) + if err != nil { + t.Fatal(err) + } + if string(got) != c.want { + t.Errorf("toJSON(%q) = %s, want %s", c.in, got, c.want) + } + } +} + +func TestFuncMapHelpers(t *testing.T) { + fm := FuncMap(testConfig()) + temp := fm["temp"].(func(any, any) template.HTML) + // Jinja's {{ temp(-10) }} literal (int) and a nullable payload pointer. + if got := temp("C", -10); got != `-23°C` { + t.Errorf("temp int literal: %q", got) + } + if got := temp("", (*float64)(nil)); got != "—" { + t.Errorf("temp nil pointer: %q", got) + } + tc := fm["temp_class"].(func(any) string) + if tc(nil) != "none" { + t.Error("temp_class nil") + } + ord := fm["ordinal"].(func(any) string) + if ord(89.1) != "89th" { + t.Error("ordinal") + } +} diff --git a/frontend/server/internal/content/funcmap.go b/frontend/server/internal/content/funcmap.go new file mode 100644 index 0000000..c1ec52c --- /dev/null +++ b/frontend/server/internal/content/funcmap.go @@ -0,0 +1,177 @@ +package content + +import ( + "fmt" + "html/template" + "strings" + "unicode/utf16" + + "thermograph/frontend/internal/config" + "thermograph/frontend/internal/format" +) + +// FuncMap builds the template helper set (the Jinja globals/filters +// content.py registered on its Environment). Pass it to render.New at boot — +// html/template resolves names at parse time. +// +// Unit-dependent helpers (temp, temp_bare) take the active unit as their +// FIRST argument: the Python scoped it in a ContextVar, but html/template +// FuncMaps are engine-global, so per-request state must arrive through the +// call. Every page context carries the active unit under the "unit" key +// (same value as "unit_default"), so template call sites are +// {{temp $.unit .high_f}} where Jinja had {{ temp(m.high_f) }}. +func FuncMap(cfg config.Config) template.FuncMap { + return template.FuncMap{ + // temp / temp_bare / temp_class accept the loosely-typed values the + // templates hand them: nullable *float64 payload fields, plain + // float64s, and int literals (Jinja's {{ temp(-10) }}). + "temp": func(unit, f any) template.HTML { + return format.Temp(unitArg(unit), floatArg(f)) + }, + "temp_bare": func(unit, f any) template.HTML { + return format.TempBare(unitArg(unit), floatArg(f)) + }, + "temp_class": func(f any) string { + return format.TempClass(floatArg(f)) + }, + // The `ordinal` filter: {{ c.percentile|ordinal }} -> {{ordinal .percentile}}. + "ordinal": format.PctOrdinal, + // Search-engine ownership-verification tags, from env (empty + // when unset). No backend dependency — the same two variables the + // Python read, resolved once into config. + "head_verify": func() template.HTML { + return headVerifyHTML(cfg.GoogleVerify, cfg.BingVerify) + }, + // Jinja's |tojson (glossary_term.html.j2's inline JSON-LD): JSON with + // the HTML-unsafe characters escaped so it can sit inside . html/template's + // contextual escaper treats . html/template's + // contextual escaper treats . html/template's + // contextual escaper treats + + +{{end}} +{{define "base_tail" -}} + + + +{{end}} +{{/* Shared breadcrumb nav (each Jinja page repeated this line verbatim; the + output is identical, so it is factored here). The separator is emitted + BEFORE every non-first crumb — same byte stream as Jinja's + `{% if not loop.last %}` after-each-but-last. Crumb.Href is *string + (terminal crumb: null). */}} +{{define "breadcrumb" -}} + +{{end}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/city.html.tmpl b/frontend/server/internal/render/templates/city.html.tmpl new file mode 100644 index 0000000..1166acd --- /dev/null +++ b/frontend/server/internal/render/templates/city.html.tmpl @@ -0,0 +1,101 @@ +{{/* Ported from templates/city.html.j2. Extra data (built by the handler + inside unit_scope(default_unit), as content.py's city_page did): + .Breadcrumb; .City (needs .Slug); .Display/.Name string; + .YearRange [2]int; .NYears int; + .Months []{Name, Slug string; HighF, LowF, RangeLoF, RangeHiF *float64; + High, Low, Precip template.HTML; + Bar *{Left, Width string; C1, C2 string}} + — Bar.Left/Width are STRINGS pre-formatted to Python's repr of + round(x, 1) ("36.0", not "36"): Go's %v drops the ".0" and the golden + diff would catch it; + .Warmest/.Coldest/.Wettest *month (entries of .Months, or nil); + .Records contentapi.AllTimeRecords (raw floats; .Fmt.Temp in-template); + .Today *{Date string; Cards []{Label, Grade, Cls string; + Value template.HTML; Percentile *float64}}; + .Flavor *{Extract string; URL string}; .Event *{Text string; URL string}; + .ToolHref string (full "{base}/#{lat:.5f},{lon:.5f}" href — composed + inline, html/template's fragment urlEscaper would %-escape the comma); + .CompareURL string ("{base}/compare#loc={lat:.4f},{lon:.4f}"); + .JSONLDStr template.JS (compacted raw payload bytes — the |safe site; + trusted backend-owned JSON-LD, never re-marshaled through a Go map). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}} +
+

{{.Display}} climate

+

Average temperatures, precipitation and all-time records for {{.Display}}, + with every day graded against ~{{.NYears}} years of local climate history + ({{index .YearRange 0}}–{{index .YearRange 1}}).{{if and .Warmest .Coldest}} Its warmest month is + {{.Warmest.Name}}, averaging a high of {{.Warmest.High}}, and its coldest is + {{.Coldest.Name}}, with an average low of {{.Coldest.Low}}.{{end}}

+ +{{if .Flavor}}

{{.Flavor.Extract}}{{if .Flavor.URL}} + via Wikipedia{{end}}

+{{end}} +{{if .Today}}
+

How today compares

+

Latest recorded day: {{.Today.Date}}. Each reading placed on {{.Display}}'s + own ±7-day seasonal distribution.

+
+{{range .Today.Cards}}
+ {{.Label}} + {{.Value}} +{{if .Percentile}}{{.Grade}} · {{ordinal .Percentile}} pct +{{else}}{{.Grade}}{{end}}
+{{end}}
+

Open {{.Name}} in the live weather grader →

+
+{{end}} +{{if .Event}}
+

Notable weather in {{.Name}}

+

{{.Event.Text}}{{if .Event.URL}} Read more →{{end}}

+
+{{end}} +
+

{{.Name}} average temperatures by month

+

Typical daily high and low, and average precipitation, for each month in {{.Display}}, + based on {{index .YearRange 0}}–{{index .YearRange 1}} records. +{{if .Wettest}}The wettest month is {{.Wettest.Name}} ({{.Wettest.Precip}} on an average day).{{end}}

+
+ + + +{{range .Months}} + + + + + +{{end}} +
MonthAvg highAvg lowAvg precip
{{.Name}}{{.High}}{{.Low}}{{.Precip}}
+
+ + +

Each bar spans the 10th-percentile daily low to the 90th-percentile daily + high (the range most days fall within), coloured cold → hot on a shared + {{.Fmt.Temp -10.0}} to {{.Fmt.Temp 115.0}} scale, so the whole year's rhythm reads at a glance.

+
+ +
+

Thinking of visiting {{.Name}}?

+

Trips live and die by the weather. See how {{.Name}}'s comfort stacks up against where + you live. {{.Name}} is pre-loaded, just add your city.

+

Compare {{.Name}}'s comfort with your city →

+
+ +{{if or .Records.Tmax .Records.Tmin}}
+

Record extremes

+
    +{{if .Records.Tmax}}
  • Hottest day on record: {{.Fmt.Temp .Records.Tmax.Max}} on {{.Records.Tmax.MaxDate}}
  • {{end}}{{if .Records.Tmin}}
  • Coldest day on record: {{.Fmt.Temp .Records.Tmin.Min}} on {{.Records.Tmin.MinDate}}
  • {{end}}
+

All {{.Name}} weather records →

+
+{{end}} +

Percentiles compare each day to {{.Display}}'s own history, so grades are + relative to this location: a “Near Record” day here isn't the same temperature as elsewhere. + How the grades work →

+
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/glossary.html.tmpl b/frontend/server/internal/render/templates/glossary.html.tmpl new file mode 100644 index 0000000..4c730b2 --- /dev/null +++ b/frontend/server/internal/render/templates/glossary.html.tmpl @@ -0,0 +1,10 @@ +{{/* Ported from templates/glossary.html.j2. Extra data: .Breadcrumb, + .Terms []{Slug, Term, Short string} (glossary.yaml file order). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}
+

Weather & climate glossary

+

Plain-language definitions of the terms Thermograph uses to grade the weather.

+{{range .Terms}}
+

{{.Term}}

+

{{.Short}}

+
+{{end}}
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/glossary_term.html.tmpl b/frontend/server/internal/render/templates/glossary_term.html.tmpl new file mode 100644 index 0000000..a1fd2d4 --- /dev/null +++ b/frontend/server/internal/render/templates/glossary_term.html.tmpl @@ -0,0 +1,21 @@ +{{/* Ported from templates/glossary_term.html.j2. Extra data: + .Breadcrumb; .Term string; .Others []{Slug, Term string}; + .JSONLDStr template.JS — the handler builds the WHOLE DefinedTerm object + (the Jinja original interpolated `term|tojson` / `page_description|tojson` + / base_url inline; Go's JS-context escaping differs byte-wise from + markupsafe's tojson, so the handler reproduces Python htmlsafe-tojson — + ensure_ascii \uXXXX escapes plus <,>,&,' → <,>,&,' — + and template.JS passes it through untouched); + .Body template.HTML — glossary yaml `body` with {base} substituted. This + is the Jinja `| safe` site: the content is trusted, committed copy from + frontend/content/glossary.yaml, not user or API input. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}} +{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/home.html.tmpl b/frontend/server/internal/render/templates/home.html.tmpl new file mode 100644 index 0000000..2ce3918 --- /dev/null +++ b/frontend/server/internal/render/templates/home.html.tmpl @@ -0,0 +1,182 @@ +{{/* Ported from templates/home.html.j2. + + The Weekly view — the product itself — with the distribution surfaces built + around it. Everything structural is server-rendered so crawlers and no-JS + readers get a complete page; app.js hydrates the live numbers in place. + + The interactive controls below (#find-btn, #date-input, #panel, #results, …) + are app.js's DOM contract, carried over verbatim from the old static + frontend/index.html. Do not rename these ids. + + Section / BrandTag ("p") / MainClass ("") come from the route handler's + render data, not the template — the SEO pages already pass Section that way. + + Extra data: .Unusual *contentapi.HomeUnusual; .Stale bool; + .Ranked []contentapi.HomeRanked (Lat/Lon are json.Number, printed raw); + .Cities []contentapi.HomeCity; + .JSONLDStr template.JS — _HOME_JSONLD + "url" appended, serialized like + Python's plain json.dumps: ", "/": " separators, ensure_ascii=True. */}}{{template "base_head_start" .}} +{{template "base_head_end" .}} +{{template "base_body_start" .}}{{/* --- Hero ------------------------------------------------------------ + The grade card is the LCP element: its frame is server-rendered and its + slots are sized here, so hydrating the live numbers causes no layout + shift. Band colour is always paired with the band word — colour is never + the only signal. */}}
+
+

How unusual is your weather?

+

Any day, anywhere on Earth, graded against 45 years of that place's own history.

+
+ +
{{/* --cat carries the band colour, the same convention .normal-card uses, + so the band token stays the single source of truth. */}} +
+

+ {{- if .Unusual -}} + Today in {{.Unusual.Display}} + {{- else -}} + Today + {{- end -}} +

+

{{if .Unusual}}{{.Unusual.Grade}}{{else}}—{{end}}

+

+ {{- if .Unusual -}} + {{.Unusual.MetricLabel}} in the {{ordinal .Unusual.Percentile}} percentile for {{.Unusual.WindowLabel}} + {{- else -}} + Pick a place to see how unusual its weather is today. + {{- end -}} +

+{{if and .Unusual .Unusual.IsDefault}}

the most unusual weather we're tracking{{if .Stale}} · as of {{.Unusual.Date}}{{end}}

+{{end}}
+
{{/* app.js hides the locate button outside a secure context, where the + browser refuses geolocation and it could only ever fail. */}} + + +
+ +

See your week ↓

+
+
+ +
+
+ + +
+
+ + + What do the grades mean? → +
+
+ +
+
+

Find a location to begin.

+

Thermograph fetches decades of daily highs, lows, and precipitation + for your ~4 sq mi cell, builds a ±7-day seasonal distribution for each + day of the year, and grades recent weather by where it falls on that distribution.

+
+ +
+ +{{/* Sits outside #panel on purpose: app.js rewrites results.innerHTML + wholesale on every grade, so anything inside it would be destroyed. */}}

+ Free. No ads. No tracking. No account needed. + How we work → +

+ +{{/* --- Unusual right now ---------------------------------------------- + Scroll-snap row, no JS carousel. Both tails are represented whenever a + cold-tail city qualifies — two-sided honesty is product identity. */}}{{if .Ranked}}
+

Unusual right now

+ +

See all records →

+
+{{end}} +{{/* --- How it works ---------------------------------------------------- */}}
+

How it works

+
    +
  1. We keep 45 years of climate history (ERA5 reanalysis) for your exact ~2-mile grid square.
  2. +
  3. Today gets compared to the same two weeks of the year, every year back to 1980.
  4. +
  5. The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.
  6. +
+

+ Full methodology → + · Data: ERA5 via Open-Meteo · NASA POWER · MET Norway +

+
+ +{{/* --- Explore --------------------------------------------------------- */}}
+

Explore

{{/* Each card takes a colour from the grade ramp rather than a decorative + palette, so the section reads as part of the product's own scale. + Calendar carries the ramp itself — it IS the heatmap. */}} + +
+ +{{/* --- City hub links -------------------------------------------------- + Real HTML links funnelling homepage authority into the ~1000-page + /climate surface. */}}
+

Climate context for 1,000 cities

+ +

Browse all cities →

+
+{{template "base_body_end" .}} + {{/* The records strip renders real temperatures server-side as .temp spans, so + it needs the same converter the SEO pages use or they'd stay in °F while + the toggle says °C. Both import units.js, which the module loader dedupes. */}} + +{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/hub.html.tmpl b/frontend/server/internal/render/templates/hub.html.tmpl new file mode 100644 index 0000000..082fd0c --- /dev/null +++ b/frontend/server/internal/render/templates/hub.html.tmpl @@ -0,0 +1,80 @@ +{{/* Ported from templates/hub.html.j2. Extra data: .Breadcrumb; + .NCities/.NCountries int; .Groups — MUST be the ordered + []contentapi.HubCountry slice from the hub payload, never a Go map + (template range over a map sorts keys; Python's dict kept the backend's + order, and the golden diff would catch the reorder). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}
+

City climates

+

Average temperatures by month, all-time records, and how today compares, for + {{.NCities}} cities across {{.NCountries}} countries. Every day is graded against that + location's own ~45 years of climate history.

+ + + + +
+{{range .Groups}}
+ {{.Country}} {{len .Cities}} + +
+{{end}}
+
+ + +{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/month.html.tmpl b/frontend/server/internal/render/templates/month.html.tmpl new file mode 100644 index 0000000..515d9a0 --- /dev/null +++ b/frontend/server/internal/render/templates/month.html.tmpl @@ -0,0 +1,60 @@ +{{/* Ported from templates/month.html.j2. Extra data (all display values are + pre-formatted by the handler inside the city's unit scope, as in Python): + .Breadcrumb; .City (needs .Slug); .Display/.Name/.MonthName string; + .YearRange [2]int; .AvgHigh/.AvgLow template.HTML (temp spans); + .AvgHighCls/.AvgLowCls string (temp_class names); + .Stats []{Label string; Value template.HTML}; + .Records []{Label, HighTag, LowTag string; High, Low template.HTML; + HighDate, LowDate string}; + .ToolHref string — the full "{base}/#{lat:.5f},{lon:.5f}" href, built by + the handler: composed inline (Jinja's {{ base }}/#{{ tool_hash }}) the + fragment would hit html/template's urlEscaper and %-escape the comma; + .CompareURL string; .Prev/.Next {Slug, Name string}. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}} +
+

Weather in {{.Display}} in {{.MonthName}}

+

In an average {{.MonthName}}, {{.Name}} sees a daily high around + {{.AvgHigh}} and a low around + {{.AvgLow}}, based on + {{index .YearRange 0}}–{{index .YearRange 1}} of local climate records.

+ + + +{{range .Stats}} +{{end}} +
{{.Label}}{{.Value}}
+ +{{if .Records}}

{{.MonthName}} records

+

The most extreme {{.MonthName}} on record for each metric, across + {{index .YearRange 0}}–{{index .YearRange 1}}. For rainfall, the wettest and driest are by + total {{.MonthName}} accumulation.

+
+{{range .Records}}
+

{{.Label}}

+
+ {{.HighTag}} + {{.High}} + {{.HighDate}} +
+
+ {{.LowTag}} + {{.Low}} + {{.LowDate}} +
+
+{{end}}
+{{end}} +

See {{.Name}}'s live weather grade →

+ +
+

Visiting {{.Name}} in {{.MonthName}}? See how its comfort stacks up against + where you live.

+ Compare {{.Name}} with your city → +
+ +

+ ‹ {{.Prev.Name}} · + {{.Name}} climate overview · + {{.Next.Name}} › +

+
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/privacy.html.tmpl b/frontend/server/internal/render/templates/privacy.html.tmpl new file mode 100644 index 0000000..5db4099 --- /dev/null +++ b/frontend/server/internal/render/templates/privacy.html.tmpl @@ -0,0 +1,31 @@ +{{/* Ported from templates/privacy.html.j2. Extra data: .Breadcrumb. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}
+

Privacy

+

Thermograph is free, has no ads, and needs no account. It is built so that + there is very little about you to collect in the first place.

+ +

Your location

+

Thermograph never looks up your location from your IP address. The only way the site + learns where you are is if you press Use my location, which asks your browser for + permission. You can decline, and picking a place on the map works just as well.

+

Whichever way you choose a place, the coordinates are sent to the server only as part of + the ordinary request for that grid square's climate data, and are not logged beyond the + normal web-server request handling every site does. The place you picked is remembered in + your own browser's local storage, not on the server, so clearing your browser data + forgets it.

+ +

Analytics

+

There is no analytics library, no cookies for tracking, and no per-visitor identifier. + The server keeps simple aggregate counts (how many people opened a page or tapped a + button, and which site referred them) with nothing tying any of it to an individual.

+ +

Email

+

If you sign up for the monthly digest, your address is used for that digest and nothing + else. It is never sold or shared, and every message can unsubscribe you.

+ +

Accounts

+

An account is optional and only exists to hold weather alerts you have asked for. It + stores your email address and the places you are watching.

+ +

Questions: see About & methodology.

+
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/internal/render/templates/records.html.tmpl b/frontend/server/internal/render/templates/records.html.tmpl new file mode 100644 index 0000000..b8a4930 --- /dev/null +++ b/frontend/server/internal/render/templates/records.html.tmpl @@ -0,0 +1,95 @@ +{{/* Ported from templates/records.html.j2. Extra data (formatted by the + handler inside unit_for_country scope, as in Python): + .Breadcrumb; .City (needs .Slug); .Display/.Name string; .YearRange [2]int; + .Rows []{Label string; High, Low template.HTML; HighDate, LowDate string} + — Low is either the formatted metric value or the "N-day dry spell" + text, LowDate already "—"-defaulted (content.py records_page); + .Monthly []{Name, Slug string; High, Low side} and + .Seasonal []{Name, Span string; High, Low side} where a side is + {Warm, Cold *{Cls string; Txt template.HTML; Date string}}; + .Hemisphere string; .AllTime contentapi.AllTimeRecords (raw floats — + .Fmt.Temp runs in the template, like Jinja's temp()); + .ToolHref string (full "{base}/#{lat:.5f},{lon:.5f}" — composed inline the + fragment comma would be %-escaped by html/template's urlEscaper); + .JSONLDStr template.JS (compacted raw payload bytes — the |safe site; + trusted backend-owned JSON-LD, never re-marshaled through a Go map). */}} +{{/* A metric's two extremes — ▲ warmest and ▼ coldest it has ever been — as tinted + chips with the date each occurred. Used for the daytime-high and overnight-low + columns of the month and season tables. (Jinja macro `extremes`.) */}} +{{define "extremes" -}} +{{if and .Warm .Cold -}} +
{{.Warm.Txt}}{{.Warm.Date}}
+
{{.Cold.Txt}}{{.Cold.Date}}
+{{else -}} +—{{end}}{{end}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}} +
+

{{.Display}} weather records

+

Record high and low temperatures for {{.Display}}, by month, by season, and + all-time, with the dates they occurred, across {{index .YearRange 0}}–{{index .YearRange 1}} of daily + climate history.{{if and .AllTime.Tmax .AllTime.Tmin}} Its hottest day on record reached + {{.Fmt.Temp .AllTime.Tmax.Max}} on {{.AllTime.Tmax.MaxDate}}; its coldest fell to + {{.Fmt.Temp .AllTime.Tmin.Min}} on {{.AllTime.Tmin.MinDate}}.{{end}}

+ +
+

Records by month

+

The warmest (▲) and coldest (▼) both the daytime high and the overnight low have ever been in + each calendar month, so each column shows its record high and its record low. Tap a + month for its full averages and typical range.

+
+ + + +{{range .Monthly}} + + + + +{{end}} +
MonthDaytime highOvernight low
{{.Name}}{{template "extremes" .High}}{{template "extremes" .Low}}
+
+
+ +
+

Records by season

+

The warmest (▲) and coldest (▼) both the daytime high and the overnight low have reached in each + meteorological season ({{.Hemisphere}} Hemisphere, each a three-month span).

+
+ + + +{{range .Seasonal}} + + + + +{{end}} +
SeasonDaytime highOvernight low
{{.Name}} ({{.Span}}){{template "extremes" .High}}{{template "extremes" .Low}}
+
+
+ +
+

All-time records

+

The most extreme value {{.Name}} has recorded for each metric across its entire history.

+
+{{range .Rows}}
+

{{.Label}}

+
+ {{if eq .Label "Precip"}}Wettest{{else}}Highest{{end}} + {{.High}} + {{.HighDate}} +
+
+ {{if eq .Label "Precip"}}Driest{{else}}Lowest{{end}} + {{.Low}} + {{.LowDate}} +
+
+{{end}}
+

For rainfall, the “driest” figure is the longest run of consecutive + days without measurable rain, dated to when that dry spell began.

+
+ +

Grade {{.Name}}'s weather now →

+

‹ Back to {{.Name}} climate

+
+{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}} \ No newline at end of file diff --git a/frontend/server/main.go b/frontend/server/main.go new file mode 100644 index 0000000..914bcf0 --- /dev/null +++ b/frontend/server/main.go @@ -0,0 +1,174 @@ +// thermograph-frontend: the SSR frontend service — server-rendered content +// pages (climate hub / per-city / month / records / glossary / about), the +// interactive tool's SPA shells, and every static asset. All climate data +// comes from the backend's content API over HTTP (internal/contentapi); this +// process holds no data of its own. +// +// Go port of app.py: configuration, the route wiring (internal/content for +// the content.py routes, internal/handlers for the SPA shells + static +// assets), and the process lifecycle uvicorn used to own (listen, access +// logs, graceful shutdown). +package main + +import ( + "context" + "errors" + "log/slog" + "mime" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "thermograph/frontend/internal/config" + "thermograph/frontend/internal/content" + "thermograph/frontend/internal/contentapi" + "thermograph/frontend/internal/contentdata" + "thermograph/frontend/internal/handlers" + "thermograph/frontend/internal/render" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + // Fail loud at boot on bad configuration or content (missing backend URL, + // garbage TTL, malformed glossary/pages YAML) — the Python raised at + // import for the same cases: a missing backend URL or a bad content edit + // should break the boot, not silently 500 on the first request. + cfg, err := config.Load() + if err != nil { + fatal(logger, "config", err) + } + + client := contentapi.New(contentapi.Options{ + BaseURL: cfg.APIBaseInternal, + BasePrefix: cfg.Base, // backend runs under the same THERMOGRAPH_BASE + APIVersion: cfg.APIVersion, + TTL: cfg.SSRCacheTTL, + }) + + glossary, err := contentdata.LoadGlossary(cfg.ContentDir) + if err != nil { + fatal(logger, "glossary", err) + } + pages, err := contentdata.LoadPages(cfg.ContentDir) + if err != nil { + fatal(logger, "pages", err) + } + + // Templates parse once, at boot, with the full FuncMap (html/template + // resolves function names at parse time) — the Go analogue of the Jinja + // environment's auto_reload=False. + engine, err := render.New(content.FuncMap(cfg)) + if err != nil { + fatal(logger, "templates", err) + } + + // The PWA manifest is served as a static file; register its media type so + // the file server labels it correctly (not in Go's default mime table). + if err := mime.AddExtensionType(".webmanifest", "application/manifest+json"); err != nil { + fatal(logger, "mime", err) + } + + // app.py-layer routes: SPA shells + static assets + the bare-BASE redirect. + app := handlers.New(handlers.Options{ + Base: cfg.Base, + StaticDir: cfg.StaticDir, + GoogleVerify: cfg.GoogleVerify, + BingVerify: cfg.BingVerify, + Log: logger, + }) + + // content.py-layer routes. content.New takes a *log.Logger; bridge it into + // slog so its lines (IndexNow boot warning, render failures) land on + // stdout structured like everything else. + contentHandlers, err := content.New(cfg, client, engine, glossary, pages, + slog.NewLogLogger(logger.Handler(), slog.LevelWarn)) + if err != nil { + fatal(logger, "content", err) + } + + mux := http.NewServeMux() + + // Liveness probe: the process is up and serving. Deliberately does no I/O + // — no call to the backend — so it stays cheap and reliable for a tight + // healthcheck interval. Readiness (backend reachable) is a separate concern. + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + + // Register order does not matter for precedence (most-specific pattern + // wins), but content registration performs the eager IndexNow key fetch — + // same point in boot as the Python's content.register(app). Its lazy + // fallback route needs the static handler to delegate non-key requests to. + contentHandlers.Register(mux, app.Static()) + app.Register(mux) + + srv := &http.Server{ + Addr: net.JoinHostPort("", cfg.Port), // 0.0.0.0:PORT, like uvicorn --host 0.0.0.0 + Handler: accessLog(logger, mux), + ReadHeaderTimeout: 10 * time.Second, + } + + // Graceful shutdown on SIGINT/SIGTERM: stop accepting, drain in-flight + // requests, then exit — what uvicorn did for the Python process. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + logger.Info("thermograph-frontend listening", "port", cfg.Port, "base", cfg.Base) + errCh <- srv.ListenAndServe() + }() + + select { + case err := <-errCh: + if !errors.Is(err, http.ErrServerClosed) { + fatal(logger, "serve", err) + } + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown", "err", err) + os.Exit(1) + } + logger.Info("shut down cleanly") + } +} + +func fatal(logger *slog.Logger, stage string, err error) { + logger.Error(stage, "err", err) + os.Exit(1) +} + +// accessLog is the uvicorn access log's replacement: one structured line per +// request on stdout. +func accessLog(logger *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + logger.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "dur_ms", time.Since(start).Milliseconds(), + ) + }) +} + +// statusRecorder captures the response status for the access log. +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (rec *statusRecorder) WriteHeader(status int) { + rec.status = status + rec.ResponseWriter.WriteHeader(status) +} diff --git a/infra/deploy/deploy.sh b/infra/deploy/deploy.sh index 487779e..51748eb 100755 --- a/infra/deploy/deploy.sh +++ b/infra/deploy/deploy.sh @@ -247,9 +247,17 @@ fi # next deploy of a tag that has it picks it up with no further action. case " ${TARGETS[*]} " in *" daemon "*) - daemon_img="$(docker compose config --images daemon 2>/dev/null | head -1 || true)" - if [ -n "$daemon_img" ] \ - && ! docker run --rm --entrypoint sh "$daemon_img" -c 'test -x /usr/local/bin/thermograph-daemon' 2>/dev/null; then + # Built directly from the same vars docker-compose.yml's `daemon.image:` + # interpolates (REGISTRY_HOST/BACKEND_IMAGE_PATH/BACKEND_IMAGE_TAG), + # NOT via `docker compose config --images daemon`: that command does not + # actually filter to the named service (confirmed live on Compose + # v5.3.1 -- it prints every service's image, one per line, in file + # order) so `| head -1` silently grabbed db's image instead. The probe + # then always found no daemon binary in a Postgres image and dropped + # daemon from EVERY backend deploy, regardless of what the real backend + # image contained -- reproduced and confirmed against beta directly. + daemon_img="${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG}" + if ! docker run --rm --entrypoint sh "$daemon_img" -c 'test -x /usr/local/bin/thermograph-daemon' 2>/dev/null; then echo "==> $daemon_img predates the daemon binary; rolling without the daemon service this run" kept=() for t in "${TARGETS[@]}"; do diff --git a/infra/deploy/forgejo/README.md b/infra/deploy/forgejo/README.md index 0325386..6f91009 100644 --- a/infra/deploy/forgejo/README.md +++ b/infra/deploy/forgejo/README.md @@ -41,6 +41,15 @@ Do this **before** the DNS + Caddy step below — Caddy's reverse_proxy target (`127.0.0.1:3080`) needs the `forgejo` service actually listening first, or its first health check just fails harmlessly until it is. +This stack has **no auto-deploy trigger** — nothing in `.forgejo/workflows/` +redeploys it on push. A change to `docker-stack.yml` only takes effect once +someone re-runs `docker stack deploy` by hand on the manager (prod). + +`db`/`forgejo` both carry `resources.limits` (defaults: db 1 CPU/1g, forgejo 2 +CPU/2g — several times observed steady-state usage), overridable with +`FORGEJO_DB_CPUS`/`FORGEJO_DB_MEMORY`/`FORGEJO_CPUS`/`FORGEJO_MEMORY` env vars +before `docker stack deploy`, same convention as the app stack. + ## DNS + TLS: reusing beta's existing Caddy, not a second reverse proxy Forgejo is pinned to beta (`role=forge`) — but beta is also **today's live @@ -98,6 +107,62 @@ See that script's header for exactly what it replaces (the pre-Forgejo GitHub self-hosted runner on this same machine) and why it registers with two labels where there used to be two separate runners. +`config.yaml`'s `runner.capacity` is raised from the tool's default of 1 to 8 +(override with `CAPACITY=`) — a single PR push fires `pr-build`, +`secrets-guard`, and `shell-lint` simultaneously (3 independent workflows, +no `needs:` between them), so capacity 1 serializes work that could run in +parallel, and even capacity 3 (an earlier, undocumented hand-tune) leaves +`build-backend`/`build-frontend`/`validate-observability` queued behind +those three before they get a slot. The desktop has 16 cores / 34GB free +today; 8 concurrent jobs is comfortable headroom without starving LAN dev's +own compose stack. + +**Adding more runner capacity should mean raising this number, or adding a +second runner on the desktop itself — not putting a runner on prod or +beta.** `container.docker_host: automount` gives job containers the *host's* +Docker socket; on prod or beta that would mean any CI job has root-equivalent +access to whatever else is running there (the live app stack, or Forgejo +itself). An earlier revision of this stack actually did run the runner as a +Swarm-hosted container on beta and was deliberately reverted to the desktop +for this reason — see the note at the top of `docker-stack.yml`. + +## Custom CI job image (`ci-runner/`) + +`ci-runner/Dockerfile` still bases on `node:20-bookworm` — Node is a hard +requirement, not leftover: Forgejo's runner executes `actions/checkout@v4` +(used by every workflow) as `node dist/index.js` *inside the job container*, +regardless of whether the workflow itself uses npm/node. (v1 of this image +tried a Node-free Debian-slim base and broke every job's checkout step — +`node: executable file not found` — within a minute of going live; reverted +immediately.) What it actually fixes: every `docker`-labeled build-push job +currently re-installs the Docker CLI on each run (`apt-get install +docker.io`), which pulls in the classic builder rather than BuildKit (the +classic builder mishandles `COPY --chown=` group resolution — a real +bug hit during the frontend Go rewrite). `ci-runner` adds `docker-ce-cli` + +`docker-buildx-plugin` (BuildKit) on top of the same Node base, plus +`git`/`python3`/`python3-yaml` for the other jobs that need them +(`shell-lint`, `observability-validate`). + +Current tag: `git.thermograph.org/emi/thermograph/ci-runner:v2` (`v1` is +broken — do not register any runner against it). Rebuild/push (requires a PAT +with `write:package` scope — the embedded git-remote token lacks it, same +requirement documented in `backend-build-push.yml`): + +```bash +docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN deploy/forgejo/ci-runner +docker push git.thermograph.org/emi/thermograph/ci-runner:vN +``` + +`register-lan-runner.sh`'s `LABELS` default points at the current tag, so +fresh registrations pick it up automatically. The live runner is cut over by +editing the `labels` array in `~/forgejo-runner/.runner` on the desktop (same +runner id/token, no re-registration needed) and restarting the service — +**verify a real job runs green under the new image before relying on it**, +same way v1's break was caught. Only after that verification should the +now-redundant `apt-get install docker.io` / `python3-yaml` steps be removed +from the workflows that had them — removing them first would break every job +still running on the stock `node:20-bookworm` image. + ## Why Postgres here and not the Thermograph app's TimescaleDB Separate instance, separate network (`forgejo_net`, not the app's compose diff --git a/infra/deploy/forgejo/ci-runner/Dockerfile b/infra/deploy/forgejo/ci-runner/Dockerfile new file mode 100644 index 0000000..e4259e0 --- /dev/null +++ b/infra/deploy/forgejo/ci-runner/Dockerfile @@ -0,0 +1,46 @@ +# Job-container image for the LAN Forgejo Actions runner's `docker`-labeled +# jobs (see ../register-lan-runner.sh) — used by every backend/frontend +# build-push, deploy, and validation workflow that needs `docker build`. +# +# Base stays node:20-bookworm, NOT a Node-free slim image (v1 of this file +# tried that and broke every job: `actions/checkout@v4` is a JS action that +# Forgejo's runner execs as `node dist/index.js` *inside the job container*, +# so a Node runtime is a hard requirement regardless of whether the workflow +# itself uses npm/node — this repo's own workflows don't, but the checkout +# step every one of them starts with does). +# +# What this image actually fixes: every workflow using the `docker` label +# currently re-provisions the Docker CLI on each run via `apt-get install +# docker.io` — that step costs ~15-20s per job and, more importantly, +# installs the CLASSIC Docker builder rather than BuildKit, which mishandles +# `COPY --chown=` group resolution on images without a matching system +# group (a real bug hit during the frontend Go rewrite). Installing +# docker-buildx-plugin here makes BuildKit the default, closing that bug +# class, and skips the per-job install entirely. +# +# Build/push (manual — see ../README.md for when to rebuild): +# docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN \ +# infra/deploy/forgejo/ci-runner +# docker push git.thermograph.org/emi/thermograph/ci-runner:vN +# +# Cutting over the live runner to a new tag means editing the `labels` array +# in ~/forgejo-runner/.runner on the runner host directly (same runner +# id/token, no re-registration needed) and updating register-lan-runner.sh's +# LABELS default so future re-provisioning picks it up too. +FROM node:20-bookworm + +RUN apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends \ + ca-certificates curl gnupg git python3 python3-yaml \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \ + > /etc/apt/sources.list.d/docker.list \ + && apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends docker-ce-cli docker-buildx-plugin \ + && rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/docker.list + +RUN docker --version && docker buildx version && git --version \ + && node --version \ + && python3 -c "import yaml; print('PyYAML', yaml.__version__)" diff --git a/infra/deploy/forgejo/docker-stack.yml b/infra/deploy/forgejo/docker-stack.yml index 9d94cbd..9891c3b 100644 --- a/infra/deploy/forgejo/docker-stack.yml +++ b/infra/deploy/forgejo/docker-stack.yml @@ -48,6 +48,10 @@ services: deploy: placement: constraints: [node.labels.role == forge] + resources: + limits: + cpus: "${FORGEJO_DB_CPUS:-1}" + memory: ${FORGEJO_DB_MEMORY:-1g} restart_policy: condition: on-failure @@ -131,6 +135,10 @@ services: deploy: placement: constraints: [node.labels.role == forge] + resources: + limits: + cpus: "${FORGEJO_CPUS:-2}" + memory: ${FORGEJO_MEMORY:-2g} restart_policy: condition: on-failure diff --git a/infra/deploy/forgejo/register-lan-runner.sh b/infra/deploy/forgejo/register-lan-runner.sh index 71f1c90..0f96f97 100755 --- a/infra/deploy/forgejo/register-lan-runner.sh +++ b/infra/deploy/forgejo/register-lan-runner.sh @@ -26,7 +26,7 @@ set -euo pipefail FORGEJO_URL="${1:?usage: $0 }" TOKEN="${2:?}" RUNNER_DIR="${RUNNER_DIR:-$HOME/forgejo-runner}" -LABELS="${LABELS:-docker:docker://node:20-bookworm,thermograph-lan}" +LABELS="${LABELS:-docker:docker://git.thermograph.org/emi/thermograph/ci-runner:v2,thermograph-lan}" echo "==> Stopping and disabling the old GitHub Actions runner service, if present" systemctl --user stop github-actions-runner 2>/dev/null || true @@ -62,11 +62,15 @@ echo "==> Registering with $FORGEJO_URL (label: $LABELS)" --name "thermograph-lan-$(hostname -s)" \ --labels "$LABELS" -echo "==> Runner config (docker.sock automount, so docker:-labeled jobs like" -echo " build-push.yml can actually run 'docker build/push' -- generated" -echo " config only overridden on the one setting that matters here)" +echo "==> Runner config (docker.sock automount so docker:-labeled jobs like" +echo " build-push.yml can actually run 'docker build/push'; capacity raised" +echo " from the default of 1 -- a single PR push fires 3+ independent" +echo " workflows (pr-build, secrets-guard, shell-lint) simultaneously, so" +echo " anything less than that serializes jobs that could run in parallel)" +CAPACITY="${CAPACITY:-8}" ./forgejo-runner generate-config \ - | sed 's/docker_host: "-"/docker_host: "automount"/' \ + | sed -e 's/docker_host: "-"/docker_host: "automount"/' \ + -e "s/capacity: 1/capacity: ${CAPACITY}/" \ > "${RUNNER_DIR}/config.yaml" echo "==> systemd --user unit" diff --git a/infra/deploy/stack/env-entrypoint.sh b/infra/deploy/stack/env-entrypoint.sh index 7af8173..da353d7 100755 --- a/infra/deploy/stack/env-entrypoint.sh +++ b/infra/deploy/stack/env-entrypoint.sh @@ -34,9 +34,14 @@ if [ -f "$ENV_FILE" ]; then done < "$ENV_FILE" fi -# Hand off: the backend image has a real entrypoint script; the frontend -# image is plain-CMD (docker passes that CMD to us as $@ when the stack -# overrides the entrypoint) -- exec whichever this image actually is. +# Hand off: the backend image has a real entrypoint script and takes that +# branch. Anything else needs an explicit `command:` in the stack yml's +# service block -- overriding `entrypoint:` here drops the image's own CMD +# entirely (Docker/Swarm does not merge them), so `$@` is empty unless the +# stack file supplies one. The frontend service does exactly that. The +# uvicorn fallback below is what ran, silently, whenever that expectation +# didn't hold; it is Python-specific and kept only as a loud, familiar-looking +# failure for a misconfigured non-Python image, not a real handoff path. if [ -x /app/deploy/entrypoint.sh ]; then exec /app/deploy/entrypoint.sh "$@" elif [ "$#" -gt 0 ]; then diff --git a/infra/deploy/stack/thermograph-stack.yml b/infra/deploy/stack/thermograph-stack.yml index 12aef0b..91fb062 100644 --- a/infra/deploy/stack/thermograph-stack.yml +++ b/infra/deploy/stack/thermograph-stack.yml @@ -245,6 +245,12 @@ services: frontend: image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required} entrypoint: ["/host/env-entrypoint.sh"] + # REQUIRED, not cosmetic: overriding `entrypoint:` with no `command:` drops + # the image's own CMD entirely (Docker/Swarm semantics, not merged) -- + # env-entrypoint.sh then sees zero args and falls through to its hardcoded + # `exec uvicorn app:app` fallback, which no longer exists in this (Go) + # image. Without this line the frontend task exits 127 on every deploy. + command: ["/usr/local/bin/thermograph-frontend"] environment: THERMOGRAPH_BASE: / PORT: 8080 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index f4d7330..2b13bd5 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -8,8 +8,8 @@ # FRONTEND_IMAGE_TAG. This replaces the earlier Stage-4 model where both # containers shared the single emi/thermograph/app image and # THERMOGRAPH_SERVICE_ROLE picked the process; the split Dockerfiles now start -# the right process directly (frontend `uvicorn app:app`, backend -# entrypoint.sh), so a backend deploy and a frontend deploy are fully +# the right process directly (frontend the thermograph-frontend Go binary, +# backend entrypoint.sh), so a backend deploy and a frontend deploy are fully # independent -- deploy.sh rolls one service without touching the other's tag. # Both get their own loopback-published port since prod/beta's host Caddy # path-splits directly to each; backend also gets a reverse-proxy fallback to @@ -256,9 +256,9 @@ services: restart: unless-stopped frontend: - # Frontend's OWN image, published by thermograph-frontend's build-push.yml - # from its own Dockerfile (which starts `uvicorn app:app` directly -- no - # THERMOGRAPH_SERVICE_ROLE process-picking anymore). Independent + # Frontend's OWN image, published by frontend-build-push.yml from + # frontend/Dockerfile (which starts the thermograph-frontend Go binary + # directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent # FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag. image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:-local} # Its own register() fetches the IndexNow key from backend at boot -- must