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