thermograph/docs/onboarding/03-repo-map.md
Emi Griffith 4e97d8e5dc
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
shell-lint / shellcheck (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
PR build (required check) / validate-observability (pull_request) Successful in 18s
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

201 lines
11 KiB
Markdown

# 3. Repo map
The monorepo was reunified on **2026-07-22** by subtree-merging four split
repos with full history (380 commits). The split's *properties* were kept
deliberately: per-domain images, per-domain deploys, and an async FE/BE
contract. That's why the layout looks like four projects sharing a root.
```
thermograph/
├── CLAUDE.md root agent instructions — branch model, deploy contract
├── CUTOVER-NOTES.md what is and isn't live; the reunification record
├── README.md domain table + how CI stays decoupled
├── .forgejo/workflows/ ALL CI. Nine files. Never add workflows elsewhere.
├── .claude/ settings.json + enforcement hooks
├── backend/ FastAPI API + Go daemon → emi/thermograph/backend
├── frontend/ Go SSR + static assets → emi/thermograph/frontend
├── infra/ compose, Swarm, deploy, secrets, terraform, ops
├── observability/ Loki + Grafana + Alloy
└── docs/onboarding/ you are here
```
> **CI lives only in root `.forgejo/workflows/`**, path-filtered per domain.
> Forgejo only reads root workflows — a copy under a domain's own `.forgejo/`
> is inert, and becomes a trap when someone edits it expecting an effect.
## `backend/` — the API service
Python 3.12 / FastAPI. Owns climate data, grading, the API, accounts,
notifications, and the SSR-content JSON API the frontend renders from. Serves
**no** HTML/CSS/JS.
```
app.py one-line re-export shim for web/app.py — keeps `app:app`
stable for systemd/CI/entrypoint. Don't change the name.
paths.py every filesystem location, resolved from the repo root.
NEVER reintroduce __file__-relative paths in a module.
web/app.py (1022) the real FastAPI app: routes, CORS, ETag, middleware,
lifespan, role gating, the frontend proxy fallback
api/
payloads.py (342) pure inputs → response dict builders. One definition per
payload shape; PAYLOAD_VER lives here.
content_payloads.py the SSR content API's builders (JSON-safe, no Markup)
content_routes.py route wiring for /content/* + the same ETag pattern
internal_routes.py /internal/* — the Go daemon's control surface. Fails
closed: no token configured ⇒ the whole surface 404s.
homepage.py (367) the precomputed "unusual right now" feed
sitemap.py
data/
climate.py (1248) fetch + cache the daily record. The source ladder lives
here. The single biggest file in the repo.
grading.py (532) day-of-year climatology, percentiles, TEMP/RAIN_BANDS
grid.py (92) lat/lon → stable ~4 sq mi cell id
store.py (329) derived-payload store (SQLite/Postgres). Pure accelerator.
climate_store.py(353) raw climate record on TimescaleDB hypertables
era5lake.py (173) the ERA5 object-storage lake: layout, grid math, clients
scoring.py (293) climate-shift scoring (recent years vs full baseline)
places.py (355) typo-tolerant local place index for /suggest
cities.py, city_events.py, meteostat.py
core/
metrics.py (805) since-start traffic counters, multi-worker via SQLite
audit.py (167) JSONL audit / errors / access / activity / heartbeat
singleton.py (129) leader election: flock (per host) or PG advisory lock
(per cluster). Read this before adding any timer.
accounts/ fastapi-users: models, schemas, db, users, api_accounts
notifications/
notify.py (542) the subscription evaluation engine (background thread)
push.py, mailer.py, digest.py, discord.py, discord_link.py,
discord_interactions.py
daemon/ the Go daemon — Discord gateway + cron timers
alembic/ Postgres migrations (run by the entrypoint on boot)
deploy/entrypoint.sh container entrypoint: alembic migrate, then serve
lake_app.py (204) the prod-only `lake` service (DuckDB over the bucket)
tests/ the hermetic pytest suite
cities.json, cities_flavor.json bundled reference data (generated)
```
Standalone scripts at the root are tooling, not runtime: `warm_cities.py`,
`indexnow.py`, `seed_era5.py`, `gen_era5_lake.py`, `gen_cities.py`,
`gen_flavor.py`, `drift_check.py`, `migrate*.py`.
Deep dive: [04-backend.md](04-backend.md).
## `frontend/` — SSR + static assets
```
server/ ★ THE LIVE SERVICE (Go 1.26, module thermograph/frontend)
main.go config + mux + static + graceful shutdown
internal/config/ every env var, with the same names/defaults as before
internal/contentapi/ backend /content/* client: TTL cache, bounded LRU,
per-key single-flight, origin forwarding
internal/content/ page handlers, funcmap, SEO
internal/contentdata/ glossary.yaml + pages.yaml loader (fail-loud)
internal/format/ unit-aware °C/°F formatting + band names
internal/handlers/ routing, SPA shells, static
internal/render/ html/template over embed.FS + ETag helpers
internal/render/templates/*.tmpl the embedded page templates
static/ every static asset — app.js, cache.js, shared.js,
account.js, calendar/day/score/compare .js + .html
shells, style.css (all design tokens), icons, sw.js
content/ glossary.yaml, pages.yaml — structured SSR copy
templates/*.html.j2 Jinja templates — used by the PYTHON service only
tests/ Python tiers: unit (hermetic, fixtures) + integration
tests/fixtures/*.json golden payloads — ALSO consumed by the Go tests
tools/shoot.py the `make shots` screenshot sweep
DESIGN.md the visual system: tokens, components, breakpoints
app.py, content.py, ⚠ the superseded Python implementation — not deployed
api_client.py, (see traps)
format.py, paths.py,
content_loader.py
```
Deep dive: [05-frontend.md](05-frontend.md).
## `infra/` — how and where images run
```
docker-compose.yml the compose stack — dev's, on vps1 (and a local
`make dev-up`): db, backend, lake, daemon, frontend.
`name: thermograph` is PINNED — see below.
docker-compose.dev.yml dev overlay: uncapped, backend bound to
`${DEV_BIND_ADDR:-127.0.0.1}` — vps1's mesh IP
(10.10.0.2) at deploy time, never 0.0.0.0
docker-compose.openmeteo.yml self-hosted Open-Meteo overlay (prod-only)
Makefile compose orchestration only
deploy/
env-topology.sh ★ single source of truth: env → host, checkout,
branch, deploy mode, stack name, env file, LB
ports, DB role/name, service prefix
deploy.sh ★ the single entry point for dev, beta and prod
deploy-dev.sh thin wrapper: routes deploy.sh at dev's compose
overlay and secrets policy
render-secrets.sh renders the env file for a given environment from
the SOPS vault
secrets/ the vault: common.yaml, prod/beta/dev.yaml,
centralis.prod.yaml, example.yaml
stack/ the SWARM path — both live on vps2:
thermograph-stack.yml (prod),
thermograph-beta-stack.yml (beta, prefixed
services, no `db` of its own), deploy-stack.sh,
autoscale.sh, lb/ (per-environment Caddyfiles)
db/provision-env-db.sh creates each environment's database + role on the
shared TimescaleDB instance
provision-dev.sh one-time bootstrap of the dev environment on vps1
swarm/, forgejo/ mesh + Forgejo/CI-runner provisioning
Caddyfile the reverse proxy (path-splits FE vs BE)
provision-*.sh host bootstrap: agent access, mail, secrets
migrations/ hand-run SQL migrations
ops/
dbq.sh read-only psql into any environment's db container
iceberg.sh duckdb over the ERA5 Iceberg lake
terraform/ host provisioning — NO STATE IS PERSISTED
ACCESS.md, DEPLOY.md, DEPLOY-DEV.md (partly stale — see traps)
```
**Why the project name is pinned.** Compose creates volumes
`thermograph_pgdata` / `_appdata` / `_applogs`, and the Swarm stacks declare
those exact names as `external: true` at the same mount paths. Running compose
from `infra/` without `name: thermograph` derives project `infra`, which makes a
whole new stack with empty volumes next to the running one. `deploy-dev.sh`
exports `COMPOSE_PROJECT_NAME=thermograph-dev` (env wins over the file key) to
keep dev separate on purpose. Keep both halves.
Deep dive: [08-infra-secrets.md](08-infra-secrets.md).
## `observability/` — the logging stack
```
docker-compose.yml Loki + Grafana (runs on vps1)
loki/config.yml mesh-only, filesystem storage, 30-day retention
grafana/provisioning/ datasource + dashboard provider (auto-loaded)
grafana/provisioning/alerting/ rules, the Discord contact point, the policy
grafana/dashboards/*.json the fleet-logs dashboard
alloy/config.alloy the per-node shipper
alloy/docker-compose.agent.yml runs Alloy on a node (ALLOY_NODE per host)
caddy-grafana.conf vps1's Grafana vhost, kept for reference
```
No build, no deploy automation — it ships by hand. **The repo is the only
durable path**: Grafana is provisioned from this directory at startup, so UI
edits are overwritten. Grafana is at **`dashboard.thermograph.org`** (Google
SSO, pre-provisioned users only) — never `grafana.thermograph.org`.
Deep dive: [09-observability.md](09-observability.md).
## The nine CI workflows
| Workflow | Trigger | Does |
|---|---|---|
| `pr-build.yml` | PR → `dev`/`main` | The `gate` required check. Diffs the PR, builds only touched domains. Deliberately **not** path-filtered. |
| `build.yml` | `workflow_call` | Build one domain's image; run the backend suite **inside** the built image. |
| `build-push.yml` | push to `dev`/`main`/`release` touching an app domain, or a `v*.*.*` tag | Builds + pushes `sha-<12hex>` images. A version tag builds **both**. |
| `deploy.yml` | push to `dev`/`main`/`release` touching an app domain | Branch selects environment — three legs: `dev`→vps1, `main`→beta on vps2, `release`→prod on vps2; matrix covers services; SSHes and runs `deploy.sh` with `THERMOGRAPH_ENV` set. |
| `infra-sync.yml` | push to `main` touching `infra/**` | Fast-forwards beta's and prod's checkouts (both on vps2) and re-renders their secrets. Rolls **no** service. |
| `observability-validate.yml` | push touching `observability/**`, or `workflow_call` | Parses every artifact; validates Alloy config with the pinned binary; strict alerting checks. |
| `ops-cron.yml` | daily 03:00 UTC | **THE prod backup** (pg_dump) + IndexNow, against prod on vps2. Uses `VPS2_SSH_*`. |
| `secrets-guard.yml` | every PR and push | Fails if any `infra/deploy/secrets/*.yaml` isn't SOPS-encrypted. |
| `shell-lint.yml` | every PR and push | shellcheck (pinned v0.11.0 + sha256) over every `*.sh`. |
Deep dive: [07-ci-and-release.md](07-ci-and-release.md).
Next: [Backend deep dive](04-backend.md).