thermograph/frontend/server/internal/config/config.go

128 lines
4.9 KiB
Go
Raw Normal View History

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