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

261 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](../../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:**
```bash
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
```bash
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.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](09-observability.md).
---
## 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:
```bash
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:
```bash
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](11-traps.md).