thermograph/docs/onboarding/10-recipes.md
emi d138f00a20
Some checks failed
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Failing after 6s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 13s
Validate observability stack / validate (push) Successful in 17s
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Successful in 18s
PR build (required check) / gate (pull_request) Successful in 2s
infra: split the estate into vps1/vps2 — beta joins prod, dev gets a home (#103)
2026-07-26 06:56:38 +00:00

11 KiB
Raw Permalink Blame History

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

  1. Build it in backend/api/payloads.py (or content_payloads.py for the SSR content API). Payload assembly is pure inputs → dict; keep HTTP semantics with the caller.
  2. Bump PAYLOAD_VER in api/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_VER is the narrower lever.
  3. Decide whether it's breaking. Additive → fine on v2. Breaking → a new v3 router mounted alongside v2 re-registering only the changed handlers, plus API_CONTRACT_VERSION bumped in the same PR.
  4. Check /cell. If the field lands in a slice, the slice→view map in frontend/static/cache.js has to track it.
  5. Consume it in frontend/server/internal/contentapi/types.go and whichever template or JS renders it.
  6. Test both sides: cd backend && make test, then cd frontend/server && go test ./....
  7. Refresh fixtures if the shape moved: cd frontend && make capture-fixtures against 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.

  1. backend/api/content_payloads.py — a builder returning JSON-safe dicts (raw floats, no Markup, no ContextVar unit rendering), including page_title, canonical_path, breadcrumb and jsonld. Those live here, not in the frontend.
  2. backend/api/content_routes.py — route wiring plus the same derived-store / ETag pattern the neighbouring routes use.
  3. frontend/server/internal/contentapi/ — a typed client method.
  4. frontend/server/internal/render/templates/<page>.html.tmpl — embedded in the binary, so a template change needs a rebuild.
  5. frontend/server/internal/content/pages.go — the handler; register the route.
  6. If the page should be crawlable, add it to the sitemap (backend/api/sitemap.py and the /content/sitemap payload).
  7. Copy that isn't per-city data goes in frontend/content/pages.yaml, loaded fail-loud at boot.

Change something visual

  1. Read frontend/DESIGN.md first. It wins over any general design guidance.
  2. Edit frontend/static/style.css. Use var(--token); never paste a hex. New surfaces take their colour from existing tokens so light mode keeps working for free.
  3. 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.
  4. Verify at 390 / 800 / 1920 / 2560 / 3840px in both light and dark:
    cd frontend && .venv/bin/python tools/shoot.py
    
    or drive a real browser with the Chrome DevTools MCP. Narrow while iterating: tools/shoot.py index --width 390 --scheme dark.
  5. Watch the hard rules: inputs at font-size: 16px minimum (iOS zoom), ~44px touch targets, honour prefers-reduced-motion, metric order always Precip · 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_URLprod.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.Job in backend/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 in backend/api/internal_routes.py and 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.

  1. Edit observability/grafana/provisioning/alerting/*.yml or observability/grafana/dashboards/*.json.
  2. 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.
  3. Open a PR. observability-validate.yml will check that every condition names a real refId, every policy routes to a real receiver, and no literal webhook URL is committed.
  4. Merging is not deploying. Ship it by hand per observability/README.md — and remember docker restart won't pick up a new environment variable.
  5. 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:

  1. GET /api/version on the affected environment — is payload_ver what you expect? If a payload shape changed and this didn't move, that's your answer.
  2. Is the derived-store token advancing? It's built from PAYLOAD_VER plus the history end date (history_token), so a cell whose history stopped updating pins its token.
  3. Is the history updating? Check climate_sync freshness (infra/ops/dbq.sh prod …) and whether upstream is in a rate-limit cooldown (the errors log, tagged retry/error).
  4. Is it the frontend's TTL cache instead? THERMOGRAPH_SSR_CACHE_TTL defaults to 600s, in front of the backend's own caching.
  5. Is it the browser? cache.js seeds IndexedDB from the /cell bundle. 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:

  1. The tree was already identical. A promotion between branches whose trees match merges nothing while still firing the deploy workflows.
  2. The domain wasn't touched. deploy.yml re-checks whether this push touched this domain, and skips the leg if not.
  3. The tag is domain-keyed. deployed_version showing a tag that isn't the branch head is correct behaviour, not drift.
  4. Someone hand-edited /opt/thermograph. deploy.sh runs git reset --hard first; the edit is gone.
  5. It's an observability change. /opt/observability points 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.