thermograph/CUTOVER-NOTES.md
Emi Griffith 4e97d8e5dc
All checks were successful
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 7s
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
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

11 KiB

Monorepo cutover notes (assembled 2026-07-22)

Scratch assembly for inspection — nothing live points here yet. Built by subtree-merging each split repo's origin/main (full history, 380 commits):

  • backend/ ← thermograph-backend 83c2e05 (Discord gateway bot landed, #7)
  • frontend/ ← thermograph-frontend 3a98146 (responsiveness hardening, #4)
  • infra/ ← thermograph-infra 99b4b3f (mail docs, #12)
  • observability/ ← thermograph-observability 19e74af (validator CI, #3)

What changed vs. the split repos

CI (all in root .forgejo/workflows/; the subtree copies were deleted — Forgejo only reads root workflows, but dead copies are a trap):

Superseded 2026-07-25. The per-domain workflow names below describe the cutover as it landed, and are kept for that history. They no longer exist: the six *-deploy*.yml files were collapsed into a single deploy.yml (branch selects environment, matrix covers the services) and the two *-build-push.yml into a single build-push.yml. The two LAN-dev deploy workflows were deleted outright rather than ported, having been inert since cutover. Everything described below about behaviour — path filtering, the domain-keyed 12-hex tag, per-domain concurrency, the v*.*.* both-images exception, separate SSH_*/PROD_SSH_* secret sets — is unchanged.

  • backend-build-push.yml / frontend-build-push.yml — the old per-repo build-push, path-filtered (paths: ['backend/**'] etc.), image paths now explicit: emi/thermograph/backend|frontend (the old ${{ github.repository }}/app derivation collides in a monorepo). Per-domain concurrency groups. Caveat: v*.*.* tag pushes carry no paths context, so a version tag builds both images (intended: a release names the set).
  • backend|frontend-deploy[.yml|-prod.yml|-dev.yml] — ports of the SSH deploy workflows, path-filtered, script path now /opt/thermograph/infra/deploy/deploy.sh. Same secret names (SSH_* beta, PROD_SSH_* prod) — but now one secret set on one repo.
  • build.yml — the build-only sanity check, now a single reusable workflow taking a domain input (context = the domain dir, so Dockerfiles see the same tree as in the split era).
  • pr-build.yml — one changes job (git-diff domain detection) + per-domain gated builds + a single always-reporting gate required check. Deliberately not path-filtered: a required check that never fires blocks auto-merge forever.
  • infra-sync.ymlnew (the infra domain's own pipeline): push to main touching infra/** → git-sync /opt/thermograph on beta+prod and re-render secrets. Rolls no services (image tags are the app domains' axis).
  • observability-validate.yml — port with observability/ prefixes + workflow_call so pr-build reuses it.
  • secrets-guard.yml — glob is now infra/deploy/secrets/*.yaml; still runs on everything (cheap backstop).
  • ops-cron.yml — compose invocations cd /opt/thermograph/infra.

Deploy scripts / compose (hosts see a monorepo checkout at /opt/thermograph):

  • deploy.sh: INFRA_DIR="$APP_DIR/infra"; git ops at the checkout root, cd "$INFRA_DIR" for all compose work; lock/tags/render paths under infra/deploy/; image-path defaults renamed.
  • docker-compose.yml: name: thermograph pinned. Critical: without it, running compose from …/infra derives project "infra" — a silently NEW project that would recreate the whole stack beside the running one. deploy-dev.sh exports COMPOSE_PROJECT_NAME=thermograph-dev (env wins over the file key), preserving LAN dev's separate project.
  • deploy/stack/deploy-stack.sh + thermograph-stack.yml: same path/image renames (prod is stack-mode; docker stack deploy takes an explicit stack name, so no project-name concern there).

Verified here: every workflow + compose file parses (PyYAML); all three deploy scripts pass bash -n; no emi/thermograph-{backend,frontend}/app stragglers in sh/yml. Not verified (impossible locally): Forgejo actually honoring on.push.paths and workflow_call inputs on our version; any live deploy.

Before cutover (in order)

  1. Run the pending prod network-recreate runbook first. Don't rebase the deploy path on a prod that wedges on its next deploy.
  2. Prove Forgejo path filters + reusable-workflow inputs with a throwaway repo: one push touching only backend/** must fire only backend workflows; a uses: ./.forgejo/workflows/build.yml + with: job must pass its input. If paths aren't honored, fall back to the pr-build pattern (diff job + gated jobs) for the push workflows too.
  3. Branch drift: the split repos' branch state as of 2026-07-22 evening is fully migrated (see "Branch migration status" below) — re-run the check (git merge-tree --write-tree per branch vs dev/main) if cutover slips and upstream moves again.

Branch migration status (2026-07-22)

Checked every split-repo branch by dry-run merge; migrated everything real:

  • dev (this repo) = main + backend/frontend origin/dev subtrees: the in-image test CI feature (ported into root build.yml — backend full suite, frontend unit tier; their subtree build.yml edits resolved as deleted) + backend test scripts/Makefile/compose.test + frontend two-tier suite with fixtures. Faithful to upstream: the feature is dev-only, so mono main doesn't have it; promote with dev→main like the split flow.
  • feature/geocode-localfeature/history-meteostat-gustsfeature/nasa-primary-flipfeature/era5-seed — the unmerged backend stack, rebuilt stacked on mono dev. ⚠️ Two conflicts were resolved here that upstream has NOT resolved (the same conflicts exist merging those branches into split-repo dev): data/climate.py (dev's hardened _request signature combined with the gust-fill wrap) and tests/data/test_climate.py (kept both appended test blocks). Unverified — tests weren't runnable locally (Docker Hub blocked); run the in-image suite on these branches before trusting them.
  • Stale (absorbed upstream, nothing migrated — safe to delete in the split repos): backend ci-run-tests-in-image, perf/*, phase5/discord-gateway-bot, port-discord-gateway-bot, reconcile-dev-with-main, test-runner-and-smoke; frontend ci-run-tests-in-image, reconcile-dev-with-main; all three infra branches (ci-fix/render-secrets-owner, phase5/discord-bot-flag, terraform-two-image-tags — all landed in infra main); both observability dashboard branches.
  • Dismissed, not stale-but-not-migrated: frontend perf/frontend-responsiveness (its "new" content would resurrect test files dev reorganized into the unit tier) and test-real-backend-harness (an older revision of dev's tests/unit/test_rendering.py).

Cutover runbook

  1. Create emi/thermograph on Forgejo (a new repo — do not resurrect the archived monorepo) and push main.
  2. Actions config, once: secrets REGISTRY_TOKEN, SSH_HOST/USER/KEY/PORT (beta), PROD_SSH_* (prod); variable REGISTRY_HOST=git.thermograph.org.
  3. Branch protection on dev + main: required check gate, squash merges, auto-merge on green.
  4. Hosts (beta, then prod): clone the monorepo to /opt/thermograph.new; copy deploy/.image-tags.env from the old checkout into infra/deploy/ (its path moved — losing it makes the next single-service deploy roll the sibling onto local); swap directories (running containers are untouched — they're images, not the checkout); /etc/thermograph* files stay as-is.
  5. LAN box: re-point ~/thermograph-dev at the monorepo the same way.
  6. First push builds the new image paths — old emi/thermograph-{backend,frontend}/app packages stay in the registry for rollback; GC after burn-in. deploy.sh's tag prune already targets the new paths only.
  7. One workflow_dispatch of backend-deploy.yml after its build-push completes = the live end-to-end validation, same as the split-era wiring test.
  8. After burn-in: archive the four split repos (leave thermograph-docs alone), update its runbooks to the monorepo paths.

Known gaps / deferred

  • Discord/notifier extraction (phase 2): discord still ships inside the backend image (backend/notifications/); a discord tweak = a backend deploy. True notifier/ domain (own image + compose service, singleton lock retired) is a refactor for after the monorepo settles.
  • Domain CLAUDE.mds still speak repo-era ("this repo", sibling-repo links) — correct them as touched; root CLAUDE.md points the right way.
  • backend/MONOREPO-CUTOVER-PLAN.md references (the split plan) are now historical.
  • Infra Makefile local-image targets still assume sibling checkouts — update when first needed locally.

vps1/vps2 host-topology re-architecture (landed 2026-07-25, NOT YET EXECUTED)

This section documents a second re-architecture, layered on top of the monorepo cutover above. It is landed IN THE REPO — code, stack files, deploy scripts, docs — but has NOT been run against the live estate. The boxes described elsewhere in this file (prod at 169.58.46.181, beta at 75.119.132.91, a desktop LAN dev box) are still what is actually running today. Do not treat anything below as live until the cutover runbook has been executed and this note updated.

What changes, in one line: hosts are renamed by role instead of by environment, and beta moves onto the same box as prod.

  • vps1 (mesh 10.10.0.2, public 75.119.132.91) — unchanged box, new job. Keeps Forgejo, Grafana/Loki/Alloy and the emigriffith.dev portfolio; gains the dev environment (its own Postgres, plain compose, mesh-only — 10.10.0.2:8137, no DNS record, no Caddy site, no TLS). This is the box that used to be called "beta".
  • vps2 (mesh 10.10.0.1, public 169.58.46.181) — unchanged box, new job. Keeps prod; gains beta as a second, co-resident Docker Swarm stack (prefixed services, own checkout, own env file, own loopback LB ports). One TimescaleDB instance now serves both prod and beta, on separate databases and separate roles. This is the box that used to be called "prod".
  • desktop (mesh 10.10.0.3) — loses the LAN dev stack and the Forgejo Actions runner entirely; becomes AI-model hosting plus flex Swarm capacity. make dev-up still works there as a laptop convenience, but it is no longer an environment.

The mesh IPs did not move — only which environment lives on which box did. See infra/deploy/env-topology.sh for the single source of truth on per-environment checkout paths, branches, stack names, ports and DB roles, and infra/deploy/RUNBOOK-vps1-vps2-cutover.md for the execution runbook. Until that runbook has been run, CLAUDE.md, README.md and docs/onboarding/ describe the target state, not today's.