thermograph/frontend/server/internal/config/config.go
Emi Griffith 9eecfc8eef
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m0s
PR build (required check) / gate (pull_request) Successful in 2s
frontend: rewrite the SSR content service in Go
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template.
No climate math, no DB, no auth here -- every route fetches from the
backend's /content/* API, so this is I/O-bound glue with no hard-porting
wall; the risk was always in reproducing the rendering exactly, not the
language.

Verified with a golden-HTML diff, not just unit tests: both the Python
original and the Go rewrite were run against the same committed fixtures
(frontend/tests/fixtures/) and every one of the 11 routes compared
byte-for-byte. The only surviving differences after that process are
insignificant inter-tag whitespace and one attribute where Go's stricter
escaper HTML-encodes an apostrophe Jinja left literal (functionally
identical in every browser) -- confirmed programmatically by normalizing
whitespace and unescaping before diffing, not by eyeballing.

That process caught defects unit tests alone would have missed, because
map[string]any has no compile-time field check:

- Render-context keys were snake_case throughout (content.py's Jinja
  convention, ported verbatim) while the templates -- written
  independently -- read PascalCase fields. A missing map key doesn't
  error in html/template, it silently renders empty, so this was invisible
  in every status code and every "it built" signal: title, meta
  description, canonical URL, OpenGraph tags, the homepage's entire ranked
  list, and the brand-tag/nav-active state were all blank across every
  page. Fixed by renaming every key to match each template's own header
  comment (the authoritative per-page field contract) and, where an
  API struct's exported fields already matched what a template needed
  (contentapi.CityInfo, Crumb, HomeRanked, HubCountry, ...), passing the
  struct straight through instead of hand-rewrapping it in a map --
  removes a whole layer of future drift risk, not just this instance of it.
- Three pages 500'd outright: `.ToolHref` needed a fully-composed href
  string, not the bare "lat,lon" fragment the handlers were building; the
  all-time-records table needed the raw contentapi.AllTimeRecords struct,
  not a re-wrapped map.
- JSON-LD was being double-encoded: `<script type="application/ld+json">`
  is JAVASCRIPT context to html/template's contextual escaper regardless
  of the script's `type` attribute, so a template.HTML-typed value placed
  there gets re-escaped as a quoted JS string instead of emitted raw --
  the entire structured-data payload shipped as a JSON string containing
  JSON, which no crawler would parse as the intended object. Needed
  template.JS instead, the type that actually means "trusted JS source."
  The glossary term page's JSON-LD was simply never built at all (the
  Jinja original assembled it inline in the template rather than through
  content.py's context dict, and that got lost in translation) -- added.
- html/template silently strips literal HTML comments AND JavaScript
  comments from the parsed output (verified in isolation, zero template
  actions involved) -- confirmed as real engine behavior, not a bug in
  either port, so both need a FuncMap function returning template.HTML /
  template.JS respectively to survive parsing rather than a literal
  `<!-- -->` or `//` in the template source.

Packaging: multi-stage Go build, final image alpine (not distroless -- the
Swarm stack's env-entrypoint.sh shim needs bash), 187MB -> 22.6MB. Two
defects caught before they reached a host:
- The Swarm stack overrides `entrypoint:` with no `command:`, which drops
  the image's own CMD entirely (Docker/Swarm semantics, not merged) --
  env-entrypoint.sh then fell through to its hardcoded `exec uvicorn
  app:app` fallback, which doesn't exist in this image. Every deploy
  would have exited 127. Fixed with an explicit `command:` on the stack's
  frontend service, and corrected the shim's stale comment claiming CMD
  passes through automatically.
- `COPY --chown=thermograph` resolves the group by NAME at copy time;
  Alpine's `adduser -S` with no `-G` doesn't create a same-named group, so
  the classic (non-BuildKit) Docker builder -- which this CI runner falls
  back to, since it installs plain `docker.io` with no buildx plugin --
  failed outright. Fixed with an explicit group and numeric --chown.

Verification: go build/vet/test -race clean across all packages; the
Docker image builds and passes its embedded go test step under both
BuildKit and the classic builder; shellcheck 0 findings on the one script
touched; rebased onto current main (the ERA5 lake stack landed on both
main and dev during this work -- confirmed additive, no overlap with
frontend/daemon).
2026-07-23 17:51:31 -07:00

127 lines
4.9 KiB
Go

// 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
// <meta> 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
}