thermograph/docs/onboarding/10-recipes.md

262 lines
11 KiB
Markdown
Raw Normal View 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`](../../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: 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 22:01:29 +00:00
`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"
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 22:01:29 +00:00
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
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 22:01:29 +00:00
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).