11 KiB
10. Recipes
Task-shaped walkthroughs. Each one names the files you'll touch and the traps specific to that change.
Add a field to a graded API payload
- Build it in
backend/api/payloads.py(orcontent_payloads.pyfor the SSR content API). Payload assembly is pureinputs → dict; keep HTTP semantics with the caller. - Bump
PAYLOAD_VERinapi/payloads.py. Non-negotiable — the derived store is token-validated with no clock expiry, so without a bump the old shape serves out of cache forever, silently. If the change is content-API only,CONTENT_VERis the narrower lever. - Decide whether it's breaking. Additive → fine on
v2. Breaking → a newv3router mounted alongsidev2re-registering only the changed handlers, plusAPI_CONTRACT_VERSIONbumped in the same PR. - Check
/cell. If the field lands in a slice, the slice→view map infrontend/static/cache.jshas to track it. - Consume it in
frontend/server/internal/contentapi/types.goand whichever template or JS renders it. - Test both sides:
cd backend && make test, thencd frontend/server && go test ./.... - Refresh fixtures if the shape moved:
cd frontend && make capture-fixturesagainst a live backend. Those fixtures feed the Go tests too.
Add a new SSR page
The backend owns the page's data and metadata; the frontend owns its markup.
backend/api/content_payloads.py— a builder returning JSON-safe dicts (raw floats, noMarkup, no ContextVar unit rendering), includingpage_title,canonical_path,breadcrumbandjsonld. Those live here, not in the frontend.backend/api/content_routes.py— route wiring plus the same derived-store / ETag pattern the neighbouring routes use.frontend/server/internal/contentapi/— a typed client method.frontend/server/internal/render/templates/<page>.html.tmpl— embedded in the binary, so a template change needs a rebuild.frontend/server/internal/content/pages.go— the handler; register the route.- If the page should be crawlable, add it to the sitemap
(
backend/api/sitemap.pyand the/content/sitemappayload). - Copy that isn't per-city data goes in
frontend/content/pages.yaml, loaded fail-loud at boot.
Change something visual
- Read
frontend/DESIGN.mdfirst. It wins over any general design guidance. - Edit
frontend/static/style.css. Usevar(--token); never paste a hex. New surfaces take their colour from existing tokens so light mode keeps working for free. - If it's a grade colour, keep the scale's order and midpoints intact — they encode meaning, not decoration, and the charts pull from the same tokens as the legend.
- Verify at 390 / 800 / 1920 / 2560 / 3840px in both light and dark:
or drive a real browser with the Chrome DevTools MCP. Narrow while iterating:cd frontend && .venv/bin/python tools/shoot.pytools/shoot.py index --width 390 --scheme dark. - Watch the hard rules: inputs at
font-size: 16pxminimum (iOS zoom), ~44px touch targets, honourprefers-reduced-motion, metric order alwaysPrecip · High · Low.
Add or rotate a secret
sops edit infra/deploy/secrets/prod.yaml # never a plain editor
git commit && push && open a PR
Then it reaches the hosts by either route: an infra/** push to main fires
infra-sync.yml (re-renders prod's and beta's env files — /etc/thermograph.env
and /etc/thermograph-beta.env, both on vps2 — rolls nothing), or the next app
deploy renders it as step 2 of deploy.sh. Dev's vault (dev.yaml) is never
part of that sync — it's rendered only by dev's own deploy on vps1, and it
never layers common.yaml regardless.
Decide which file first:
- identical on prod and beta, and not a "lets one environment act as another"
credential →
common.yaml(never reaches dev, on vps1, at all); - per-environment, or one of
POSTGRES_PASSWORD/THERMOGRAPH_AUTH_SECRET/THERMOGRAPH_DATABASE_URL→prod.yaml/beta.yaml; - dev →
dev.yaml, and give it its own value. Never a copy of prod's.
Preview with infra/deploy/secrets/dry-run-render.sh. Audit cross-environment
drift with the key-gaps skill (names only, never values).
Never hand-edit /etc/thermograph.env, paste a plaintext secret onto a box,
or run seed-from-live.sh.
If a rotated value needs containers recreated to take effect, remember
infra-sync rolls nothing — you need an app deploy or a by-hand
SERVICE=all … deploy.sh.
Add a recurring background job
Ask one question first: does it need data?
- No (a timer, a connection, an external poke) → add a
cron.Jobinbackend/daemon/main.go. One replica, so no election needed. Give it an env var for its interval, defaulting sensibly and falling back to the default on a malformed value. - Yes → add an
/internal/*route inbackend/api/internal_routes.pyand have the daemon POST to it. Grading depends on polars and the cache; reimplementing it out-of-process would let results drift from the API's.
If you genuinely need an in-process background thread in the Python app instead,
read core/singleton.py first. Anything that issues upstream fetches on a
timer must run behind claim_leader() — N workers × M hosts multiplies quota
use N×M, and that has already caused overnight 429s in production. Things that
drain a per-worker request-fed queue (the neighbour warmer) correctly run
everywhere; so does the heartbeat, because a beat costs one local file append.
Then give it a heartbeat (audit.log_heartbeat()) so observability can alert on
its death — see the "too quiet to alert on" gap in
observability.
Change a Grafana alert or dashboard
Never through the UI. Provisioning overwrites it on the next start.
- Edit
observability/grafana/provisioning/alerting/*.ymlorobservability/grafana/dashboards/*.json. - If you're changing a threshold, re-derive it against real Loki data — the working for the current number is in the comments beside the rule.
- Open a PR.
observability-validate.ymlwill check that every condition names a real refId, every policy routes to a real receiver, and no literal webhook URL is committed. - Merging is not deploying. Ship it by hand per
observability/README.md— and rememberdocker restartwon't pick up a new environment variable. - Verify by forcing a real message into
#ops-alerts. The API redacts the webhook, so arrival is the only proof.
Debug: "the site is serving stale data"
Work the token chain, in this order:
GET /api/versionon the affected environment — ispayload_verwhat you expect? If a payload shape changed and this didn't move, that's your answer.- Is the derived-store token advancing? It's built from
PAYLOAD_VERplus the history end date (history_token), so a cell whose history stopped updating pins its token. - Is the history updating? Check
climate_syncfreshness (infra/ops/dbq.sh prod …) and whether upstream is in a rate-limit cooldown (the errors log, taggedretry/error). - Is it the frontend's TTL cache instead?
THERMOGRAPH_SSR_CACHE_TTLdefaults to 600s, in front of the backend's own caching. - Is it the browser?
cache.jsseeds IndexedDB from the/cellbundle. If ETags stopped being visible cross-origin (expose_headers), revalidation silently stops.
Debug: "a deploy went green but nothing changed"
Common causes, most likely first:
- The tree was already identical. A promotion between branches whose trees match merges nothing while still firing the deploy workflows.
- The domain wasn't touched.
deploy.ymlre-checks whether this push touched this domain, and skips the leg if not. - The tag is domain-keyed.
deployed_versionshowing a tag that isn't the branch head is correct behaviour, not drift. - Someone hand-edited
/opt/thermograph.deploy.shrunsgit reset --hardfirst; the edit is gone. - It's an observability change.
/opt/observabilitypoints at the archived repo and cannot pull. See above.
Promote to beta, and then to prod
dev → main your call, batched. Beta is the test environment;
not promoting is not testing.
main → release ALWAYS the owner's call. Prepare it, state what would ship,
the CI evidence and how long it soaked on beta, then recommend —
including recommending against.
Centralis's promote(from, to) does the mechanics and enforces the rule;
forge_pr_merge refuses main → release under any flag. Before promoting,
compare the two tips' trees, not their commit counts.
Roll back
Redeploying the previous image tag:
SERVICE=backend BACKEND_IMAGE_TAG=sha-<previous12hex> \
/opt/thermograph/infra/deploy/deploy.sh
Check the tag actually exists first. Both deploy scripts end by deleting
images outside the running pair. Beta now runs Swarm exactly like prod
(co-resident on vps2), so both usually keep a handful of stopped-task
references; dev, the one remaining compose environment (on vps1), is the one
that typically holds no local rollback target at all. rollback_to(dry_run: true) checks host and registry and will tell you when it's in neither.
Regenerate the bundled city data
backend/cities.json and cities_flavor.json are generated reference data, not
runtime state:
cd backend
python gen_cities.py # the curated city set
python gen_flavor.py # the editorial blurbs
Editorial copy has its own voice guide — the city-copy skill defines what
good reads like, and backfill-city-copy drives generation at scale. Both
enforce the relative-grades rule.
After a deploy, warm_cities.py pre-warms those cells so /climate pages serve
from cache and a crawl never bursts the archive quota. It's idempotent, paced,
and claims a flock — deploys launch it detached with no overlap check, so two
overlapping runs would otherwise double-spend quota and race the cache writes.
Work on a live host
Prefer Centralis: run_on_host, fleet_status, deployed_version,
logs_query, sql_query.
You may, without asking: read anything; restart or roll a wedged service; clear a full disk; kill a runaway process; bounce a crash-looping container; run the blessed deploy path onto an already-published image.
You must not: hand-edit the checkout to persist a change (git reset --hard
eats it); hand-edit /etc/thermograph.env (rendered artifact); force-push;
deploy prod from an unmerged branch; docker system prune -a on a live box (it
deletes your rollback image — docker image prune -f is the safe form); or
restart the _db service casually. Postgres/Timescale restarts and volume
operations get confirmed with the operator first.
Next: Traps and stale docs.