thermograph/docs/onboarding/10-recipes.md
Emi Griffith 4e97d8e5dc
All checks were successful
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 7s
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, moving beta next to prod and dev onto vps1
Environments stop being machines. Until now each box WAS an environment --
"beta" named both a deploy target and a host, "the desktop" named both the
operator's computer and the dev server -- so every path could assume one
environment per host and hardcode /opt/thermograph, /etc/thermograph.env and
ports 8137/8080. That assumption ends here:

  vps1  75.119.132.91  Forgejo, Grafana/Loki, the portfolio site, and DEV
                       (own Postgres, mesh-only on 10.10.0.2:8137)
  vps2  169.58.46.181  PROD and BETA as two Swarm stacks sharing one
                       TimescaleDB instance, plus Centralis, Postfix, backups
  desktop              AI model hosting + flex Swarm capacity, no environment

Nothing live has moved; the ordered cutover is in
infra/deploy/RUNBOOK-vps1-vps2-cutover.md.

deploy/env-topology.sh is the single source of truth: env -> host, checkout,
branch, deploy mode, stack name, env file, LB ports, DB role/database, service
prefix. THERMOGRAPH_ENV is the input, and on vps2 it is the only thing
distinguishing a beta deploy from a prod one -- deploy.sh refuses to run when it
disagrees with the checkout it was invoked from. The host-wide secrets-env and
deploy-mode markers survive only as a fallback for a single-environment box.

Beta gets its own stack file rather than an overlay (a merge cannot REMOVE the
db service, and beta having no database of its own is the point). Its services
are prefixed beta-*: Swarm registers a service's short name as a DNS alias on
every network it joins, so two stacks both calling a service `web` on the shared
data network would let prod's frontend resolve a beta task. Prod's stack, env
and LB config are untouched.

One Postgres, two databases with two roles: deploy/db/provision-env-db.sh
creates thermograph_beta (NOSUPERUSER, owns only its own database, CONNECT
revoked from PUBLIC) plus a <role>_ro for ad-hoc queries, and refuses to run for
the environment that owns the instance -- doing so would demote prod's bootstrap
superuser.

Fixes that co-residency would otherwise have broken silently:

- CI secrets are keyed by host (VPS1_SSH_*, VPS2_SSH_*). SSH_* meant "beta" and
  also "the Forgejo box" because those shared a machine; that conflation is what
  once had the prod backup dumping beta.
- The nightly backup dumps BOTH application databases to separate off-box
  prefixes, and fails loudly on a missing one instead of skipping it.
- Alloy stopped deriving the `host` label from the node -- on vps2 that would
  have filed every beta line as prod, feeding prod's alert rules with beta's
  traffic. It is now derived per source, with a new `node` label for the machine,
  and beta's log volume is mounted via a vps2-only overlay.
- ops/dbq.sh, ops/iceberg.sh and the secrets seed scripts derived their SSH
  target and env-file path from the topology instead of hardcoding beta to
  75.119.132.91 -- which is vps1's address now.
- Dev's overlay binds DEV_BIND_ADDR (loopback by default, the mesh address on
  vps1) instead of 0.0.0.0: correct for a home LAN box, a public exposure of
  unreviewed branches on a VPS.

Terraform's hosts variable is keyed by machine with a nested environments map,
and main.tf flattens (host, environment) pairs so two environments on one box
cannot share a checkout. Caddy's single reference config is split per host.

Docs, onboarding and the runbooks are updated throughout, including the security
rationales that co-residency makes false: separate SSH credentials no longer put
a host boundary between beta and prod, and the boundary that remains is the
database and the filesystem.
2026-07-25 15:01:29 -07:00

11 KiB
Raw 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.