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