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
261 lines
11 KiB
Markdown
261 lines
11 KiB
Markdown
# 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).
|