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
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).
127 lines
4.9 KiB
Go
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
|
|
}
|