docs: add a developer onboarding guide for the monorepo
Twelve documents under docs/onboarding/ covering orientation, local setup,
the repo map, per-domain deep dives, the cross-service contracts, CI and the
release flow, infra and secrets, observability, task recipes, and a list of
which docs in this tree are currently stale.
Every command in the setup guide was run against this checkout: the backend
suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the
backend, and a build+boot of the Go frontend.
Two findings recorded along the way:
- static/units.js's F_REGIONS is guarded by no test, despite three source
comments claiming "a test asserts all three stay identical". The Go test
only cross-checks the Go copy against the backend's Python. All four copies
are currently identical.
- backend/ and frontend/docker-compose.test.yml still default to the retired
emi/thermograph-backend/app image path, and the frontend harness pins the
split-era v0.0.2-split-ci tag.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:25:52 +00:00
|
|
|
|
# 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.
|
docs: add a developer onboarding guide for the monorepo
Twelve documents under docs/onboarding/ covering orientation, local setup,
the repo map, per-domain deep dives, the cross-service contracts, CI and the
release flow, infra and secrets, observability, task recipes, and a list of
which docs in this tree are currently stale.
Every command in the setup guide was run against this checkout: the backend
suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the
backend, and a build+boot of the Go frontend.
Two findings recorded along the way:
- static/units.js's F_REGIONS is guarded by no test, despite three source
comments claiming "a test asserts all three stay identical". The Go test
only cross-checks the Go copy against the backend's Python. All four copies
are currently identical.
- backend/ and frontend/docker-compose.test.yml still default to the retired
emi/thermograph-backend/app image path, and the frontend harness pins the
split-era v0.0.2-split-ci tag.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:25:52 +00:00
|
|
|
|
|
|
|
|
|
|
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` /
|
docs: add a developer onboarding guide for the monorepo
Twelve documents under docs/onboarding/ covering orientation, local setup,
the repo map, per-domain deep dives, the cross-service contracts, CI and the
release flow, infra and secrets, observability, task recipes, and a list of
which docs in this tree are currently stale.
Every command in the setup guide was run against this checkout: the backend
suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the
backend, and a build+boot of the Go frontend.
Two findings recorded along the way:
- static/units.js's F_REGIONS is guarded by no test, despite three source
comments claiming "a test asserts all three stay identical". The Go test
only cross-checks the Go copy against the backend's Python. All four copies
are currently identical.
- backend/ and frontend/docker-compose.test.yml still default to the retired
emi/thermograph-backend/app image path, and the frontend harness pins the
split-era v0.0.2-split-ci tag.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:25:52 +00:00
|
|
|
|
`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.
|
docs: add a developer onboarding guide for the monorepo
Twelve documents under docs/onboarding/ covering orientation, local setup,
the repo map, per-domain deep dives, the cross-service contracts, CI and the
release flow, infra and secrets, observability, task recipes, and a list of
which docs in this tree are currently stale.
Every command in the setup guide was run against this checkout: the backend
suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the
backend, and a build+boot of the Go frontend.
Two findings recorded along the way:
- static/units.js's F_REGIONS is guarded by no test, despite three source
comments claiming "a test asserts all three stay identical". The Go test
only cross-checks the Go copy against the backend's Python. All four copies
are currently identical.
- backend/ and frontend/docker-compose.test.yml still default to the retired
emi/thermograph-backend/app image path, and the frontend harness pins the
split-era v0.0.2-split-ci tag.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:25:52 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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).
|