infra: split the estate into vps1/vps2, moving beta next to prod and dev onto vps1
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
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
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.
This commit is contained in:
parent
5f20fba9f5
commit
4e97d8e5dc
85 changed files with 4207 additions and 1482 deletions
|
|
@ -13,7 +13,7 @@ They are wired up in `.claude/settings.json`.
|
|||
|
||||
## Why prod-guard exists
|
||||
|
||||
`agent` has passwordless sudo on prod and beta, and the operator's global settings
|
||||
`agent` has passwordless sudo on vps1 and vps2, and the operator's global settings
|
||||
allow `Bash(ssh prod:*)` outright. Nothing about `ssh prod 'docker service rm …'`
|
||||
goes through a pull request, so CI cannot see it — before this hook, any session in
|
||||
any directory could delete production with no prompt.
|
||||
|
|
@ -24,9 +24,17 @@ is wrong by construction — the first destructive verb nobody thought of sails
|
|||
straight through. Being wrong in the "ask" direction costs a keystroke; being wrong
|
||||
the other way costs production.
|
||||
|
||||
Beta is guarded as strictly as prod: it serves beta.thermograph.org *and* hosts
|
||||
Forgejo, so a destructive command there takes out git, CI and the registry at once.
|
||||
The LAN dev box is deliberately unguarded.
|
||||
Beta is guarded as strictly as prod — more strictly than the names alone suggest,
|
||||
now: vps2 (`169.58.46.181`) runs **both** prod and beta as separate Swarm stacks on
|
||||
the same manager, sharing one TimescaleDB instance, so a "beta" command there has
|
||||
prod's blast radius (see `infra/deploy/env-topology.sh`). vps1 (`75.119.132.91`) is
|
||||
the other guarded host: Forgejo (git + CI + registry) and Grafana/Loki live there,
|
||||
plus the `dev` environment, so a destructive command there takes out git, CI and the
|
||||
registry at once. Mesh IPs did not move in the vps1/vps2 split (vps1 is still
|
||||
`10.10.0.2`, vps2 is still `10.10.0.1`); what moved is which environment runs where
|
||||
— `HOST_TOKEN_RE` in `prod-guard.sh` recognises `vps1`/`vps2` alongside the
|
||||
environment names (`prod`/`beta`) and the raw IPs, so a command naming the host
|
||||
either way still gets caught. The LAN dev box is deliberately unguarded.
|
||||
|
||||
## Changing the classifier
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,15 @@
|
|||
# That is also what happens if this script errors or jq is missing, so a bug here
|
||||
# fails toward the prompt rather than toward silent execution.
|
||||
#
|
||||
# Beta is guarded as strictly as prod: it serves beta.thermograph.org AND hosts
|
||||
# Forgejo, so a destructive command there can take out the git server, CI and the
|
||||
# container registry at once.
|
||||
# Beta is guarded as strictly as prod -- more strictly than the names alone
|
||||
# suggest, now: vps2 (169.58.46.181) runs prod AND beta as two Swarm stacks on
|
||||
# the SAME manager, sharing the same TimescaleDB instance, so a "beta" command
|
||||
# there has PROD's blast radius (shared Docker host, shared Postgres server --
|
||||
# see infra/deploy/env-topology.sh). vps1 (75.119.132.91) is the other guarded
|
||||
# host: Forgejo (git + CI + registry), Grafana/Loki, and the dev environment --
|
||||
# a destructive command there can take out the git server, CI and the container
|
||||
# registry at once. Mesh IPs did NOT move in the vps1/vps2 split (vps1 is still
|
||||
# 10.10.0.2, vps2 is still 10.10.0.1); what moved is which environment runs where.
|
||||
set -uo pipefail
|
||||
|
||||
payload=$(cat)
|
||||
|
|
@ -32,7 +38,10 @@ tool=$(printf '%s' "$payload" | jq -r '.tool_name // ""')
|
|||
# An ssh target naming a live host, matched as a whole token after any `user@`.
|
||||
# Backslashes are doubled because awk -v processes escape sequences in the value
|
||||
# before the regex ever sees it; a single \. arrives as a bare dot (and warns).
|
||||
readonly HOST_TOKEN_RE='^(prod|beta|169\\.58\\.46\\.181|75\\.119\\.132\\.91|10\\.10\\.0\\.[12])$'
|
||||
# vps2 (169.58.46.181) carries BOTH prod and beta; vps1 (75.119.132.91) carries
|
||||
# Forgejo/Grafana/dev -- "vps1"/"vps2" are recognised alongside the environment
|
||||
# names and raw IPs so a command naming the host either way still gets caught.
|
||||
readonly HOST_TOKEN_RE='^(prod|beta|vps1|vps2|169\\.58\\.46\\.181|75\\.119\\.132\\.91|10\\.10\\.0\\.[12])$'
|
||||
|
||||
emit() { # $1=allow|ask|deny $2=reason
|
||||
jq -n --arg d "$1" --arg r "$2" \
|
||||
|
|
|
|||
|
|
@ -10,16 +10,32 @@ name: Deploy
|
|||
# parameterised, so the duplication bought nothing and cost six files to keep in
|
||||
# step.
|
||||
#
|
||||
# The two *-deploy-dev.yml workflows are NOT ported. They were documented as
|
||||
# inert: they call the monorepo layout at ~/thermograph-dev on the LAN box,
|
||||
# which is still a split-era thermograph-infra checkout, so that path does not
|
||||
# exist there. LAN dev is a local `make dev-up` concern, not a CI environment.
|
||||
# DEV IS A REAL DEPLOY TARGET AGAIN. It previously was not: dev meant a
|
||||
# sudo-free compose stack on the operator's desktop, and the two
|
||||
# *-deploy-dev.yml workflows that drove it were inert. Dev now lives on vps1 at
|
||||
# /opt/thermograph-dev — a normal fleet host, reached over SSH exactly like beta
|
||||
# and prod — so it gets a leg here.
|
||||
#
|
||||
# SECRETS ARE KEYED BY HOST, NOT BY ENVIRONMENT. `SSH_*` used to mean "beta" and
|
||||
# `PROD_SSH_*` "prod", which worked only while each environment owned a box. It
|
||||
# stopped being true in two directions at once: vps2 now runs beta AND prod, and
|
||||
# the box `SSH_*` pointed at is now vps1, which runs dev, Forgejo and Grafana.
|
||||
# The old names would have made "the beta secret" and "the Forgejo box secret"
|
||||
# the same value by accident — the ops-cron file already records one incident
|
||||
# caused by exactly that conflation. So: VPS1_SSH_* and VPS2_SSH_*, named for
|
||||
# the machine, and the environment is passed separately as THERMOGRAPH_ENV.
|
||||
#
|
||||
# BETA AND PROD DEPLOYING TO ONE BOX DO NOT RACE. They have separate checkouts
|
||||
# (/opt/thermograph-beta and /opt/thermograph), so the `git reset --hard` in one
|
||||
# cannot pull the tree out from under the other, and deploy.sh's flock is per
|
||||
# checkout. The concurrency groups below stay keyed by ref+service, which keeps
|
||||
# two pushes to the SAME branch serialised — the property that actually matters.
|
||||
#
|
||||
# DELIBERATELY BORING EXPRESSIONS. No dynamic matrix (fromJSON), no
|
||||
# `cond && secrets.A || secrets.B` ternary. Those are GitHub idioms that a
|
||||
# Forgejo/act runner may evaluate differently, and the failure mode here is
|
||||
# "production does not deploy" or, worse, "deploys with an empty SSH host". The
|
||||
# two environments therefore get two explicit, mutually exclusive steps.
|
||||
# three environments therefore get three explicit, mutually exclusive steps.
|
||||
#
|
||||
# What is preserved from the originals, all of it load-bearing:
|
||||
# - fetch-depth: 0, because the image tag is keyed to the LAST COMMIT THAT
|
||||
|
|
@ -29,14 +45,18 @@ name: Deploy
|
|||
# - The 12-hex truncation, matching build-push exactly.
|
||||
# - Per-service, per-environment concurrency with cancel-in-progress: false --
|
||||
# a half-finished deploy must never be cancelled by a newer one.
|
||||
# - Separate PROD_SSH_* credentials, so a beta credential leak cannot reach
|
||||
# prod.
|
||||
# - Separate credentials per host. Note what this does and does not buy now:
|
||||
# it keeps vps1 (Forgejo, its CI, and whatever unreviewed branch dev is
|
||||
# running) away from vps2 entirely. It no longer puts a host boundary
|
||||
# between beta and prod, because they share vps2 by design — that boundary
|
||||
# is now at the database (separate roles and databases) and the filesystem
|
||||
# (separate checkouts and rendered env files).
|
||||
# - appleboy/ssh-action by full URL; it is not mirrored in Forgejo's default
|
||||
# action registry.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release]
|
||||
branches: [dev, main, release]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
|
|
@ -71,10 +91,15 @@ jobs:
|
|||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Branch selects the environment. main -> beta, release -> prod.
|
||||
# Branch selects the environment; the environment selects the host and
|
||||
# the script path. dev -> dev (vps1), main -> beta (vps2),
|
||||
# release -> prod (vps2). The paths differ because each environment
|
||||
# has its own checkout — two environments on vps2 must never share
|
||||
# one — and dev has its own entry point for its secrets policy.
|
||||
case "${{ github.ref_name }}" in
|
||||
main) environment=beta ;;
|
||||
release) environment=prod ;;
|
||||
dev) environment=dev ; script=/opt/thermograph-dev/infra/deploy/deploy-dev.sh ;;
|
||||
main) environment=beta ; script=/opt/thermograph-beta/infra/deploy/deploy.sh ;;
|
||||
release) environment=prod ; script=/opt/thermograph/infra/deploy/deploy.sh ;;
|
||||
*) echo "::error::Deploy triggered on unexpected ref '${{ github.ref_name }}'"; exit 1 ;;
|
||||
esac
|
||||
|
||||
|
|
@ -105,6 +130,7 @@ jobs:
|
|||
|
||||
{
|
||||
echo "environment=$environment"
|
||||
echo "script=$script"
|
||||
echo "changed=$changed"
|
||||
echo "tag=$tag"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -126,38 +152,58 @@ jobs:
|
|||
|
||||
echo "==> ${{ matrix.service }} -> $environment | changed=$changed | tag=$tag"
|
||||
|
||||
- name: Deploy to beta
|
||||
- name: Deploy to dev (vps1)
|
||||
if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'dev'
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.VPS1_SSH_HOST }}
|
||||
username: ${{ secrets.VPS1_SSH_USER }}
|
||||
key: ${{ secrets.VPS1_SSH_KEY }}
|
||||
port: ${{ secrets.VPS1_SSH_PORT }}
|
||||
envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG,THERMOGRAPH_ENV
|
||||
script: ${{ steps.plan.outputs.script }}
|
||||
env:
|
||||
SERVICE: ${{ matrix.service }}
|
||||
THERMOGRAPH_ENV: dev
|
||||
BACKEND_IMAGE_TAG: ${{ matrix.service == 'backend' && steps.plan.outputs.tag || '' }}
|
||||
FRONTEND_IMAGE_TAG: ${{ matrix.service == 'frontend' && steps.plan.outputs.tag || '' }}
|
||||
|
||||
- name: Deploy to beta (vps2)
|
||||
if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'beta'
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG
|
||||
script: /opt/thermograph/infra/deploy/deploy.sh
|
||||
host: ${{ secrets.VPS2_SSH_HOST }}
|
||||
username: ${{ secrets.VPS2_SSH_USER }}
|
||||
key: ${{ secrets.VPS2_SSH_KEY }}
|
||||
port: ${{ secrets.VPS2_SSH_PORT }}
|
||||
envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG,THERMOGRAPH_ENV
|
||||
script: ${{ steps.plan.outputs.script }}
|
||||
env:
|
||||
SERVICE: ${{ matrix.service }}
|
||||
# THERMOGRAPH_ENV is what makes this a BETA deploy rather than a prod
|
||||
# one: same host, same credentials, same script — the environment is
|
||||
# the only thing that differs, and deploy.sh refuses to run if it
|
||||
# disagrees with the checkout it was invoked from.
|
||||
THERMOGRAPH_ENV: beta
|
||||
# Only the matching one is read by deploy.sh for a single-service roll;
|
||||
# the other stays empty and the persisted .image-tags.env supplies the
|
||||
# sibling's live tag, so this roll never disturbs it.
|
||||
BACKEND_IMAGE_TAG: ${{ matrix.service == 'backend' && steps.plan.outputs.tag || '' }}
|
||||
FRONTEND_IMAGE_TAG: ${{ matrix.service == 'frontend' && steps.plan.outputs.tag || '' }}
|
||||
|
||||
- name: Deploy to prod
|
||||
- name: Deploy to prod (vps2)
|
||||
if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'prod'
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
# A completely separate secret set from beta's, deliberately: a beta
|
||||
# credential leak must not reach prod.
|
||||
host: ${{ secrets.PROD_SSH_HOST }}
|
||||
username: ${{ secrets.PROD_SSH_USER }}
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
port: ${{ secrets.PROD_SSH_PORT }}
|
||||
envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG
|
||||
script: /opt/thermograph/infra/deploy/deploy.sh
|
||||
host: ${{ secrets.VPS2_SSH_HOST }}
|
||||
username: ${{ secrets.VPS2_SSH_USER }}
|
||||
key: ${{ secrets.VPS2_SSH_KEY }}
|
||||
port: ${{ secrets.VPS2_SSH_PORT }}
|
||||
envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG,THERMOGRAPH_ENV
|
||||
script: ${{ steps.plan.outputs.script }}
|
||||
env:
|
||||
SERVICE: ${{ matrix.service }}
|
||||
THERMOGRAPH_ENV: prod
|
||||
BACKEND_IMAGE_TAG: ${{ matrix.service == 'backend' && steps.plan.outputs.tag || '' }}
|
||||
FRONTEND_IMAGE_TAG: ${{ matrix.service == 'frontend' && steps.plan.outputs.tag || '' }}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,65 +2,114 @@ name: Sync infra to hosts
|
|||
|
||||
# The infra DOMAIN's own pipeline (new with the monorepo -- the split era had
|
||||
# no infra deploy workflow at all; infra changes rode along lazily with the
|
||||
# next app deploy's `git reset`). On a push to `main` touching infra/**, SSH
|
||||
# to beta AND prod, fast-forward the host's /opt/thermograph monorepo checkout
|
||||
# and re-render /etc/thermograph.env from the SOPS vault -- so a compose edit
|
||||
# or a secret rotation lands push-button instead of waiting for the next app
|
||||
# deploy.
|
||||
# next app deploy's `git reset`). On a push touching infra/**, SSH to every
|
||||
# host, fast-forward each ENVIRONMENT's monorepo checkout and re-render that
|
||||
# environment's env file from the SOPS vault -- so a compose edit or a secret
|
||||
# rotation lands push-button instead of waiting for the next app deploy.
|
||||
#
|
||||
# Deliberately does NOT roll any service: image tags are the app domains'
|
||||
# axis, not infra's. A compose change that must recreate containers takes
|
||||
# effect on the next app deploy, or a by-hand
|
||||
# axis, not infra's. A compose or stack change that must recreate containers
|
||||
# takes effect on the next app deploy, or a by-hand
|
||||
# `SERVICE=all ... infra/deploy/deploy.sh` run (see that script's header).
|
||||
#
|
||||
# Both hosts track infra via `main` (the split-era model, unchanged): prod's
|
||||
# *app images* are staged by the `release` branch, but its checkout follows
|
||||
# main -- deploy.sh's own BRANCH default encodes the same thing.
|
||||
# THREE CHECKOUTS, TWO HOSTS. This is the shape the vps1/vps2 split forces:
|
||||
#
|
||||
# vps1 /opt/thermograph-dev branch dev -> /etc/thermograph.env
|
||||
# vps2 /opt/thermograph-beta branch main -> /etc/thermograph-beta.env
|
||||
# vps2 /opt/thermograph branch main -> /etc/thermograph.env
|
||||
#
|
||||
# The two checkouts on vps2 are separate directories on purpose: a `git reset
|
||||
# --hard` for beta must not be able to move prod's tree, and each environment
|
||||
# renders its own env file from its own vault file. Because the paths differ,
|
||||
# the beta and prod jobs below can safely run at the same time on one box.
|
||||
#
|
||||
# Beta and prod both track `main` (infra is not environment-staged -- only app
|
||||
# IMAGES are, via tags). Dev tracks `dev`, which is why this workflow now
|
||||
# triggers on both branches: a dev-branch infra change has to reach the dev
|
||||
# checkout, and only that one.
|
||||
#
|
||||
# Each `render_thermograph_secrets` call passes the environment name and the
|
||||
# output path EXPLICITLY. It used to rely on the host's
|
||||
# /etc/thermograph/secrets-env marker, which cannot answer the question on vps2
|
||||
# -- one marker, two environments. Passing them here means a beta sync can
|
||||
# never render prod's vault, and vice versa.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [dev, main]
|
||||
paths: ['infra/**']
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
sync-dev:
|
||||
# Only the dev branch feeds the dev checkout.
|
||||
if: github.ref_name == 'dev'
|
||||
runs-on: docker
|
||||
concurrency:
|
||||
group: infra-sync-dev
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Sync dev checkout on vps1 + re-render secrets
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.VPS1_SSH_HOST }}
|
||||
username: ${{ secrets.VPS1_SSH_USER }}
|
||||
key: ${{ secrets.VPS1_SSH_KEY }}
|
||||
port: ${{ secrets.VPS1_SSH_PORT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/thermograph-dev
|
||||
git fetch --prune origin dev
|
||||
git reset --hard origin/dev
|
||||
if [ -f infra/deploy/render-secrets.sh ]; then
|
||||
. infra/deploy/render-secrets.sh
|
||||
# SKIP_COMMON is dev's standing rule, not a preference: common.yaml
|
||||
# is the fleet's shared production credential set, and vps1 also
|
||||
# runs Forgejo and its CI. See render-secrets.sh.
|
||||
THERMOGRAPH_SECRETS_SKIP_COMMON=1 \
|
||||
render_thermograph_secrets /opt/thermograph-dev/infra dev /etc/thermograph.env
|
||||
fi
|
||||
echo "synced to $(git log --oneline -1)"
|
||||
|
||||
sync-beta:
|
||||
if: github.ref_name == 'main'
|
||||
runs-on: docker
|
||||
concurrency:
|
||||
group: infra-sync-beta
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Sync beta checkout + re-render secrets
|
||||
- name: Sync beta checkout on vps2 + re-render secrets
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
host: ${{ secrets.VPS2_SSH_HOST }}
|
||||
username: ${{ secrets.VPS2_SSH_USER }}
|
||||
key: ${{ secrets.VPS2_SSH_KEY }}
|
||||
port: ${{ secrets.VPS2_SSH_PORT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/thermograph
|
||||
cd /opt/thermograph-beta
|
||||
git fetch --prune origin main
|
||||
git reset --hard origin/main
|
||||
if [ -f infra/deploy/render-secrets.sh ]; then
|
||||
. infra/deploy/render-secrets.sh
|
||||
render_thermograph_secrets /opt/thermograph/infra
|
||||
render_thermograph_secrets /opt/thermograph-beta/infra beta /etc/thermograph-beta.env
|
||||
fi
|
||||
echo "synced to $(git log --oneline -1)"
|
||||
|
||||
sync-prod:
|
||||
if: github.ref_name == 'main'
|
||||
runs-on: docker
|
||||
concurrency:
|
||||
group: infra-sync-prod
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Sync prod checkout + re-render secrets
|
||||
- name: Sync prod checkout on vps2 + re-render secrets
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.PROD_SSH_HOST }}
|
||||
username: ${{ secrets.PROD_SSH_USER }}
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
port: ${{ secrets.PROD_SSH_PORT }}
|
||||
host: ${{ secrets.VPS2_SSH_HOST }}
|
||||
username: ${{ secrets.VPS2_SSH_USER }}
|
||||
key: ${{ secrets.VPS2_SSH_KEY }}
|
||||
port: ${{ secrets.VPS2_SSH_PORT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/thermograph
|
||||
|
|
@ -68,6 +117,6 @@ jobs:
|
|||
git reset --hard origin/main
|
||||
if [ -f infra/deploy/render-secrets.sh ]; then
|
||||
. infra/deploy/render-secrets.sh
|
||||
render_thermograph_secrets /opt/thermograph/infra
|
||||
render_thermograph_secrets /opt/thermograph/infra prod /etc/thermograph.env
|
||||
fi
|
||||
echo "synced to $(git log --oneline -1)"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
name: Validate observability stack
|
||||
|
||||
# The observability DOMAIN deploys by hand (`docker compose up -d` on beta +
|
||||
# The observability DOMAIN deploys by hand (`docker compose up -d` on vps1 —
|
||||
# the monitoring host, not the beta environment, which runs on vps2 now — plus
|
||||
# the Alloy agent on each node) with no build step, so nothing caught a
|
||||
# malformed compose file, a broken dashboard JSON, or an unparseable config
|
||||
# until it failed live on the host. This is that missing guard: it parses
|
||||
|
|
|
|||
|
|
@ -6,19 +6,32 @@ name: Ops cron (backup + IndexNow)
|
|||
# classification table in
|
||||
# thermograph-docs/architecture/repo-topology-and-infrastructure.md §7.
|
||||
#
|
||||
# Both jobs SSH into the prod host and run inside the already-running compose
|
||||
# stack (docker compose exec), the same way deploy.sh already runs its own
|
||||
# post-deploy IndexNow ping -- no new network exposure, no separate dependency
|
||||
# install. Runs on the `docker` label (the always-on Swarm-hosted runner
|
||||
# deploy/forgejo/ stood up).
|
||||
# The jobs SSH into a host and run inside the already-running stack (docker
|
||||
# exec), the same way deploy.sh runs its own post-deploy IndexNow ping -- no new
|
||||
# network exposure, no separate dependency install. Runs on the `docker` label
|
||||
# (the always-on Swarm-hosted runner deploy/forgejo/ stood up).
|
||||
#
|
||||
# Targets PROD via the PROD_SSH_* secrets (prod = 169.58.46.181, `agent` user,
|
||||
# in the docker group so no sudo needed; /etc/thermograph.env is agent-readable).
|
||||
# These are the same secrets deploy-prod.yml uses for the release->prod deploy --
|
||||
# NOT the SSH_* secrets, which point at BETA (deploy.yml's `main`->beta path). An
|
||||
# earlier revision reused SSH_* here, so the "prod" backup was silently dumping
|
||||
# beta; prod itself had no backup at all. The prod database is the one that must
|
||||
# be backed up, so both jobs use PROD_SSH_*.
|
||||
# WHICH BOX EACH JOB TALKS TO, and why the secret names say so:
|
||||
#
|
||||
# backup, indexnow -> VPS2_SSH_* (vps2 = 169.58.46.181: prod AND beta, the
|
||||
# shared TimescaleDB, Postfix, the backups)
|
||||
# forgejo-backup -> VPS1_SSH_* (vps1 = 75.119.132.91: Forgejo, Grafana,
|
||||
# Loki, and the dev environment)
|
||||
#
|
||||
# The secrets are keyed by HOST rather than by environment because an
|
||||
# environment no longer implies a machine: vps2 runs two of them. The previous
|
||||
# names encoded the opposite assumption and had already caused one incident --
|
||||
# an early revision used SSH_* here, which meant "beta", so the job labelled
|
||||
# "prod backup" was silently dumping beta while prod had no backup at all. The
|
||||
# same conflation is why Forgejo's backup used to be described as running "on
|
||||
# beta": beta and Forgejo happened to share a box, so one secret served both
|
||||
# meanings. It does not any more.
|
||||
#
|
||||
# BOTH APPLICATION DATABASES ARE BACKED UP. prod and beta are separate databases
|
||||
# (`thermograph` and `thermograph_beta`) on one shared instance. Dumping only
|
||||
# the prod database would recreate the original failure in a new shape: a job
|
||||
# that looks like "the backup" while one environment's data is silently
|
||||
# uncovered. The loop below dumps each, to its own off-box prefix.
|
||||
|
||||
# Monorepo port: the compose file now lives under infra/ of the host's
|
||||
# /opt/thermograph monorepo checkout, so every docker-compose invocation cd's
|
||||
|
|
@ -44,22 +57,22 @@ jobs:
|
|||
group: ops-backup
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Dump the prod database over SSH
|
||||
- name: Dump both application databases over SSH
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
# S3 creds for the encrypted off-box copy to Contabo Object Storage, passed
|
||||
# into the remote script via `envs:` (the host env file isn't a reliable
|
||||
# source across boxes -- beta's /etc/thermograph.env isn't readable by the
|
||||
# deploy user -- so the CI secret is the uniform home, like PROD_SSH_*).
|
||||
# into the remote script via `envs:` (a host env file isn't a reliable
|
||||
# source across boxes -- on vps1 it isn't readable by the deploy user --
|
||||
# so the CI secret is the uniform home, like the VPS*_SSH_* pairs).
|
||||
env:
|
||||
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
with:
|
||||
host: ${{ secrets.PROD_SSH_HOST }}
|
||||
username: ${{ secrets.PROD_SSH_USER }}
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
port: ${{ secrets.PROD_SSH_PORT }}
|
||||
host: ${{ secrets.VPS2_SSH_HOST }}
|
||||
username: ${{ secrets.VPS2_SSH_USER }}
|
||||
key: ${{ secrets.VPS2_SSH_KEY }}
|
||||
port: ${{ secrets.VPS2_SSH_PORT }}
|
||||
envs: S3_ENDPOINT,S3_BUCKET,S3_ACCESS_KEY,S3_SECRET_KEY
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
|
@ -73,23 +86,45 @@ jobs:
|
|||
backup_dir="$HOME/thermograph-backups"
|
||||
mkdir -p "$backup_dir"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
out="$backup_dir/thermograph-$stamp.dump"
|
||||
# The db may run under plain compose OR as a Swarm stack task
|
||||
# (prod post-cutover); resolve the container either way so the
|
||||
# backup survives the deploy-mode switch.
|
||||
# The db may run under plain compose OR as a Swarm stack task;
|
||||
# resolve the container either way so the backup survives a
|
||||
# deploy-mode switch. One instance now serves both environments.
|
||||
dbc=$(docker ps -q --filter "label=com.docker.swarm.service.name=thermograph_db" | head -1)
|
||||
[ -z "$dbc" ] && dbc=$(cd /opt/thermograph/infra && docker compose ps -q db 2>/dev/null | head -1)
|
||||
[ -n "$dbc" ] || { echo "!! no db container found (compose or stack)"; exit 1; }
|
||||
# Write to a .partial and rename on success so a mid-dump failure can
|
||||
# never leave a truncated file that looks like a good backup.
|
||||
docker exec "$dbc" pg_dump -U thermograph -d thermograph \
|
||||
--format=custom > "$out.partial"
|
||||
mv "$out.partial" "$out"
|
||||
echo "wrote $out ($(du -h "$out" | cut -f1))"
|
||||
# env:database pairs. Both are dumped every night: they are separate
|
||||
# databases on one instance, and backing up only one would leave the
|
||||
# other silently uncovered.
|
||||
for pair in prod:thermograph beta:thermograph_beta; do
|
||||
envname="${pair%%:*}"; dbname="${pair##*:}"
|
||||
# A missing database is a HARD failure, never a quiet skip -- a
|
||||
# backup job that shrugs off an absent database is exactly how an
|
||||
# environment ends up with no backups and nobody noticing.
|
||||
if ! docker exec "$dbc" psql -U thermograph -d postgres -tAc \
|
||||
"select 1 from pg_database where datname='$dbname'" | grep -q 1; then
|
||||
echo "!! database '$dbname' ($envname) does not exist on this instance."
|
||||
if [ "$envname" = prod ]; then
|
||||
echo "!! That is prod's own database on prod's own instance — this is not a"
|
||||
echo "!! provisioning gap, something is badly wrong. Do not 'fix' it by creating"
|
||||
echo "!! an empty database; find out where the real one went."
|
||||
else
|
||||
echo "!! provision it first: sudo bash /opt/thermograph-beta/infra/deploy/db/provision-env-db.sh $envname"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
out="$backup_dir/$dbname-$stamp.dump"
|
||||
# Write to a .partial and rename on success so a mid-dump failure can
|
||||
# never leave a truncated file that looks like a good backup.
|
||||
docker exec "$dbc" pg_dump -U thermograph -d "$dbname" \
|
||||
--format=custom > "$out.partial"
|
||||
mv "$out.partial" "$out"
|
||||
echo "wrote $out ($(du -h "$out" | cut -f1))"
|
||||
dumps="${dumps:-} $envname:$out"
|
||||
done
|
||||
# The dumps are the disaster-recovery copy, not a versioned
|
||||
# archive -- keep the last 14 days and let the rest age out.
|
||||
find "$backup_dir" -name 'thermograph-*.dump' -mtime +14 -delete
|
||||
find "$backup_dir" -name 'thermograph-*.dump.partial' -mtime +1 -delete
|
||||
find "$backup_dir" -name '*.dump' -mtime +14 -delete
|
||||
find "$backup_dir" -name '*.dump.partial' -mtime +1 -delete
|
||||
# --- off-box copy to Contabo Object Storage (age-encrypted) ---
|
||||
# Streams the just-written dump through age (to the vault's age
|
||||
# recipient -- the same key each host renders secrets with, so
|
||||
|
|
@ -105,23 +140,33 @@ jobs:
|
|||
RCLONE_CONFIG_ARCHIVE_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
|
||||
RCLONE_CONFIG_ARCHIVE_REGION=default RCLONE_CONFIG_ARCHIVE_FORCE_PATH_STYLE=true
|
||||
recip=age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
|
||||
s3base="$S3_BUCKET/backups/db/prod"
|
||||
age -r "$recip" < "$out" | rclone rcat "archive:$s3base/$(basename "$out").age"
|
||||
echo "off-box: uploaded $(basename "$out").age to $s3base"
|
||||
rclone delete --min-age 30d "archive:$s3base/" 2>/dev/null || true
|
||||
# One prefix per environment, so a restore never has to guess which
|
||||
# database a dump came from: backups/db/prod/ and backups/db/beta/.
|
||||
for entry in $dumps; do
|
||||
envname="${entry%%:*}"; f="${entry##*:}"
|
||||
s3base="$S3_BUCKET/backups/db/$envname"
|
||||
age -r "$recip" < "$f" | rclone rcat "archive:$s3base/$(basename "$f").age"
|
||||
echo "off-box: uploaded $(basename "$f").age to $s3base"
|
||||
rclone delete --min-age 30d "archive:$s3base/" 2>/dev/null || true
|
||||
done
|
||||
else
|
||||
echo "!! S3_* secrets unset -- skipped off-box push (add them as repo secrets)"
|
||||
fi
|
||||
|
||||
forgejo-backup:
|
||||
name: Forgejo backup (beta) -> S3
|
||||
name: Forgejo backup (vps1) -> S3
|
||||
runs-on: docker
|
||||
# Forgejo (the git host + all CI history) lives on beta and had NO backup at
|
||||
# Forgejo (the git host + all CI history) lives on vps1 and had NO backup at
|
||||
# all. Dumps its Postgres db (consistent) + tars its data volume (repos, LFS,
|
||||
# config, avatars), age-encrypts both, pushes off-box, keeps 30 days. Runs as
|
||||
# the beta SSH user (SSH_*), which is in the docker group -- so docker exec/run
|
||||
# need no host sudo, and the data tar runs inside a throwaway container (the
|
||||
# volume dir is root-owned on the host). rclone + age must be present on beta.
|
||||
# vps1's SSH user (VPS1_SSH_*), which is in the docker group -- so docker
|
||||
# exec/run need no host sudo, and the data tar runs inside a throwaway
|
||||
# container (the volume dir is root-owned on the host). rclone + age must be
|
||||
# present on vps1.
|
||||
#
|
||||
# This job follows FORGEJO, not beta. It used to use the same secret as the
|
||||
# beta deploy purely because Forgejo and beta shared a box; beta has since
|
||||
# moved to vps2 and Forgejo has not moved at all.
|
||||
concurrency:
|
||||
group: ops-forgejo-backup
|
||||
cancel-in-progress: false
|
||||
|
|
@ -134,16 +179,16 @@ jobs:
|
|||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
host: ${{ secrets.VPS1_SSH_HOST }}
|
||||
username: ${{ secrets.VPS1_SSH_USER }}
|
||||
key: ${{ secrets.VPS1_SSH_KEY }}
|
||||
port: ${{ secrets.VPS1_SSH_PORT }}
|
||||
envs: S3_ENDPOINT,S3_BUCKET,S3_ACCESS_KEY,S3_SECRET_KEY
|
||||
script: |
|
||||
set -euo pipefail
|
||||
[ -n "${S3_ACCESS_KEY:-}" ] || { echo "!! S3_* secrets unset"; exit 1; }
|
||||
command -v rclone >/dev/null 2>&1 || { echo "!! rclone missing on beta"; exit 1; }
|
||||
command -v age >/dev/null 2>&1 || { echo "!! age missing on beta"; exit 1; }
|
||||
command -v rclone >/dev/null 2>&1 || { echo "!! rclone missing on vps1"; exit 1; }
|
||||
command -v age >/dev/null 2>&1 || { echo "!! age missing on vps1"; exit 1; }
|
||||
export RCLONE_CONFIG_ARCHIVE_TYPE=s3 RCLONE_CONFIG_ARCHIVE_PROVIDER=Other \
|
||||
RCLONE_CONFIG_ARCHIVE_ENDPOINT="$S3_ENDPOINT" \
|
||||
RCLONE_CONFIG_ARCHIVE_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
|
||||
|
|
@ -177,10 +222,10 @@ jobs:
|
|||
- name: Ping IndexNow if the URL set changed
|
||||
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.PROD_SSH_HOST }}
|
||||
username: ${{ secrets.PROD_SSH_USER }}
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
port: ${{ secrets.PROD_SSH_PORT }}
|
||||
host: ${{ secrets.VPS2_SSH_HOST }}
|
||||
username: ${{ secrets.VPS2_SSH_USER }}
|
||||
key: ${{ secrets.VPS2_SSH_KEY }}
|
||||
port: ${{ secrets.VPS2_SSH_PORT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/thermograph/infra
|
||||
|
|
|
|||
60
CLAUDE.md
60
CLAUDE.md
|
|
@ -15,24 +15,30 @@ every change, so a stale one is a correctness bug, not a documentation bug.
|
|||
| Branch | Deploys to | Workflow |
|
||||
|---|---|---|
|
||||
| feature branch | nothing | PR into `dev` |
|
||||
| `dev` | nothing | integration branch only — see below |
|
||||
| `main` | beta (beta.thermograph.org) | `deploy.yml` |
|
||||
| `release` | prod (thermograph.org) | `deploy.yml` |
|
||||
| `dev` | dev (vps1, mesh-only, own Postgres) | `deploy.yml` |
|
||||
| `main` | beta (beta.thermograph.org, vps2) | `deploy.yml` |
|
||||
| `release` | prod (thermograph.org, vps2) | `deploy.yml` |
|
||||
|
||||
`dev`, `main` and `release` are protected: **everything is a PR**, for humans and
|
||||
agents alike. Promotion is one PR per hop, `dev` → `main` → `release`.
|
||||
|
||||
**`dev` deploys nowhere.** It is purely the integration branch that feature PRs
|
||||
land on before promotion to `main`. The two LAN-dev deploy workflows were deleted
|
||||
rather than kept: they called a monorepo path that does not exist on the LAN box
|
||||
(`~/thermograph-dev` is still a split-era `thermograph-infra` checkout), so they
|
||||
had been inert since cutover. Run LAN dev locally with `infra/`'s `make dev-up`.
|
||||
**`dev` is a first-class hosted environment, not just an integration branch.**
|
||||
It runs on vps1 — the same box as Forgejo and the monitoring stack — with its
|
||||
own Postgres container, reachable only on the WireGuard mesh
|
||||
(`10.10.0.2:8137`): no public DNS record, no Caddy site, no TLS. It is deployed
|
||||
by CI like beta and prod. The desktop hosts no Thermograph environment at all;
|
||||
`make dev-up` there is a laptop convenience for running the stack locally, not
|
||||
a deployment target.
|
||||
|
||||
**One workflow deploys everything.** `deploy.yml` handles both services and both
|
||||
environments: the branch selects the environment (`main` → beta, `release` →
|
||||
prod), a matrix covers backend and frontend, and each leg checks whether this
|
||||
push actually touched its domain before rolling. `build-push.yml` is the same
|
||||
shape for images. They replaced six and two near-identical files respectively.
|
||||
**One workflow deploys everything.** `deploy.yml` handles both services and all
|
||||
three environments: the branch selects the environment (`dev` → vps1, `main` →
|
||||
beta on vps2, `release` → prod on vps2), a matrix covers backend and frontend,
|
||||
and each leg checks whether this push actually touched its domain before
|
||||
rolling. `build-push.yml` is the same shape for images. `THERMOGRAPH_ENV` is the
|
||||
input that tells a leg which environment it's deploying — load-bearing on vps2,
|
||||
which runs beta and prod side by side and has no other way to tell them apart.
|
||||
See `infra/deploy/env-topology.sh` for the single source of truth on where each
|
||||
environment's checkout, branch, stack and ports live.
|
||||
|
||||
## The deploy contract
|
||||
|
||||
|
|
@ -44,15 +50,25 @@ SERVICE=backend|frontend|all BACKEND_IMAGE_TAG=sha-<12hex> FRONTEND_IMAGE_TAG=
|
|||
```
|
||||
|
||||
`deploy.sh` resets the host checkout, renders secrets from the SOPS vault, then
|
||||
either rolls compose services or — if `/etc/thermograph/deploy-mode` contains
|
||||
`stack` — execs `infra/deploy/stack/deploy-stack.sh`.
|
||||
either rolls compose services or — if `infra/deploy/env-topology.sh` says the
|
||||
environment's deploy mode is `stack` — execs `infra/deploy/stack/deploy-stack.sh`.
|
||||
The old host-wide `/etc/thermograph/deploy-mode` marker survives only as a
|
||||
fallback for a by-hand run with no environment resolvable any other way; it
|
||||
cannot describe vps2 alone, since vps2 runs beta and prod side by side.
|
||||
|
||||
- **prod runs Swarm.** Its stack is `infra/deploy/stack/thermograph-stack.yml`
|
||||
(db, web, worker, lake, daemon, frontend, autoscaler, autoscaler-lake).
|
||||
`deploy-stack.sh` also offers `STACK_TEST=1`: a full parallel rehearsal on
|
||||
throwaway volumes and ports that cannot touch live data.
|
||||
- **beta and LAN dev run compose**, from `infra/docker-compose.yml`
|
||||
(db, backend, lake, daemon, frontend).
|
||||
- **prod and beta both run Swarm, as two separate stacks co-resident on vps2.**
|
||||
Prod's is `infra/deploy/stack/thermograph-stack.yml` (db, web, worker, lake,
|
||||
daemon, frontend, autoscaler, autoscaler-lake). Beta's is the separate
|
||||
`infra/deploy/stack/thermograph-beta-stack.yml` — its services are prefixed
|
||||
(`beta-web`, `beta-worker`, …) because Swarm registers a service's short name
|
||||
as a DNS alias on every network it joins, and beta shares prod's `data`
|
||||
network to reach the database. Beta has no `db` service of its own: one
|
||||
TimescaleDB instance serves both, on separate databases and separate
|
||||
NOSUPERUSER roles. `deploy-stack.sh` also offers `STACK_TEST=1`: a full
|
||||
parallel rehearsal on throwaway volumes and ports that cannot touch live data.
|
||||
- **dev runs compose**, from `infra/docker-compose.yml` (db, backend, lake,
|
||||
daemon, frontend), on its own host (vps1) with its own Postgres container —
|
||||
the one environment not sharing a database with anything else.
|
||||
- Each service's live tag is persisted host-side, so a single-service roll never
|
||||
disturbs the sibling's running tag.
|
||||
|
||||
|
|
@ -76,7 +92,7 @@ reunification.
|
|||
- Anything named "prefetch" must never spend the Open-Meteo quota.
|
||||
Nominatim ≤ 1 req/s.
|
||||
- The compose project name is pinned (`name: thermograph` in
|
||||
`infra/docker-compose.yml`); LAN dev overrides with
|
||||
`infra/docker-compose.yml`); dev (and a local `make dev-up`) overrides with
|
||||
`COMPOSE_PROJECT_NAME=thermograph-dev`. Don't remove either half — the pinned
|
||||
name is what makes the Swarm stack's external volume names line up.
|
||||
- `CUTOVER-NOTES.md` is the source of truth for what is and isn't live yet.
|
||||
|
|
|
|||
|
|
@ -153,3 +153,38 @@ Checked every split-repo branch by dry-run merge; migrated everything real:
|
|||
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`](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.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ for: **per-domain images, per-domain deploys, and an async FE/BE contract**.
|
|||
|---|---|---|
|
||||
| `backend/` | FastAPI graded-climate API, accounts, notifications (Discord bot, push, mail), data pipeline | `build-push` → image `emi/thermograph/backend`; `deploy` |
|
||||
| `frontend/` | Public client: static JS/CSS + SSR pages | same `build-push` / `deploy` workflows, matrixed by domain; image `emi/thermograph/frontend` |
|
||||
| `infra/` | Compose (beta, LAN dev) + the Swarm stack (prod), deploy scripts, terraform, SOPS secrets vault, ops cron | `infra-sync` (host checkout + secrets render), `secrets-guard`, `ops-cron` |
|
||||
| `infra/` | Compose (dev, on vps1) + two Swarm stacks co-resident on vps2 (beta, prod), deploy scripts, terraform, SOPS secrets vault, ops cron | `infra-sync` (host checkout + secrets render), `secrets-guard`, `ops-cron` |
|
||||
| `observability/` | Loki + Grafana + Alloy stack | `observability-validate` |
|
||||
|
||||
`thermograph-docs` deliberately **stays its own repo** (ADRs + runbooks, no
|
||||
|
|
@ -36,6 +36,9 @@ The one intentionally *coupled* piece is `pr-build.yml`: a single always-running
|
|||
path-filtered required check would deadlock auto-merge).
|
||||
|
||||
Branch model (unchanged from the split era): PRs → `dev`, `main` → beta,
|
||||
`release` → prod; infra tracked via `main` on all hosts.
|
||||
`release` → prod. Infra isn't environment-staged the same way app images are:
|
||||
beta's and prod's checkouts (both on vps2) track `main`; dev's checkout (on
|
||||
vps1) tracks `dev` itself, since it's the one environment that isn't a
|
||||
rehearsal for something downstream.
|
||||
|
||||
**Before pointing anything live at this repo, read `CUTOVER-NOTES.md`.**
|
||||
|
|
|
|||
|
|
@ -55,40 +55,59 @@ and `PAYLOAD_VER` is the lever that moves it.** See
|
|||
|
||||
## The estate
|
||||
|
||||
Four machines on a WireGuard mesh (`10.10.0.0/24`):
|
||||
Hosts are named by **role**, not by environment, plus the operator's desktop —
|
||||
all on a WireGuard mesh (`10.10.0.0/24`):
|
||||
|
||||
| Host | Mesh IP | Public | Runs |
|
||||
|---|---|---|---|
|
||||
| **prod** | `10.10.0.1` | `169.58.46.181`, thermograph.org | Docker **Swarm** app stack, TimescaleDB, Postfix, backups, Centralis |
|
||||
| **beta** | `10.10.0.2` | `75.119.132.91`, beta.thermograph.org | **compose** app stack, Forgejo (git + CI + registry), Grafana + Loki |
|
||||
| **desktop** | `10.10.0.3` | — | LAN dev stack, the Forgejo Actions runner, your editor |
|
||||
| **vps1** | `10.10.0.2` | `75.119.132.91`, git.thermograph.org, dashboard.thermograph.org | Forgejo (git + CI + registry), Grafana + Loki + Alloy, the `emigriffith.dev` portfolio, and **dev** — its own Postgres, plain compose, **mesh-only** (no public DNS, no Caddy site, no TLS) |
|
||||
| **vps2** | `10.10.0.1` | `169.58.46.181`, thermograph.org, beta.thermograph.org | **prod** and **beta** as two separate Docker **Swarm** stacks, Centralis, Postfix, backups |
|
||||
| **desktop** | `10.10.0.3` | — | AI-model hosting (voice-to-text, an upcoming-feature LLM) + flex Swarm capacity. Hosts **no** Thermograph environment — `make dev-up` there is a laptop convenience only |
|
||||
| phone | — | — | alerts |
|
||||
|
||||
vps2 runs prod and beta **co-resident on one box**: one TimescaleDB instance
|
||||
serves both, on separate databases and separate roles (`thermograph` /
|
||||
`thermograph_beta`, the latter `NOSUPERUSER`/`NOCREATEDB`, `CONNECT` revoked
|
||||
from `PUBLIC`); separate checkouts, env files and loopback LB ports; beta's
|
||||
Swarm service names are prefixed (`beta-web`, not `web`) because Swarm
|
||||
registers a service's short name as a DNS alias on every network it joins, and
|
||||
beta shares prod's network to reach the database. Beta rehearses prod's actual
|
||||
orchestrator this way — that's the point of putting it next to prod rather
|
||||
than next to dev.
|
||||
|
||||
The mesh IPs did not move when this topology landed — vps1 is the box that used
|
||||
to be called "beta", vps2 the one that used to be called "prod". What moved is
|
||||
which environment lives where. `infra/deploy/env-topology.sh` is the single
|
||||
source of truth for every per-environment path, port and role; `THERMOGRAPH_ENV`
|
||||
(`dev`/`beta`/`prod`) is the input a deploy leg passes, since vps2 can no longer
|
||||
tell beta and prod apart by which host it's running on.
|
||||
|
||||
Two consequences you will hit within the first week:
|
||||
|
||||
- **Forgejo is mesh-only.** `git.thermograph.org` resolves publicly to beta's
|
||||
IP, but beta's Caddy rejects `/v2/*` (the registry API) from outside the mesh.
|
||||
Any host that pulls images needs `10.10.0.2 git.thermograph.org` in
|
||||
- **Forgejo is mesh-only.** `git.thermograph.org` resolves publicly to vps1's
|
||||
IP, but vps1's Caddy rejects `/v2/*` (the registry API) from outside the
|
||||
mesh. Any host that pulls images needs `10.10.0.2 git.thermograph.org` in
|
||||
`/etc/hosts`.
|
||||
- **Prod and beta run different orchestrators.** Prod is Swarm
|
||||
(`infra/deploy/stack/`), beta and LAN dev are compose
|
||||
(`infra/docker-compose.yml`). `deploy.sh` routes between them by reading
|
||||
`/etc/thermograph/deploy-mode` on the box. Most of the time you don't care —
|
||||
but when you're reading logs or naming containers, you do
|
||||
(see [observability](09-observability.md)).
|
||||
- **Beta and prod run the same orchestrator now — Swarm, as two stacks on one
|
||||
box.** Dev is the only environment on compose (`infra/docker-compose.yml`),
|
||||
and it lives alone on vps1. Most of the time you don't care which stack
|
||||
you're reading — but when you're reading logs or naming containers, you do
|
||||
(see [observability](09-observability.md)), and on vps2 you additionally have
|
||||
to say *which* environment: `deploy.sh` there routes on the explicit
|
||||
`THERMOGRAPH_ENV` a caller passes, not on which host it's running on.
|
||||
|
||||
## How a change travels
|
||||
|
||||
```
|
||||
PR ──(required check: `gate`)──▶ dev ──▶ LAN dev box
|
||||
PR ──(required check: `gate`)──▶ dev ──▶ dev (vps1, mesh-only)
|
||||
│
|
||||
promotion PR
|
||||
▼
|
||||
main ──▶ beta.thermograph.org
|
||||
main ──▶ beta.thermograph.org (vps2)
|
||||
│
|
||||
promotion PR ← the owner's call
|
||||
▼
|
||||
release ──▶ thermograph.org
|
||||
release ──▶ thermograph.org (vps2)
|
||||
```
|
||||
|
||||
`dev`, `main` and `release` are **protected** — no direct pushes, for humans or
|
||||
|
|
|
|||
|
|
@ -172,11 +172,18 @@ ends fail closed. With Discord unconfigured it logs once and runs cron-only.
|
|||
```bash
|
||||
cd infra
|
||||
make dev-up # docker-compose.yml + docker-compose.dev.yml overlay:
|
||||
# uncapped CPU, backend published on 0.0.0.0:8137 for the LAN
|
||||
# uncapped CPU, backend published on 0.0.0.0:8137 for your LAN
|
||||
make dev-down
|
||||
```
|
||||
|
||||
The dev overlay exports `COMPOSE_PROJECT_NAME=thermograph-dev` so the LAN stack
|
||||
This is a **laptop convenience**, not the hosted dev environment: the actual
|
||||
`dev` environment now runs on vps1 with its own Postgres, deployed by CI, and
|
||||
bound only to the WireGuard mesh (`10.10.0.2:8137`) — never `0.0.0.0`, since
|
||||
vps1 is a public box. See [Infra and secrets](08-infra-secrets.md). A local
|
||||
`make dev-up` is fine on `0.0.0.0` because it's your own machine's LAN, not the
|
||||
public internet.
|
||||
|
||||
The dev overlay exports `COMPOSE_PROJECT_NAME=thermograph-dev` so this stack
|
||||
keeps volumes separate from anything else. **Do not remove either half of the
|
||||
project-name pinning** — `infra/docker-compose.yml` pins `name: thermograph`,
|
||||
and without it running compose from `infra/` derives the project name `infra`,
|
||||
|
|
@ -216,7 +223,7 @@ it will be fresher than this page.
|
|||
|
||||
| Hook | When | What |
|
||||
|---|---|---|
|
||||
| `prod-guard.sh` | before Bash / live-host MCP calls | Classifies by **allowlist**: only positively-recognised read-only commands pass; everything else asks. Beta is guarded as strictly as prod — it hosts Forgejo, Grafana *and* beta.thermograph.org. |
|
||||
| `prod-guard.sh` | before Bash / live-host MCP calls | Classifies by **allowlist**: only positively-recognised read-only commands pass; everything else asks. vps1 is guarded as strictly as vps2 — it hosts Forgejo, Grafana *and* the mesh-only `dev` environment, so a destructive command there takes out git, CI and the registry at once, not just a dev sandbox. |
|
||||
| `secrets-guard.sh` | before Write/Edit | Denies any direct write to `infra/deploy/secrets/*.yaml`. Use `sops edit`. |
|
||||
| `lint-after-edit.sh` | after Write/Edit | shellchecks an edited `*.sh` and feeds findings straight back. Exits quietly if shellcheck is missing. |
|
||||
|
||||
|
|
|
|||
|
|
@ -114,24 +114,36 @@ Deep dive: [05-frontend.md](05-frontend.md).
|
|||
## `infra/` — how and where images run
|
||||
|
||||
```
|
||||
docker-compose.yml the compose stack (beta + LAN dev):
|
||||
db, backend, lake, daemon, frontend.
|
||||
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 LAN overlay: uncapped, backend on 0.0.0.0:8137
|
||||
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/
|
||||
deploy.sh ★ the single entry point for beta and prod
|
||||
render-secrets.sh renders /etc/thermograph.env from the SOPS vault
|
||||
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 (live on prod):
|
||||
thermograph-stack.yml, deploy-stack.sh,
|
||||
autoscale.sh, the LB
|
||||
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, dev LAN, mail, secrets
|
||||
db/init/ TimescaleDB init + tuning
|
||||
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
|
||||
|
|
@ -141,26 +153,26 @@ 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 stack declares
|
||||
`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 LAN dev separate on purpose. Keep both halves.
|
||||
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 beta)
|
||||
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 beta's Grafana vhost, kept for reference
|
||||
caddy-grafana.conf vps1's Grafana vhost, kept for reference
|
||||
```
|
||||
|
||||
No build, no deploy automation — it ships by hand. **The repo is the only
|
||||
|
|
@ -177,10 +189,10 @@ Deep dive: [09-observability.md](09-observability.md).
|
|||
| `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 `main`/`release` touching an app domain | Branch selects environment; matrix covers services; SSHes and runs `deploy.sh`. |
|
||||
| `infra-sync.yml` | push to `main` touching `infra/**` | Fast-forwards each host's checkout and re-renders secrets. Rolls **no** service. |
|
||||
| `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. Uses `PROD_SSH_*`. |
|
||||
| `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`. |
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ them is impossible.
|
|||
| **web** | `THERMOGRAPH_ROLE=web` | Serves requests. Never starts the notifier, even if it would win the election — so the web tier scales to N replicas without also scaling background sweeps. |
|
||||
| **worker** | `THERMOGRAPH_ROLE=worker` | Owns the subscription notifier. Still serves requests today. |
|
||||
| **all** | default | Both — the single-process dev default. |
|
||||
| **lake** | `THERMOGRAPH_ROLE=lake` | `deploy/entrypoint.sh` execs `uvicorn lake_app:app` on port 8141 instead. No database, no migrations. Prod only. |
|
||||
| **lake** | `THERMOGRAPH_ROLE=lake` | `deploy/entrypoint.sh` execs `uvicorn lake_app:app` on port 8141 instead. No database, no migrations. Runs in every environment — its own Swarm service on prod and beta (`lake` / `beta-lake`), a compose service on dev. |
|
||||
| **daemon** | compose/Swarm sets the command to `/usr/local/bin/thermograph-daemon` | The Go binary: Discord gateway + cron timers. |
|
||||
|
||||
`deploy/entrypoint.sh` runs `alembic upgrade head` (retried — a fresh Postgres
|
||||
|
|
@ -169,7 +169,7 @@ warming, also warm-only.
|
|||
|
||||
**The catch-all proxy** — the frontend owns every page and asset. In prod and
|
||||
beta, Caddy path-splits directly, so this proxy is never exercised. It exists as
|
||||
the fallback for environments with no Caddy in front (LAN dev, bare-metal). It
|
||||
the fallback for environments with no Caddy in front (dev, bare-metal). It
|
||||
forwards `X-Forwarded-Host`/`-Proto` so the frontend can build correct absolute
|
||||
URLs instead of resolving to the internal hop's own address. Because it's
|
||||
registered dead last, every real backend route wins first —
|
||||
|
|
@ -236,7 +236,8 @@ never overlaps a job with itself, and drops ticks that fire mid-run.
|
|||
|
||||
## The lake (`lake_app.py` + `data/era5lake.py`)
|
||||
|
||||
Prod-only Swarm service, same image, `THERMOGRAPH_ROLE=lake`.
|
||||
Runs as a Swarm service on both prod and beta (`lake` / `beta-lake`, same
|
||||
image, `THERMOGRAPH_ROLE=lake`), and as a compose service on dev.
|
||||
|
||||
The lake is parquet in an S3-compatible bucket (Contabo, `era5-thermograph`),
|
||||
extracted once from the public Earthmover ERA5 Icechunk archive by
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ The static mount is registered last so the explicit routes win.
|
|||
|
||||
The shell HTML files in `static/` carry an `__ORIGIN__` placeholder for the
|
||||
link-preview/Open Graph tags — preview crawlers need absolute URLs and the host
|
||||
differs between LAN and prod. The substituted HTML and its ETag are **memoized
|
||||
differs between dev and prod. The substituted HTML and its ETag are **memoized
|
||||
per origin**, not recomputed per request.
|
||||
|
||||
Origin resolution prefers `X-Forwarded-Host` over `Host`. That matters when the
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@
|
|||
## The branch model
|
||||
|
||||
```
|
||||
PR ──(required check: `gate`)──▶ dev ──▶ LAN dev box
|
||||
PR ──(required check: `gate`)──▶ dev ──▶ dev (vps1, mesh-only)
|
||||
│
|
||||
promotion PR
|
||||
▼
|
||||
main ──▶ beta.thermograph.org
|
||||
main ──▶ beta.thermograph.org (vps2)
|
||||
│
|
||||
promotion PR ← the owner's call, always
|
||||
▼
|
||||
release ──▶ thermograph.org
|
||||
release ──▶ thermograph.org (vps2)
|
||||
```
|
||||
|
||||
`dev`, `main` and `release` are protected — direct pushes are blocked for
|
||||
|
|
@ -40,9 +40,10 @@ for the separate hotfix path.
|
|||
**Commit counts are not deliverable content.** Before promoting, compare the two
|
||||
tips' *trees*. Identical trees mean the same changes are already on the target
|
||||
under different SHAs — promoting there merges nothing while still firing the
|
||||
target's deploy workflows, including `infra-sync` re-rendering
|
||||
`/etc/thermograph.env` on prod and beta. The ahead/behind numbers will look like
|
||||
real work. `promote` refuses this outright.
|
||||
target's deploy workflows, including `infra-sync` re-rendering prod's and
|
||||
beta's env files (`/etc/thermograph.env` and `/etc/thermograph-beta.env`, both
|
||||
on vps2). The ahead/behind numbers will look like real work. `promote` refuses
|
||||
this outright.
|
||||
|
||||
**Expect `dev`, `main` and `release` to be mutually divergent, and expect
|
||||
`--ff-only` to fail.** Every promotion merges the source into the target,
|
||||
|
|
@ -111,16 +112,17 @@ Triggers on pushes to `dev`/`main`/`release` touching `backend/**` or
|
|||
- The registry is mesh-only, so the runner host needs
|
||||
`10.10.0.2 git.thermograph.org` in `/etc/hosts`.
|
||||
|
||||
### `deploy.yml` — one file, two environments, two services
|
||||
### `deploy.yml` — one file, three environments, two services
|
||||
|
||||
Formerly six near-identical files. Branch selects the environment
|
||||
(`main → beta`, `release → prod`); a matrix covers the services.
|
||||
(`dev → dev`, `main → beta`, `release → prod`) — **three legs now, not two** —
|
||||
and a matrix covers the services.
|
||||
|
||||
The style is **deliberately boring**: no dynamic `fromJSON` matrix, no
|
||||
`cond && secrets.A || secrets.B` ternary. Those are GitHub idioms a Forgejo/act
|
||||
runner may evaluate differently, and the failure mode here is "production does
|
||||
not deploy" or, worse, "deploys with an empty SSH host". So the two environments
|
||||
get two explicit, mutually exclusive steps and the tag is computed in shell.
|
||||
not deploy" or, worse, "deploys with an empty SSH host". So each environment
|
||||
gets its own explicit, mutually exclusive step and the tag is computed in shell.
|
||||
|
||||
Preserved from the originals, all load-bearing:
|
||||
|
||||
|
|
@ -129,11 +131,34 @@ Preserved from the originals, all load-bearing:
|
|||
- 12-hex truncation matching `build-push.yml` exactly.
|
||||
- Per-service, per-environment concurrency with **`cancel-in-progress: false`** —
|
||||
a half-finished deploy must never be cancelled by a newer one.
|
||||
- **Separate `PROD_SSH_*` credentials**, so a beta credential leak cannot reach
|
||||
prod.
|
||||
- `appleboy/ssh-action` referenced by full URL; it isn't mirrored in Forgejo's
|
||||
default action registry.
|
||||
|
||||
**SSH credentials are keyed by HOST, not by environment**: `VPS1_SSH_*` and
|
||||
`VPS2_SSH_*`, replacing the old `SSH_*` (which actually meant beta) and
|
||||
`PROD_SSH_*` (which actually meant prod). That old naming used to read as a
|
||||
security boundary — "separate `PROD_SSH_*` credentials, so a beta credential
|
||||
leak cannot reach prod" — and on the old topology it happened to be true,
|
||||
because beta and prod were also on separate hosts. They no longer are: beta and
|
||||
prod are now two co-resident Swarm stacks on vps2, so a `VPS2_SSH_*` credential
|
||||
reaches both by construction, and there is no host-level SSH boundary between
|
||||
them to preserve. The boundary that replaces it lives at two other levels
|
||||
instead: the **database** (beta connects as its own `NOSUPERUSER` role to its
|
||||
own database, `CONNECT` revoked from `PUBLIC` on prod's) and the **filesystem**
|
||||
(separate checkouts, separate rendered env files, separate deploy locks —
|
||||
`env-topology.sh` derives all of it so nothing is shared by accident).
|
||||
|
||||
The credential boundary that *is* still real, and still matters, is
|
||||
**vps1-vs-vps2**: vps1 runs Forgejo, its own CI, and whatever's currently on
|
||||
`dev` — unreviewed by definition, since `dev` is exactly the branch PRs land on
|
||||
before review gates them into `main`. That is precisely why the `dev`
|
||||
environment renders `dev.yaml` alone and never layers `common.yaml` (the
|
||||
fleet's shared production credentials, including a read-write S3 keypair and
|
||||
the VAPID push-signing key) — see [Infra and secrets](08-infra-secrets.md). A
|
||||
`VPS1_SSH_*` leak should never be able to reach a production credential, which
|
||||
is a property `common.yaml`'s dev exclusion buys independently of which SSH key
|
||||
was used to get there.
|
||||
|
||||
There's also a per-leg refinement: the workflow-level `paths` filter only says
|
||||
*backend or frontend moved*, so each leg re-checks whether **this** push touched
|
||||
**this** domain. Without it a backend-only push would also roll the frontend,
|
||||
|
|
@ -144,17 +169,20 @@ skipping silently strands a change.
|
|||
|
||||
### `infra-sync.yml` — infra's own pipeline
|
||||
|
||||
Push to `main` touching `infra/**` → SSH to beta **and** prod, fast-forward
|
||||
`/opt/thermograph`, re-render `/etc/thermograph.env` from the vault.
|
||||
Push to `main` touching `infra/**` → SSH to vps2, fast-forward **both**
|
||||
checkouts there (`/opt/thermograph` for prod, `/opt/thermograph-beta` for
|
||||
beta), re-render each one's own env file from the vault.
|
||||
|
||||
**Rolls no service.** Image tags are the app domains' axis, not infra's. A
|
||||
compose change that must recreate containers takes effect on the next app
|
||||
deploy, or on a by-hand `SERVICE=all … infra/deploy/deploy.sh`.
|
||||
compose or stack change that must recreate containers takes effect on the next
|
||||
app deploy, or on a by-hand `SERVICE=all … infra/deploy/deploy.sh` (or
|
||||
`deploy-dev.sh` for dev).
|
||||
|
||||
Note the asymmetry: app code *is* environment-staged (`dev`→`main`→`release`
|
||||
maps to LAN→beta→prod via image tags); **infra is not** — both hosts track infra
|
||||
via `main`. Prod's *app images* are staged by `release`, but its checkout
|
||||
follows `main`.
|
||||
maps to dev(vps1)→beta→prod(vps2) via image tags); **infra is not** — beta's and
|
||||
prod's checkouts both track infra via `main`, while dev's checkout tracks `dev`
|
||||
itself. Prod's *app images* are staged by `release`, but its checkout follows
|
||||
`main`.
|
||||
|
||||
### `observability-validate.yml`
|
||||
|
||||
|
|
@ -171,13 +199,17 @@ silent no-op is exactly what this check exists to prevent.
|
|||
|
||||
### `ops-cron.yml` — the prod backup
|
||||
|
||||
Daily at 03:00 UTC: `pg_dump` on prod, plus an IndexNow ping. Both SSH into prod
|
||||
and run inside the already-running stack.
|
||||
Daily at 03:00 UTC: `pg_dump` on prod, plus an IndexNow ping. Both SSH into
|
||||
vps2 and run inside prod's already-running stack — specifically prod's, not
|
||||
beta's, even though both now live on the same box.
|
||||
|
||||
**It uses `PROD_SSH_*`, not `SSH_*`.** An earlier revision reused `SSH_*` — so
|
||||
the "prod" backup was silently dumping *beta*, and prod had no backup at all. If
|
||||
you touch this file, verify a dump actually lands in
|
||||
`agent@prod:~/thermograph-backups/`.
|
||||
**It uses `VPS2_SSH_*`, not a beta-flavoured secret.** An earlier revision (on
|
||||
the old topology) reused the credential meant for beta's host — so the "prod"
|
||||
backup was silently dumping *beta*, and prod had no backup at all. On vps2's
|
||||
co-residency, the host-keyed secret alone doesn't disambiguate prod from beta
|
||||
the way it used to; the script also needs to target prod's checkout and `db`
|
||||
specifically. If you touch this file, verify a dump actually lands in
|
||||
`agent@vps2:~/thermograph-backups/` and that it's prod's data, not beta's.
|
||||
|
||||
### `secrets-guard.yml` and `shell-lint.yml` — the backstops
|
||||
|
||||
|
|
@ -199,7 +231,8 @@ guard.
|
|||
|
||||
## Runner facts
|
||||
|
||||
- Jobs run on the `docker` label — the LAN box's Forgejo Actions runner.
|
||||
- Jobs run on the `docker` label — vps1's Forgejo Actions runner (vps1 is where
|
||||
Forgejo itself lives; the desktop no longer runs a runner).
|
||||
- Job containers get the **host's docker.sock** automounted
|
||||
(Docker-outside-of-Docker, no privileged mode). The job image
|
||||
(`node:20-bookworm`) ships no docker CLI, which is why several workflows
|
||||
|
|
|
|||
|
|
@ -7,23 +7,36 @@ Read [`infra/CLAUDE.md`](../../infra/CLAUDE.md) alongside this.
|
|||
|
||||
## The machines
|
||||
|
||||
| Host | Public | Mesh | Orchestrator | Login |
|
||||
Two VPS boxes named by **role**, not by environment, plus the operator's
|
||||
desktop:
|
||||
|
||||
| Host | Public | Mesh | Runs | Login |
|
||||
|---|---|---|---|---|
|
||||
| **prod** | `169.58.46.181` / thermograph.org | `10.10.0.1` | Docker **Swarm** | `agent`, passwordless sudo |
|
||||
| **beta** | `75.119.132.91` / beta.thermograph.org | `10.10.0.2` | **compose** | `agent`, passwordless sudo |
|
||||
| **desktop** | — | `10.10.0.3` | compose (LAN dev) | it's your box |
|
||||
| **vps1** | `75.119.132.91` / git.thermograph.org, dashboard.thermograph.org | `10.10.0.2` | Forgejo, Grafana + Loki + Alloy, `emigriffith.dev`, and **dev** (own Postgres, compose, mesh-only) | `agent`, passwordless sudo |
|
||||
| **vps2** | `169.58.46.181` / thermograph.org, beta.thermograph.org | `10.10.0.1` | **prod** and **beta**, as two co-resident Docker **Swarm** stacks; Centralis; Postfix; backups | `agent`, passwordless sudo |
|
||||
| **desktop** | — | `10.10.0.3` | AI-model hosting + flex Swarm capacity. No Thermograph environment; `make dev-up` is a laptop convenience there | it's your box |
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@169.58.46.181 # prod
|
||||
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@75.119.132.91 # beta
|
||||
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@169.58.46.181 # vps2 (prod + beta)
|
||||
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@75.119.132.91 # vps1 (Forgejo + Grafana + dev)
|
||||
```
|
||||
|
||||
Beta also hosts **Forgejo** (git + CI + registry) and **Grafana + Loki**, which
|
||||
is why beta is guarded as strictly as prod: a destructive command there takes
|
||||
out git, CI and the registry at once. The LAN dev box is deliberately
|
||||
unguarded.
|
||||
The mesh IPs did not move when this topology landed — vps1 is the box that used
|
||||
to be called "beta" (still mesh `10.10.0.2`), vps2 the one that used to be
|
||||
called "prod" (still mesh `10.10.0.1`). What moved is which environment runs on
|
||||
which box: beta moved from vps1 onto vps2 to sit next to prod, and dev moved
|
||||
onto vps1.
|
||||
|
||||
Every root-effective command on the VPSes is logged by `auditd`
|
||||
**vps1 is guarded as strictly as vps2**, not more loosely because it "used to
|
||||
be beta" or because dev now lives there. It hosts Forgejo, its CI runner and
|
||||
the registry, plus Grafana/Loki — a destructive command there takes out git, CI
|
||||
and the registry at once. Dev being mesh-only and a public VPS's tenant, rather
|
||||
than someone's desktop, is exactly why it gets the same guard as everything
|
||||
else on that box: it shares a kernel with the estate's source of truth for code
|
||||
and the monitoring stack, sudo there is passwordless, and it runs whatever
|
||||
branch is currently in flight.
|
||||
|
||||
Every root-effective command on both VPSes is logged by `auditd`
|
||||
(`ausearch -k agentcmd`). That's a feature — fixes are attributable.
|
||||
|
||||
Prefer Centralis for routine work (`run_on_host`, `fleet_status`,
|
||||
|
|
@ -31,20 +44,36 @@ Prefer Centralis for routine work (`run_on_host`, `fleet_status`,
|
|||
|
||||
## The two orchestrators
|
||||
|
||||
Which path a host takes is decided by **`/etc/thermograph/deploy-mode`**: the
|
||||
string `stack` makes `deploy.sh` exec `deploy/stack/deploy-stack.sh`; anything
|
||||
else is compose. The workflows never need to know which mode a host runs.
|
||||
Which path an environment takes is decided by **`infra/deploy/env-topology.sh`**
|
||||
(`thermograph_topology <env>`, keyed on `THERMOGRAPH_ENV=dev|beta|prod`): it
|
||||
derives the checkout, branch, deploy mode, stack/compose name, env file, LB
|
||||
ports and DB role for that environment, and `deploy.sh` execs
|
||||
`deploy/stack/deploy-stack.sh` when the mode is `stack`, or rolls compose
|
||||
otherwise. The old host-wide `/etc/thermograph/deploy-mode` marker still exists
|
||||
as a **fallback** for a by-hand run on a box that predates this file, but it
|
||||
cannot describe vps2 on its own — vps2 runs two environments, so "which mode
|
||||
does this host use" is no longer a well-formed question there.
|
||||
|
||||
| | compose (beta, LAN dev) | Swarm (prod) |
|
||||
| | compose (dev) | Swarm (prod, beta) |
|
||||
|---|---|---|
|
||||
| File | `infra/docker-compose.yml` | `infra/deploy/stack/thermograph-stack.yml` |
|
||||
| Services | `db`, `backend`, `lake`, `daemon`, `frontend` | `db`, `web`, `worker`, `lake`, `daemon`, `frontend`, `autoscaler`, `autoscaler-lake` |
|
||||
| Host | vps1 | vps2, both stacks co-resident |
|
||||
| File | `infra/docker-compose.yml` | `infra/deploy/stack/thermograph-stack.yml` (prod), `thermograph-beta-stack.yml` (beta) |
|
||||
| Services | `db`, `backend`, `lake`, `daemon`, `frontend` | prod: `db`, `web`, `worker`, `lake`, `daemon`, `frontend`, `autoscaler`, `autoscaler-lake`. beta: `beta-web`, `beta-worker`, `beta-lake`, `beta-daemon`, `beta-frontend` — prefixed, no `db`, no autoscalers |
|
||||
| Rolling | `up -d --no-deps <targets>` | start-first, health-gated, auto-rollback |
|
||||
| Tag file | `deploy/.image-tags.env` | `deploy/.stack-image-tags.env` |
|
||||
|
||||
Note prod splits `backend` into **`web`** and **`worker`** (the
|
||||
`THERMOGRAPH_ROLE` split from [04](04-backend.md)); compose runs one `backend`
|
||||
service in role `all`.
|
||||
`THERMOGRAPH_ROLE` split from [04](04-backend.md)); dev's compose stack runs one
|
||||
`backend` service in role `all`. Beta's Swarm services are prefixed
|
||||
(`beta-web`, not `web`) because Swarm registers a service's short name as a DNS
|
||||
alias on every network it joins — two stacks both naming a service `web` on
|
||||
the shared `data` network would make `web` ambiguous, and beta joins that
|
||||
network (declared `external` in its stack file) purely to reach prod's `db`.
|
||||
Beta keeps its own `internal` overlay for beta-to-beta traffic and has no `db`
|
||||
service of its own: **one TimescaleDB instance serves both environments**, on
|
||||
separate databases (`thermograph` / `thermograph_beta`) and separate roles
|
||||
(beta's is `NOSUPERUSER`/`NOCREATEDB`/`NOCREATEROLE`, `CONNECT` revoked from
|
||||
`PUBLIC` on prod's database).
|
||||
|
||||
`STACK_TEST=1` rehearses the entire Swarm deploy under stack name
|
||||
`thermograph-test` on throwaway volumes and ports `18137`/`18080`. Leftover
|
||||
|
|
@ -58,11 +87,15 @@ coupling is why `name: thermograph` is pinned in the compose file — running
|
|||
compose from `infra/` without it derives project `infra`, silently creating a
|
||||
whole new stack with empty volumes beside the running one. `deploy-dev.sh`
|
||||
exports `COMPOSE_PROJECT_NAME=thermograph-dev` (env wins over the file key) to
|
||||
keep LAN dev separate on purpose. **Keep both halves.**
|
||||
keep dev separate on purpose. **Keep both halves.**
|
||||
|
||||
## `deploy/deploy.sh` — read this before you touch a deploy
|
||||
|
||||
Single entry point for beta and prod. Contract:
|
||||
Single entry point for dev, beta and prod — the same script for all three;
|
||||
`infra/deploy/env-topology.sh` is what tells it which checkout, branch, stack
|
||||
and ports apply. Contract, run from that environment's own checkout
|
||||
(`/opt/thermograph` for prod, `/opt/thermograph-beta` for beta, both on vps2;
|
||||
`/opt/thermograph-dev` on vps1 via the `deploy-dev.sh` wrapper):
|
||||
|
||||
```bash
|
||||
SERVICE=backend BACKEND_IMAGE_TAG=sha-<12hex> /opt/thermograph/infra/deploy/deploy.sh
|
||||
|
|
@ -76,13 +109,17 @@ What it does, in order, and why each step is the way it is:
|
|||
the whole run. Backend and frontend deploys can fire for the same push
|
||||
seconds apart and both SSH into one checkout; concurrent runs race the git
|
||||
reset, the compose project and the tag file. `-w 600` bounds the wait.
|
||||
2. **Render secrets** from the SOPS vault into `/etc/thermograph.env`, then
|
||||
source it, so a by-hand run interpolates the same as the systemd unit does.
|
||||
Guarded on the helper's existence so the very deploy that *introduces*
|
||||
`render-secrets.sh` is safe.
|
||||
2. **Render secrets** from the SOPS vault into that environment's own env file
|
||||
(`/etc/thermograph.env` for prod, `/etc/thermograph-beta.env` for beta,
|
||||
likewise `/etc/thermograph.env` on dev's separate host), then source it, so
|
||||
a by-hand run interpolates the same as CI's does. Guarded on the helper's
|
||||
existence so the very deploy that *introduces* `render-secrets.sh` is safe.
|
||||
3. **`git reset --hard origin/$BRANCH`** on the checkout root (`BRANCH` defaults
|
||||
to `main`). ⚠️ **Uncommitted edits in `/opt/thermograph` evaporate here.**
|
||||
The one exception is `deploy/.image-tags.env`, untracked on purpose.
|
||||
to `main` for beta/prod, `dev` for dev). ⚠️ **Uncommitted edits in that
|
||||
checkout evaporate here** — on vps2 that's `/opt/thermograph` for a prod
|
||||
deploy or `/opt/thermograph-beta` for a beta one; each reset only ever
|
||||
touches its own checkout, never the sibling's. The one exception is
|
||||
`deploy/.image-tags.env`, untracked on purpose.
|
||||
4. **Read `.image-tags.env`** so a single-service roll re-renders compose with
|
||||
*both* services' real tags and never accidentally recreates or downgrades the
|
||||
sibling.
|
||||
|
|
@ -115,12 +152,15 @@ What it does, in order, and why each step is the way it is:
|
|||
|
||||
Rollback is redeploying the previous image tag. But **do not treat image
|
||||
retention as a rollback guarantee**: both `deploy.sh` and `deploy-stack.sh`
|
||||
*end* by deleting images outside the running pair. On beta that succeeds, so
|
||||
beta typically holds **no local rollback target at all**; on prod it usually
|
||||
fails only because Swarm's stopped task containers still hold references, which
|
||||
is why prod keeps a handful. Verify the target tag is actually present before
|
||||
promising a rollback — Centralis's `rollback_to(dry_run: true)` checks both host
|
||||
and registry.
|
||||
*end* by deleting images outside the running pair. Beta now runs Swarm exactly
|
||||
like prod (it used to be compose, on the old topology, where this pruning
|
||||
reliably left no rollback target at all); on both Swarm stacks it usually
|
||||
fails only because Swarm's stopped task containers still hold references,
|
||||
which is why prod and beta both tend to keep a handful. Dev, the one remaining
|
||||
compose environment, is the one where this prune still reliably succeeds and
|
||||
leaves nothing to roll back to. Verify the target tag is actually present
|
||||
before promising a rollback — Centralis's `rollback_to(dry_run: true)` checks
|
||||
both host and registry.
|
||||
|
||||
`docker system prune -a` on a live box is forbidden for the same reason.
|
||||
`docker image prune -f` (dangling only) is the safe form.
|
||||
|
|
@ -129,55 +169,81 @@ and registry.
|
|||
|
||||
**The single source of truth is `infra/deploy/secrets/*.yaml`**, committed
|
||||
encrypted (values only — keys stay readable so diffs mean something) and
|
||||
rendered into `/etc/thermograph.env` at deploy time by
|
||||
`deploy/render-secrets.sh`.
|
||||
rendered at deploy time by `deploy/render-secrets.sh` into each environment's
|
||||
own env file (`/etc/thermograph.env` for prod, `/etc/thermograph-beta.env` for
|
||||
beta, `/etc/thermograph.env` again on dev's separate host).
|
||||
|
||||
**Cycling a key is: edit → commit → deploy.** No SSH, no hand-edited root file,
|
||||
no per-host duplication.
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `common.yaml` | the 16 values identical on prod **and** beta — VAPID keypair, metrics token, IndexNow key, `REGISTRY_TOKEN`, S3 endpoint/bucket and both S3 keypairs, plus shared non-secret config |
|
||||
| `common.yaml` | the 16 values identical on prod **and** beta — VAPID keypair, metrics token, IndexNow key, `REGISTRY_TOKEN`, S3 endpoint/bucket and both S3 keypairs, plus shared non-secret config. **Never layered under `dev`** — see below |
|
||||
| `prod.yaml` | prod's own — the three held-back credentials, sizing, `THERMOGRAPH_BASE_URL`, Discord + mail credentials that exist nowhere else |
|
||||
| `beta.yaml` | beta's own — the three held-back credentials, sizing, base URL |
|
||||
| `dev.yaml` | the LAN box's own, **self-contained**, 12 values, no production credential |
|
||||
| `dev.yaml` | dev's own, **self-contained**, 12 values, no production credential |
|
||||
| `centralis.prod.yaml` | Centralis's nine variables → `/etc/centralis.env`, its own renderer |
|
||||
| `example.yaml` | format reference / CI fixture (fake values) |
|
||||
| `../../.sops.yaml` | which age recipients files encrypt to (plaintext config) |
|
||||
|
||||
The renderer concatenates `common.yaml` then `<env>.yaml`, so a **host value
|
||||
wins** (last occurrence of a duplicate key). `<env>` comes from
|
||||
`/etc/thermograph/secrets-env` on the box.
|
||||
wins** (last occurrence of a duplicate key). `<env>` is passed explicitly as
|
||||
`THERMOGRAPH_ENV` by the deploy caller — required now that vps2 alone hosts two
|
||||
environments and a host-wide marker can't tell them apart; the marker
|
||||
(`/etc/thermograph/secrets-env`) survives only as the fallback for a by-hand
|
||||
run on a single-environment box.
|
||||
|
||||
### Two design decisions worth understanding
|
||||
### Two design decisions worth understanding — re-derived for vps1/vps2
|
||||
|
||||
**Three credentials are deliberately *not* in `common.yaml`** —
|
||||
`POSTGRES_PASSWORD`, `THERMOGRAPH_AUTH_SECRET`, `THERMOGRAPH_DATABASE_URL`.
|
||||
They hold identical values on prod and beta today, so by the mechanical rule
|
||||
they belong there. They're kept per-host anyway because these are the
|
||||
credentials that let one environment *act as* another: with them, a foothold on
|
||||
beta (the more exposed box — public Forgejo and Grafana) is a foothold on prod's
|
||||
database and prod's session signing. They match because beta was seeded from
|
||||
prod, not because the two are meant to be one system. Keeping them per-host
|
||||
costs one extra line and buys the ability to diverge.
|
||||
They hold identical *values* on prod and beta today (beta was seeded from
|
||||
prod), so by the mechanical rule they'd belong in `common.yaml`. They're kept
|
||||
per-host anyway, and the reason used to be phrased as a host-isolation
|
||||
argument — "a foothold on beta is a foothold on prod's database, and beta is
|
||||
the more exposed box." **That framing no longer holds**: beta and prod are now
|
||||
two Swarm stacks co-resident on vps2, so a foothold on the host reaches both
|
||||
regardless of which file a credential lives in. What these three credentials
|
||||
still buy, correctly stated, is a **database-level** and **file-level**
|
||||
boundary, not a host-level one: beta's `THERMOGRAPH_DATABASE_URL` names its own
|
||||
`NOSUPERUSER` role (`thermograph_beta`) against its own database on the shared
|
||||
TimescaleDB instance, with `CONNECT` revoked from `PUBLIC` on prod's database,
|
||||
and each environment's `POSTGRES_PASSWORD`/`THERMOGRAPH_AUTH_SECRET` live in a
|
||||
separate file so rotating prod's stops implying "and beta's too." Keeping them
|
||||
per-host costs one extra line and buys the ability to diverge — that part is
|
||||
unchanged. What changed is what "diverge" is defending against: not a beta
|
||||
compromise reaching a separate prod host, but a beta compromise reaching
|
||||
prod's role and session-signing key on the host they now already share.
|
||||
|
||||
**`dev` renders `dev.yaml` alone** — `deploy-dev.sh` exports
|
||||
`THERMOGRAPH_SECRETS_SKIP_COMMON=1`. Eleven of `common.yaml`'s sixteen values
|
||||
are live production credentials, including a **read-write** object-storage
|
||||
keypair on the bucket that also holds prod's database backups, and the VAPID
|
||||
private key that signs push to real subscribers. The render is plaintext
|
||||
concatenation, so layering it would put all of those into every container in the
|
||||
dev stack — on the operator's desktop, which is also the CI runner whose
|
||||
`docker`-labelled jobs get the host socket, running unreviewed `dev` code. An
|
||||
**The credential boundary that actually maps to hosts now is vps1-vs-vps2, not
|
||||
beta-vs-prod** — which is exactly why `dev` renders `dev.yaml` **alone**,
|
||||
never layering `common.yaml` (the fleet's shared production credentials):
|
||||
`deploy-dev.sh` exports `THERMOGRAPH_SECRETS_SKIP_COMMON=1`. Eleven of
|
||||
`common.yaml`'s sixteen values are live production credentials, including a
|
||||
**read-write** object-storage keypair on the bucket that also holds prod's
|
||||
database backups, and the VAPID private key that signs push to real
|
||||
subscribers. The render is plaintext concatenation, so layering it would put
|
||||
all of those into every container in the dev stack — on vps1, the box that
|
||||
also runs Forgejo and its CI runner, whose `docker`-labelled jobs get the host
|
||||
socket, running whatever branch is currently `dev` (unreviewed by definition:
|
||||
that's the branch PRs land on before the gate promotes them to `main`). An
|
||||
override in `dev.yaml` wouldn't help: last-wins governs *consumers*, but the
|
||||
production value is still physically a line in the file.
|
||||
production value is still physically a line in the file. This is the load-
|
||||
bearing boundary in the vault today, not a per-environment credential split
|
||||
that co-residency has already dissolved at the host level.
|
||||
|
||||
Dev works fine without them: no S3 keys means the `lake` service answers 503 and
|
||||
history falls through to the Open-Meteo archive (an accelerator, never a
|
||||
dependency); VAPID and IndexNow both self-generate and persist to the `appdata`
|
||||
volume; with no metrics token `/api/v2/metrics` is direct-loopback-only, which
|
||||
is right on a LAN box. One `common.yaml` value is simply *wrong* for dev —
|
||||
`THERMOGRAPH_COOKIE_SECURE=1` silently breaks login over plain HTTP.
|
||||
Dev works fine without `common.yaml`'s values: no S3 keys means the `lake`
|
||||
service answers 503 and history falls through to the Open-Meteo archive (an
|
||||
accelerator, never a dependency); VAPID and IndexNow both self-generate and
|
||||
persist to the `appdata` volume; with no metrics token `/api/v2/metrics` is
|
||||
direct-loopback-only, which is right for a mesh-only box. One `common.yaml`
|
||||
value is simply *wrong* for dev — `THERMOGRAPH_COOKIE_SECURE=1` silently breaks
|
||||
login over dev's plain-HTTP mesh URL.
|
||||
|
||||
None of this makes dev unimportant or unguarded. It's a public VPS's tenant
|
||||
now, sharing a box with Forgejo and the monitoring stack — not somebody's
|
||||
desktop — which is precisely why it gets its own exclusion from `common.yaml`
|
||||
rather than being treated as beneath the vault's concern.
|
||||
|
||||
### Working with the vault
|
||||
|
||||
|
|
@ -223,12 +289,14 @@ infra/ops/iceberg.sh prod -c "select count(*) from era5_daily"
|
|||
```
|
||||
|
||||
Both exec **into the container**. No database is exposed over TCP — each listens
|
||||
only on its private docker network, and prod's is a Swarm **overlay the prod
|
||||
host itself cannot route to**, so `ssh -L` works for beta and is *impossible*
|
||||
for prod. Exec works identically everywhere with no ports, no tunnels, no infra
|
||||
changes. The prod container name is a Swarm task name that changes on every
|
||||
redeploy, so it's resolved at call time via `docker ps --filter name=`, never
|
||||
hardcoded.
|
||||
only on its private docker network, and prod's and beta's are both Swarm
|
||||
**overlays the vps2 host itself cannot route to**, so `ssh -L` is *impossible*
|
||||
for either. (Dev's, on vps1, is plain compose, so `ssh -L` would actually work
|
||||
there — but exec is used uniformly across environments anyway, so it doesn't
|
||||
matter which one happens to allow the shortcut.) Exec works identically
|
||||
everywhere with no ports, no tunnels, no infra changes. The prod and beta
|
||||
container names are Swarm task names that change on every redeploy, so they're
|
||||
resolved at call time via `docker ps --filter name=`, never hardcoded.
|
||||
|
||||
Queries connect as **`thermograph_ro`** — `NOSUPERUSER`, granted only
|
||||
`pg_read_all_data`. Read-only is enforced by Postgres, not by convention:
|
||||
|
|
@ -244,8 +312,9 @@ confirmation — on prod, know what you are doing.
|
|||
|
||||
## Backups
|
||||
|
||||
The nightly `ops-cron.yml` `pg_dump` into `agent@prod:~/thermograph-backups/`
|
||||
is it. Known gaps, stated plainly: **a single copy on the same box as the
|
||||
The nightly `ops-cron.yml` `pg_dump` into `agent@vps2:~/thermograph-backups/`
|
||||
(prod's data specifically — see [CI and release](07-ci-and-release.md)) is it.
|
||||
Known gaps, stated plainly: **a single copy on the same box as the
|
||||
database, no offsite yet**, and restores must handle TimescaleDB's
|
||||
`continuous_agg` circular-FK warning (`--disable-triggers`). The `backups/`
|
||||
prefix in the object-storage bucket belongs to those jobs — don't write outside
|
||||
|
|
|
|||
|
|
@ -1,26 +1,34 @@
|
|||
# 9. Observability
|
||||
|
||||
Fleet-wide log aggregation: **Loki + Grafana on beta**, fed by a **Grafana
|
||||
Alloy** agent on every node, all over the WireGuard mesh.
|
||||
Fleet-wide log aggregation: **Loki + Grafana on vps1**, fed by a **Grafana
|
||||
Alloy** agent on every node, all over the WireGuard mesh. vps2 runs two
|
||||
environments (beta, prod) behind that one Alloy agent; vps1 runs Alloy
|
||||
alongside the things it's shipping logs *from* (Forgejo, Grafana itself, dev).
|
||||
|
||||
```
|
||||
prod (10.10.0.1) beta (10.10.0.2) desktop / LAN dev (10.10.0.3)
|
||||
vps2 (10.10.0.1) vps1 (10.10.0.2) desktop (10.10.0.3)
|
||||
prod + beta Loki, Grafana, dev
|
||||
┌────────────┐ ┌──────────────────┐ ┌────────────┐
|
||||
│ Alloy agent│──wg0──┐ │ Loki ◀── Alloy │ ┌───│ Alloy agent│
|
||||
└────────────┘ └──▶│ Grafana (Caddy) │◀─┘ └────────────┘
|
||||
docker+caddy+app └──────────────────┘ docker+caddy+app
|
||||
docker+caddy+app └──────────────────┘ docker (AI models)
|
||||
```
|
||||
|
||||
- **Loki** — filesystem storage, **30-day retention**, listening on the mesh IP
|
||||
`10.10.0.2:3100`. Never public.
|
||||
- **Grafana** — the UI, fronted by beta's Caddy at
|
||||
- **Grafana** — the UI, fronted by vps1's Caddy at
|
||||
**`dashboard.thermograph.org`** (Google SSO, pre-provisioned accounts only,
|
||||
`allow_sign_up=false`, plus a break-glass local admin). Use that hostname
|
||||
everywhere; **never** `grafana.thermograph.org`.
|
||||
- **Alloy** — on each node, shipping three sources: every Docker container's
|
||||
stdout/stderr, Caddy's host access logs, and the app's structured JSON logs
|
||||
(`errors`/`access`/`audit` `*.jsonl`, parsed so `level`/`tag`/`phase` become
|
||||
labels). Every line is tagged `host = prod|beta|dev`.
|
||||
- **Alloy** — one agent per *host*, not per environment: vps2's single agent
|
||||
ships logs for both beta and prod, vps1's ships Forgejo/Grafana/dev, and the
|
||||
desktop's ships whatever runs there now. Each ships three sources: every
|
||||
Docker container's stdout/stderr, Caddy's host access logs, and the app's
|
||||
structured JSON logs (`errors`/`access`/`audit` `*.jsonl`, parsed so
|
||||
`level`/`tag`/`phase` become labels). Every app log line is tagged
|
||||
`host = prod|beta|dev` — that label is the *environment*, not the box, which
|
||||
matters on vps2 where one Alloy agent ships lines carrying two different
|
||||
`host` values.
|
||||
|
||||
There is **no build and no deploy automation** for this domain — it ships by
|
||||
hand. But `observability-validate.yml` parses every artifact that ships to a
|
||||
|
|
@ -42,20 +50,24 @@ Start wide, then narrow:
|
|||
2. `logs_overview since="1h"` — where the volume is, and what has gone quiet.
|
||||
3. `logs_query service=… host=…` — the actual lines.
|
||||
|
||||
### ⚠ The prod trap
|
||||
### ⚠ The prod (and now beta) trap
|
||||
|
||||
Prod runs a Swarm stack, and its `job="docker"` streams carry **no `service`
|
||||
label**. A raw `{host="prod", service="backend"}` query matches nothing and
|
||||
reads as *"no logs"* when it actually means *"wrong selector"*.
|
||||
Prod and beta both run Swarm stacks — co-resident on vps2 — and their
|
||||
`job="docker"` streams carry **no `service` label**. A raw
|
||||
`{host="prod", service="backend"}` query matches nothing and reads as *"no
|
||||
logs"* when it actually means *"wrong selector"*. This used to be a prod-only
|
||||
trap when beta ran compose; it now applies to both environments on vps2, and
|
||||
dev (compose, on vps1) is the one environment this does *not* affect.
|
||||
|
||||
Use Centralis's `logs_query` `service` argument, which rewrites it to a
|
||||
container name-regex with the churning task suffix wildcarded. Prod service
|
||||
names are `thermograph_web`, `_worker`, `_frontend`, `_db`, `_autoscaler`,
|
||||
`_lake`.
|
||||
`_lake`; beta's are the same names prefixed `beta-` (`beta-web`, `beta-worker`,
|
||||
…) with no `_db` or autoscaler equivalents.
|
||||
|
||||
Related: **prod container names change on every redeploy** (the Swarm task
|
||||
suffix). Never hardcode one; resolve via `docker ps --filter name=` at call
|
||||
time.
|
||||
Related: **prod and beta container names both change on every redeploy** (the
|
||||
Swarm task suffix). Never hardcode one; resolve via `docker ps --filter name=`
|
||||
at call time.
|
||||
|
||||
## The dashboard
|
||||
|
||||
|
|
@ -81,16 +93,16 @@ Three reasons, all learned the hard way:
|
|||
- Grafana's factory default pointed at the literal string
|
||||
`<example@email.com>` — a paging path that had never delivered a message to
|
||||
anyone.
|
||||
- Beta's Grafana relays SMTP through **prod's** Postfix. Email alerts therefore
|
||||
travel through the box most likely to be on fire, and vanish exactly when they
|
||||
matter.
|
||||
- vps1's Grafana relays SMTP through **vps2's** Postfix (prod's mail service).
|
||||
Email alerts therefore travel through the box most likely to be on fire, and
|
||||
vanish exactly when they matter.
|
||||
- Discord is off-estate, works when prod is dead, and reaches a phone.
|
||||
|
||||
`#ops-alerts` is dedicated. `#dev` / `#uat` / `#prod` are the *product's* alert
|
||||
subscription output — real deliveries to real subscribers — so routing ops noise
|
||||
there would corrupt the evidence.
|
||||
|
||||
The webhook URL is a secret and lives only in beta's gitignored `.env` as
|
||||
The webhook URL is a secret and lives only in vps1's gitignored `.env` as
|
||||
`DISCORD_ALERT_WEBHOOK_URL`. Grafana refuses to start if it's unset. A literal
|
||||
webhook URL committed to the repo is a **hard CI failure**.
|
||||
|
||||
|
|
@ -135,7 +147,7 @@ pages nobody. **The only proof is a message actually arriving in
|
|||
## Deploying an observability change
|
||||
|
||||
> ⚠️ **Merging is not deploying — and this one has already bitten.**
|
||||
> `/opt/observability` on beta and prod is a checkout of the **archived**
|
||||
> `/opt/observability` on vps1 and vps2 is a checkout of the **archived**
|
||||
> `emi/thermograph-observability` repo. It can never `git pull` again; the
|
||||
> content now lives here under `observability/`. A previous observability PR
|
||||
> merged and had **zero live effect** for exactly this reason.
|
||||
|
|
@ -146,13 +158,14 @@ hand: back up the live file with a UTC-timestamped suffix, `scp` the new one up,
|
|||
|
||||
Two gotchas in that sequence:
|
||||
|
||||
- Use `sudo docker restart <container>` on **prod** — not `docker compose`,
|
||||
- Use `sudo docker restart <container>` on **vps2** — not `docker compose`,
|
||||
which there demands a `GF_SECURITY_ADMIN_PASSWORD` it has no `.env` to
|
||||
interpolate from. Beta's stack *does* have an `.env`, so compose works there.
|
||||
interpolate from. vps1's stack *does* have an `.env`, so compose works there.
|
||||
- **`docker restart` will not pick up a new environment variable.** Any change
|
||||
that introduces one needs `docker compose up -d grafana` on beta.
|
||||
that introduces one needs `docker compose up -d grafana` on vps1.
|
||||
|
||||
Alerting lives on **beta only** — prod and dev run Alloy agents, not Grafana.
|
||||
Alerting lives on **vps1 only** — vps2 (prod and beta alike) and dev run Alloy
|
||||
agents, not Grafana.
|
||||
|
||||
Full step-by-step, including rollback, is in
|
||||
[`observability/README.md`](../../observability/README.md).
|
||||
|
|
@ -177,9 +190,9 @@ Full step-by-step, including rollback, is in
|
|||
- **Grafana cannot alert on its own death.** `AlertingWatchdog` is a *manual*
|
||||
dead-man's switch — it relies on somebody noticing the daily message stopped.
|
||||
An external uptime pinger from off-estate would close this properly.
|
||||
- **Beta runs a `docker-compose.override.yml` that is not in this repo**, wiring
|
||||
Grafana's SMTP to prod's Postfix. It should be mirrored into the tracked
|
||||
compose file or deleted; alerting no longer depends on it.
|
||||
- **vps1 runs a `docker-compose.override.yml` that is not in this repo**, wiring
|
||||
Grafana's SMTP to vps2's Postfix (prod's). It should be mirrored into the
|
||||
tracked compose file or deleted; alerting no longer depends on it.
|
||||
|
||||
## When something breaks
|
||||
|
||||
|
|
|
|||
|
|
@ -79,14 +79,17 @@ 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 `/etc/thermograph.env` on beta and prod, rolls
|
||||
nothing), or the next app deploy renders it as step 2 of `deploy.sh`.
|
||||
`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`;
|
||||
- per-host, or one of `POSTGRES_PASSWORD` / `THERMOGRAPH_AUTH_SECRET` /
|
||||
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.
|
||||
|
||||
|
|
@ -209,9 +212,11 @@ SERVICE=backend BACKEND_IMAGE_TAG=sha-<previous12hex> \
|
|||
```
|
||||
|
||||
**Check the tag actually exists first.** Both deploy scripts *end* by deleting
|
||||
images outside the running pair, so beta 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -48,19 +48,31 @@ walkthrough as superseded — but the drift goes further than that note admits.
|
|||
The **key-management** sections (Part 1) and the `/etc/hosts` registry note are
|
||||
still accurate and useful.
|
||||
|
||||
### `infra/DEPLOY-DEV.md` and `infra/ACCESS.md` reference deleted workflows
|
||||
### `infra/DEPLOY-DEV.md` and `infra/ACCESS.md` reference deleted workflows — and predate dev becoming a real environment
|
||||
|
||||
Both talk about `.forgejo/workflows/deploy-dev.yml`. It does not exist. The two
|
||||
LAN-dev deploy workflows were deleted rather than ported at the CI
|
||||
consolidation — they were documented as inert (they targeted a monorepo layout
|
||||
at `~/thermograph-dev` on a box still holding a split-era checkout). **LAN dev
|
||||
is a local `make dev-up` concern, not a CI environment.**
|
||||
Both talk about `.forgejo/workflows/deploy-dev.yml`. It does not exist under
|
||||
that name. The two original LAN-dev deploy workflows were deleted rather than
|
||||
ported at the CI consolidation — at the time they were genuinely inert (they
|
||||
targeted a monorepo layout at `~/thermograph-dev` on a box still holding a
|
||||
split-era checkout), which is where the old claim **"LAN dev is a local `make
|
||||
dev-up` concern, not a CI environment"** came from.
|
||||
|
||||
**That claim is now false, and worth flagging precisely because it used to be
|
||||
true.** `dev` is a first-class hosted environment on vps1 — its own Postgres
|
||||
container, mesh-only exposure, deployed by CI like beta and prod, as the third
|
||||
leg of `deploy.yml` (see [07](07-ci-and-release.md) and
|
||||
[08](08-infra-secrets.md)). The desktop's `make dev-up` is still a real,
|
||||
useful thing — a laptop-local rehearsal — but it is no longer the *only* way
|
||||
`dev` runs, and neither doc has been updated to say so.
|
||||
|
||||
`ACCESS.md` §3c also lists `{build,pr-build,deploy-dev,deploy}.yml` as the
|
||||
workflow set; there are now nine files and the deploy/build ones were collapsed.
|
||||
It also still describes the host table from before the vps1/vps2 rename — see
|
||||
`CUTOVER-NOTES.md`'s vps1/vps2 section for the current one.
|
||||
|
||||
Everything else in `ACCESS.md` — the host table, the agent-access model, the key
|
||||
rotation procedure, the Swarm/Forgejo tracks — is current and worth reading.
|
||||
Everything else in `ACCESS.md` — the agent-access model, the key rotation
|
||||
procedure, the Swarm/Forgejo tracks — is current and worth reading; just
|
||||
mentally translate host names.
|
||||
|
||||
### `backend/README.md` is written for the split-repo era
|
||||
|
||||
|
|
@ -93,7 +105,7 @@ it — that's the standing instruction in the root `CLAUDE.md`.
|
|||
|
||||
### `observability/README.md`: merging is not deploying
|
||||
|
||||
`/opt/observability` on beta and prod is a checkout of the **archived**
|
||||
`/opt/observability` on vps1 and vps2 is a checkout of the **archived**
|
||||
`emi/thermograph-observability` repo. It can never `git pull` again. A previous
|
||||
observability PR merged and had **zero live effect** for exactly this reason.
|
||||
The README documents the hand-ship procedure; until the checkouts are re-pointed
|
||||
|
|
@ -153,7 +165,7 @@ test run.
|
|||
`infra/docker-compose.yml` pins `name: thermograph`. Without it, running compose
|
||||
from `infra/` derives project `infra` — a silently **new** stack with empty
|
||||
volumes beside the running one. `deploy-dev.sh` exports
|
||||
`COMPOSE_PROJECT_NAME=thermograph-dev` to keep LAN dev separate on purpose.
|
||||
`COMPOSE_PROJECT_NAME=thermograph-dev` to keep dev separate on purpose.
|
||||
Keep both halves.
|
||||
|
||||
### `deploy.sh` hard-resets the host checkout
|
||||
|
|
@ -167,11 +179,13 @@ roll the sibling onto `local`.
|
|||
### The deploy scripts eat your rollback image
|
||||
|
||||
Both `deploy.sh` and `deploy-stack.sh` **end** by deleting images outside the
|
||||
running pair. On beta that succeeds, so beta typically holds **no local rollback
|
||||
target at all**; on prod it usually fails only because Swarm's stopped task
|
||||
containers still hold references. `docker system prune -a` is forbidden on a
|
||||
live box for the same reason — but the deploy script is the thing that actually
|
||||
eats the image, not prune. Verify a tag exists before promising a rollback.
|
||||
running pair. Beta now runs Swarm co-resident with prod on vps2, so both
|
||||
usually fail to fully prune only because Swarm's stopped task containers still
|
||||
hold references — dev, the one remaining compose environment (on vps1), is the
|
||||
one that typically holds **no local rollback target at all**. `docker system
|
||||
prune -a` is forbidden on a live box for the same reason — but the deploy
|
||||
script is the thing that actually eats the image, not prune. Verify a tag
|
||||
exists before promising a rollback.
|
||||
|
||||
### `daemon` and `lake` are never deploy targets on their own
|
||||
|
||||
|
|
@ -189,13 +203,14 @@ line, in file order. A `| head -1` therefore grabbed `db`'s image, and
|
|||
and dropped the daemon from *every* backend deploy. The script now builds the
|
||||
image reference from the same variables compose interpolates.
|
||||
|
||||
### Prod's Loki streams carry no `service` label
|
||||
### Prod's (and beta's) Loki streams carry no `service` label
|
||||
|
||||
Prod runs Swarm; its `job="docker"` streams have no `service` label. A raw
|
||||
`{host="prod", service="backend"}` query matches nothing and reads as "no logs"
|
||||
when it means "wrong selector". Use `logs_query`'s `service` argument. Prod
|
||||
container names also change on every redeploy (the task suffix) — resolve via
|
||||
`docker ps --filter name=` at call time, never hardcode.
|
||||
Prod and beta both run Swarm, co-resident on vps2; their `job="docker"` streams
|
||||
have no `service` label. A raw `{host="prod", service="backend"}` query matches
|
||||
nothing and reads as "no logs" when it means "wrong selector". Use
|
||||
`logs_query`'s `service` argument. Prod and beta container names also change on
|
||||
every redeploy (the task suffix) — resolve via `docker ps --filter name=` at
|
||||
call time, never hardcode. Dev, on compose, doesn't have this problem.
|
||||
|
||||
### A Grafana alert rule can provision cleanly and never fire
|
||||
|
||||
|
|
@ -212,9 +227,9 @@ It's executable documentation until state is bootstrapped.
|
|||
|
||||
### The registry is mesh-only
|
||||
|
||||
`git.thermograph.org` resolves publicly to beta's IP, but beta's Caddy rejects
|
||||
`git.thermograph.org` resolves publicly to vps1's IP, but vps1's Caddy rejects
|
||||
`/v2/*` from outside the WireGuard mesh. Any host that pulls needs
|
||||
`10.10.0.2 git.thermograph.org` in `/etc/hosts`. Beta itself doesn't — it *is*
|
||||
`10.10.0.2 git.thermograph.org` in `/etc/hosts`. vps1 itself doesn't — it *is*
|
||||
the box.
|
||||
|
||||
### Forgejo runners only have the labels they registered with
|
||||
|
|
|
|||
|
|
@ -42,12 +42,13 @@ readings.
|
|||
the superseded original — see [traps](11-traps.md).)
|
||||
- **`infra/`** — compose and Swarm files, deploy scripts, the SOPS secrets
|
||||
vault, Terraform, ops query tooling.
|
||||
- **`observability/`** — Loki + Grafana on beta, an Alloy agent per node.
|
||||
- **`observability/`** — Loki + Grafana on vps1, an Alloy agent per node.
|
||||
|
||||
Branches stage environments: PR → `dev` (LAN dev) → `main` (beta) → `release`
|
||||
(prod). The two app domains build and deploy **independently** — that
|
||||
independence is the whole reason the split-then-reunify history exists, and
|
||||
[contracts](06-contracts.md) is the list of things that keep it safe.
|
||||
Branches stage environments: PR → `dev` (vps1, mesh-only) → `main` (beta, vps2)
|
||||
→ `release` (prod, vps2). The two app domains build and deploy
|
||||
**independently** — that independence is the whole reason the split-then-reunify
|
||||
history exists, and [contracts](06-contracts.md) is the list of things that
|
||||
keep it safe.
|
||||
|
||||
## Your first day
|
||||
|
||||
|
|
|
|||
|
|
@ -28,12 +28,24 @@ python3 .claude/skills/key-gaps/key_gaps.py \
|
|||
|
||||
## Audit the LIVE boxes (read-only, key names only)
|
||||
|
||||
Gather the key names over SSH (never the values), then audit. Hosts/keys per INFRA.md:
|
||||
Gather the key names over SSH (never the values), then audit. Hosts per the
|
||||
root `CLAUDE.md` topology:
|
||||
|
||||
- **vps2** (`169.58.46.181`) hosts BOTH prod and beta as separate Swarm
|
||||
stacks — TWO live env files on the same box: `/etc/thermograph.env` (prod)
|
||||
and `/etc/thermograph-beta.env` (beta). "The live env file per environment"
|
||||
is no longer one path per host; it's one path per environment, and both
|
||||
environments' files live on this one host. Get both in the same SSH round
|
||||
trip, or two separate commands against the same host — never assume one
|
||||
host means one file here.
|
||||
- **vps1** (`75.119.132.91`) hosts dev, at the same `/etc/thermograph.env`
|
||||
path (it's a different host, so no collision with prod's file of the same
|
||||
name).
|
||||
|
||||
```sh
|
||||
K=~/.ssh/thermograph_agent_ed25519
|
||||
# sudo: /etc/thermograph.env is root-owned (0640). On prod `agent` can read it
|
||||
# directly too, but sudo works uniformly on both boxes.
|
||||
# sudo: the env files are root-owned (0640). On these boxes `agent` can often
|
||||
# read them directly too, but sudo works uniformly regardless.
|
||||
#
|
||||
# The character class must allow DIGITS. This was `^[A-Z_]+=` until 2026-07-24,
|
||||
# which silently skipped every key whose name contains a digit — in this estate
|
||||
|
|
@ -44,9 +56,10 @@ K=~/.ssh/thermograph_agent_ed25519
|
|||
# no audit: the invented finding sends someone to provision a credential that
|
||||
# already exists, and it was briefly recorded as a root cause of a real bug.
|
||||
# The match still stops at the `=`, so no value is ever read.
|
||||
ssh -i $K agent@169.58.46.181 'sudo grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph.env' > /tmp/prod.keys # prod
|
||||
ssh -i $K agent@75.119.132.91 'sudo grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph.env' > /tmp/beta.keys # beta
|
||||
python3 .claude/skills/key-gaps/key_gaps.py prod=/tmp/prod.keys beta=/tmp/beta.keys
|
||||
ssh -i $K agent@169.58.46.181 'sudo grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph.env' > /tmp/prod.keys # vps2: prod
|
||||
ssh -i $K agent@169.58.46.181 'sudo grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph-beta.env' > /tmp/beta.keys # vps2: beta
|
||||
ssh -i $K agent@75.119.132.91 'sudo grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph.env' > /tmp/dev.keys # vps1: dev
|
||||
python3 .claude/skills/key-gaps/key_gaps.py prod=/tmp/prod.keys beta=/tmp/beta.keys dev=/tmp/dev.keys
|
||||
```
|
||||
|
||||
## Verify a SOPS cutover matches live
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# Local docker-compose overrides. Copy to .env (gitignored) for `docker compose
|
||||
# up` / `make up` on a dev box: cp .env.example .env
|
||||
# up` / `make up` on a laptop: cp .env.example .env
|
||||
#
|
||||
# Compose auto-reads a repo-root .env for ${VAR} interpolation in
|
||||
# docker-compose.yml. In production these live in /etc/thermograph.env instead
|
||||
# (loaded by the systemd unit), so this file is only for local runs.
|
||||
# docker-compose.yml. On the fleet (dev/beta/prod), these values live in each
|
||||
# environment's own rendered env file instead (/etc/thermograph.env,
|
||||
# /etc/thermograph-beta.env — see deploy/secrets/README.md), so this file is
|
||||
# only for a local, unmanaged run.
|
||||
|
||||
# Database password. Compose uses it to initialize the postgres container AND to
|
||||
# build the app's THERMOGRAPH_DATABASE_URL. Change it before first `up`.
|
||||
|
|
@ -33,8 +35,13 @@ THERMOGRAPH_INTERNAL_TOKEN=
|
|||
# replicate with another -- see docker-compose.yml's db service comment.
|
||||
# TIMESCALEDB_TAG=latest-pg18
|
||||
|
||||
# Postgres sizing. Terraform sets these per host in prod/beta (prod DB_MEMORY
|
||||
# 16g); local/beta default to 8g / 2 CPUs.
|
||||
# Postgres sizing. Only meaningful for an environment that runs its OWN db
|
||||
# service — dev's compose stack (this file) and prod's Swarm stack, which
|
||||
# sizes the ONE shared TimescaleDB instance on vps2 (see
|
||||
# deploy/secrets/prod.yaml, currently DB_MEMORY=16g). Beta shares that same
|
||||
# instance rather than running a second one, so a DB_MEMORY/DB_CPUS value in
|
||||
# beta's own vault file no longer sizes anything — don't be misled by its
|
||||
# presence there. Local/dev default to 8g / 2 CPUs.
|
||||
# DB_MEMORY=8g
|
||||
# DB_CPUS=2
|
||||
|
||||
|
|
|
|||
158
infra/ACCESS.md
158
infra/ACCESS.md
|
|
@ -5,28 +5,55 @@ provisioning / Postgres / Terraform work described in `terraform/README.md`
|
|||
and (for the historical Track A/Track B plan this whole effort grew from,
|
||||
including the decision to split this repo out of the app monorepo)
|
||||
`thermograph-docs/runbooks/implementation-handoff.md` and
|
||||
`thermograph-docs/architecture/repo-topology-and-infrastructure.md`. Terraform
|
||||
provisions **prod** (the new 48 GB / 12-core box, `thermograph.org`) and
|
||||
**beta** (the old VPS, `75.119.132.91`) — see `terraform/README.md` /
|
||||
`terraform.tfvars.example`. **None of this touches the app repo** (its
|
||||
backend/frontend source, `Dockerfile`, or CI) — this repo owns only how and
|
||||
where the already-built app image runs; the app repo owns building it.
|
||||
`thermograph-docs/architecture/repo-topology-and-infrastructure.md`.
|
||||
**Note:** `terraform/README.md`'s own host table still describes the
|
||||
pre-cutover shape (`prod` and `beta` as two single-purpose boxes) and has not
|
||||
yet been updated to the vps1/vps2 split below — that file is out of this
|
||||
pass's scope; treat this document as the current source of truth for the
|
||||
physical topology in the meantime.
|
||||
|
||||
```
|
||||
Track 1: deploy/provision-agent-access.sh — a dedicated full-root login for me
|
||||
Track 2: deploy/swarm/ — Swarm cluster spanning THREE nodes
|
||||
Track 2: deploy/swarm/ — Swarm mesh spanning THREE nodes
|
||||
Track 3: deploy/forgejo/ + .forgejo/ — Forgejo + Forgejo Actions, replacing GitHub
|
||||
```
|
||||
|
||||
**Three nodes, not two**: prod, beta, and **the desktop** (the LAN dev
|
||||
machine — same box that already runs the pre-Forgejo GitHub self-hosted
|
||||
runner). This matches the canonical Track B design in the handoff doc; an
|
||||
earlier revision of this whole effort covered just prod+beta and has been
|
||||
realigned.
|
||||
## The estate (renamed by role, not by environment)
|
||||
|
||||
The same two VPS boxes as always — **neither public IP nor WireGuard mesh
|
||||
address moved** — plus the operator's desktop, now named for what they *do*
|
||||
rather than which environment happens to live there:
|
||||
|
||||
| Host | Public IP | Mesh IP | Role |
|
||||
|------|-----------|---------|------|
|
||||
| **vps1** | `75.119.132.91` | `10.10.0.2` | "Operational programs": Forgejo (git + CI + registry, `git.thermograph.org`), Grafana/Loki/Alloy (`dashboard.thermograph.org`), the `emigriffith.dev` portfolio, and the **dev** environment (mesh-only, `10.10.0.2:8137`, no public DNS/Caddy/TLS) |
|
||||
| **vps2** | `169.58.46.181` | `10.10.0.1` | "The deployed environment": **prod** and **beta**, as two separate Docker Swarm stacks, plus Centralis, Postfix, the backups, and the one shared TimescaleDB instance both stacks use |
|
||||
| **desktop** | — | `10.10.0.3` | AI-model hosting (voice-to-text, an upcoming-feature LLM) plus flex Swarm-worker capacity. Hosts **no** Thermograph environment — `make dev-up` still works there, but only as a laptop-local rehearsal |
|
||||
|
||||
This is a **rename**, not a re-provisioning: vps1 is the box that used to be
|
||||
called "beta" (it already ran Forgejo and Grafana before this split), and vps2
|
||||
is the box that used to be called "prod". What moved is the **beta
|
||||
environment** — off vps1 and onto vps2, so a beta green light is evidence
|
||||
about prod (same orchestrator, same Postgres build, same Caddy, same mesh
|
||||
position) — and the **dev environment**, off the desktop and onto vps1, so the
|
||||
desktop can retire from the Thermograph estate entirely. See
|
||||
`deploy/env-topology.sh`'s header comment for the full rationale.
|
||||
|
||||
**Three nodes on the Swarm mesh, not two**: vps2 (manager), vps1 (worker), and
|
||||
the desktop (worker, flex capacity + AI model hosting). One manager, not more:
|
||||
Raft needs 3 nodes for real quorum-based HA, and this cluster only has 3 nodes
|
||||
total, so a second manager would still fall short of real HA while adding
|
||||
split-brain risk. If the manager (vps2) goes down, the workers keep running
|
||||
whatever was already scheduled on them (Forgejo, pinned to vps1) but the
|
||||
cluster can't reschedule anything until vps2 is back — acceptable for a small
|
||||
cluster whose only Swarm-scheduled workload today is Forgejo (prod and beta's
|
||||
app stacks are separate Swarm stacks that also happen to run on vps2, the
|
||||
manager, because their volumes are local to that node — see
|
||||
`deploy/stack/thermograph-stack.yml`'s header).
|
||||
|
||||
## Track 1 — Agent access
|
||||
|
||||
Run `sudo bash deploy/provision-agent-access.sh` on **prod and beta** (not
|
||||
Run `sudo bash deploy/provision-agent-access.sh` on **vps1 and vps2** (not
|
||||
the desktop — that's wherever you're already working from, no separate
|
||||
access-provisioning step needed there). Creates a dedicated `agent` user (not
|
||||
raw root login — a distinct name gives a clean audit trail) with passwordless
|
||||
|
|
@ -42,20 +69,27 @@ Both VPS boxes are provisioned and reachable as of this writing:
|
|||
|
||||
| Host | Role | Public IP | Login |
|
||||
|------|------|-----------|-------|
|
||||
| prod | Swarm manager; Thermograph's live prod home (`release`, thermograph.org) | `169.58.46.181` | `agent` |
|
||||
| beta | Swarm worker; `main`/beta.thermograph.org + hosts Forgejo | `75.119.132.91` | `agent` |
|
||||
| desktop | Swarm worker, LAN dev machine + Forgejo Actions runner | (local) | (already have access) |
|
||||
| vps2 | Swarm manager; prod AND beta live here (`thermograph.org`, `beta.thermograph.org`) | `169.58.46.181` | `agent` |
|
||||
| vps1 | Swarm worker; Forgejo + Grafana/Loki + dev (`git.thermograph.org`, `dashboard.thermograph.org`) | `75.119.132.91` | `agent` |
|
||||
| desktop | Swarm worker, AI-model hosting | (local) | (already have access) |
|
||||
|
||||
```
|
||||
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@169.58.46.181 # prod
|
||||
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@75.119.132.91 # beta
|
||||
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@169.58.46.181 # vps2 (prod + beta)
|
||||
ssh -i ~/.ssh/thermograph_agent_ed25519 agent@75.119.132.91 # vps1 (dev + Forgejo + Grafana)
|
||||
```
|
||||
|
||||
**On vps2, always say which environment you mean.** A shell on vps2 can act on
|
||||
either `/opt/thermograph` (prod) or `/opt/thermograph-beta` (beta) — there is
|
||||
no "the app" on that box any more. `deploy.sh` refuses to run if
|
||||
`THERMOGRAPH_ENV` disagrees with the checkout it was invoked from (see
|
||||
`deploy/env-topology.sh`), which catches the one genuinely dangerous mistake
|
||||
here: running prod's checkout with `THERMOGRAPH_ENV=beta`, or the reverse.
|
||||
|
||||
The private half of the agent's dedicated keypair lives at
|
||||
`~/.ssh/thermograph_agent_ed25519` on the operator's own machine (never in
|
||||
this repo, never in a CI secret) — same convention `DEPLOY.md` already uses
|
||||
for the separate CI deploy key (`~/.ssh/thermograph_ci`). If you're setting
|
||||
this up fresh elsewhere, generate a new pair the same way
|
||||
for the separate CI deploy keys. If you're setting this up fresh elsewhere,
|
||||
generate a new pair the same way
|
||||
(`ssh-keygen -t ed25519 -a 100 -C "claude-agent@thermograph-infra" -f
|
||||
~/.ssh/thermograph_agent_ed25519 -N ""`), embed the new public half in
|
||||
`AGENT_PUBKEY` at the top of `provision-agent-access.sh`, and re-run it on
|
||||
|
|
@ -67,31 +101,21 @@ directory that gets cleaned up, not anywhere-in-repo. Losing it just means
|
|||
regenerating and re-running the provisioning script; it does not lock either
|
||||
box, since your own login is untouched.
|
||||
|
||||
**Live as of 2026-07-21** (was: "no Docker / Terraform not applied / Swarm
|
||||
inactive" — all now done): Docker is installed on all three nodes; the
|
||||
WireGuard mesh and Docker Swarm are up with all three nodes `Ready` (prod
|
||||
manager, beta + desktop workers); Terraform has been applied to both VPS
|
||||
boxes (prod stood up fresh on `release`, beta rebuilt on `main`); Forgejo is
|
||||
serving at `git.thermograph.org`; and the desktop runs the Forgejo Actions
|
||||
runner. See the verification checklist at the end.
|
||||
|
||||
## Track 2 — Swarm
|
||||
|
||||
See `deploy/swarm/README.md` for the exact order of operations across all
|
||||
**three** nodes (WireGuard mesh first, then swarm init/join, then lock the
|
||||
Swarm ports down to the tunnel interface, then label beta for Forgejo
|
||||
placement). This cluster's only job is hosting Forgejo — it does not
|
||||
orchestrate the Terraform-managed app deploys.
|
||||
Swarm ports down to the tunnel interface, then label vps1 for Forgejo
|
||||
placement). This cluster's Swarm-scheduled workload is Forgejo; prod and
|
||||
beta's app stacks are separate `docker stack deploy`s that happen to live on
|
||||
the same manager node (vps2) because their volumes are local to it today.
|
||||
|
||||
## Track 3 — Forgejo, replacing GitHub
|
||||
|
||||
### 3a. Stand up Forgejo
|
||||
See `deploy/forgejo/README.md` — deploys the stack (pinned to beta), then
|
||||
walks through registering the Actions runner **on the desktop** as a plain
|
||||
systemd service (`register-lan-runner.sh`), not as a Swarm-scheduled
|
||||
container. Only one Swarm secret to mint now (`forgejo_db_password`) — the
|
||||
runner token is no longer a Swarm secret, since the runner isn't a Swarm
|
||||
service anymore.
|
||||
See `deploy/forgejo/README.md` — deploys the stack (pinned to **vps1**), then
|
||||
walks through registering the Actions runner as a plain systemd service
|
||||
(`register-lan-runner.sh`), not as a Swarm-scheduled container.
|
||||
|
||||
### 3b. Migrate the repo (mirror first, verify, then cut over)
|
||||
1. In Forgejo: **+ New Migration → GitHub**. Point it at
|
||||
|
|
@ -103,13 +127,12 @@ service anymore.
|
|||
3. **Don't retarget any secret or disable a GitHub workflow yet** — see 3d.
|
||||
|
||||
### 3c. Workflows
|
||||
`.forgejo/workflows/{build,pr-build,deploy-dev,deploy}.yml` mirror
|
||||
`.forgejo/workflows/{build,pr-build,deploy}.yml` (a single `Deploy` workflow
|
||||
now covers all three environments — see its own header comment) mirror
|
||||
`.github/workflows/*.yml`, copied with the mechanical changes Forgejo needs:
|
||||
- `runs-on: ubuntu-latest` → `runs-on: docker` (one of the two labels the
|
||||
desktop's runner registers under — see 3a; general CI/deploy jobs get a
|
||||
fresh container, the LAN-deploy job in `deploy-dev.yml` runs bare/host-native
|
||||
under the `thermograph-lan` label instead, since it needs real filesystem
|
||||
access).
|
||||
- `runs-on: ubuntu-latest` → `runs-on: docker` (the runner's `docker` label —
|
||||
containerized jobs get a fresh container via the docker-socket-automounted
|
||||
runner).
|
||||
- `appleboy/ssh-action` referenced by full GitHub URL (not mirrored in
|
||||
Forgejo's default action registry, unlike `actions/checkout` /
|
||||
`actions/setup-python`, which resolve unchanged).
|
||||
|
|
@ -119,44 +142,51 @@ service anymore.
|
|||
`build` status check required (Settings → Branches). Forgejo does **not**
|
||||
auto-merge on green by itself — a ready, green, mergeable PR is merged
|
||||
**explicitly** (`POST .../pulls/{n}/merge`, `{"Do": "squash"}`), per
|
||||
`CLAUDE.md`. That merge is an ordinary push, so `deploy-dev.yml`'s push
|
||||
`CLAUDE.md`. That merge is an ordinary push, so `deploy.yml`'s push
|
||||
trigger fires naturally afterward.
|
||||
- **Branch/deploy mapping (settled):** `.forgejo/workflows/deploy.yml`
|
||||
("Deploy to beta VPS") triggers on `main` and SSHes to **beta**
|
||||
(`75.119.132.91`) running `deploy/deploy.sh`. **Prod is not deployed by a
|
||||
push-triggered workflow** — it's deployed with `terraform apply`
|
||||
(`terraform/README.md`) on the `release` branch. So the promotion chain is
|
||||
`dev` → `main` (this workflow deploys beta) → `release` (Terraform deploys
|
||||
prod). There is deliberately no `release`-triggered workflow.
|
||||
triggers on `dev`, `main` and `release`, and SSHes to the environment each
|
||||
branch maps to (`dev` → dev on **vps1**, `main` → beta on **vps2**,
|
||||
`release` → prod on **vps2**), running `deploy/deploy-dev.sh` or
|
||||
`deploy/deploy.sh` per `deploy/env-topology.sh`. Credentials are keyed by
|
||||
**host** (`VPS1_SSH_*`, `VPS2_SSH_*`), not by environment, since vps2 alone
|
||||
now answers to two of them — see the workflow's own header comment for why
|
||||
that rename matters.
|
||||
|
||||
A second, independent set of Forgejo workflows
|
||||
(`.forgejo/workflows/{ci,build-push,ops-cron}.yml`) was added by a parallel
|
||||
(`.forgejo/workflows/{build-push,ops-cron}.yml`) was added by a parallel
|
||||
effort implementing `thermograph-docs/runbooks/implementation-handoff.md` Track A chunk
|
||||
7 — see that doc and its own reconciliation PR (which already fixed its
|
||||
runner-label and ssh-action guesses to match the real infrastructure here).
|
||||
`ci.yml` duplicated this PR's `build.yml` exactly and was dropped in that
|
||||
reconciliation; `build-push.yml` (image → Forgejo's built-in registry) and
|
||||
`ops-cron.yml` (backup + IndexNow) are novel and still present.
|
||||
7 — see that doc and its own reconciliation PR. `build-push.yml` (image →
|
||||
Forgejo's built-in registry) and `ops-cron.yml` (backup + IndexNow) are novel
|
||||
and still present; the latter's secrets are also keyed by host now (see its
|
||||
own header comment).
|
||||
|
||||
### 3d. Cut over — DONE (2026-07-21)
|
||||
The GitHub → Forgejo cutover is complete; this records what was done:
|
||||
1. Deploy secrets (`SSH_HOST`, `SSH_USER`, `SSH_KEY`, `SSH_PORT`) live in
|
||||
Forgejo's repo Secrets, pointing at beta with a dedicated CI deploy key.
|
||||
2. The LAN dev runner was re-pointed to Forgejo via
|
||||
`deploy/forgejo/register-lan-runner.sh` (systemd --user, on the desktop).
|
||||
1. Deploy secrets live in Forgejo's repo Secrets, keyed by host
|
||||
(`VPS1_SSH_*`, `VPS2_SSH_*`) since the vps1/vps2 split, pointing at each
|
||||
box with a dedicated CI deploy key.
|
||||
2. The Actions runner is registered against Forgejo via
|
||||
`deploy/forgejo/register-lan-runner.sh`.
|
||||
3. The repo was migrated into Forgejo; `dev`/`main`/`release` all promoted
|
||||
through Forgejo and deploys verified live.
|
||||
4. GitHub is retired as git host and CI (PR "Retire GitHub as the git host and
|
||||
CI platform") — `origin` remains only as a read-only mirror of history.
|
||||
|
||||
## Verification checklist — all met (2026-07-21)
|
||||
- [x] `ssh agent@<prod>` and `ssh agent@<beta>` both work; `sudo whoami` → `root`
|
||||
## Verification checklist — all met (2026-07-21, topology as of 2026-07-25)
|
||||
- [x] `ssh agent@<vps1>` and `ssh agent@<vps2>` both work; `sudo whoami` → `root`
|
||||
- [x] Password SSH auth confirmed dead on both boxes (tested with an actual
|
||||
failed login attempt, not just config inspection)
|
||||
- [x] `docker node ls` (from prod) shows all three nodes `Ready`
|
||||
- [x] `docker node ls` (from vps2, the manager) shows all three nodes `Ready`
|
||||
- [x] Swarm ports (2377/7946/4789) unreachable from outside the WireGuard tunnel
|
||||
- [x] `https://git.thermograph.org` serves Forgejo over TLS; `/v2/` registry
|
||||
API is 403 from off-mesh (firewalled to the WireGuard CIDR)
|
||||
- [x] A test PR flow verified: required `build` check runs → explicit squash
|
||||
merge → LAN dev deploy lands
|
||||
merge → deploy lands on the branch's mapped environment
|
||||
- [x] GitHub retired as git host + CI; kept only as a read-only history mirror
|
||||
|
||||
TODO(cutover): re-verify the checklist above against the vps1/vps2 rename
|
||||
specifically (e.g. confirm the Forgejo Swarm label now reads `vps1`'s node
|
||||
name, not a leftover `beta` node name) — this document was updated to match
|
||||
the new topology brief, but a fresh live check wasn't run as part of this
|
||||
pass.
|
||||
|
|
|
|||
|
|
@ -8,33 +8,67 @@ orchestrator.
|
|||
|
||||
## The machines
|
||||
|
||||
Per `ACCESS.md`: **dev** (operator's box — LAN dev server and CI runner),
|
||||
**prod** (`169.58.46.181`, thermograph.org, `agent` user, passwordless sudo),
|
||||
**beta** (`75.119.132.91`, beta.thermograph.org + Forgejo + Grafana/Loki,
|
||||
`agent` user). All four are on a WireGuard mesh. Hosts' `/opt/thermograph` is a
|
||||
checkout of **this monorepo**, not an infra-only repo.
|
||||
Two VPS boxes plus the operator's desktop, on one WireGuard mesh, named by
|
||||
ROLE rather than by environment:
|
||||
|
||||
- **vps1** — `75.119.132.91`, mesh `10.10.0.2`. "Operational programs": Forgejo
|
||||
(git + CI + registry, `git.thermograph.org`), Grafana/Loki/Alloy
|
||||
(`dashboard.thermograph.org`), the `emigriffith.dev` portfolio site, and the
|
||||
**dev** environment (`/opt/thermograph-dev`, tracks `dev`), with its own
|
||||
Postgres container. Dev is mesh-only here — published on `10.10.0.2:8137`,
|
||||
no public DNS, no Caddy site, no TLS.
|
||||
- **vps2** — `169.58.46.181`, mesh `10.10.0.1`. "The deployed environment" —
|
||||
everything an external user can reach: **prod** (`/opt/thermograph`, tracks
|
||||
`main`, Swarm stack `thermograph`) AND **beta** (`/opt/thermograph-beta`,
|
||||
tracks `main`, Swarm stack `thermograph-beta`), Centralis, Postfix, and the
|
||||
backups. One shared TimescaleDB instance serves both, on separate
|
||||
databases/roles (`thermograph` / `thermograph_beta`).
|
||||
- **desktop** — mesh `10.10.0.3`. Hosts no Thermograph environment at all: AI
|
||||
model hosting plus flex Swarm-worker capacity. `make dev-up` still works
|
||||
there as a laptop-local rehearsal, but that is not an environment.
|
||||
|
||||
`agent` user, passwordless sudo, on vps1 and vps2. A host's `/opt/thermograph*`
|
||||
is a checkout of **this monorepo**, not an infra-only repo — note vps2 carries
|
||||
**two** such checkouts side by side (`/opt/thermograph` and
|
||||
`/opt/thermograph-beta`), which is the one thing most deploy logic here has to
|
||||
get right that a single-environment host never had to.
|
||||
|
||||
## Deploy paths
|
||||
|
||||
- **`deploy/deploy.sh`** — the single entry point for beta and prod. Resets the
|
||||
host checkout to `BRANCH` (default `main`), renders secrets, then routes:
|
||||
if `/etc/thermograph/deploy-mode` says `stack` it execs
|
||||
`deploy/stack/deploy-stack.sh`, otherwise it rolls compose services. Per-service
|
||||
tags persist in untracked `deploy/.image-tags.env` (compose) and
|
||||
- **`deploy/env-topology.sh`** — sourced by every path below. `THERMOGRAPH_ENV`
|
||||
(`dev`/`beta`/`prod`) is the input; host, checkout, branch, deploy mode,
|
||||
stack/compose name, env file, LB ports, DB role/database and service-name
|
||||
prefix are all derived from it. This exists because vps2 alone runs two
|
||||
environments — a host-wide marker can no longer answer "which environment is
|
||||
this", so the caller (the deploy workflow) says so explicitly.
|
||||
- **`deploy/deploy.sh`** — the single entry point for dev, beta and prod.
|
||||
Resets the host checkout to `BRANCH`, renders secrets, then routes: beta and
|
||||
prod (`TG_DEPLOY_MODE=stack`) exec `deploy/stack/deploy-stack.sh`; dev
|
||||
(`compose`) rolls compose services. The old host-wide
|
||||
`/etc/thermograph/deploy-mode` marker is honoured only when
|
||||
`env-topology.sh` can't resolve an environment at all (a checkout that
|
||||
predates the split, or a by-hand run with nothing set). Per-service tags
|
||||
persist in untracked `deploy/.image-tags.env` (compose) and
|
||||
`deploy/.stack-image-tags.env` (stack) so the two never mix.
|
||||
- **`deploy/stack/deploy-stack.sh`** — the Swarm path, live on prod. `backend`
|
||||
rolls web **and** worker; `frontend` rolls frontend; `all` runs a full
|
||||
`docker stack deploy`. Start-first, health-gated, auto-rollback.
|
||||
`STACK_TEST=1` rehearses the whole thing under stack name `thermograph-test`
|
||||
- **`deploy/stack/deploy-stack.sh`** — the Swarm path, live on vps2 for both
|
||||
prod and beta, picking the stack file/ports/DB role/service prefix out of
|
||||
`env-topology.sh`. `backend` rolls web **and** worker; `frontend` rolls
|
||||
frontend; `all` runs a full `docker stack deploy`. Start-first, health-gated,
|
||||
auto-rollback. `STACK_TEST=1` rehearses under stack name `thermograph-test`
|
||||
on throwaway volumes and ports `18137`/`18080`.
|
||||
- **`deploy/deploy-dev.sh`** — thin LAN-dev wrapper (dev compose overlay,
|
||||
`~/thermograph-dev`). Its CI trigger is inert; see root `CLAUDE.md`.
|
||||
- **`deploy/deploy-dev.sh`** — thin wrapper around `deploy.sh` for dev on vps1
|
||||
(dev compose overlay, `/opt/thermograph-dev`, `THERMOGRAPH_SECRETS_SKIP_COMMON=1`).
|
||||
Deployed like beta/prod now — a push to `dev` triggers the same `Deploy`
|
||||
workflow over SSH, no LAN-specific runner involved.
|
||||
- **`Makefile`** — compose orchestration only: `up`, `down`, `db-up`, `dev-up`,
|
||||
`om-up`, `om-backfill`.
|
||||
|
||||
Volumes are the reason the compose project name is pinned: compose creates
|
||||
`thermograph_pgdata`/`_appdata`/`_applogs`, and the Swarm stack declares those
|
||||
same names as `external: true` at the same mount paths.
|
||||
same names as `external: true` at the same mount paths. Beta's stack has no
|
||||
`db` of its own — it reaches prod's `db` service over prod's `thermograph_internal`
|
||||
overlay (declared `external: true` in beta's stack file) and keeps a separate
|
||||
`internal` overlay for its own beta-to-beta traffic.
|
||||
|
||||
## Rules
|
||||
|
||||
|
|
@ -42,13 +76,29 @@ same names as `external: true` at the same mount paths.
|
|||
an apply would attempt full re-provisioning of live hosts. Terraform here is
|
||||
executable documentation until state is bootstrapped.
|
||||
- **Secrets: SOPS vault only** (`deploy/secrets/*.yaml`, `sops edit` → commit →
|
||||
deploy). Never hand-edit `/etc/thermograph.env` — it is a rendered artifact.
|
||||
`secrets-guard` CI rejects plaintext. `deploy/secrets/seed-from-live.sh` reads
|
||||
production secrets and is **not** for an agent to run.
|
||||
deploy). Never hand-edit `/etc/thermograph.env` / `/etc/thermograph-beta.env`
|
||||
— they are rendered artifacts. `secrets-guard` CI rejects plaintext.
|
||||
`deploy/secrets/seed-from-live.sh` reads production secrets and is **not**
|
||||
for an agent to run.
|
||||
- **vps1 must never hold prod credentials.** It's the box that runs Forgejo,
|
||||
its CI runner, and dev's unreviewed branch — the opposite of an isolation
|
||||
boundary. This is why dev renders `dev.yaml` **alone** and never layers
|
||||
`common.yaml` (the fleet's shared production credential set). See
|
||||
`deploy/secrets/README.md`.
|
||||
- **A foothold on vps2 is a foothold on both beta and prod's host** — they are
|
||||
co-resident by design now. What still separates them is the database
|
||||
(separate roles/databases, `CONNECT` revoked from `PUBLIC`) and the
|
||||
filesystem (separate checkouts, separate rendered env files). Don't describe
|
||||
beta and prod as isolated at the host or SSH-credential level; they aren't
|
||||
anymore.
|
||||
- **The ops cron (`.forgejo/workflows/ops-cron.yml`, at the repo root) is THE
|
||||
prod backup.** It uses `PROD_SSH_*`, not `SSH_*` — an earlier revision reused
|
||||
`SSH_*` and silently dumped beta while prod had no backup at all. If you touch
|
||||
it, verify a dump actually lands in `agent@prod:~/thermograph-backups/`.
|
||||
backup for both application databases and for Forgejo.** Secrets are keyed
|
||||
by host, not environment: `VPS2_SSH_*` for the `backup`/`indexnow` jobs
|
||||
(prod and beta's databases, both on vps2) and `VPS1_SSH_*` for
|
||||
`forgejo-backup` (Forgejo now lives on vps1, not co-located with beta). If
|
||||
you touch it, verify a dump actually lands for **both** the prod and beta
|
||||
databases, not just one — an earlier revision backed up only prod and
|
||||
silently left beta uncovered.
|
||||
- Shell here runs as root over SSH against live hosts with no test suite in
|
||||
front of it. `shell-lint` CI (pinned shellcheck) is the only guard — keep the
|
||||
tree at zero findings.
|
||||
|
|
|
|||
|
|
@ -1,33 +1,45 @@
|
|||
# Dev CI/CD → LAN server on this machine
|
||||
# Dev CI/CD → the dev environment on vps1
|
||||
|
||||
Parallel to the prod pipeline (`main` → VPS, see `DEPLOY.md`), the **`dev`**
|
||||
branch continuously deploys to a **LAN server running on this computer**. Git
|
||||
hosting and CI are self-hosted **Forgejo** (`git.thermograph.org`, reachable
|
||||
at `http://10.10.0.2:3080` over the WireGuard mesh) — GitHub is retired.
|
||||
Parallel to beta/prod's pipeline (`main`→beta, `release`→prod, see `DEPLOY.md`),
|
||||
the **`dev`** branch continuously deploys to the **dev environment on vps1**
|
||||
(`75.119.132.91`, mesh `10.10.0.2`) — `/opt/thermograph-dev`, a normal fleet
|
||||
checkout reached over SSH exactly like beta and prod, **not** a stack on the
|
||||
operator's desktop or LAN any more. Git hosting and CI are self-hosted
|
||||
**Forgejo** (`git.thermograph.org`, also on vps1, reachable at
|
||||
`http://10.10.0.2:3080` over the WireGuard mesh) — GitHub is retired.
|
||||
|
||||
```
|
||||
open PR ──▶ CI (build + boot/health, on the LAN Forgejo runner)
|
||||
open PR ──▶ CI (build + boot/health, on the `docker`-labeled Forgejo runner)
|
||||
│ green
|
||||
▼
|
||||
merge into dev (explicit — Forgejo does not auto-merge on its own;
|
||||
see "Landing a PR" below)
|
||||
│
|
||||
▼
|
||||
LAN runner (thermograph-lan label) runs deploy/deploy-dev.sh
|
||||
the Deploy workflow SSHes into vps1 (VPS1_SSH_*, THERMOGRAPH_ENV=dev)
|
||||
and runs deploy/deploy-dev.sh
|
||||
│
|
||||
▼
|
||||
~/thermograph-dev updated ─▶ docker compose stack (backend + frontend +
|
||||
Postgres 18) brought up, backend on 0.0.0.0:8137
|
||||
/opt/thermograph-dev updated ─▶ docker compose stack (backend + frontend +
|
||||
Postgres) brought up, backend published
|
||||
MESH-ONLY on 10.10.0.2:8137
|
||||
```
|
||||
|
||||
Reachable at `http://<lan-ip>:8137/` from any device on your Wi-Fi.
|
||||
Reachable at `http://10.10.0.2:8137/` from anything already on the WireGuard
|
||||
mesh, and from nowhere else — no public DNS record, no Caddy site, no TLS.
|
||||
vps1 is a public VPS running whatever branch is currently in flight (including
|
||||
unreviewed code), so unlike the old LAN box it must **never** publish on
|
||||
`0.0.0.0`.
|
||||
|
||||
The dev server runs the **same containerized stack as prod** (`docker-compose.yml`),
|
||||
overlaid with `docker-compose.dev.yml`: **uncapped** (no CPU limits, unlike prod's
|
||||
backend=4 / frontend=2 / db=2) and backend published on `0.0.0.0:8137` for the
|
||||
LAN (prod binds loopback behind Caddy; frontend has no published port here
|
||||
either way, no Caddy to reach it directly, so it's only reached through
|
||||
backend's own reverse-proxy fallback). Bring it up by hand with `make dev-up`.
|
||||
The dev stack runs the **same containerized stack as prod** (`docker-compose.yml`),
|
||||
overlaid with `docker-compose.dev.yml`: **uncapped** (no CPU limits, unlike
|
||||
prod's Swarm-managed replicas) and backend published on the WireGuard address
|
||||
(`10.10.0.2:8137` — set via `DEV_BIND_ADDR` in `deploy/env-topology.sh`; prod
|
||||
and beta bind loopback behind their own Caddy LBs instead). Frontend has no
|
||||
published port either way — reached only through backend's own
|
||||
reverse-proxy fallback. Bring dev up by hand with `make dev-up`, which is a
|
||||
**laptop-local convenience** (uncapped, loopback-published) — not the dev
|
||||
environment itself, which only ever runs on vps1.
|
||||
|
||||
## Why it's built this way
|
||||
|
||||
|
|
@ -39,26 +51,40 @@ via the API (`POST .../pulls/{n}/merge`, `{"Do": "squash"}`). Nothing merges
|
|||
a ready PR for you automatically just because checks pass, unlike GitHub's
|
||||
old in-workflow `ci-cd.yml` (retired along with the rest of `.github/`).
|
||||
|
||||
**Dev moved off the desktop and onto vps1 so the desktop could retire from the
|
||||
Thermograph estate entirely.** The desktop now hosts AI models and offers flex
|
||||
Swarm-worker capacity — it is no longer "the LAN dev server" and no longer the
|
||||
primary CI runner story. What dev keeps that beta and prod do not:
|
||||
|
||||
- It renders `dev.yaml` **alone**, never layering `common.yaml` (the fleet's
|
||||
shared production credentials) — vps1 also runs Forgejo and its CI, and dev
|
||||
runs whatever branch is in flight. See the long note in
|
||||
`deploy/render-secrets.sh` and `deploy/secrets/README.md`.
|
||||
- It is **mesh-only** — see above. A public VPS running unreviewed branches
|
||||
must never be reachable from the open internet the way the old LAN box
|
||||
(behind a home router, reachable only on the Wi-Fi) safely could be.
|
||||
|
||||
## Moving parts
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `.forgejo/workflows/pr-build.yml` | required status check for PRs into `dev` (calls `build.yml`) |
|
||||
| `.forgejo/workflows/deploy-dev.yml` | build + deploy on pushes to `dev` (direct, or a PR merge) |
|
||||
| `.forgejo/workflows/deploy.yml` | the single Deploy workflow — `dev` branch pushes SSH into vps1 and run `deploy-dev.sh` |
|
||||
| `.forgejo/workflows/build.yml` | shared build gate: deps, backend tests, JS syntax check, boot/health check |
|
||||
| `deploy/deploy-dev.sh` | pull `dev` into `~/thermograph-dev`, `docker compose up` the uncapped LAN stack, health check |
|
||||
| `deploy/env-topology.sh` | source of truth: dev = vps1, `/opt/thermograph-dev`, branch `dev`, compose mode, mesh-only bind address |
|
||||
| `deploy/deploy-dev.sh` | thin wrapper around `deploy.sh`: dev's compose overlay + secrets policy, then delegates to the shared pull/roll/health-check logic |
|
||||
| `deploy/secrets/dev.yaml` | dev's SOPS vault — its own secrets and config, rendered to `/etc/thermograph.env` at deploy time (see "Config and secrets") |
|
||||
| `docker-compose.dev.yml` | dev overlay: no CPU limits, backend published on `0.0.0.0:8137` (frontend unpublished) |
|
||||
| `deploy/provision-dev-lan.sh` | one-time sudo-free bootstrap of the checkout + runner |
|
||||
| `deploy/forgejo/register-lan-runner.sh` | registers the Forgejo Actions runner on this machine |
|
||||
| `docker-compose.dev.yml` | dev overlay: no CPU limits, backend published on the mesh address (frontend unpublished) |
|
||||
| `deploy/provision-dev.sh` | one-time bootstrap of the checkout + vault marker on vps1 |
|
||||
| `deploy/forgejo/register-lan-runner.sh` | registers the Forgejo Actions runner (name predates the vps1/vps2 split — see the script's own header) |
|
||||
|
||||
`deploy-dev.yml`'s `deploy` job runs on the `thermograph-lan` label — **not**
|
||||
`[self-hosted, thermograph-lan]` (the array form is what the original GitHub
|
||||
version used; GitHub implicitly tags every self-hosted runner `self-hosted`,
|
||||
but Forgejo's runner has only the labels it was explicitly registered with,
|
||||
so the array form is permanently unschedulable there — a real bug found and
|
||||
fixed once, worth knowing about if a "deploy" job ever silently stops
|
||||
appearing in the Actions history again).
|
||||
`deploy.yml`'s dev leg SSHes in with `VPS1_SSH_*` and runs
|
||||
`/opt/thermograph-dev/infra/deploy/deploy-dev.sh` directly over SSH — the same
|
||||
shape as the beta and prod legs, just a different host and script. There is no
|
||||
longer a `thermograph-lan`-labeled runner job doing host-native
|
||||
`systemctl`/`docker-compose` calls from inside a CI job; the deploy happens the
|
||||
same way for all three environments now (a `docker`-labeled runner opens an SSH
|
||||
session to the target host and runs that environment's deploy script there).
|
||||
|
||||
`deploy-dev.sh`'s git auth (`GH_TOKEN`/`GITHUB_TOKEN`, Forgejo's per-job
|
||||
token exposed under that name for compatibility) is scoped to `REPO_URL`'s
|
||||
|
|
@ -68,15 +94,25 @@ hardcoded host would make git silently skip the header (no error) and fall
|
|||
through to whatever ambient credentials happen to be available, not fail
|
||||
loudly.
|
||||
|
||||
## The self-hosted runner
|
||||
## The Forgejo Actions runner
|
||||
|
||||
A Forgejo Actions **runner** (`forgejo-runner`) runs on this machine, labels
|
||||
TODO(cutover): `deploy/forgejo/register-lan-runner.sh`'s own header comment
|
||||
still describes "the desktop" as the canonical placement for this runner, and
|
||||
its `docker`-labeled jobs are what actually execute `deploy.yml`'s SSH-based
|
||||
dev/beta/prod deploy steps today. Whether the runner has actually moved to
|
||||
vps1 (alongside Forgejo) or still runs on the desktop under the new topology
|
||||
is not settled by anything read for this pass — that script is out of this
|
||||
document's edit scope. Confirm where it actually lives before relying on the
|
||||
description below.
|
||||
|
||||
A Forgejo Actions **runner** (`forgejo-runner`) serves this repo, labels
|
||||
`docker` (containerized jobs, node:20-bookworm, with the host's docker.sock
|
||||
automounted in — `container.docker_host: automount` in
|
||||
`~/forgejo-runner/config.yaml` — so `docker build`/`push` work without
|
||||
privileged Docker-in-Docker) and `thermograph-lan` (host-native jobs, for
|
||||
`deploy-dev.sh`'s systemctl/docker-compose calls). Installed under
|
||||
`~/forgejo-runner`, runs as the `forgejo-runner` systemd `--user` service.
|
||||
privileged Docker-in-Docker) and `thermograph-lan` (a legacy label from when a
|
||||
deploy job ran host-native on the LAN box; nothing in the current `deploy.yml`
|
||||
schedules against it, since every environment now deploys over SSH from the
|
||||
`docker`-labeled job instead).
|
||||
|
||||
```bash
|
||||
# status / logs
|
||||
|
|
@ -87,62 +123,66 @@ journalctl --user -u forgejo-runner -f
|
|||
bash deploy/forgejo/register-lan-runner.sh <forgejo_url> <registration_token>
|
||||
```
|
||||
|
||||
`git.thermograph.org`'s public DNS resolves to beta's **public** IP, which
|
||||
the registry's mesh-only ACL (see `deploy/forgejo/README.md`) rejects for
|
||||
`/v2/` paths — `docker build-push.yml` pushes run against the *host's*
|
||||
automounted daemon, so it's this **host's** own resolver that needs to route
|
||||
git.thermograph.org over the mesh, not anything settable from a job
|
||||
container. If registry pushes ever start failing with an HTTP/TLS mismatch
|
||||
or a 403 on `/v2/`, check `getent hosts git.thermograph.org` here — it needs
|
||||
to resolve to beta's WireGuard IP (`10.10.0.2`), typically via a `/etc/hosts`
|
||||
line, not the public one.
|
||||
Registry pushes (`build-push.yml`) run against the runner host's own
|
||||
automounted Docker daemon, so it's that **host's** own resolver that needs to
|
||||
route `git.thermograph.org` over the mesh, not anything settable from a job
|
||||
container. If registry pushes ever start failing with an HTTP/TLS mismatch or
|
||||
a 403 on `/v2/`, check `getent hosts git.thermograph.org` on the runner host —
|
||||
it needs to resolve to vps1's WireGuard IP (`10.10.0.2`), typically via a
|
||||
`/etc/hosts` line, not the public one (a non-issue if the runner is co-located
|
||||
with Forgejo on vps1 itself, since `git.thermograph.org` then resolves to
|
||||
itself either way).
|
||||
|
||||
## The LAN dev service (Docker Compose)
|
||||
## The dev environment (Docker Compose, on vps1)
|
||||
|
||||
Runs as a Docker Compose stack, not a bare systemd unit — `docker ps --filter
|
||||
name=thermograph-dev` shows `thermograph-dev-backend-1`, `thermograph-dev-frontend-1`
|
||||
(repo-split Stage 4 split the single "app" container in two — frontend has no
|
||||
published port, reached only through backend's own reverse-proxy fallback) and
|
||||
`thermograph-dev-db-1` (TimescaleDB). A legacy pre-container
|
||||
`thermograph-dev.service` systemd --user unit may still exist from before this
|
||||
cutover; it's stale and `deploy-dev.sh` stops/disables it on every run — don't
|
||||
trust `systemctl --user status thermograph-dev` as a liveness check, use `docker
|
||||
ps` instead.
|
||||
name=thermograph-dev` on vps1 shows `thermograph-dev-backend-1`,
|
||||
`thermograph-dev-frontend-1` (frontend has no published port, reached only
|
||||
through backend's own reverse-proxy fallback) and `thermograph-dev-db-1`
|
||||
(TimescaleDB, dev's **own** container — not the shared instance on vps2).
|
||||
|
||||
```bash
|
||||
docker ps --filter name=thermograph-dev # is it up?
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend frontend # app logs (from ~/thermograph-dev)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend frontend # app logs (from /opt/thermograph-dev/infra)
|
||||
```
|
||||
|
||||
Bootstrap (or rebuild) it by hand:
|
||||
Bootstrap (or rebuild) it:
|
||||
|
||||
```bash
|
||||
bash deploy/provision-dev-lan.sh
|
||||
sudo bash deploy/provision-dev.sh
|
||||
```
|
||||
|
||||
The dedicated checkout at `~/thermograph-dev` is separate from your working tree,
|
||||
so a deploy never touches uncommitted edits in `~/Code/Thermograph`.
|
||||
replacing the retired `deploy/provision-dev-lan.sh`, which bootstrapped dev on
|
||||
the operator's desktop as a sudo-free `systemd --user` stack. Dev is
|
||||
provisioned like any other fleet environment now — a checkout at
|
||||
`/opt/thermograph-dev`, root-owned, deployed over SSH by CI like beta and
|
||||
prod. The dedicated checkout is separate from your own working tree, so a
|
||||
deploy never touches uncommitted edits there.
|
||||
|
||||
## Config and secrets
|
||||
|
||||
Dev's configuration lives in the same SOPS vault as prod's and beta's —
|
||||
`deploy/secrets/dev.yaml`, encrypted to the same age recipient, rendered to
|
||||
`/etc/thermograph.env` by the same `render-secrets.sh` at deploy time. Changing a
|
||||
dev value is `sops deploy/secrets/dev.yaml` → commit → deploy, exactly as for the
|
||||
VPS boxes. **`deploy/secrets/README.md` is the reference**; what follows is only
|
||||
what is specific to this machine.
|
||||
dev value is `sops deploy/secrets/dev.yaml` → commit → deploy, exactly as for
|
||||
beta and prod. **`deploy/secrets/README.md` is the reference**; what follows
|
||||
is only what is specific to this environment.
|
||||
|
||||
Dev renders `dev.yaml` **alone**. It does *not* layer `common.yaml`, which is the
|
||||
fleet's shared production credential set — registry token, both S3 keypairs (one
|
||||
read-write pair, on the bucket that also holds prod's backups), the VAPID private
|
||||
key that signs push to real subscribers, the IndexNow key, the metrics token. This
|
||||
box is the CI runner and runs unreviewed `dev`-branch code; a plaintext render of
|
||||
that set into `/etc/thermograph.env` would put all of it in every dev container's
|
||||
environment. `deploy-dev.sh` exports `THERMOGRAPH_SECRETS_SKIP_COMMON=1` to prevent
|
||||
it, and **aborts the deploy** if the renderer in the checkout cannot honour that.
|
||||
box also runs Forgejo and its CI, and executes unreviewed `dev`-branch code; a
|
||||
plaintext render of that set into `/etc/thermograph.env` would put all of it in
|
||||
every dev container's environment. `deploy-dev.sh` exports
|
||||
`THERMOGRAPH_SECRETS_SKIP_COMMON=1` to prevent it, and **aborts the deploy** if
|
||||
the renderer in the checkout cannot honour that. This is a property of the
|
||||
`dev` **environment** (encoded in `deploy/env-topology.sh`'s `TG_SKIP_COMMON`),
|
||||
not just of `deploy-dev.sh` — so it holds regardless of which entry point is
|
||||
used to deploy it.
|
||||
|
||||
So dev holds its own `THERMOGRAPH_AUTH_SECRET` (its own, not prod's — the `daemon`
|
||||
service refuses to start without one), its own `POSTGRES_PASSWORD`, a LAN
|
||||
service refuses to start without one), its own `POSTGRES_PASSWORD`, a mesh-only
|
||||
`THERMOGRAPH_BASE_URL`, `THERMOGRAPH_COOKIE_SECURE=0` (plain HTTP — the shared
|
||||
value is `1` and would silently break login here), and the non-secret config it
|
||||
would otherwise have inherited. It holds **no** S3 keys, no VAPID keypair, no
|
||||
|
|
@ -150,20 +190,26 @@ IndexNow key, no Discord or mail credentials: the lake falls through to the
|
|||
Open-Meteo archive without bucket creds, and the app generates and persists its own
|
||||
VAPID and IndexNow keys into the `appdata` volume.
|
||||
|
||||
Two dev-only mechanics, both because this box has **no passwordless sudo** and the
|
||||
runner is a `systemd --user` service with no tty:
|
||||
Two dev-only mechanics carried over from the old LAN setup, both worth keeping
|
||||
even though dev is no longer sudo-free (vps1's `agent` user has passwordless
|
||||
sudo like any other fleet host) — the render path just doesn't strictly need
|
||||
them any more:
|
||||
|
||||
- the age key is read from `~/.config/sops/age/keys.txt`, not
|
||||
`/etc/thermograph/age.key` — a root-owned `0400` key would send the renderer down
|
||||
its `sudo cat` path, where it would hang on a prompt no CI job can answer;
|
||||
- `/etc/thermograph.env` is **pre-created** owned by the deploying user, so the
|
||||
renderer takes its in-place write path and no deploy needs `sudo` at all.
|
||||
- the age key can be read from `~/.config/sops/age/keys.txt` as a fallback
|
||||
when `/etc/thermograph/age.key` isn't present or readable — see
|
||||
`deploy-dev.sh`'s own comment on this;
|
||||
- `/etc/thermograph.env` being pre-created and owned by the deploying user
|
||||
lets the renderer take its in-place write path without needing `sudo` at
|
||||
all, which still matters under the Forgejo runner's `systemd --user`
|
||||
context (no tty to answer a `sudo` prompt).
|
||||
|
||||
Setting this up on a fresh dev machine is four typed commands — see **"Setting up a
|
||||
new dev machine"** in `deploy/secrets/README.md`. Until the
|
||||
`/etc/thermograph/secrets-env` marker exists the box renders nothing and falls back
|
||||
to `deploy-dev.sh`'s built-in `POSTGRES_PASSWORD` default, which is the safe state
|
||||
and the one to return to (delete the marker) if the vault ever gets in the way.
|
||||
Setting this up on a fresh dev host is a few typed commands — see **"Setting up a
|
||||
new dev machine"** in `deploy/secrets/README.md` (written for the old desktop
|
||||
setup; the mechanics are the same on vps1, just root-owned rather than
|
||||
sudo-free). Until the `/etc/thermograph/secrets-env` marker exists the box
|
||||
renders nothing and falls back to `deploy-dev.sh`'s built-in
|
||||
`POSTGRES_PASSWORD` default, which is the safe state and the one to return to
|
||||
(delete the marker) if the vault ever gets in the way.
|
||||
|
||||
`REGISTRY_TOKEN` is deliberately not in the vault: dev pulls with the persistent
|
||||
`docker login` credential already in this host's docker config, like the prod/beta
|
||||
|
|
@ -196,25 +242,28 @@ with `{"Do": "squash", "delete_branch_after_merge": true}`.
|
|||
## Day-to-day
|
||||
|
||||
- **Ship:** open a PR into `dev`, confirm CI is green, merge it explicitly —
|
||||
the merge triggers the deploy here.
|
||||
- **Manual redeploy:** trigger `deploy-dev.yml` via `workflow_dispatch`
|
||||
(`POST /api/v1/repos/{owner}/{repo}/actions/workflows/deploy-dev.yml/dispatches`,
|
||||
`{"ref": "dev"}`), or locally `APP_DIR=~/thermograph-dev bash deploy/deploy-dev.sh`.
|
||||
- **Promote to prod:** merge/fast-forward `dev` into `main` and push — that
|
||||
triggers the VPS pipeline in `DEPLOY.md`.
|
||||
the merge triggers a deploy to vps1.
|
||||
- **Manual redeploy:** trigger the `Deploy` workflow via `workflow_dispatch`
|
||||
(`POST /api/v1/repos/{owner}/{repo}/actions/workflows/deploy.yml/dispatches`,
|
||||
`{"ref": "dev"}`), or directly on vps1:
|
||||
`APP_DIR=/opt/thermograph-dev bash deploy/deploy-dev.sh`.
|
||||
- **Promote to beta:** merge/fast-forward `dev` into `main` and push — that
|
||||
triggers the beta leg of the same `Deploy` workflow, per `DEPLOY.md`.
|
||||
|
||||
## Monitoring
|
||||
|
||||
Logs and dashboards moved to a **separate project** — a central Grafana + Loki
|
||||
stack fed by a Grafana Alloy agent on every node (including this LAN dev box),
|
||||
over the WireGuard mesh (`thermograph-observability` on Forgejo; UI at
|
||||
`https://dashboard.thermograph.org`). The dev node's Alloy agent ships its
|
||||
container/app logs under `host="dev"`, so LAN dev shows up alongside prod and
|
||||
beta in the same dashboards. This replaced the old `scripts/dashboard.py`.
|
||||
Logs and dashboards live in a **separate project** — a central Grafana + Loki
|
||||
stack, hosted on **vps1 itself**, fed by a Grafana Alloy agent on every node
|
||||
(prod and beta on vps2, dev and the desktop's own agent, all over the
|
||||
WireGuard mesh; `thermograph-observability` on Forgejo; UI at
|
||||
`https://dashboard.thermograph.org`). Dev's Alloy agent ships its
|
||||
container/app logs under its own `host`/service labels, so dev shows up
|
||||
alongside prod and beta in the same dashboards. This replaced the old
|
||||
`scripts/dashboard.py`.
|
||||
|
||||
For a quick terminal check without Grafana, the app still serves raw counters at
|
||||
the gated `GET /api/v2/metrics` route. Dev sets no `THERMOGRAPH_METRICS_TOKEN`, so
|
||||
the route is direct-loopback-only — `curl localhost:8137/api/v2/metrics` from this
|
||||
machine, which is where you already are. (A token exists on the VPS boxes to allow
|
||||
reads through an SSH tunnel; dev has no reason to carry a copy, and the estate's
|
||||
copy must not land here — see `deploy/secrets/README.md`.)
|
||||
the route is direct-loopback-only — `curl localhost:8137/api/v2/metrics` from vps1
|
||||
itself. (A token exists on prod/beta to allow reads through an SSH tunnel; dev has
|
||||
no reason to carry a copy, and the estate's copy must not land here — see
|
||||
`deploy/secrets/README.md`.)
|
||||
|
|
|
|||
188
infra/DEPLOY.md
188
infra/DEPLOY.md
|
|
@ -1,62 +1,71 @@
|
|||
# Deploying Thermograph to a prod VPS
|
||||
# Deploying Thermograph to prod (and beta) on vps2
|
||||
|
||||
> **Prod and beta are now provisioned by Terraform** (`terraform/README.md`) and
|
||||
> run as a **`docker compose` stack** (backend + frontend + Postgres/TimescaleDB;
|
||||
> repo-split Stage 4 split the single "app" service in two), not the
|
||||
> manual venv + systemd model this document originally described. Current shape:
|
||||
> prod = new box `169.58.46.181`, branch `release`, deployed with `terraform
|
||||
> apply`; beta = old box `75.119.132.91`, branch `main`, deployed when `main` is
|
||||
> pushed (`.forgejo/workflows/deploy.yml` SSHes in and runs `deploy/deploy.sh`,
|
||||
> which `docker compose pull`s the image `build-push.yml` already pushed for
|
||||
> that commit and `up`s it -- repo-split Stage 6's registry-pull cutover;
|
||||
> `deploy.sh` no longer builds in place). The **manual walkthrough below
|
||||
> (`provision.sh`, `thermograph.service`, the venv) is legacy** — kept as a
|
||||
> from-scratch, no-Terraform reference; the process model is compose, so the
|
||||
> systemd/venv specifics no longer match how prod/beta actually run.
|
||||
> **Prod and beta both run as Docker Swarm stacks on vps2** (`169.58.46.181`,
|
||||
> mesh `10.10.0.1`) — two separate stacks, two separate checkouts
|
||||
> (`/opt/thermograph` and `/opt/thermograph-beta`), sharing one host because a
|
||||
> beta green light is meant to be evidence about prod (same orchestrator, same
|
||||
> Postgres build, same Caddy, same mesh position). `deploy/env-topology.sh` is
|
||||
> the single source of truth for which environment lives where; every path
|
||||
> below sources it rather than hardcoding a path or a port. The **manual
|
||||
> walkthrough below (`provision.sh`, `thermograph.service`, the venv) is
|
||||
> legacy** — kept as a from-scratch, no-orchestrator reference; prod and beta's
|
||||
> actual process model is a Swarm stack (`deploy/stack/`), and dev's is plain
|
||||
> compose on vps1 (see `DEPLOY-DEV.md`), so the systemd/venv specifics below no
|
||||
> longer match how any live environment actually runs.
|
||||
|
||||
Pipeline (beta, on `main`): **push to `main` on Forgejo → `build-push.yml` builds
|
||||
+ pushes the image, tagged by SHA → Forgejo Actions SSHes to beta →
|
||||
`deploy/deploy.sh` (`git reset` + `docker login` + `docker compose pull && up`,
|
||||
schema migration runs in the app entrypoint) → Caddy fronts it with Let's Encrypt
|
||||
TLS.** Prod (on `release`) is deployed with `terraform apply`, not a push
|
||||
trigger — its `remote-exec` provisioner does the same pull-instead-of-build.
|
||||
(GitHub is retired/archived — Forgejo, self-hosted at `git.thermograph.org` /
|
||||
`10.10.0.2:3080` over the WireGuard mesh, is now the sole git host and CI.)
|
||||
Pipeline, both environments: **push to `main` (beta) or `release` (prod) on
|
||||
Forgejo → `build-push.yml` builds + pushes the image, tagged by SHA → the
|
||||
single `Deploy` workflow (`.forgejo/workflows/deploy.yml`) SSHes into **vps2**
|
||||
with `THERMOGRAPH_ENV=beta` or `THERMOGRAPH_ENV=prod` → `deploy/deploy.sh`
|
||||
resolves the right checkout/branch/stack from `env-topology.sh`, `git reset`s
|
||||
it, renders that environment's secrets, and execs `deploy/stack/deploy-stack.sh`
|
||||
(schema migration runs as a one-shot task before the roll) → each environment's
|
||||
own loopback Caddy LB fronts it with TLS.** Credentials are keyed by **host**
|
||||
(`VPS2_SSH_*`), not by environment — vps2 answers to both prod and beta, so the
|
||||
environment is passed as data (`THERMOGRAPH_ENV`), not baked into which secret
|
||||
is used. (GitHub is retired/archived — Forgejo, self-hosted at
|
||||
`git.thermograph.org` on **vps1** over the WireGuard mesh, is now the sole git
|
||||
host and CI.)
|
||||
|
||||
Files that make this work:
|
||||
|
||||
| File | Where it lives in prod | Purpose |
|
||||
| File | Where it lives on vps2 | Purpose |
|
||||
|------|------------------------|---------|
|
||||
| `.forgejo/workflows/deploy.yml` | Forgejo | CI job that SSHes in and runs the deploy script (beta, on `main`) |
|
||||
| `.forgejo/workflows/build-push.yml` | Forgejo | builds + pushes the SHA-tagged image `deploy.sh`/Terraform pull |
|
||||
| `deploy/deploy.sh` | `/opt/thermograph/deploy/deploy.sh` | `git reset` + `docker login` + `docker compose pull && up` + health check + warm |
|
||||
| `docker-compose.yml` | `/opt/thermograph/docker-compose.yml` | app + Postgres/TimescaleDB services (the process model) |
|
||||
| `deploy/thermograph.env.example` | `/etc/thermograph.env` | Postgres password, VAPID/auth secrets, `WORKERS`, sizing, base path |
|
||||
| `deploy/Caddyfile` | `/etc/caddy/Caddyfile` | reverse proxy + automatic HTTPS |
|
||||
| `terraform/` | run from your machine | provisions the box + brings the stack up (replaces `provision.sh`) |
|
||||
| `deploy/provision.sh`, `deploy/thermograph.service` | *(legacy)* | pre-compose venv+systemd bootstrap — superseded by Terraform |
|
||||
| `.forgejo/workflows/deploy.yml` | Forgejo | CI job that SSHes in and runs the deploy script, for all three environments |
|
||||
| `.forgejo/workflows/build-push.yml` | Forgejo | builds + pushes the SHA-tagged image `deploy.sh` pulls |
|
||||
| `deploy/env-topology.sh` | `/opt/thermograph{,-beta}/infra/deploy/env-topology.sh` | resolves `THERMOGRAPH_ENV` to a checkout, branch, stack name, ports, DB role |
|
||||
| `deploy/deploy.sh` | `/opt/thermograph{,-beta}/infra/deploy/deploy.sh` | `git reset` + render secrets + routes to the Swarm-stack deploy |
|
||||
| `deploy/stack/thermograph-stack.yml` / `thermograph-beta-stack.yml` | same | prod's / beta's Swarm stack (db, web, worker, lake, daemon, frontend; beta has no `db` of its own) |
|
||||
| `deploy/thermograph.env.example` | `/etc/thermograph.env` (prod) / `/etc/thermograph-beta.env` (beta) | Postgres password, VAPID/auth secrets, `WORKERS`, sizing, base path |
|
||||
| `deploy/stack/lb/Caddyfile` / `Caddyfile.beta` | each stack's loopback LB container | reverse proxy from the host's real Caddy into the Swarm overlay |
|
||||
| `terraform/` | run from your machine | provisions the box (host-level only; does not know about the two-environment split — see `ACCESS.md`) |
|
||||
| `deploy/provision.sh`, `deploy/thermograph.service` | *(legacy)* | pre-compose venv+systemd bootstrap — superseded by the Swarm stack |
|
||||
|
||||
The app serves on **loopback only**; Caddy is the only thing exposed to the
|
||||
internet (ports 80/443). The parquet cache in `data/cache/` lives inside the
|
||||
`/opt/thermograph` checkout and is gitignored, so `git pull` never touches it.
|
||||
Each app serves on **loopback only** (prod `127.0.0.1:8137`/`:8080`, beta
|
||||
`127.0.0.1:8237`/`:8180`); the host's real Caddy is the only thing exposed to
|
||||
the internet (ports 80/443), reverse-proxying into whichever loopback LB
|
||||
matches the domain requested. Each environment's own checkout keeps its own
|
||||
gitignored cache under `data/cache/`, so a `git pull` in one never touches the
|
||||
other's.
|
||||
|
||||
**Any deploy host needs a `/etc/hosts` entry for the registry.**
|
||||
`git.thermograph.org`'s public DNS resolves to beta's public IP, and beta's
|
||||
own Caddy rejects `/v2/*` (the registry API) from anything outside the
|
||||
`git.thermograph.org`'s public DNS resolves to **vps1's** public IP, and
|
||||
vps1's own Caddy rejects `/v2/*` (the registry API) from anything outside the
|
||||
WireGuard mesh (10.10.0.0/24) — confirmed live: `docker login`/`pull` from
|
||||
prod failed with a 403 until adding `10.10.0.2 git.thermograph.org` to
|
||||
`/etc/hosts` (10.10.0.2 is beta's mesh IP; every deploy host is already on
|
||||
the mesh, this is purely a DNS-routing gap, not a connectivity one). Beta
|
||||
vps2 failed with a 403 until adding `10.10.0.2 git.thermograph.org` to
|
||||
`/etc/hosts` (10.10.0.2 is vps1's mesh IP; every deploy host is already on
|
||||
the mesh, this is purely a DNS-routing gap, not a connectivity one). vps1
|
||||
itself doesn't need this — it's the box Forgejo runs on, so `git.thermograph.org`
|
||||
just resolves to itself either way. `build-push.yml`'s own header comment
|
||||
documents the same requirement for the CI runner host.
|
||||
|
||||
**Current production layout** (`deploy/Caddyfile`): the app owns
|
||||
**`thermograph.org`** at its root (`THERMOGRAPH_BASE=/`, so uvicorn serves `/`,
|
||||
`/calendar`, `/api/v2/…` with no prefix), and **`emigriffith.dev`** serves a
|
||||
static portfolio at its root with `emigriffith.dev/thermograph*` permanently
|
||||
redirecting to `thermograph.org` (prefix stripped). Both domains' `A` records
|
||||
point at the same VPS; Caddy provisions a separate cert for each.
|
||||
**Current production layout**: prod's Caddy config owns **`thermograph.org`**
|
||||
at its root (`THERMOGRAPH_BASE=/`, so uvicorn serves `/`, `/calendar`,
|
||||
`/api/v2/…` with no prefix) and beta's owns **`beta.thermograph.org`** the same
|
||||
way. **`emigriffith.dev`** is a separate static portfolio site, served from
|
||||
**vps1**, not vps2 — it shares no host, checkout or Caddy config with either
|
||||
app environment. `emigriffith.dev/thermograph*` permanently redirects to
|
||||
`thermograph.org` (prefix stripped).
|
||||
|
||||
> **Note:** `deploy.sh` (the CI deploy) only pulls code, reinstalls deps, and
|
||||
> restarts the app service — it does **not** touch Caddy or `/etc/thermograph.env`.
|
||||
|
|
@ -202,24 +211,32 @@ If the repo is **public**, skip this — `git pull` needs no auth.
|
|||
## Accounts & notifications
|
||||
|
||||
Accounts, subscriptions, and notifications are **authoritative and not
|
||||
regenerable** — back them up. On **prod/beta they live in Postgres/TimescaleDB**
|
||||
(the compose `db` service, on the `pgdata` volume) alongside the climate record;
|
||||
on **LAN dev** they fall back to SQLite (`data/accounts.sqlite`) when
|
||||
`THERMOGRAPH_DATABASE_URL` isn't a Postgres URL.
|
||||
regenerable** — back them up. **Prod, beta and dev all keep them in
|
||||
Postgres/TimescaleDB** — prod and beta on the one shared instance on vps2 (as
|
||||
the `thermograph` and `thermograph_beta` databases, separate roles, `CONNECT`
|
||||
revoked from `PUBLIC` — see `deploy/db/provision-env-db.sh`), dev in its own
|
||||
`db` container on vps1. SQLite (`data/accounts.sqlite`) is only a fallback for
|
||||
running the app **outside** any of these stacks (a bare venv, the test suite)
|
||||
when `THERMOGRAPH_DATABASE_URL` isn't a Postgres URL — it's not what any live
|
||||
environment actually uses.
|
||||
|
||||
- **Back up the Postgres volume** (e.g. `pg_dump` the `thermograph` database) as
|
||||
part of the VPS backup routine — losing it wipes all accounts and alerts. (On
|
||||
a SQLite LAN dev box, back up `data/accounts.sqlite` and its `-wal`/`-shm`
|
||||
sidecars instead.)
|
||||
- **Multiple workers, one notifier (leader election).** Prod runs several uvicorn
|
||||
workers (`WORKERS`, currently 8 on prod / 4 on beta). The subscription
|
||||
evaluator and the recurring scheduler must run in exactly one, so the workers
|
||||
elect a single leader — a host-local `flock` (`THERMOGRAPH_SINGLETON_LOCK`,
|
||||
set by the compose app service to `/app/data/notifier.lock`), or a cluster-wide
|
||||
Postgres advisory lock (`THERMOGRAPH_SINGLETON_PG=1`) if the notifier must be
|
||||
single across *hosts*. See `backend/core/singleton.py`. `THERMOGRAPH_ROLE`
|
||||
(`web`/`worker`/`all`, default `all`) further restricts which processes may own
|
||||
it, so a stateless web tier can scale without scaling notifiers.
|
||||
- **Back up the Postgres data** (e.g. `pg_dump` the relevant database) as part
|
||||
of each environment's backup routine — losing it wipes all accounts and
|
||||
alerts for that environment. (Only a bare-venv/offline run backs up
|
||||
`data/accounts.sqlite` and its `-wal`/`-shm` sidecars instead.)
|
||||
- **Multiple workers, one notifier (leader election).** Each environment runs
|
||||
several uvicorn workers (`WORKERS`, sized per environment — see
|
||||
`deploy/secrets/{prod,beta,dev}.yaml` and each stack file's replica/worker
|
||||
counts). The subscription evaluator and the recurring scheduler must run in
|
||||
exactly one, so the workers elect a single leader — a host-local `flock`
|
||||
(`THERMOGRAPH_SINGLETON_LOCK`, set by the compose/stack app service to
|
||||
`/app/data/notifier.lock`), or a cluster-wide Postgres advisory lock
|
||||
(`THERMOGRAPH_SINGLETON_PG=1`) if the notifier must be single across
|
||||
*replicas on multiple hosts* — this is what prod's Swarm stack uses, since
|
||||
`web` scales to N replicas under the autoscaler. See
|
||||
`backend/core/singleton.py`. `THERMOGRAPH_ROLE` (`web`/`worker`/`all`,
|
||||
default `all`) further restricts which processes may own it, so a stateless
|
||||
web tier can scale without scaling notifiers.
|
||||
- **Env vars** (all optional, sensible defaults):
|
||||
- `THERMOGRAPH_COOKIE_SECURE` — set to `1` when serving over HTTPS (the VPS/TLS
|
||||
deploy) so the session cookie is `Secure`. Leave unset for plain-HTTP LAN, where
|
||||
|
|
@ -296,9 +313,11 @@ Why route through a local MTA rather than a provider's API:
|
|||
`ReadWritePaths=…`) needs no change.
|
||||
|
||||
**Default is safe:** `THERMOGRAPH_MAIL_BACKEND` defaults to `console` — it logs
|
||||
the message and sends nothing — so LAN dev and the test suite exercise the whole
|
||||
signup path with no mail server and no risk of mailing a real person. Production
|
||||
opts in with `=smtp`.
|
||||
the message and sends nothing — so dev and the test suite exercise the whole
|
||||
signup path with no mail server and no risk of mailing a real person. Prod
|
||||
opts in with `=smtp`; beta stays on `console` too (see
|
||||
`deploy/stack/thermograph-beta-stack.yml`'s header for why beta must never
|
||||
hold live mail or Discord credentials).
|
||||
|
||||
### Postfix unit topology — and the check that lies
|
||||
|
||||
|
|
@ -431,8 +450,8 @@ Check placement with <https://www.mail-tester.com>, which scores all four at onc
|
|||
confirmation or password-reset link is mailed. It currently defaults to a
|
||||
per-boot random value, so every outstanding link would break on restart.
|
||||
- **Digest signups** land in the `pending_digest` table in the accounts DB
|
||||
(Postgres on prod/beta; authoritative, back it up). The form ships ahead of
|
||||
delivery on purpose:
|
||||
(Postgres on every environment; authoritative, back it up). The form ships
|
||||
ahead of delivery on purpose:
|
||||
addresses are collected now and confirmed once SMTP is live.
|
||||
- **Logs:** `journalctl -u postfix -f`; queue with `mailq`.
|
||||
|
||||
|
|
@ -464,26 +483,33 @@ regenerated with `python gen_cities.py [n_global] [n_english] [n_extended]`.
|
|||
proved unreliable (wrong matches), so they're edited by hand; a city not in the list
|
||||
simply shows its flavor blurb. Add entries freely — a test checks the slugs are valid.
|
||||
|
||||
- **Archive warming runs automatically on every deploy.** `deploy.sh` (prod) and
|
||||
`deploy-dev.sh` (dev) launch `warm_cities.py` detached in the background after the
|
||||
health check, so the ~750 city pages serve from cache and a crawl can't burst the
|
||||
archive API quota. It's idempotent (skips already-cached cells), so only the first
|
||||
deploy does the full ~25-min warm; later deploys just top up new cities. A page
|
||||
also self-heals (fetches its archive once) if hit before warming finishes. To run
|
||||
it by hand: `cd backend && python warm_cities.py --pace 2`. Logs: prod
|
||||
`logs/warm-cities.log`; dev `journalctl --user -u thermograph-warm-cities`.
|
||||
- **Archive warming runs automatically on every deploy of prod and dev — not
|
||||
beta.** `deploy/stack/deploy-stack.sh` (prod) and `deploy.sh`'s compose path
|
||||
(dev, via `deploy-dev.sh`) launch `warm_cities.py` detached in the background
|
||||
after the health check, so the ~750 city pages serve from cache and a crawl
|
||||
can't burst the archive API quota. Beta deliberately skips this
|
||||
(`TG_POST_DEPLOY=0` in `env-topology.sh`) — it would spend the shared
|
||||
upstream archive quota filling a cache only a rehearsal environment reads.
|
||||
Warming is idempotent (skips already-cached cells), so only the first deploy
|
||||
does the full ~25-min warm; later deploys just top up new cities. A page
|
||||
also self-heals (fetches its archive once) if hit before warming finishes. To
|
||||
run it by hand: `cd backend && python warm_cities.py --pace 2`. Logs land at
|
||||
`logs/warm-cities.log` inside each environment's own backend container (dev
|
||||
is a normal container stack on vps1 now, not a bare systemd unit; there's no
|
||||
`journalctl --user` path any more).
|
||||
- **Submit the sitemap** in Google Search Console: `https://thermograph.org/sitemap.xml`.
|
||||
- `jinja2` is a new dependency (already in `requirements.txt`).
|
||||
|
||||
## Monitoring
|
||||
|
||||
Fleet-wide logs and dashboards live in a **separate project** — a central
|
||||
Grafana + Loki stack fed by a Grafana Alloy agent on every node, over the
|
||||
WireGuard mesh (`thermograph-observability` on Forgejo; UI at
|
||||
**`https://dashboard.thermograph.org`**, Google SSO). It ingests every
|
||||
container's stdout, Caddy's access logs, and the app's structured JSON logs
|
||||
(`logs/{errors,access,audit}/*.jsonl`). This replaced the old SSH-tailed
|
||||
`scripts/dashboard.py`.
|
||||
Grafana + Loki stack, hosted on **vps1** alongside Forgejo, fed by a Grafana
|
||||
Alloy agent on every node over the WireGuard mesh (`thermograph-observability`
|
||||
on Forgejo; UI at **`https://dashboard.thermograph.org`**, Google SSO). It
|
||||
ingests every container's stdout, Caddy's access logs, and the app's
|
||||
structured JSON logs (`logs/{errors,access,audit}/*.jsonl`) — from prod and
|
||||
beta on vps2 as well as dev on vps1, each distinguishable by its `host`/service
|
||||
labels. This replaced the old SSH-tailed `scripts/dashboard.py`.
|
||||
|
||||
The app still exposes raw counters at the gated `GET /api/v2/metrics` route
|
||||
(loopback-only; refuses any request carrying a proxy `X-Forwarded-*` header, so
|
||||
|
|
|
|||
|
|
@ -30,8 +30,14 @@ db-up:
|
|||
db-down:
|
||||
docker compose stop db
|
||||
|
||||
# The LAN dev stack: uncapped (no CPU limits) and published on 0.0.0.0:8137 so
|
||||
# phones on the Wi-Fi can reach it (the base file is prod: capped + loopback).
|
||||
# The dev overlay: uncapped (no CPU limits). Default publish is 0.0.0.0:8137,
|
||||
# right for a laptop-local rehearsal (e.g. phones on the same Wi-Fi) -- this is
|
||||
# also the *fleet* dev environment's own overlay, but that environment now runs
|
||||
# on vps1 (a public VPS, not a LAN box) and deploys via deploy/deploy-dev.sh,
|
||||
# which overrides the bind address to the WireGuard mesh IP only
|
||||
# (DEV_BIND_ADDR, see deploy/env-topology.sh) -- never invoke `make dev-up`
|
||||
# directly on a fleet host, or it publishes on every interface. (The base file
|
||||
# is prod/beta's shape: capped + loopback, fronted by their own Caddy LB.)
|
||||
DEV_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||
dev-up:
|
||||
POSTGRES_PASSWORD=$${POSTGRES_PASSWORD:-thermograph-dev} $(DEV_COMPOSE) up -d --build
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
Infrastructure for [Thermograph](https://thermograph.org): Terraform host
|
||||
provisioning, the SOPS+age secrets vault, WireGuard/Swarm networking, Forgejo,
|
||||
Caddy, mail, and the deploy scripts that run the already-built app images on each
|
||||
host. This is a domain of the `emi/thermograph` monorepo — hosts' `/opt/thermograph`
|
||||
is a checkout of the whole monorepo, and `infra/` never builds app source; it only
|
||||
host. This is a domain of the `emi/thermograph` monorepo — a host's checkout
|
||||
(`/opt/thermograph`, `/opt/thermograph-beta`, or `/opt/thermograph-dev`) is a
|
||||
checkout of the whole monorepo, and `infra/` never builds app source; it only
|
||||
runs published images.
|
||||
|
||||
- **`terraform/`** — provisions/configures hosts (SSH-driven by default; an
|
||||
|
|
@ -13,37 +14,61 @@ runs published images.
|
|||
executable documentation, not a routine operation.
|
||||
- **`deploy/secrets/`** — the git-native SOPS+age secrets vault (every app secret,
|
||||
encrypted at rest, rendered at deploy time). See `deploy/secrets/README.md`.
|
||||
- **`deploy/swarm/`, `deploy/forgejo/`** — the WireGuard/Swarm cluster hosting
|
||||
Forgejo (git + CI + registry). See `ACCESS.md`.
|
||||
- **`deploy/deploy.sh`** — the single deploy entry point for beta and prod.
|
||||
Takes `SERVICE=backend|frontend|all` plus `BACKEND_IMAGE_TAG`/`FRONTEND_IMAGE_TAG`,
|
||||
resets the host checkout, renders secrets, and routes to the right orchestrator.
|
||||
- **`deploy/stack/`** — the **Swarm** path, live on **prod**:
|
||||
`thermograph-stack.yml` (db, web, worker, lake, daemon, frontend, autoscaler,
|
||||
autoscaler-lake), `deploy-stack.sh`, `autoscale.sh`, and the LB. Rolling updates
|
||||
are start-first, health-gated, with auto-rollback. `STACK_TEST=1` rehearses the
|
||||
whole stack on throwaway volumes and ports.
|
||||
- **`docker-compose*.yml`** — the **compose** path, live on **beta** and LAN dev
|
||||
(db, backend, lake, daemon, frontend). `docker-compose.dev.yml` is the LAN
|
||||
overlay; `docker-compose.openmeteo.yml` is the self-hosted Open-Meteo overlay.
|
||||
- **`deploy/swarm/`, `deploy/forgejo/`** — the WireGuard/Swarm mesh spanning
|
||||
vps1, vps2 and the desktop, and Forgejo (git + CI + registry), pinned to
|
||||
vps1. See `ACCESS.md`.
|
||||
- **`deploy/env-topology.sh`** — the single source of truth for where each
|
||||
environment (`dev`/`beta`/`prod`) lives: host, checkout path, branch, deploy
|
||||
mode, stack/compose name, env file, LB ports, DB role/database, service-name
|
||||
prefix. Every deploy path sources it and derives its behavior from it rather
|
||||
than guessing from the host it happens to be running on — necessary since
|
||||
vps2 alone now runs two environments.
|
||||
- **`deploy/deploy.sh`** — the single deploy entry point for dev, beta and prod.
|
||||
Takes `SERVICE=backend|frontend|all` plus `BACKEND_IMAGE_TAG`/`FRONTEND_IMAGE_TAG`
|
||||
(and, on vps2, `THERMOGRAPH_ENV=beta|prod` to say which of the two checkouts
|
||||
it's acting on), resets the host checkout, renders secrets, and routes to the
|
||||
right orchestrator per `env-topology.sh`.
|
||||
- **`deploy/deploy-dev.sh`** — a thin dev-specific wrapper around `deploy.sh`
|
||||
(dev compose overlay, dev's secrets policy). See `DEPLOY-DEV.md`.
|
||||
- **`deploy/stack/`** — the **Swarm** path, live on **vps2** for both prod and
|
||||
beta: `thermograph-stack.yml` (prod: db, web, worker, lake, daemon, frontend,
|
||||
autoscaler, autoscaler-lake) and `thermograph-beta-stack.yml` (beta: the same
|
||||
service shape minus `db` and the autoscalers, every service name prefixed
|
||||
`beta-`). `deploy-stack.sh`, `autoscale.sh` and `lb/` are shared by both.
|
||||
Rolling updates are start-first, health-gated, with auto-rollback.
|
||||
`STACK_TEST=1` rehearses the whole stack on throwaway volumes and ports.
|
||||
- **`docker-compose*.yml`** — the **compose** path, live only on **dev** (vps1)
|
||||
(db, backend, lake, daemon, frontend). `docker-compose.dev.yml` is dev's
|
||||
mesh-only overlay; `docker-compose.openmeteo.yml` is the self-hosted
|
||||
Open-Meteo overlay (prod only). `make dev-up` also runs this path locally as
|
||||
a laptop convenience — that is not an "environment", just a local rehearsal.
|
||||
|
||||
Which path a host takes is decided by `/etc/thermograph/deploy-mode`: the string
|
||||
`stack` makes `deploy.sh` exec `deploy/stack/deploy-stack.sh`; anything else is
|
||||
compose. The workflows never need to know which mode a host runs.
|
||||
Which path an environment takes is decided by `deploy/env-topology.sh`
|
||||
(`TG_DEPLOY_MODE`, keyed by `dev`/`beta`/`prod`): dev is `compose`, beta and prod
|
||||
are both `stack`. The old host-wide marker `/etc/thermograph/deploy-mode` still
|
||||
exists as a fallback for a by-hand run with no explicit environment, but it
|
||||
cannot describe vps2, which runs two environments in two different checkouts —
|
||||
so it is no longer the thing that decides where files go. The workflows never
|
||||
need to know which mode an environment runs; they only pass `THERMOGRAPH_ENV`.
|
||||
|
||||
## Branches & how changes reach each environment
|
||||
|
||||
- **`main`** — what **prod and beta** run. `infra-sync.yml` fires on a push to
|
||||
`main` touching `infra/**`, fast-forwards each host's `/opt/thermograph` checkout
|
||||
and re-renders `/etc/thermograph.env` from the vault. It deliberately does
|
||||
**not** roll any service: image tags are the app domains' axis, not infra's. A
|
||||
compose or stack change that must recreate containers takes effect on the next
|
||||
app deploy, or a by-hand `SERVICE=all … deploy/deploy.sh`.
|
||||
- **`dev`** — what LAN dev would run via `deploy/deploy-dev.sh`. The CI trigger
|
||||
for this is currently inert (the LAN box still holds a split-era checkout); use
|
||||
`make dev-up` locally.
|
||||
- **`release`** — consumed by app deploys only. Both hosts track infra via `main`;
|
||||
prod's *app images* are staged by `release`, but its checkout follows `main`.
|
||||
- **`dev`** — deploys to **dev on vps1** (`/opt/thermograph-dev`).
|
||||
- **`main`** — deploys to **beta on vps2** (`/opt/thermograph-beta`).
|
||||
- **`release`** — deploys to **prod on vps2** (`/opt/thermograph`).
|
||||
|
||||
Note the asymmetry with the app domains: app code IS environment-staged
|
||||
(`dev`→`main`→`release` maps to LAN→beta→prod via image tags); infra is not.
|
||||
App code IS environment-staged this way (`dev`→`main`→`release` maps to
|
||||
vps1/dev → vps2/beta → vps2/prod via image tags, one `Deploy` workflow keyed by
|
||||
branch). **Infra itself is not environment-staged** the same way: `infra-sync.yml`
|
||||
fires on a push touching `infra/**` — on `dev` it fast-forwards vps1's dev
|
||||
checkout, on `main` it fast-forwards **both** of vps2's checkouts (beta and
|
||||
prod) — re-rendering each environment's own env file from the vault. It
|
||||
deliberately does **not** roll any service: image tags are the app domains'
|
||||
axis, not infra's. A compose or stack change that must recreate containers
|
||||
takes effect on the next app deploy, or a by-hand `SERVICE=all …
|
||||
deploy/deploy.sh` (or `deploy-dev.sh`) run.
|
||||
|
||||
Note the asymmetry this leaves: dev's infra checkout tracks `dev`, the same
|
||||
branch its app images are staged by. Beta's and prod's infra checkouts both
|
||||
track `main` — prod's *app images* are staged by `release`, but prod's *infra
|
||||
checkout* follows `main`, same as beta's.
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
# /etc/caddy/Caddyfile on the VPS.
|
||||
# Each domain's A/AAAA record must already point at this VPS — Caddy provisions a
|
||||
# Let's Encrypt cert on first request and auto-renews. Nothing else to do for TLS
|
||||
# (just make sure ports 80 and 443 are open).
|
||||
#
|
||||
# Layout:
|
||||
# thermograph.org/* -> the Thermograph app (path-split across
|
||||
# backend/frontend -- see below)
|
||||
# emigriffith.dev/ -> static portfolio site (served straight from disk)
|
||||
# emigriffith.dev/thermograph* -> permanent redirect to thermograph.org (the app moved)
|
||||
#
|
||||
# Thermograph now owns thermograph.org's root, so both services run with
|
||||
# THERMOGRAPH_BASE=/ (see /etc/thermograph.env) — pages, assets and API all sit
|
||||
# at "/" with no sub-path prefix. Repo-split Stage 4: backend and frontend are
|
||||
# two containers now (docker-compose.yml), each on its own loopback port --
|
||||
# Caddy path-splits directly to whichever owns a given path, so the browser
|
||||
# still sees one apparent origin. Repo-split Stage 7a flipped which side owns
|
||||
# the enumerated list: frontend now owns everything (content pages, the
|
||||
# interactive tool's SPA shells, every static asset, the dynamic IndexNow key
|
||||
# file) except the short, stable set below, which mirrors backend/web/app.py's
|
||||
# own routing exactly (a single catch-all proxy to frontend for everything
|
||||
# else) -- unlike frontend's paths, backend's don't grow every time a new
|
||||
# static asset filename is added. A gap in this list still just degrades to
|
||||
# "one extra hop" through backend's own proxy fallback, never a 404.
|
||||
|
||||
thermograph.org {
|
||||
encode zstd gzip
|
||||
|
||||
@backend_paths path /api/* /digest /discord/interactions
|
||||
|
||||
# Active health check on the same cheap /healthz route each container's own
|
||||
# HEALTHCHECK uses (Dockerfile) — so a deploy that's still restarting/booting
|
||||
# never gets proxied into (a reload alone has no gate, hop-1 runbook hazard #10).
|
||||
# 15s (was 5s): plenty responsive for a process that only restarts on a deploy,
|
||||
# and a quarter of the polling load.
|
||||
handle @backend_paths {
|
||||
reverse_proxy 127.0.0.1:8137 {
|
||||
health_uri /healthz
|
||||
health_interval 15s
|
||||
health_timeout 3s
|
||||
health_status 2xx
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
health_uri /healthz
|
||||
health_interval 15s
|
||||
health_timeout 3s
|
||||
health_status 2xx
|
||||
}
|
||||
}
|
||||
|
||||
# Access-log hygiene: the default JSON encoder serializes full request headers,
|
||||
# the TLS block, and response headers on every line (measured ~1,133B/line) --
|
||||
# strip those with the `filter` format encoder. Also strip the query string from
|
||||
# the logged URI: Caddy's default logger records request.uri *including* the
|
||||
# query string, so every `?q=<search text>` a visitor typed sat in Loki next to
|
||||
# their client IP for the full 30-day retention -- a real privacy leak, not just
|
||||
# noise. Bot/crawler skipping stays out of here: `log_skip` needs Caddy >= 2.7
|
||||
# and an upgrade is out of scope, so that's handled downstream in Alloy's
|
||||
# loki.process "caddy" stage instead (see observability/alloy/config.alloy).
|
||||
log {
|
||||
output file /var/log/caddy/thermograph.log {
|
||||
roll_size 20MiB
|
||||
roll_keep 5
|
||||
}
|
||||
format filter {
|
||||
wrap json
|
||||
fields {
|
||||
request>headers delete
|
||||
request>tls delete
|
||||
resp_headers delete
|
||||
request>uri regexp \?.* ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emigriffith.dev {
|
||||
encode zstd gzip
|
||||
|
||||
# Thermograph moved to its own domain. Send the old sub-path there with a
|
||||
# permanent redirect, stripping the /thermograph prefix so deep links map
|
||||
# straight across (…/thermograph/calendar -> thermograph.org/calendar). The
|
||||
# bare /thermograph (no trailing slash) goes to the new root.
|
||||
handle_path /thermograph/* {
|
||||
redir https://thermograph.org{uri} permanent
|
||||
}
|
||||
handle /thermograph {
|
||||
redir https://thermograph.org/ permanent
|
||||
}
|
||||
|
||||
# Portfolio at the root. Point `root` at the built static site (for the Astro
|
||||
# portfolio that's its `dist/` output). file_server serves index.html for
|
||||
# directories and returns a real 404 for missing paths.
|
||||
handle {
|
||||
root * /var/www/emigriffith
|
||||
file_server
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/emigriffith.log
|
||||
}
|
||||
}
|
||||
|
||||
# Old bookmarks to the raw IP (the pre-domain URL) would otherwise get bounced to
|
||||
# HTTPS-on-the-IP, which has no cert and fails. Redirect them to the portfolio domain.
|
||||
http://75.119.132.91 {
|
||||
redir https://emigriffith.dev{uri} permanent
|
||||
}
|
||||
|
||||
# Optional: redirect www -> apex for either domain. Add the www CNAME/A record
|
||||
# first, then uncomment the matching block.
|
||||
# www.emigriffith.dev {
|
||||
# redir https://emigriffith.dev{uri} permanent
|
||||
# }
|
||||
# www.thermograph.org {
|
||||
# redir https://thermograph.org{uri} permanent
|
||||
# }
|
||||
61
infra/deploy/Caddyfile.vps1
Normal file
61
infra/deploy/Caddyfile.vps1
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# /etc/caddy/Caddyfile on **vps1** (75.119.132.91) — the operational-programs box.
|
||||
#
|
||||
# vps1 serves:
|
||||
# emigriffith.dev -> static portfolio site (from disk)
|
||||
# git.thermograph.org -> Forgejo (see deploy/forgejo/caddy-git.conf)
|
||||
# dashboard.thermograph.org -> Grafana (see observability/caddy-grafana.conf)
|
||||
#
|
||||
# and hosts the **dev** environment, which is deliberately ABSENT from this
|
||||
# file. Dev has no site block, no DNS record and no certificate: it publishes on
|
||||
# the WireGuard address only (http://10.10.0.2:8137) and is reachable from the
|
||||
# mesh and nowhere else. Dev runs whatever branch is in flight, on the same box
|
||||
# as Forgejo and its CI — putting it behind a public hostname would be handing
|
||||
# the internet an unreviewed build. If you ever need it reachable from a phone,
|
||||
# join the phone to the mesh; do not add a site block here.
|
||||
#
|
||||
# beta.thermograph.org is NOT here either — beta moved to vps2, next to prod.
|
||||
# That is the one edit in this file most likely to be made by mistake: a "beta"
|
||||
# reference elsewhere in the repo that still points at 75.119.132.91 means this
|
||||
# box, which no longer runs beta at all.
|
||||
#
|
||||
# Each domain's A/AAAA record must already point here — Caddy provisions a
|
||||
# Let's Encrypt cert on first request and auto-renews.
|
||||
|
||||
emigriffith.dev {
|
||||
encode zstd gzip
|
||||
|
||||
# Thermograph moved to its own domain. Send the old sub-path there with a
|
||||
# permanent redirect, stripping the /thermograph prefix so deep links map
|
||||
# straight across (…/thermograph/calendar -> thermograph.org/calendar). The
|
||||
# bare /thermograph (no trailing slash) goes to the new root.
|
||||
handle_path /thermograph/* {
|
||||
redir https://thermograph.org{uri} permanent
|
||||
}
|
||||
handle /thermograph {
|
||||
redir https://thermograph.org/ permanent
|
||||
}
|
||||
|
||||
# Portfolio at the root. Point `root` at the built static site (for the Astro
|
||||
# portfolio that's its `dist/` output). file_server serves index.html for
|
||||
# directories and returns a real 404 for missing paths.
|
||||
handle {
|
||||
root * /var/www/emigriffith
|
||||
file_server
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/emigriffith.log
|
||||
}
|
||||
}
|
||||
|
||||
# Old bookmarks to the raw IP (the pre-domain URL) would otherwise get bounced to
|
||||
# HTTPS-on-the-IP, which has no cert and fails. Redirect them to the portfolio domain.
|
||||
http://75.119.132.91 {
|
||||
redir https://emigriffith.dev{uri} permanent
|
||||
}
|
||||
|
||||
# Optional: redirect www -> apex. Add the www CNAME/A record first, then
|
||||
# uncomment.
|
||||
# www.emigriffith.dev {
|
||||
# redir https://emigriffith.dev{uri} permanent
|
||||
# }
|
||||
145
infra/deploy/Caddyfile.vps2
Normal file
145
infra/deploy/Caddyfile.vps2
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# /etc/caddy/Caddyfile on **vps2** (169.58.46.181) — the public-facing box.
|
||||
#
|
||||
# vps2 serves BOTH environments an external user can reach:
|
||||
# thermograph.org -> prod (loopback LB on 127.0.0.1:8137 / :8080)
|
||||
# beta.thermograph.org -> beta (loopback LB on 127.0.0.1:8237 / :8180)
|
||||
#
|
||||
# and Centralis at mcp.thermograph.org, which is provisioned separately.
|
||||
#
|
||||
# Each domain's A/AAAA record must already point here — Caddy provisions a
|
||||
# Let's Encrypt cert on first request and auto-renews. Nothing else to do for
|
||||
# TLS (just make sure ports 80 and 443 are open).
|
||||
#
|
||||
# This file was split out of a single deploy/Caddyfile that described both
|
||||
# boxes' sites at once. That was workable while each box served an unrelated
|
||||
# set; it stopped being workable when the two ports 8137/8080 started meaning
|
||||
# "prod on vps2" here and nothing at all on the other box. See Caddyfile.vps1
|
||||
# for git/dashboard/portfolio.
|
||||
#
|
||||
# Thermograph owns thermograph.org's root, so both services run with
|
||||
# THERMOGRAPH_BASE=/ — pages, assets and API all sit at "/" with no sub-path
|
||||
# prefix. Backend and frontend are separate containers, each on its own loopback
|
||||
# port; Caddy path-splits directly to whichever owns a given path, so the
|
||||
# browser still sees one apparent origin. The enumerated backend list below
|
||||
# mirrors backend/web/app.py's own routing exactly (a single catch-all proxy to
|
||||
# frontend for everything else) -- unlike frontend's paths, backend's don't grow
|
||||
# every time a new static asset filename is added. A gap in this list still just
|
||||
# degrades to "one extra hop" through backend's own proxy fallback, never a 404.
|
||||
|
||||
thermograph.org {
|
||||
encode zstd gzip
|
||||
|
||||
@backend_paths path /api/* /digest /discord/interactions
|
||||
|
||||
# Active health check on the same cheap /healthz route each container's own
|
||||
# HEALTHCHECK uses (Dockerfile) — so a deploy that's still restarting/booting
|
||||
# never gets proxied into (a reload alone has no gate, hop-1 runbook hazard #10).
|
||||
# 15s (was 5s): plenty responsive for a process that only restarts on a deploy,
|
||||
# and a quarter of the polling load.
|
||||
handle @backend_paths {
|
||||
reverse_proxy 127.0.0.1:8137 {
|
||||
health_uri /healthz
|
||||
health_interval 15s
|
||||
health_timeout 3s
|
||||
health_status 2xx
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
health_uri /healthz
|
||||
health_interval 15s
|
||||
health_timeout 3s
|
||||
health_status 2xx
|
||||
}
|
||||
}
|
||||
|
||||
# Access-log hygiene: the default JSON encoder serializes full request headers,
|
||||
# the TLS block, and response headers on every line (measured ~1,133B/line) --
|
||||
# strip those with the `filter` format encoder. Also strip the query string from
|
||||
# the logged URI: Caddy's default logger records request.uri *including* the
|
||||
# query string, so every `?q=<search text>` a visitor typed sat in Loki next to
|
||||
# their client IP for the full 30-day retention -- a real privacy leak, not just
|
||||
# noise. Bot/crawler skipping stays out of here: `log_skip` needs Caddy >= 2.7
|
||||
# and an upgrade is out of scope, so that's handled downstream in Alloy's
|
||||
# loki.process "caddy" stage instead (see observability/alloy/config.alloy).
|
||||
#
|
||||
# The FILENAME is load-bearing now: Alloy attributes each access log to an
|
||||
# environment by filename (thermograph.log -> host="prod", beta.log ->
|
||||
# host="beta"). One Caddy fronts two environments here, so a single log file
|
||||
# would file every beta request under prod. Renaming either file means
|
||||
# updating loki.process "caddy" in observability/alloy/config.alloy.
|
||||
log {
|
||||
output file /var/log/caddy/thermograph.log {
|
||||
roll_size 20MiB
|
||||
roll_keep 5
|
||||
}
|
||||
format filter {
|
||||
wrap json
|
||||
fields {
|
||||
request>headers delete
|
||||
request>tls delete
|
||||
resp_headers delete
|
||||
request>uri regexp \?.* ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Beta — same shape as prod, pointed at beta's loopback LB. It moved here from
|
||||
# its own box; the DNS A record for beta.thermograph.org must point at vps2.
|
||||
#
|
||||
# Deliberately NOT indexed: beta serves the same content as prod at a different
|
||||
# hostname, which is a duplicate-content problem for search engines and an
|
||||
# invitation for users to land on a rehearsal environment from a search result.
|
||||
# The app itself never pings IndexNow from beta (see env-topology.sh's
|
||||
# TG_POST_DEPLOY), and this header closes the other half.
|
||||
beta.thermograph.org {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
X-Robots-Tag "noindex, nofollow"
|
||||
}
|
||||
|
||||
@backend_paths path /api/* /digest /discord/interactions
|
||||
|
||||
handle @backend_paths {
|
||||
reverse_proxy 127.0.0.1:8237 {
|
||||
health_uri /healthz
|
||||
health_interval 15s
|
||||
health_timeout 3s
|
||||
health_status 2xx
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:8180 {
|
||||
health_uri /healthz
|
||||
health_interval 15s
|
||||
health_timeout 3s
|
||||
health_status 2xx
|
||||
}
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/beta.log {
|
||||
roll_size 20MiB
|
||||
roll_keep 5
|
||||
}
|
||||
format filter {
|
||||
wrap json
|
||||
fields {
|
||||
request>headers delete
|
||||
request>tls delete
|
||||
resp_headers delete
|
||||
request>uri regexp \?.* ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Optional: redirect www -> apex. Add the www CNAME/A record first, then
|
||||
# uncomment.
|
||||
# www.thermograph.org {
|
||||
# redir https://thermograph.org{uri} permanent
|
||||
# }
|
||||
409
infra/deploy/RUNBOOK-vps1-vps2-cutover.md
Normal file
409
infra/deploy/RUNBOOK-vps1-vps2-cutover.md
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
# Runbook — moving beta to vps2 and dev to vps1
|
||||
|
||||
**Status: not executed.** Everything this runbook describes is landed in the
|
||||
repo and live nowhere. The estate still runs the old shape until someone works
|
||||
through the steps below.
|
||||
|
||||
## What changes
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| `75.119.132.91` (**vps1**) | beta + Forgejo + Grafana/Loki | Forgejo + Grafana/Loki + **dev** |
|
||||
| `169.58.46.181` (**vps2**) | prod + Centralis + Postfix + backups | **prod + beta** + Centralis + Postfix + backups |
|
||||
| desktop | LAN dev server + CI runner | AI models + flex Swarm worker, **no environment** |
|
||||
| databases | one Postgres per environment | one instance on vps2, `thermograph` + `thermograph_beta` |
|
||||
|
||||
Mesh addresses do **not** move: vps1 stays `10.10.0.2`, vps2 stays `10.10.0.1`.
|
||||
Every "beta = 75.119.132.91" reference anywhere is wrong after this — that
|
||||
address is vps1, and sending a beta-intended command there is the most likely
|
||||
way to do damage during this cutover.
|
||||
|
||||
## Order, and why it is this order
|
||||
|
||||
Beta moves first and completely, while dev stays where it is. Then dev moves.
|
||||
The two halves are independent, so a problem in one never forces a rollback of
|
||||
the other — and beta is the half that carries a public hostname and a TLS
|
||||
certificate, so it gets done while you have the most attention.
|
||||
|
||||
Prod is touched twice, and both touches are additive: a new database + role on
|
||||
its Postgres instance, and a new site block in its Caddyfile. Prod's stack file,
|
||||
its services, its volumes and its env file are not modified by this work at all.
|
||||
|
||||
**Expected downtime:** beta, roughly the length of a dump/restore plus a stack
|
||||
bring-up (~10–20 min). Dev, as long as you like. **Prod: none** — unless step 3
|
||||
or 8 is done wrong, which is what the verification lines are for.
|
||||
|
||||
---
|
||||
|
||||
## 0. Before you start
|
||||
|
||||
- [ ] **Lower the DNS TTL** on `beta.thermograph.org` to 300s, at least an hour
|
||||
before step 7. Do this first; it is the only step with a lead time.
|
||||
- [ ] **Create the new Forgejo Actions secrets** (Settings → Secrets). The
|
||||
workflows on this branch reference them and will fail without them:
|
||||
|
||||
VPS1_SSH_HOST=75.119.132.91 VPS1_SSH_USER=agent
|
||||
VPS1_SSH_KEY=<the CI deploy key> VPS1_SSH_PORT=22
|
||||
VPS2_SSH_HOST=169.58.46.181 VPS2_SSH_USER=agent
|
||||
VPS2_SSH_KEY=<the CI deploy key> VPS2_SSH_PORT=22
|
||||
|
||||
Keep the old `SSH_*` / `PROD_SSH_*` secrets until step 11 — they are the
|
||||
rollback path, and deleting them early strands you.
|
||||
- [ ] **Take a fresh prod backup and confirm it landed**, rather than trusting
|
||||
the schedule:
|
||||
|
||||
```
|
||||
# from a machine with repo access
|
||||
# Actions -> "Ops cron (backup + IndexNow)" -> Run workflow
|
||||
ssh agent@169.58.46.181 'ls -lh ~/thermograph-backups | tail -3'
|
||||
```
|
||||
- [ ] **Confirm you can decrypt the vault** (`sops -d infra/deploy/secrets/beta.yaml`
|
||||
from `infra/`). Steps 3 and 5 need the rendered beta password.
|
||||
- [ ] Have the PR merged to `dev` and promoted to `main`. Hosts pull `main`, so
|
||||
nothing below works from an unmerged branch.
|
||||
|
||||
---
|
||||
|
||||
## 1. Give beta a checkout on vps2
|
||||
|
||||
```
|
||||
ssh agent@169.58.46.181
|
||||
sudo git clone --branch main http://10.10.0.2:3080/emi/thermograph.git /opt/thermograph-beta
|
||||
sudo chown -R agent:agent /opt/thermograph-beta
|
||||
```
|
||||
|
||||
Prod's checkout at `/opt/thermograph` is untouched. Two checkouts side by side
|
||||
is the whole point: a `git reset --hard` for beta must never be able to move
|
||||
prod's tree.
|
||||
|
||||
**Verify:** `git -C /opt/thermograph-beta log --oneline -1` matches
|
||||
`git -C /opt/thermograph log --oneline -1`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Render beta's env file on vps2
|
||||
|
||||
```
|
||||
ssh agent@169.58.46.181
|
||||
cd /opt/thermograph-beta/infra
|
||||
. deploy/render-secrets.sh
|
||||
render_thermograph_secrets /opt/thermograph-beta/infra beta /etc/thermograph-beta.env
|
||||
```
|
||||
|
||||
The age key is already at `/etc/thermograph/age.key` on this box (prod uses it);
|
||||
beta needs no second copy.
|
||||
|
||||
**Verify** — beta's file must name beta's role and database, and prod's must be
|
||||
untouched:
|
||||
|
||||
```
|
||||
sudo grep -c . /etc/thermograph-beta.env # non-zero
|
||||
sudo grep -o 'thermograph_beta' /etc/thermograph-beta.env | head -1
|
||||
sudo grep -o '@db:5432/thermograph$' /etc/thermograph.env # prod: still 'thermograph'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Create beta's role and database on the shared instance
|
||||
|
||||
This is the first step that touches prod's Postgres. It only ever adds; it drops
|
||||
nothing.
|
||||
|
||||
```
|
||||
ssh agent@169.58.46.181
|
||||
sudo bash /opt/thermograph-beta/infra/deploy/db/provision-env-db.sh beta
|
||||
```
|
||||
|
||||
**Verify the isolation is real** — this is the whole justification for one
|
||||
instance serving two environments, so actually run it:
|
||||
|
||||
```
|
||||
DBC=$(docker ps -q --filter label=com.docker.swarm.service.name=thermograph_db | head -1)
|
||||
# beta's role must NOT be able to reach prod's database:
|
||||
docker exec "$DBC" psql -U thermograph_beta -d thermograph -c 'select 1' # must FAIL
|
||||
# beta's read-only role exists and really is read-only:
|
||||
docker exec "$DBC" psql -U thermograph_beta_ro -d thermograph_beta -c 'select 1' # must SUCCEED
|
||||
docker exec "$DBC" psql -U thermograph_beta_ro -d thermograph_beta \
|
||||
-c 'create table _x(i int)' # must FAIL
|
||||
# prod's own role is UNTOUCHED — still a superuser:
|
||||
docker exec "$DBC" psql -U thermograph -d postgres -tAc \
|
||||
"select rolsuper from pg_roles where rolname='thermograph'" # must be t
|
||||
# and prod is still healthy:
|
||||
curl -fsS -o /dev/null -w '%{http_code}\n' https://thermograph.org/api/health
|
||||
```
|
||||
|
||||
The script **refuses to run for prod** — prod's `thermograph` role is the
|
||||
instance's bootstrap superuser, not a guest, and provisioning it would demote
|
||||
it. That guard is why the superuser check above should never fail; run it
|
||||
anyway.
|
||||
|
||||
If the first command SUCCEEDS, stop. `CONNECT` was not revoked and beta would be
|
||||
able to read production data; re-run the provisioning script and re-check before
|
||||
going further.
|
||||
|
||||
**Rollback:** `DROP DATABASE thermograph_beta; DROP ROLE thermograph_beta;` —
|
||||
prod is unaffected either way.
|
||||
|
||||
---
|
||||
|
||||
## 4. Move beta's data (optional)
|
||||
|
||||
Beta's existing database still lives on vps1. Decide honestly whether you want
|
||||
it: beta's value is a rehearsal of prod, and a fresh schema with a handful of
|
||||
test accounts is often *better* than carrying old beta state across. If you do
|
||||
want it:
|
||||
|
||||
```
|
||||
# on vps1 — dump the old beta database
|
||||
ssh agent@75.119.132.91 \
|
||||
'docker exec thermograph-db-1 pg_dump -U thermograph -d thermograph --format=custom' \
|
||||
> /tmp/beta-premove.dump
|
||||
|
||||
# on vps2 — restore into beta's database as beta's role
|
||||
scp /tmp/beta-premove.dump agent@169.58.46.181:/tmp/
|
||||
ssh agent@169.58.46.181 '
|
||||
DBC=$(docker ps -q --filter label=com.docker.swarm.service.name=thermograph_db | head -1)
|
||||
docker cp /tmp/beta-premove.dump "$DBC":/tmp/
|
||||
docker exec "$DBC" pg_restore -U thermograph -d thermograph_beta --no-owner --role=thermograph_beta /tmp/beta-premove.dump
|
||||
'
|
||||
```
|
||||
|
||||
`--no-owner --role=thermograph_beta` matters: the dump's objects are owned by the
|
||||
old `thermograph` role, and restoring them verbatim would leave beta's tables
|
||||
owned by a role beta does not connect as.
|
||||
|
||||
**Do not** restore a *prod* dump into beta. Beta's daemon runs the notification
|
||||
timers, and beta's subscriber table would then be full of real people. Beta's
|
||||
vault sets `THERMOGRAPH_MAIL_BACKEND=console` so nothing would actually send,
|
||||
but that is one `sops edit` away from not being true.
|
||||
|
||||
---
|
||||
|
||||
## 5. Bring beta's stack up on vps2
|
||||
|
||||
```
|
||||
ssh agent@169.58.46.181
|
||||
cd /opt/thermograph-beta
|
||||
THERMOGRAPH_ENV=beta SERVICE=all \
|
||||
BACKEND_IMAGE_TAG=<current beta backend tag> \
|
||||
FRONTEND_IMAGE_TAG=<current beta frontend tag> \
|
||||
infra/deploy/deploy.sh
|
||||
```
|
||||
|
||||
Get the current tags from the old beta host first
|
||||
(`cat /opt/thermograph/infra/deploy/.image-tags.env` on vps1), so beta comes up
|
||||
on exactly the code it was already running.
|
||||
|
||||
**Verify** — beta answers on its own loopback ports, and prod's are unmoved:
|
||||
|
||||
```
|
||||
curl -fsS -o /dev/null -w 'beta %{http_code}\n' http://127.0.0.1:8237/healthz
|
||||
curl -fsS -o /dev/null -w 'prod %{http_code}\n' http://127.0.0.1:8137/healthz
|
||||
docker stack services thermograph-beta # 5 services, all 1/1
|
||||
docker stack services thermograph # prod: unchanged, still 1/1
|
||||
```
|
||||
|
||||
**Verify the DNS-collision fix actually held** — this is the failure mode that
|
||||
would be subtle and awful in production:
|
||||
|
||||
```
|
||||
# prod's frontend must resolve prod's web, not beta's
|
||||
docker exec $(docker ps -q -f label=com.docker.swarm.service.name=thermograph_frontend | head -1) \
|
||||
getent hosts web
|
||||
docker exec $(docker ps -q -f label=com.docker.swarm.service.name=thermograph_frontend | head -1) \
|
||||
getent hosts beta-web # should NOT resolve on prod's network
|
||||
```
|
||||
|
||||
**Rollback:** `docker stack rm thermograph-beta` and remove the LB container
|
||||
(`docker rm -f thermograph-beta-lb`). Beta on vps1 is still running and still
|
||||
serving; nothing has moved yet.
|
||||
|
||||
---
|
||||
|
||||
## 6. Add beta's site block to vps2's Caddy
|
||||
|
||||
`infra/deploy/Caddyfile.vps2` is the reference copy. Append its
|
||||
`beta.thermograph.org` block to the live `/etc/caddy/Caddyfile` on vps2.
|
||||
|
||||
```
|
||||
ssh agent@169.58.46.181
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile # BEFORE reloading
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
Validate before reload, always: a malformed Caddyfile takes `thermograph.org`
|
||||
down with it, and prod is on this box now.
|
||||
|
||||
Caddy cannot issue the certificate until DNS moves (step 7), so expect the beta
|
||||
hostname to fail TLS until then. That is fine and expected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Move DNS
|
||||
|
||||
Point `beta.thermograph.org`'s A record at **169.58.46.181**.
|
||||
|
||||
Then wait for propagation and the certificate:
|
||||
|
||||
```
|
||||
dig +short beta.thermograph.org # 169.58.46.181
|
||||
curl -fsS -o /dev/null -w '%{http_code}\n' https://beta.thermograph.org/api/health
|
||||
ssh agent@169.58.46.181 'sudo journalctl -u caddy -n 30 --no-pager | grep -i certificate'
|
||||
```
|
||||
|
||||
**Rollback:** point the A record back at 75.119.132.91. Beta on vps1 is still
|
||||
up (you have not stopped it yet — that is step 9), so this is a clean revert
|
||||
for as long as you leave it running.
|
||||
|
||||
---
|
||||
|
||||
## 8. Stand dev up on vps1
|
||||
|
||||
Independent of everything above; do it whenever.
|
||||
|
||||
```
|
||||
ssh agent@75.119.132.91
|
||||
sudo bash /opt/thermograph-dev/infra/deploy/provision-dev.sh # clones if absent
|
||||
```
|
||||
|
||||
The script writes `/etc/thermograph/secrets-env` = `dev` and removes any
|
||||
`deploy-mode` marker. Then deploy:
|
||||
|
||||
```
|
||||
cd /opt/thermograph-dev
|
||||
SERVICE=all BACKEND_IMAGE_TAG=<dev tag> FRONTEND_IMAGE_TAG=<dev tag> \
|
||||
infra/deploy/deploy-dev.sh
|
||||
```
|
||||
|
||||
**Verify it is mesh-only.** This is the security-relevant check of the whole
|
||||
cutover — vps1 is a public box and dev runs unreviewed branches:
|
||||
|
||||
```
|
||||
ss -ltnp | grep 8137 # MUST show 10.10.0.2:8137, never 0.0.0.0:8137
|
||||
curl -fsS -o /dev/null -w '%{http_code}\n' http://10.10.0.2:8137/healthz # from the mesh
|
||||
curl --max-time 5 http://75.119.132.91:8137/healthz # MUST fail/refuse
|
||||
```
|
||||
|
||||
Also confirm dev did **not** get the fleet's shared production credentials —
|
||||
`dev.yaml` is rendered alone, and vps1 is exactly the box that must not hold
|
||||
them:
|
||||
|
||||
```
|
||||
sudo grep -c 'THERMOGRAPH_S3_SECRET_KEY\|REGISTRY_TOKEN\|VAPID_PRIVATE' /etc/thermograph.env # expect 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Retire beta from vps1
|
||||
|
||||
Only after beta has been serving from vps2 through step 7 for long enough that
|
||||
you believe it.
|
||||
|
||||
```
|
||||
ssh agent@75.119.132.91
|
||||
cd /opt/thermograph/infra
|
||||
docker compose down # stops the old beta app stack
|
||||
# remove beta's site block from /etc/caddy/Caddyfile (see Caddyfile.vps1)
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
**Keep the old `thermograph_pgdata` volume on vps1** until you are certain beta
|
||||
on vps2 is healthy and backed up. It is the only copy of pre-move beta data.
|
||||
Delete it deliberately, later, not as part of this runbook.
|
||||
|
||||
Forgejo, Grafana, Loki and the portfolio site all keep running on this box
|
||||
untouched — do not stop anything else here.
|
||||
|
||||
---
|
||||
|
||||
## 10. Repoint the Alloy agents
|
||||
|
||||
Both nodes need the new variables; the label model changed (see
|
||||
`observability/alloy/config.alloy`).
|
||||
|
||||
```
|
||||
# vps2 — two environments, so two log volumes and the beta overlay
|
||||
ssh agent@169.58.46.181
|
||||
cd /opt/thermograph/observability/alloy
|
||||
ALLOY_NODE=vps2 ALLOY_ENV=prod \
|
||||
APPLOGS_VOLUME=thermograph_applogs BETA_APPLOGS_VOLUME=thermograph-beta_applogs \
|
||||
LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push \
|
||||
docker compose -f docker-compose.agent.yml -f docker-compose.agent.beta.yml up -d
|
||||
|
||||
# vps1
|
||||
ssh agent@75.119.132.91
|
||||
cd /opt/thermograph/observability/alloy
|
||||
ALLOY_NODE=vps1 ALLOY_ENV=dev APPLOGS_VOLUME=thermograph-dev_applogs \
|
||||
LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push \
|
||||
docker compose -f docker-compose.agent.yml up -d
|
||||
```
|
||||
|
||||
**Verify each environment is labelled as itself** — the point of the change is
|
||||
that beta's logs on vps2 are not filed as prod:
|
||||
|
||||
```
|
||||
# in Grafana Explore, or via the Loki API:
|
||||
# {host="beta"} |= "" -> should show beta traffic, from node="vps2"
|
||||
# {host="prod"} |= "" -> prod only
|
||||
# {host="dev"} |= "" -> from node="vps1"
|
||||
# count by (host) (count_over_time({job="docker"}[5m])) -> three values, not one
|
||||
```
|
||||
|
||||
If `{host="beta"}` is empty while beta is clearly serving, the container-name
|
||||
relabel did not match — check `docker ps --format '{{.Names}}'` on vps2 against
|
||||
the `/thermograph-beta_.*` regex.
|
||||
|
||||
---
|
||||
|
||||
## 11. Clean up
|
||||
|
||||
- [ ] Delete the old `SSH_*` and `PROD_SSH_*` Forgejo secrets (only now — they
|
||||
were the rollback path).
|
||||
- [ ] Run the ops cron by hand and confirm **both** databases are dumped:
|
||||
`ssh agent@169.58.46.181 'ls ~/thermograph-backups | tail -4'` should show
|
||||
both a `thermograph-*.dump` and a `thermograph_beta-*.dump`.
|
||||
- [ ] Confirm the Forgejo backup job still works — it now uses `VPS1_SSH_*`,
|
||||
and it follows Forgejo (vps1), not beta.
|
||||
- [ ] Restore the DNS TTL on `beta.thermograph.org`.
|
||||
- [ ] Desktop: stop the old LAN dev stack and free the box for the AI models.
|
||||
Leave it joined to the Swarm as a worker; nothing schedules onto it today
|
||||
(every app service is pinned `node.role == manager`), which is what makes
|
||||
it safe to also run models there.
|
||||
- [ ] Update Centralis — see below.
|
||||
|
||||
---
|
||||
|
||||
## Centralis (outside this repo)
|
||||
|
||||
Centralis' own text still describes the old estate, and it is not in this
|
||||
repository. These need a separate change in the Centralis repo:
|
||||
|
||||
- The `onboarding` tool's estate paragraph ("Four machines on a WireGuard
|
||||
mesh... beta (beta.thermograph.org, Forgejo, Grafana + Loki), the operator's
|
||||
desktop (LAN dev, the main CI runner)").
|
||||
- The MCP server's own protocol-level `instructions` string, which carries a
|
||||
second, separately-worded copy of the same paragraph.
|
||||
- The `thermograph-orientation` skill's estate table, and `thermograph-ops`'
|
||||
release-flow diagram ("LAN dev box"), its "ssh -L works for beta and is
|
||||
impossible for prod" claim (beta is a Swarm overlay now, so it behaves like
|
||||
prod), and its prod-only Swarm service-name list (beta has
|
||||
`beta-`-prefixed ones now).
|
||||
- Tool descriptions that hardcode the old shape: `fleet_status` ("Swarm services
|
||||
on prod, compose containers on beta/dev"), `estate_status` and `rollback_to`
|
||||
("Centralis cannot SSH to the dev desktop"), `logs_query`'s per-environment
|
||||
service lists (Forgejo/Grafana/Loki are listed under `beta`; they belong under
|
||||
`dev`/vps1 now), and `secrets_render`'s assumption that a host's own
|
||||
`secrets-env` marker identifies one environment — vps2 has two.
|
||||
|
||||
---
|
||||
|
||||
## If it goes wrong
|
||||
|
||||
| symptom | most likely cause | action |
|
||||
|---|---|---|
|
||||
| prod 502s after step 5 | beta's stack collided with prod's service DNS | `docker stack rm thermograph-beta`; prod recovers on its own. Check beta's services are `beta-`-prefixed |
|
||||
| beta 502s, prod fine | beta's LB or stack not up | `docker ps \| grep beta-lb`; `docker stack ps thermograph-beta --no-trunc` |
|
||||
| beta can't reach the database | role/database missing, or the wrong network | re-run `provision-env-db.sh beta`; confirm beta's tasks are on `thermograph_internal` |
|
||||
| beta serves prod's data | beta's env file has prod's URL | check `/etc/thermograph-beta.env` names `thermograph_beta` twice; re-render (step 2). **Stop beta until fixed** |
|
||||
| prod's nightly backup fails | `thermograph_beta` doesn't exist yet | either finish step 3 or revert the ops-cron change; the job fails loudly by design rather than skipping silently |
|
||||
| dev reachable from the internet | `DEV_BIND_ADDR` not applied | `ss -ltnp \| grep 8137`; redeploy via `deploy-dev.sh`, which sets it from `env-topology.sh` |
|
||||
|
|
@ -6,11 +6,21 @@ single VPS. Driven by `.forgejo/workflows/ops-cron.yml` (03:00 UTC daily +
|
|||
|
||||
## What is backed up
|
||||
|
||||
| Prefix in bucket `era5-thermograph` | Source | Job | Retention off-box |
|
||||
|---|---|---|---|
|
||||
| `backups/db/prod/` | prod Postgres/TimescaleDB (`pg_dump --format=custom`) | `backup` (prod, `PROD_SSH_*`) | 30 days |
|
||||
| `backups/forgejo/db/` | Forgejo Postgres (`forgejo_db`) | `forgejo-backup` (beta, `SSH_*`) | 30 days |
|
||||
| `backups/forgejo/data/` | Forgejo data volume (repos/LFS/config) | `forgejo-backup` | 30 days |
|
||||
| Prefix in bucket `era5-thermograph` | Source | Job | Host / secrets | Retention off-box |
|
||||
|---|---|---|---|---|
|
||||
| `backups/db/prod/` | prod's `thermograph` database (`pg_dump --format=custom`) | `backup` | vps2, `VPS2_SSH_*` | 30 days |
|
||||
| `backups/db/beta/` | beta's `thermograph_beta` database, same shared instance | `backup` | vps2, `VPS2_SSH_*` | 30 days |
|
||||
| `backups/forgejo/db/` | Forgejo Postgres (`forgejo_db`) | `forgejo-backup` | vps1, `VPS1_SSH_*` | 30 days |
|
||||
| `backups/forgejo/data/` | Forgejo data volume (repos/LFS/config) | `forgejo-backup` | vps1, `VPS1_SSH_*` | 30 days |
|
||||
|
||||
Both application databases live on the ONE shared TimescaleDB instance on
|
||||
vps2 now (separate roles/databases, not separate boxes) — the `backup` job
|
||||
dumps each by name so beta isn't silently left uncovered. Forgejo moved to
|
||||
**vps1** with the vps1/vps2 rename (it used to be co-located with beta on the
|
||||
box now called vps2's predecessor); its backup targets vps1 accordingly. See
|
||||
`ops-cron.yml`'s own header comment for the history: an earlier revision keyed
|
||||
these secrets by environment name rather than host, which is what let "the
|
||||
prod backup" silently dump the wrong box for a while.
|
||||
|
||||
Everything is streamed through **`age`** encryption before upload — encrypted to the
|
||||
vault recipient `age1xx4dz…`, so the private key each host already has at
|
||||
|
|
@ -23,9 +33,10 @@ decrypts it. Nothing plaintext leaves the box.
|
|||
(also mirrored into the SOPS vault `deploy/secrets/{prod,beta}.yaml` for host-side
|
||||
use). Contabo needs **path-style** addressing (`force_path_style=true`,
|
||||
`provider=Other`, `region=default`).
|
||||
- `rclone` + `age` must be installed on prod and beta (one-time: `apt-get install -y
|
||||
rclone age`). The prod job apt-installs them if missing; the beta user has no sudo,
|
||||
so they are pre-installed there.
|
||||
- `rclone` + `age` must be installed on vps1 and vps2 (one-time: `apt-get install -y
|
||||
rclone age`). The prod/beta job on vps2 apt-installs them if missing; check
|
||||
whether the Forgejo-backup job's user on vps1 has the sudo to do the same, or
|
||||
whether they need pre-installing there.
|
||||
|
||||
## Restore (DR runbook)
|
||||
|
||||
|
|
@ -33,17 +44,27 @@ Prereq on the restoring host: `rclone` + `age` + the age key at
|
|||
`/etc/thermograph/age.key` (or the operator key), and rclone configured for the
|
||||
`archive:` remote (env vars `RCLONE_CONFIG_ARCHIVE_*` or `/etc/rclone/rclone.conf`).
|
||||
|
||||
### Prod database
|
||||
### Prod / beta database
|
||||
|
||||
Both restore into the SAME shared instance on vps2 (`<db-container>` is that
|
||||
one instance's container/task, resolved the same way `dbq.sh` does) — only
|
||||
the bucket prefix and the target database/role differ:
|
||||
|
||||
```sh
|
||||
# list available encrypted dumps
|
||||
rclone ls archive:era5-thermograph/backups/db/prod/
|
||||
rclone ls archive:era5-thermograph/backups/db/beta/
|
||||
# download newest, decrypt, restore into a fresh db
|
||||
OBJ=archive:era5-thermograph/backups/db/prod/<name>.dump.age
|
||||
rclone cat "$OBJ" | sudo age -d -i /etc/thermograph/age.key \
|
||||
| docker exec -i <db-container> pg_restore -U thermograph -d thermograph --clean --if-exists
|
||||
# beta: same instance, its own role/database
|
||||
OBJ=archive:era5-thermograph/backups/db/beta/<name>.dump.age
|
||||
rclone cat "$OBJ" | sudo age -d -i /etc/thermograph/age.key \
|
||||
| docker exec -i <db-container> pg_restore -U thermograph -d thermograph_beta --clean --if-exists
|
||||
```
|
||||
|
||||
### Forgejo
|
||||
### Forgejo (restore on vps1)
|
||||
```sh
|
||||
# database
|
||||
rclone cat archive:era5-thermograph/backups/forgejo/db/<name>.dump.age \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
#!/bin/bash
|
||||
# Postgres memory / performance tuning, scaled to the container's DB_MEMORY budget so
|
||||
# the same init serves every host (beta 8g; prod 16g on the 48 GB box) with no
|
||||
# hardcoding. Runs once on a fresh data volume from /docker-entrypoint-initdb.d, after
|
||||
# the same init works for any host with no hardcoding. There is ONE shared TimescaleDB
|
||||
# instance now (prod and beta are separate databases/roles on it, not separate
|
||||
# containers), sized from prod's vault values (16g on the 48 GB box) -- beta no longer
|
||||
# gets a second database or a second tuning pass. Dev keeps its own, separate container
|
||||
# (8g). Runs once on a fresh data volume from /docker-entrypoint-initdb.d, after
|
||||
# 10-timescaledb.sql enables timescaledb. Settings are written via ALTER SYSTEM
|
||||
# (persisted to postgresql.auto.conf, which the timescaledb image's own
|
||||
# timescaledb-tune postgresql.conf defers to); the container's post-init restart brings
|
||||
|
|
|
|||
161
infra/deploy/db/provision-env-db.sh
Executable file
161
infra/deploy/db/provision-env-db.sh
Executable file
|
|
@ -0,0 +1,161 @@
|
|||
#!/usr/bin/env bash
|
||||
# Give an environment its own role + database on the SHARED Postgres instance.
|
||||
#
|
||||
# sudo bash infra/deploy/db/provision-env-db.sh beta # run on vps2
|
||||
#
|
||||
# Since beta moved onto vps2 there is ONE TimescaleDB instance serving two
|
||||
# environments. That is a capacity decision — one Postgres to tune, back up and
|
||||
# keep on one extension build — and it is emphatically NOT a decision to let the
|
||||
# two environments see each other's data. This script is what makes the second
|
||||
# half true:
|
||||
#
|
||||
# * a role per environment (thermograph_beta), with its own password taken
|
||||
# from that environment's own vault render,
|
||||
# * a database per environment (thermograph_beta) OWNED by that role,
|
||||
# * CONNECT revoked from PUBLIC on it, so the split is enforced by Postgres
|
||||
# rather than by everyone remembering to use the right URL,
|
||||
# * and NO superuser, NO CREATEDB, NO CREATEROLE on that role.
|
||||
#
|
||||
# The prod role keeps its own database and cannot be reached with beta's
|
||||
# credentials; beta's role cannot connect to prod's database at all.
|
||||
#
|
||||
# Idempotent by construction — safe to re-run after a password rotation (it
|
||||
# re-applies the password) or on a fresh instance. It never drops anything.
|
||||
#
|
||||
# WHY THIS IS NOT WIRED INTO deploy.sh: creating roles and databases is a
|
||||
# privileged, once-per-environment act, and a deploy that can mint database
|
||||
# roles is a deploy that can mint them wrongly at 3am. Run it by hand from the
|
||||
# runbook when an environment is first stood up, and after a password rotation.
|
||||
set -euo pipefail
|
||||
|
||||
ENV_NAME="${1:?usage: provision-env-db.sh <env> (beta|prod)}"
|
||||
|
||||
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. "$SELF_DIR/../env-topology.sh"
|
||||
thermograph_topology "$ENV_NAME"
|
||||
|
||||
if [ "$TG_DEPLOY_MODE" != stack ]; then
|
||||
echo "!! $ENV_NAME does not use the shared instance (dev keeps its own db container)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# REFUSE to run for the environment that OWNS the instance.
|
||||
#
|
||||
# This script provisions a GUEST environment onto someone else's Postgres. Run
|
||||
# for prod, it would target prod's `thermograph` role — which is the instance's
|
||||
# bootstrap superuser, not a guest — and the `ALTER ROLE ... NOSUPERUSER` below
|
||||
# would strip superuser from the role the whole instance is administered with.
|
||||
# Prod's roles predate this script and are created by the container's own
|
||||
# initdb; there is nothing here for prod to need.
|
||||
if [ "$TG_DB_SERVICE" = "${TG_STACK_NAME}_db" ]; then
|
||||
echo "!! '$ENV_NAME' OWNS this Postgres instance (${TG_DB_SERVICE} is its own stack's db)." >&2
|
||||
echo "!! This script provisions a guest environment onto a shared instance." >&2
|
||||
echo "!! Running it here would demote ${TG_DB_USER} from superuser. Refusing." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The database SERVER is prod's, wherever we are provisioning FOR.
|
||||
DB_CID=$(docker ps -q --filter "label=com.docker.swarm.service.name=${TG_DB_SERVICE}" | head -1)
|
||||
if [ -z "$DB_CID" ]; then
|
||||
echo "!! no running task for ${TG_DB_SERVICE} on this host" >&2
|
||||
echo "!! run this on vps2, with prod's stack up." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The new role's password is whatever that environment's vault says it is, so
|
||||
# the database and the app can never disagree about it. Read from the rendered
|
||||
# env file (a deploy of that environment writes it) rather than from sops here:
|
||||
# this script should not need the age key.
|
||||
if [ ! -r "$TG_ENV_FILE" ]; then
|
||||
PW=$(sudo grep -m1 '^POSTGRES_PASSWORD=' "$TG_ENV_FILE" 2>/dev/null | cut -d= -f2- || true)
|
||||
else
|
||||
PW=$(grep -m1 '^POSTGRES_PASSWORD=' "$TG_ENV_FILE" | cut -d= -f2- || true)
|
||||
fi
|
||||
if [ -z "${PW:-}" ]; then
|
||||
echo "!! no POSTGRES_PASSWORD in $TG_ENV_FILE" >&2
|
||||
echo "!! deploy $ENV_NAME once first so the vault renders it, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Provisioning role/database '${TG_DB_USER}'/'${TG_DB_NAME}' on ${TG_DB_SERVICE}"
|
||||
|
||||
# The bootstrap superuser of the instance is prod's POSTGRES_USER (`thermograph`),
|
||||
# and psql runs inside the container over its local socket — the database is
|
||||
# never exposed on a TCP port anyone outside the overlay can reach.
|
||||
#
|
||||
# The password reaches psql as an environment variable rather than a -v
|
||||
# argument, so it is not in psql's argv inside the container. It IS briefly in
|
||||
# the `docker exec` argv on the host; that is visible only to root on vps2, who
|
||||
# can read the vault render anyway. It is never interpolated into SQL text:
|
||||
# :'pw' is psql's quote-and-escape form, which is also what makes a password
|
||||
# containing a quote safe.
|
||||
#
|
||||
# Note both statements below are generated and run via \gexec rather than a
|
||||
# DO block. psql does NOT substitute :variables inside dollar-quoted strings,
|
||||
# so a DO $$ ... :'role' ... $$ body would be sent to the server literally.
|
||||
docker exec -i -e ENV_DB_PASSWORD="$PW" "$DB_CID" \
|
||||
psql -v ON_ERROR_STOP=1 -U thermograph -d postgres \
|
||||
-v role="$TG_DB_USER" -v dbname="$TG_DB_NAME" <<'SQL'
|
||||
\set pw `echo "$ENV_DB_PASSWORD"`
|
||||
|
||||
-- Role: create if absent.
|
||||
SELECT format('CREATE ROLE %I LOGIN', :'role')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'role')
|
||||
\gexec
|
||||
|
||||
-- Always (re)apply the password and the negative privileges, so a vault
|
||||
-- rotation is just "rotate, redeploy, re-run this" — and so a role that was
|
||||
-- created by hand with more rights than it should have gets corrected.
|
||||
ALTER ROLE :"role" WITH LOGIN PASSWORD :'pw' NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
||||
|
||||
-- CREATE DATABASE cannot run inside a transaction block, hence \gexec here too.
|
||||
SELECT format('CREATE DATABASE %I OWNER %I', :'dbname', :'role')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'dbname')
|
||||
\gexec
|
||||
SQL
|
||||
|
||||
# Extension + privileges have to run INSIDE the new database, hence a second
|
||||
# connection. CREATE EXTENSION needs superuser, which is why it is done here
|
||||
# rather than left to the app's own migration to attempt as the env role.
|
||||
docker exec -i "$DB_CID" \
|
||||
psql -v ON_ERROR_STOP=1 -U thermograph -d "$TG_DB_NAME" \
|
||||
-v role="$TG_DB_USER" -v dbname="$TG_DB_NAME" -v rorole="${TG_DB_USER}_ro" <<'SQL'
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
|
||||
-- Nobody but this environment's roles connects to this database. Without this,
|
||||
-- PUBLIC retains CONNECT and any role on the instance could read it.
|
||||
REVOKE CONNECT ON DATABASE :"dbname" FROM PUBLIC;
|
||||
GRANT CONNECT ON DATABASE :"dbname" TO :"role";
|
||||
|
||||
-- The role owns the database but not necessarily the public schema, which is
|
||||
-- owned by the bootstrap superuser on a fresh database; Alembic needs to create
|
||||
-- tables in it.
|
||||
ALTER SCHEMA public OWNER TO :"role";
|
||||
|
||||
-- The read-only role that ad-hoc queries use (ops/dbq.sh). Every environment
|
||||
-- gets one, named <role>_ro, so a human or an agent poking at data cannot
|
||||
-- write. Without it, beta's queries would have had to connect as the OWNER —
|
||||
-- read-write on its own database — losing a guarantee prod and dev already had
|
||||
-- purely because beta was newer.
|
||||
SELECT format('CREATE ROLE %I LOGIN', :'rorole')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'rorole')
|
||||
\gexec
|
||||
ALTER ROLE :"rorole" WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
||||
|
||||
GRANT CONNECT ON DATABASE :"dbname" TO :"rorole";
|
||||
GRANT USAGE ON SCHEMA public TO :"rorole";
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO :"rorole";
|
||||
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO :"rorole";
|
||||
-- Future tables too: without this, every new migration would create a table the
|
||||
-- read-only role cannot see, and the omission would only surface as a confusing
|
||||
-- "permission denied" months later.
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE :"role" IN SCHEMA public
|
||||
GRANT SELECT ON TABLES TO :"rorole";
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE :"role" IN SCHEMA public
|
||||
GRANT SELECT ON SEQUENCES TO :"rorole";
|
||||
SQL
|
||||
|
||||
echo "==> OK: ${TG_DB_USER} owns ${TG_DB_NAME} (timescaledb enabled, PUBLIC revoked)"
|
||||
echo " Verify isolation:"
|
||||
echo " docker exec $DB_CID psql -U ${TG_DB_USER} -d thermograph -c 'select 1' # must FAIL"
|
||||
|
|
@ -1,41 +1,54 @@
|
|||
#!/usr/bin/env bash
|
||||
# LAN-dev deploy: roll the per-service registry-pull stack (see deploy.sh) onto
|
||||
# the ~/thermograph-dev overlay instead of prod/beta's loopback-only stack.
|
||||
# Dev deploy: roll the per-service registry-pull stack (see deploy.sh) onto the
|
||||
# dev compose overlay instead of beta/prod's Swarm stacks.
|
||||
#
|
||||
# This script assumes $APP_DIR is a checkout of the monorepo (deploy assets under infra/),
|
||||
# the same way /opt/thermograph is on prod/beta. That is the live state: the LAN
|
||||
# box's ~/thermograph-dev was reprovisioned as an infra checkout during the
|
||||
# 2026-07-22 cutover (the app monorepo is archived), provision-dev-lan.sh's
|
||||
# REPO_URL defaults to this repo, and the app repos' deploy-dev.yml workflows
|
||||
# invoke this script on the thermograph-lan runner. This IS the live LAN-dev
|
||||
# deploy path.
|
||||
# Dev lives on vps1 now, at /opt/thermograph-dev — not on the operator's desktop.
|
||||
# The desktop hosts no Thermograph environment any more; it runs the AI models
|
||||
# and offers flex Swarm capacity. What moved is only WHERE and HOW dev is
|
||||
# reached: it is a normal fleet host now (SSH deploy from CI, SOPS render at
|
||||
# /etc/thermograph.env, mesh-only exposure on 10.10.0.2:8137), rather than a
|
||||
# sudo-free systemd --user stack on someone's Wi-Fi.
|
||||
#
|
||||
# $APP_DIR is a checkout of the monorepo (deploy assets under infra/), the same
|
||||
# way /opt/thermograph is for prod. A laptop can still point APP_DIR at a
|
||||
# checkout under $HOME and get the old local behaviour.
|
||||
#
|
||||
# Design: a thin wrapper around deploy.sh, not a duplicate. deploy.sh already owns
|
||||
# the entire per-service registry-pull mechanism -- secrets sourcing, docker login,
|
||||
# the retry-pull loop, --no-deps single-service rolls vs. --remove-orphans `all`,
|
||||
# .image-tags.env persistence, and the 8137/8080 health checks. None of that is
|
||||
# dev-specific; the only things LAN dev actually changes are WHERE it deploys
|
||||
# (a separate checkout + branch) and WHICH compose files are in play (the base
|
||||
# file plus docker-compose.dev.yml's uncapped/LAN-published overrides). Both are
|
||||
# expressible as environment (APP_DIR/BRANCH that deploy.sh already reads, and
|
||||
# docker compose's own COMPOSE_FILE variable), so re-exec'ing deploy.sh with that
|
||||
# environment set covers it with no forked copy of the pull/roll/health logic to
|
||||
# drift out of sync. If LAN dev ever needs deploy logic that genuinely diverges
|
||||
# from prod/beta (not just "different files/directory"), promote this to a
|
||||
# standalone script at that point rather than growing special cases into deploy.sh.
|
||||
# dev-specific; the only things dev actually changes are WHERE it deploys (a
|
||||
# separate checkout + branch), WHICH compose files are in play (the base file
|
||||
# plus docker-compose.dev.yml's uncapped/mesh-published overrides), and its
|
||||
# secrets policy (below). All are expressible as environment (APP_DIR/BRANCH
|
||||
# that deploy.sh already reads, and docker compose's own COMPOSE_FILE
|
||||
# variable), so re-exec'ing deploy.sh with that environment set covers it with
|
||||
# no forked copy of the pull/roll/health logic to drift out of sync. If dev ever
|
||||
# needs deploy logic that genuinely diverges from beta/prod (not just "different
|
||||
# files/directory"), promote this to a standalone script at that point rather
|
||||
# than growing special cases into deploy.sh.
|
||||
#
|
||||
# Usage, mirrors deploy.sh directly:
|
||||
# # roll just the backend onto a dev-tagged image:
|
||||
# ssh dev-box 'SERVICE=backend BACKEND_IMAGE_TAG=sha-<12hex> deploy/deploy-dev.sh'
|
||||
# ssh vps1 'SERVICE=backend BACKEND_IMAGE_TAG=sha-<12hex> /opt/thermograph-dev/infra/deploy/deploy-dev.sh'
|
||||
# # bring the whole dev stack up (both tags required, same as deploy.sh):
|
||||
# ssh dev-box 'SERVICE=all BACKEND_IMAGE_TAG=sha-<a> FRONTEND_IMAGE_TAG=sha-<b> deploy/deploy-dev.sh'
|
||||
# ssh vps1 'SERVICE=all BACKEND_IMAGE_TAG=sha-<a> FRONTEND_IMAGE_TAG=sha-<b> /opt/thermograph-dev/infra/deploy/deploy-dev.sh'
|
||||
set -euo pipefail
|
||||
|
||||
# Dev context: a separate checkout + branch from prod/beta's /opt/thermograph
|
||||
# (main), so a dev deploy never touches or is touched by the prod/beta one.
|
||||
APP_DIR="${APP_DIR:-$HOME/thermograph-dev}"
|
||||
BRANCH="${BRANCH:-dev}"
|
||||
# Dev context: its own checkout and its own branch, on its own host (vps1).
|
||||
# Both come from deploy/env-topology.sh so there is one place that says where
|
||||
# dev lives. $APP_DIR is still honoured when set explicitly — that is how a
|
||||
# laptop points this at a checkout under $HOME.
|
||||
_dev_self="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. "$_dev_self/env-topology.sh"
|
||||
thermograph_topology dev
|
||||
APP_DIR="${APP_DIR:-$TG_APP_DIR}"
|
||||
BRANCH="${BRANCH:-$TG_BRANCH}"
|
||||
export APP_DIR BRANCH
|
||||
# deploy.sh resolves the environment itself; say so explicitly rather than
|
||||
# letting it fall back to a host marker that, on vps1, names dev anyway.
|
||||
export THERMOGRAPH_ENV=dev
|
||||
|
||||
# Point every `docker compose` invocation inside deploy.sh at the LAN-dev overlay
|
||||
# (uncapped CPU, backend published on 0.0.0.0:8137, frontend unpublished -- see
|
||||
|
|
@ -72,23 +85,33 @@ export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-thermograph-dev}"
|
|||
# genuinely needs, and nothing else.
|
||||
export THERMOGRAPH_SECRETS_SKIP_COMMON=1
|
||||
|
||||
# (1b) Docker on this box is the snap package, confined to $HOME (plus a short
|
||||
# allowlist) -- it silently can't see /etc/thermograph.env at all, so
|
||||
# env_file: /etc/thermograph.env resolves to nothing for every container no
|
||||
# matter how correctly it's rendered. Mirror the render to a path under
|
||||
# $APP_DIR too; docker-compose.dev.yml points env_file at this copy instead of
|
||||
# /etc/thermograph.env. Untracked (see infra/.gitignore); re-rendered on every
|
||||
# deploy, never committed.
|
||||
export THERMOGRAPH_SECRETS_ENV_FILE_MIRROR="$APP_DIR/infra/deploy/dev-secrets.env"
|
||||
# (1b) Snap-packaged Docker is confined to $HOME (plus a short allowlist) and
|
||||
# silently can't see /etc/thermograph.env at all, so env_file: /etc/thermograph.env
|
||||
# resolves to nothing for every container no matter how correctly it's rendered.
|
||||
# Where that is the case, mirror the render to a path under $APP_DIR;
|
||||
# docker-compose.dev.yml carries a matching optional env_file entry.
|
||||
#
|
||||
# CONDITIONAL, not unconditional: this writes a plaintext copy of the render
|
||||
# into the checkout, and on vps1 (ordinary apt Docker, which reads /etc fine)
|
||||
# that copy would be pure liability. Detect the confinement rather than add a
|
||||
# knob someone has to remember — the docker binary resolving under /snap is
|
||||
# exactly the condition that breaks the /etc path.
|
||||
_docker_bin="$(command -v docker || true)"
|
||||
if [ -n "$_docker_bin" ] && case "$(readlink -f "$_docker_bin")" in /snap/*) true ;; *) false ;; esac; then
|
||||
export THERMOGRAPH_SECRETS_ENV_FILE_MIRROR="$APP_DIR/infra/deploy/dev-secrets.env"
|
||||
fi
|
||||
|
||||
# (2) The age key is read from wherever it is READABLE, not necessarily
|
||||
# /etc/thermograph/age.key. render-secrets.sh falls back to `sudo cat` for a
|
||||
# root-owned 0400 key, and this box has no passwordless sudo -- under the Forgejo
|
||||
# runner (a systemd --user service, no tty) that sudo cannot prompt, so the
|
||||
# fleet-standard placement would make every dev deploy fail at the render. Prefer
|
||||
# the standard path when it is readable; otherwise the operator's own keyring, which
|
||||
# is where dev's key already lives (this box is the machine `sops` is run on), so
|
||||
# provisioning dev needs no second copy of the estate's recovery root.
|
||||
# root-owned 0400 key, which needs passwordless sudo.
|
||||
#
|
||||
# On vps1 that is the normal case: the `agent` user has passwordless sudo and
|
||||
# the key sits at the fleet-standard path, so this block does nothing. The
|
||||
# fallback exists for the laptop case — a checkout where the key lives only in
|
||||
# the operator's own keyring (that machine is where `sops` is run), and where a
|
||||
# non-interactive shell could not prompt for sudo even if the key were in /etc.
|
||||
# Preferring the standard path when readable keeps vps1 on the fleet convention
|
||||
# and means provisioning dev needs no second copy of the estate's recovery root.
|
||||
if [ -z "${THERMOGRAPH_AGE_KEY:-}" ] && [ ! -r /etc/thermograph/age.key ] \
|
||||
&& [ -r "$HOME/.config/sops/age/keys.txt" ]; then
|
||||
export THERMOGRAPH_AGE_KEY="$HOME/.config/sops/age/keys.txt"
|
||||
|
|
|
|||
|
|
@ -26,11 +26,59 @@
|
|||
# two axes are independent and rarely change together.
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/thermograph}"
|
||||
# --- which environment is this? ------------------------------------------------
|
||||
# vps2 runs beta AND prod, so "the host" no longer answers this — the caller does,
|
||||
# via THERMOGRAPH_ENV (the deploy workflow always passes it). A by-hand run on a
|
||||
# single-environment box still falls back to the host marker, and a box with
|
||||
# neither still defaults to prod's historical paths, so nothing about an existing
|
||||
# single-env host changes.
|
||||
#
|
||||
# Resolved from the SCRIPT'S OWN LOCATION, not a hardcoded /opt/thermograph:
|
||||
# invoking /opt/thermograph-beta/infra/deploy/deploy.sh must act on the beta
|
||||
# checkout even if something in the environment says otherwise. That is also the
|
||||
# check that catches the one genuinely dangerous mistake on a two-environment
|
||||
# host — running prod's checkout with THERMOGRAPH_ENV=beta, or the reverse.
|
||||
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SELF_APP_DIR=$(cd "$SELF_DIR/../.." && pwd)
|
||||
|
||||
# Guarded exactly like render-secrets.sh below: the deploy that INTRODUCES this
|
||||
# file runs with a checkout that predates it (it arrives with the git reset
|
||||
# further down, after which deploy.sh re-execs). Missing => keep the pre-split
|
||||
# behaviour rather than fail.
|
||||
if [ -f "$SELF_DIR/env-topology.sh" ]; then
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. "$SELF_DIR/env-topology.sh"
|
||||
ENV_NAME=$(thermograph_env_name)
|
||||
# Nothing to go on anywhere: this is a pre-split host whose paths are prod's.
|
||||
[ -n "$ENV_NAME" ] || ENV_NAME=prod
|
||||
thermograph_topology "$ENV_NAME"
|
||||
if [ "$SELF_APP_DIR" != "$TG_APP_DIR" ] && [ -z "${APP_DIR:-}" ]; then
|
||||
echo "!! environment/checkout mismatch: THERMOGRAPH_ENV=$ENV_NAME expects" >&2
|
||||
echo "!! $TG_APP_DIR but this script lives in $SELF_APP_DIR." >&2
|
||||
echo "!! On vps2 that means beta and prod have been crossed. Refusing to deploy." >&2
|
||||
echo "!! If this checkout really is $ENV_NAME (a rehearsal copy, a relocated" >&2
|
||||
echo "!! checkout), say so explicitly: APP_DIR=$SELF_APP_DIR ..." >&2
|
||||
exit 2
|
||||
fi
|
||||
APP_DIR="${APP_DIR:-$TG_APP_DIR}"
|
||||
BRANCH="${BRANCH:-$TG_BRANCH}"
|
||||
ENV_FILE="$TG_ENV_FILE"
|
||||
DEPLOY_MODE="$TG_DEPLOY_MODE"
|
||||
[ "$TG_SKIP_COMMON" = 1 ] && export THERMOGRAPH_SECRETS_SKIP_COMMON=1
|
||||
# The second pass (after the re-exec) must resolve the SAME environment, and
|
||||
# the stack path needs it too.
|
||||
export THERMOGRAPH_ENV="$ENV_NAME"
|
||||
else
|
||||
ENV_NAME=""
|
||||
APP_DIR="${APP_DIR:-/opt/thermograph}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
ENV_FILE=/etc/thermograph.env
|
||||
DEPLOY_MODE=""
|
||||
fi
|
||||
|
||||
# Monorepo layout: git operations act on the checkout root ($APP_DIR); all
|
||||
# compose files and deploy assets live under infra/.
|
||||
INFRA_DIR="$APP_DIR/infra"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
# Which service this deploy rolls: backend | frontend | all. Defaults to `all`
|
||||
# (a full-stack bring-up) so a by-hand run with both tags still works; the
|
||||
# per-repo deploy.yml workflows always pass an explicit single service.
|
||||
|
|
@ -69,16 +117,20 @@ fi
|
|||
# after which deploy.sh re-execs), so a missing helper simply falls back to the
|
||||
# existing /etc/thermograph.env. Then source it so a by-hand run interpolates the
|
||||
# same as the systemd unit does. See deploy/render-secrets.sh + deploy/secrets/.
|
||||
#
|
||||
# $ENV_FILE, not a hardcoded /etc/thermograph.env: on vps2 prod renders
|
||||
# prod.yaml there while beta renders beta.yaml to /etc/thermograph-beta.env.
|
||||
# One file per environment, never shared.
|
||||
if [ -f "$INFRA_DIR/deploy/render-secrets.sh" ]; then
|
||||
# shellcheck source=infra/deploy/render-secrets.sh
|
||||
. "$INFRA_DIR/deploy/render-secrets.sh"
|
||||
render_thermograph_secrets "$INFRA_DIR"
|
||||
render_thermograph_secrets "$INFRA_DIR" "$ENV_NAME" "$ENV_FILE"
|
||||
fi
|
||||
# /etc/thermograph.env is rendered at deploy time from the SOPS vault — it
|
||||
# cannot exist at lint time, so don't ask shellcheck to follow it.
|
||||
# $ENV_FILE is rendered at deploy time from the SOPS vault — it cannot exist at
|
||||
# lint time, so don't ask shellcheck to follow it.
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/thermograph.env 2>/dev/null || true
|
||||
. "$ENV_FILE" 2>/dev/null || true
|
||||
set +a
|
||||
|
||||
# Pre-warm the ~750 city-page archives so /climate pages serve from cache and a
|
||||
|
|
@ -122,12 +174,18 @@ if [ -z "${DEPLOY_SH_REEXECED:-}" ]; then
|
|||
exec "$0" "$@"
|
||||
fi
|
||||
|
||||
# Stack-mode routing: a host whose /etc/thermograph/deploy-mode says "stack"
|
||||
# (prod, after the Swarm cutover) deploys via the Swarm stack path instead of
|
||||
# compose. Checked AFTER the reset+re-exec so the stack script is always the
|
||||
# freshly-pulled one, and the SERVICE/tag contract passes through unchanged --
|
||||
# the app repos' workflows never need to know which mode a host runs.
|
||||
if [ "$(cat /etc/thermograph/deploy-mode 2>/dev/null || true)" = "stack" ]; then
|
||||
# Stack-mode routing: prod and beta are both Swarm stacks, dev is compose. The
|
||||
# mode is a property of the ENVIRONMENT (env-topology.sh), not of the host --
|
||||
# vps2 runs two stacks, and a host-wide /etc/thermograph/deploy-mode marker
|
||||
# cannot describe a host that runs more than one environment. The marker is
|
||||
# still honoured when the topology file is absent (a checkout that predates it).
|
||||
# Checked AFTER the reset+re-exec so the stack script is always the freshly
|
||||
# pulled one, and the SERVICE/tag contract passes through unchanged -- the
|
||||
# deploy workflow never needs to know which mode an environment runs.
|
||||
if [ -z "$DEPLOY_MODE" ]; then
|
||||
DEPLOY_MODE=$(cat /etc/thermograph/deploy-mode 2>/dev/null || true)
|
||||
fi
|
||||
if [ "$DEPLOY_MODE" = "stack" ]; then
|
||||
exec bash "$INFRA_DIR/deploy/stack/deploy-stack.sh"
|
||||
fi
|
||||
|
||||
|
|
@ -137,6 +195,20 @@ fi
|
|||
# rename the project and recreate the stack under a second name.
|
||||
cd "$INFRA_DIR"
|
||||
|
||||
# Compose-mode environments (dev) carry their project name, file list and bind
|
||||
# address in the topology table. Set only when not already in the environment,
|
||||
# so deploy-dev.sh's own exports and a by-hand override both still win.
|
||||
if [ -n "${TG_COMPOSE_PROJECT:-}" ]; then
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$TG_COMPOSE_PROJECT}"
|
||||
export COMPOSE_FILE="${COMPOSE_FILE:-$TG_COMPOSE_FILE}"
|
||||
fi
|
||||
# Which address the dev overlay publishes on. Defaults to loopback in the
|
||||
# compose file; dev on vps1 sets the mesh address here, because a public VPS
|
||||
# must never publish an unreviewed branch's stack on 0.0.0.0.
|
||||
if [ -n "${TG_BIND_ADDR:-}" ]; then
|
||||
export DEV_BIND_ADDR="${DEV_BIND_ADDR:-$TG_BIND_ADDR}"
|
||||
fi
|
||||
|
||||
# Registry-pull cutover: pull the image each app repo's build-push.yml already
|
||||
# built and pushed, instead of building in place. This checkout is
|
||||
# thermograph-infra, not an app repo, so there's no "current commit" to derive
|
||||
|
|
|
|||
237
infra/deploy/env-topology.sh
Normal file
237
infra/deploy/env-topology.sh
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
#!/usr/bin/env bash
|
||||
# WHERE EACH ENVIRONMENT LIVES — the single source of truth, sourced by every
|
||||
# deploy path. Not executable on its own; `. env-topology.sh` then call
|
||||
# `thermograph_topology <env>`.
|
||||
#
|
||||
# Why this file exists at all. Until the vps1/vps2 split, "which environment is
|
||||
# this?" was answerable from the machine you were standing on: one host ran one
|
||||
# environment, so a host-wide marker (/etc/thermograph/secrets-env) and a
|
||||
# host-wide deploy mode (/etc/thermograph/deploy-mode) were enough, and every
|
||||
# path could hardcode /opt/thermograph, /etc/thermograph.env, ports 8137/8080.
|
||||
# vps2 now runs BOTH beta and prod. Every one of those assumptions becomes a
|
||||
# collision: two checkouts, two rendered env files, two stacks, two loopback
|
||||
# port pairs, two image-tag files, two deploy locks — on one box. So the
|
||||
# environment is now an INPUT (THERMOGRAPH_ENV, passed by the deploy caller),
|
||||
# and everything else is derived here rather than re-guessed per script.
|
||||
#
|
||||
# The host markers survive as the fallback for a by-hand run with no explicit
|
||||
# env (`deploy.sh` on prod still means prod), but they are no longer the thing
|
||||
# that decides where files go.
|
||||
#
|
||||
# THE ESTATE
|
||||
#
|
||||
# vps1 75.119.132.91 10.10.0.2 "operational programs"
|
||||
# Forgejo (git + CI + registry), Grafana/Loki/Alloy, emigriffith.dev,
|
||||
# and the DEV environment with its own Postgres. Nothing here is
|
||||
# reachable by an external user except git/dashboard/the portfolio site;
|
||||
# dev itself is mesh-only, deliberately (see DEV_BIND_ADDR below).
|
||||
#
|
||||
# vps2 169.58.46.181 10.10.0.1 "the deployed environment"
|
||||
# Everything an external user can touch: prod (thermograph.org) AND beta
|
||||
# (beta.thermograph.org), Centralis, Postfix, the backups, and the ONE
|
||||
# TimescaleDB instance that serves both environments on separate
|
||||
# databases with separate roles.
|
||||
#
|
||||
# desktop 10.10.0.3 operator's box
|
||||
# AI model hosting (voice-to-text, the LLM behind upcoming features) and
|
||||
# flex capacity as a Swarm worker. It hosts NO Thermograph environment —
|
||||
# that is the whole point of the vps1/vps2 split. A laptop-style
|
||||
# `make dev-up` still works locally; it is just not the dev server.
|
||||
#
|
||||
# WHY BETA SITS NEXT TO PROD RATHER THAN NEXT TO DEV
|
||||
#
|
||||
# Beta is a pre-production rehearsal, so what it needs to rehearse is prod's
|
||||
# environment: the same orchestrator (Swarm, not compose), the same Postgres
|
||||
# major and extension build, the same Caddy in front, the same mail path, the
|
||||
# same mesh position. Co-locating beta with dev bought resemblance to the thing
|
||||
# it is NOT trying to predict. Co-locating it with prod means a beta green light
|
||||
# is evidence about prod. The cost — a bad beta deploy shares a machine with
|
||||
# prod — is bounded by the per-environment isolation this file defines: separate
|
||||
# stacks, separate volumes, separate DB roles, separate CPU limits.
|
||||
|
||||
# thermograph_topology <env>
|
||||
#
|
||||
# Exports the TG_* variables describing that environment. Every value is a
|
||||
# derived fact about the estate, not a preference: change one here and the
|
||||
# deploy scripts, the stack files and the runbooks all follow.
|
||||
thermograph_topology() {
|
||||
local env_name="${1:?thermograph_topology needs an environment: dev|beta|prod}"
|
||||
|
||||
# Reset first: this function is sourced into long-lived shells (deploy.sh
|
||||
# sources it before and after its self re-exec), and a stale TG_STACK_NAME
|
||||
# from a previous call would silently target the wrong stack.
|
||||
TG_ENV=""; TG_HOST=""; TG_APP_DIR=""; TG_BRANCH=""; TG_DEPLOY_MODE=""
|
||||
TG_STACK_NAME=""; TG_STACK_FILE=""; TG_LB_CONFIG=""
|
||||
TG_COMPOSE_PROJECT=""; TG_COMPOSE_FILE=""
|
||||
TG_ENV_FILE=""; TG_STACK_ENV_FILE=""; TG_LB_NAME=""
|
||||
TG_LB_HTTP_PORT=""; TG_LB_FE_PORT=""; TG_DB_NAME=""; TG_DB_USER=""
|
||||
TG_TAGS_FILE=""; TG_LOCK_FILE=""; TG_BIND_ADDR=""; TG_SKIP_COMMON=0
|
||||
TG_SVC_PREFIX=""; TG_DATA_NETWORK=""; TG_DB_SERVICE=""; TG_POST_DEPLOY=0
|
||||
TG_SSH_HOST=""; TG_SSH_TARGET=""
|
||||
|
||||
case "$env_name" in
|
||||
prod)
|
||||
TG_ENV=prod
|
||||
TG_HOST=vps2
|
||||
# Unchanged from before the split — prod keeps every path it already has,
|
||||
# so nothing about the live prod host moves during the cutover.
|
||||
TG_SSH_HOST=169.58.46.181
|
||||
TG_SSH_TARGET=agent@169.58.46.181
|
||||
TG_APP_DIR=/opt/thermograph
|
||||
TG_BRANCH=main
|
||||
TG_DEPLOY_MODE=stack
|
||||
TG_STACK_NAME=thermograph
|
||||
TG_STACK_FILE=deploy/stack/thermograph-stack.yml
|
||||
TG_ENV_FILE=/etc/thermograph.env
|
||||
TG_STACK_ENV_FILE=/etc/thermograph/stack.env
|
||||
TG_LB_NAME=thermograph-lb
|
||||
TG_LB_CONFIG=deploy/stack/lb/Caddyfile
|
||||
TG_LB_HTTP_PORT=8137
|
||||
TG_LB_FE_PORT=8080
|
||||
TG_DB_NAME=thermograph
|
||||
TG_DB_USER=thermograph
|
||||
# Prod's services keep their bare names (web, worker, frontend, ...): the
|
||||
# cutover must not touch prod's stack file, its env or its LB config.
|
||||
TG_SVC_PREFIX=""
|
||||
# Where the database lives. Prod's own overlay, which is `attachable` and
|
||||
# which beta joins as an external network.
|
||||
TG_DATA_NETWORK=thermograph_internal
|
||||
TG_DB_SERVICE=thermograph_db
|
||||
# Post-deploy city warm + IndexNow ping. Prod only — see the beta note.
|
||||
TG_POST_DEPLOY=1
|
||||
;;
|
||||
beta)
|
||||
TG_ENV=beta
|
||||
TG_HOST=vps2
|
||||
# Same box, same public IP as prod — beta and prod are two stacks on one
|
||||
# Swarm manager now, not two separate hosts. A beta SSH target is
|
||||
# therefore prod's blast radius too (see .claude/hooks/prod-guard.sh).
|
||||
TG_SSH_HOST=169.58.46.181
|
||||
TG_SSH_TARGET=agent@169.58.46.181
|
||||
# A SECOND checkout on the same box. Separate from prod's so the two can
|
||||
# sit on different commits of this repo, and so `git reset --hard` in one
|
||||
# deploy can never yank the tree out from under the other's running
|
||||
# deploy. Everything below is likewise a distinct name/port/path from
|
||||
# prod's, on purpose: co-location is only safe if nothing is shared by
|
||||
# accident. The one deliberate exception is the database SERVER (see
|
||||
# TG_DB_* — separate role and database on a shared instance).
|
||||
TG_APP_DIR=/opt/thermograph-beta
|
||||
TG_BRANCH=main
|
||||
TG_DEPLOY_MODE=stack
|
||||
TG_STACK_NAME=thermograph-beta
|
||||
TG_STACK_FILE=deploy/stack/thermograph-beta-stack.yml
|
||||
TG_ENV_FILE=/etc/thermograph-beta.env
|
||||
TG_STACK_ENV_FILE=/etc/thermograph/beta-stack.env
|
||||
TG_LB_NAME=thermograph-beta-lb
|
||||
TG_LB_CONFIG=deploy/stack/lb/Caddyfile.beta
|
||||
# NOT 8137/8080: prod's loopback LB already owns those on this host.
|
||||
TG_LB_HTTP_PORT=8237
|
||||
TG_LB_FE_PORT=8180
|
||||
TG_DB_NAME=thermograph_beta
|
||||
# Its own role, not prod's `thermograph` superuser-of-its-own-database.
|
||||
# One instance is a capacity decision; it is not a decision to let a beta
|
||||
# deploy running an unmerged branch read or write the production database.
|
||||
TG_DB_USER=thermograph_beta
|
||||
# Beta's Swarm services are named beta-web, beta-worker, ... Swarm
|
||||
# registers a service's SHORT name as a DNS alias on every network it
|
||||
# joins, so two stacks that both call a service `web` on one shared
|
||||
# network make `web` ambiguous — prod's frontend could resolve a beta
|
||||
# task. Prefixing beta's names removes the collision without editing a
|
||||
# single line of prod's stack.
|
||||
TG_SVC_PREFIX="beta-"
|
||||
# Beta has no db service of its own: it joins prod's overlay (declared
|
||||
# `external` in its stack file) purely to reach `db`, and keeps its own
|
||||
# `internal` overlay for beta-to-beta traffic.
|
||||
TG_DATA_NETWORK=thermograph_internal
|
||||
TG_DB_SERVICE=thermograph_db
|
||||
# No warm, no IndexNow on beta. IndexNow announces URLs to Bing/DDG/
|
||||
# Yandex — from beta that means asking search engines to index
|
||||
# beta.thermograph.org, which is the opposite of what beta is for. The
|
||||
# city warm is worse than useless here: it spends the shared upstream
|
||||
# archive quota to fill a cache that only a rehearsal environment reads.
|
||||
# (Both ran on beta under compose; that was a side effect of beta and
|
||||
# prod sharing one script, not a decision.)
|
||||
TG_POST_DEPLOY=0
|
||||
;;
|
||||
dev)
|
||||
TG_ENV=dev
|
||||
TG_HOST=vps1
|
||||
# vps1 — Forgejo/Grafana/dev, mesh-only for dev itself (see TG_BIND_ADDR).
|
||||
TG_SSH_HOST=75.119.132.91
|
||||
TG_SSH_TARGET=agent@75.119.132.91
|
||||
TG_APP_DIR=/opt/thermograph-dev
|
||||
# The only environment that tracks `dev`; beta and prod both run this
|
||||
# repo's `main` (infra is not environment-staged — app code is, via image
|
||||
# tags). See the branch model in infra/README.md.
|
||||
TG_BRANCH=dev
|
||||
# Compose, not Swarm: dev's value is a fast, legible, single-box stack
|
||||
# you can `docker compose logs` at. It is the one environment not
|
||||
# pretending to be prod.
|
||||
TG_DEPLOY_MODE=compose
|
||||
TG_COMPOSE_PROJECT=thermograph-dev
|
||||
TG_COMPOSE_FILE="docker-compose.yml:docker-compose.dev.yml"
|
||||
TG_ENV_FILE=/etc/thermograph.env
|
||||
# Dev keeps its OWN Postgres container (a `db` service in its compose
|
||||
# project). It is not on the shared instance and must not be: the shared
|
||||
# instance lives on vps2 and holds real user data.
|
||||
TG_DB_NAME=thermograph
|
||||
TG_DB_USER=thermograph
|
||||
# Mesh-only. vps1 is a public VPS, so the dev overlay must not publish on
|
||||
# 0.0.0.0 the way it did on the operator's LAN box — dev runs whatever
|
||||
# branch is in flight, including unreviewed ones. Reachable at
|
||||
# http://10.10.0.2:8137 from anything on the WireGuard mesh, and from
|
||||
# nowhere else. There is deliberately no dev DNS record and no Caddy site.
|
||||
TG_BIND_ADDR=10.10.0.2
|
||||
# Dev NEVER layers common.yaml — that file is the internet-facing fleet's
|
||||
# shared production credential set (both S3 keypairs, the VAPID signing
|
||||
# key, REGISTRY_TOKEN, the metrics token), and dev runs whatever branch is
|
||||
# in flight on the same box as Forgejo and its CI runner. This lives here
|
||||
# rather than only in deploy-dev.sh so the protection is a property of the
|
||||
# ENVIRONMENT, not of which entry point happened to be used. See the long
|
||||
# note in render-secrets.sh.
|
||||
TG_SKIP_COMMON=1
|
||||
TG_DB_SERVICE=db
|
||||
# Dev warms nothing and pings nothing: it must never spend the upstream
|
||||
# archive quota, and it must never tell a search engine it exists.
|
||||
TG_POST_DEPLOY=0
|
||||
;;
|
||||
*)
|
||||
echo "!! unknown environment '$env_name' (expected dev|beta|prod)" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# Derived, never hand-set: keeping these next to the definitions above is what
|
||||
# stops a second environment on one host from sharing a lock or a tag file.
|
||||
TG_LOCK_FILE="$TG_APP_DIR/infra/deploy/.deploy.lock"
|
||||
if [ "$TG_DEPLOY_MODE" = stack ]; then
|
||||
TG_TAGS_FILE="$TG_APP_DIR/infra/deploy/.stack-image-tags.env"
|
||||
else
|
||||
TG_TAGS_FILE="$TG_APP_DIR/infra/deploy/.image-tags.env"
|
||||
fi
|
||||
|
||||
export TG_ENV TG_HOST TG_APP_DIR TG_BRANCH TG_DEPLOY_MODE
|
||||
export TG_STACK_NAME TG_STACK_FILE TG_LB_CONFIG TG_COMPOSE_PROJECT TG_COMPOSE_FILE
|
||||
export TG_ENV_FILE TG_STACK_ENV_FILE TG_LB_NAME
|
||||
export TG_LB_HTTP_PORT TG_LB_FE_PORT TG_DB_NAME TG_DB_USER
|
||||
export TG_TAGS_FILE TG_LOCK_FILE TG_BIND_ADDR TG_SKIP_COMMON
|
||||
export TG_SVC_PREFIX TG_DATA_NETWORK TG_DB_SERVICE TG_POST_DEPLOY
|
||||
export TG_SSH_HOST TG_SSH_TARGET
|
||||
}
|
||||
|
||||
# thermograph_env_name
|
||||
#
|
||||
# Which environment is this run for? Explicit input first (the deploy workflows
|
||||
# pass THERMOGRAPH_ENV, and on vps2 it is the ONLY thing distinguishing a beta
|
||||
# deploy from a prod one), then the host marker for a by-hand run on a
|
||||
# single-environment box. Empty output means "cannot tell" and the caller must
|
||||
# fail rather than guess — guessing wrong on vps2 means deploying beta's image
|
||||
# tag onto prod.
|
||||
thermograph_env_name() {
|
||||
local marker="${THERMOGRAPH_SECRETS_ENV_FILE:-/etc/thermograph/secrets-env}"
|
||||
if [ -n "${THERMOGRAPH_ENV:-}" ]; then
|
||||
printf '%s\n' "$THERMOGRAPH_ENV"
|
||||
return 0
|
||||
fi
|
||||
cat "$marker" 2>/dev/null || true
|
||||
}
|
||||
|
|
@ -1,22 +1,24 @@
|
|||
# Forgejo on the Swarm cluster
|
||||
|
||||
Runs as `deploy/forgejo/docker-stack.yml` — the only Swarm-scheduled workload
|
||||
this cluster carries (the Thermograph app itself stays on the
|
||||
Terraform-managed `docker compose` deploys; see `terraform/README.md`).
|
||||
Pinned to the **beta** node (old VPS) via the `role=forge` label from
|
||||
this cluster carries (prod and beta's app stacks are separate `docker stack
|
||||
deploy`s that happen to run on the manager node, vps2 — see
|
||||
`deploy/stack/README` context in `DEPLOY.md`). Pinned to the **vps1** node
|
||||
(`75.119.132.91`) via the `role=forge` label from
|
||||
`deploy/swarm/label-forge-node.sh`.
|
||||
|
||||
The Actions **runner** is deliberately *not* part of this stack — it runs on
|
||||
the **desktop** as a plain systemd service (`register-lan-runner.sh` below),
|
||||
per `thermograph-docs/runbooks/implementation-handoff.md` Track B step 5. That's the
|
||||
canonical placement; an earlier revision of this stack ran the runner as a
|
||||
Swarm-scheduled Docker-in-Docker sidecar pinned to beta, which is gone now.
|
||||
The Actions **runner** is deliberately *not* part of this stack — see
|
||||
`DEPLOY-DEV.md` and the note in `register-lan-runner.sh`'s own header for
|
||||
where it runs today. That's the canonical placement per
|
||||
`thermograph-docs/runbooks/implementation-handoff.md` Track B step 5; an
|
||||
earlier revision of this stack ran the runner as a Swarm-scheduled
|
||||
Docker-in-Docker sidecar pinned to the Forgejo node, which is gone now.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. All three nodes have joined the swarm (`deploy/swarm/`) and beta is
|
||||
1. All three nodes have joined the swarm (`deploy/swarm/`) and vps1 is
|
||||
labeled `role=forge`.
|
||||
2. `docker node ls` (from the manager) shows all three `Ready`.
|
||||
2. `docker node ls` (from the manager, vps2) shows all three `Ready`.
|
||||
|
||||
## One-time setup: Swarm secret
|
||||
|
||||
|
|
@ -43,27 +45,27 @@ its first health check just fails harmlessly until it is.
|
|||
|
||||
This stack has **no auto-deploy trigger** — nothing in `.forgejo/workflows/`
|
||||
redeploys it on push. A change to `docker-stack.yml` only takes effect once
|
||||
someone re-runs `docker stack deploy` by hand on the manager (prod).
|
||||
someone re-runs `docker stack deploy` by hand on the manager (vps2).
|
||||
|
||||
`db`/`forgejo` both carry `resources.limits` (defaults: db 1 CPU/1g, forgejo 2
|
||||
CPU/2g — several times observed steady-state usage), overridable with
|
||||
`FORGEJO_DB_CPUS`/`FORGEJO_DB_MEMORY`/`FORGEJO_CPUS`/`FORGEJO_MEMORY` env vars
|
||||
before `docker stack deploy`, same convention as the app stack.
|
||||
|
||||
## DNS + TLS: reusing beta's existing Caddy, not a second reverse proxy
|
||||
## DNS + TLS: reusing vps1's existing Caddy, not a second reverse proxy
|
||||
|
||||
Forgejo is pinned to beta (`role=forge`) — but beta is also **today's live
|
||||
thermograph.org host**, and its Caddy already owns ports 80/443
|
||||
(`/etc/caddy/Caddyfile` on that box). A second ingress (Traefik) trying to
|
||||
bind the same ports would collide with it. So there's no Traefik in this
|
||||
stack: `forgejo`'s web port publishes to `127.0.0.1:3080` only (host-local),
|
||||
and beta's *existing* Caddy gets one more site block reverse-proxying to it —
|
||||
same pattern as its `thermograph.org` block, same automatic-HTTPS.
|
||||
Forgejo is pinned to vps1 (`role=forge`) — the same box that also runs
|
||||
Grafana/Loki/Alloy and the `emigriffith.dev` portfolio site, each fronted by
|
||||
that host's own Caddy. A second ingress (Traefik) trying to bind the same
|
||||
ports would collide with it. So there's no Traefik in this stack: `forgejo`'s
|
||||
web port publishes to `127.0.0.1:3080` only (host-local), and vps1's
|
||||
*existing* Caddy gets one more site block reverse-proxying to it — same
|
||||
pattern as its other site blocks, same automatic-HTTPS.
|
||||
|
||||
1. Point the Forgejo domain (default `git.thermograph.org`; override with
|
||||
`FORGEJO_DOMAIN=...` before `docker stack deploy`) at **beta's** public IP
|
||||
— that's where the task actually runs, not prod's or the desktop's.
|
||||
2. Append `deploy/forgejo/caddy-git.conf` to beta's `/etc/caddy/Caddyfile`,
|
||||
`FORGEJO_DOMAIN=...` before `docker stack deploy`) at **vps1's** public IP
|
||||
— that's where the task actually runs, not vps2's or the desktop's.
|
||||
2. Append `deploy/forgejo/caddy-git.conf` to vps1's `/etc/caddy/Caddyfile`,
|
||||
adjusting the domain if you didn't use the default, then `systemctl reload
|
||||
caddy`.
|
||||
3. That file also resolves the registry-exposure hazard (#15 in
|
||||
|
|
@ -75,28 +77,28 @@ same pattern as its `thermograph.org` block, same automatic-HTTPS.
|
|||
|
||||
## Registry access from mesh clients
|
||||
|
||||
Any node that needs `docker login`/push/pull against the registry (the
|
||||
desktop's CI runner building/pushing images, later any Swarm node pulling
|
||||
them) must reach `git.thermograph.org` **over the WireGuard tunnel**, not
|
||||
beta's public IP — otherwise Caddy's `/v2/*` block above refuses the
|
||||
connection. Public DNS resolves the domain to beta's public IP, so add a
|
||||
`/etc/hosts` override on each such node pinning it to beta's WireGuard
|
||||
address instead:
|
||||
Any node that needs `docker login`/push/pull against the registry (the CI
|
||||
runner building/pushing images, any Swarm node pulling them, prod or beta on
|
||||
vps2 pulling app images) must reach `git.thermograph.org` **over the
|
||||
WireGuard tunnel**, not vps1's public IP — otherwise Caddy's `/v2/*` block
|
||||
above refuses the connection. Public DNS resolves the domain to vps1's public
|
||||
IP, so add a `/etc/hosts` override on each such node pinning it to vps1's
|
||||
WireGuard address instead:
|
||||
|
||||
```
|
||||
echo "10.10.0.2 git.thermograph.org" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
(`10.10.0.2` is beta's WG address per `deploy/swarm/README.md`'s peer
|
||||
(`10.10.0.2` is vps1's WG address per `deploy/swarm/README.md`'s peer
|
||||
numbering — adjust if you assigned it differently.) The git/web UI keeps
|
||||
working normally for everyone else since only `/v2/*` is restricted.
|
||||
|
||||
## Register the Actions runner (on the desktop, not through Swarm)
|
||||
## Register the Actions runner
|
||||
|
||||
Once Forgejo answers at its domain:
|
||||
|
||||
```bash
|
||||
# On the desktop:
|
||||
# On the runner host (see DEPLOY-DEV.md for where that is today):
|
||||
# Forgejo web UI -> Site Administration -> Actions -> Runners -> Create new Runner
|
||||
# (or, for a repo-scoped runner: <repo> -> Settings -> Actions -> Runners)
|
||||
# copy the registration token, then:
|
||||
|
|
@ -104,8 +106,8 @@ bash deploy/forgejo/register-lan-runner.sh https://<forgejo-domain> <token>
|
|||
```
|
||||
|
||||
See that script's header for exactly what it replaces (the pre-Forgejo GitHub
|
||||
self-hosted runner on this same machine) and why it registers with two
|
||||
labels where there used to be two separate runners.
|
||||
self-hosted runner) and why it registers with two labels where there used to
|
||||
be two separate runners.
|
||||
|
||||
`config.yaml`'s `runner.capacity` is raised from the tool's default of 1 to 8
|
||||
(override with `CAPACITY=`) — a single PR push fires `pr-build`,
|
||||
|
|
@ -113,18 +115,16 @@ labels where there used to be two separate runners.
|
|||
no `needs:` between them), so capacity 1 serializes work that could run in
|
||||
parallel, and even capacity 3 (an earlier, undocumented hand-tune) leaves
|
||||
`build-backend`/`build-frontend`/`validate-observability` queued behind
|
||||
those three before they get a slot. The desktop has 16 cores / 34GB free
|
||||
today; 8 concurrent jobs is comfortable headroom without starving LAN dev's
|
||||
own compose stack.
|
||||
those three before they get a slot.
|
||||
|
||||
**Adding more runner capacity should mean raising this number, or adding a
|
||||
second runner on the desktop itself — not putting a runner on prod or
|
||||
beta.** `container.docker_host: automount` gives job containers the *host's*
|
||||
Docker socket; on prod or beta that would mean any CI job has root-equivalent
|
||||
access to whatever else is running there (the live app stack, or Forgejo
|
||||
itself). An earlier revision of this stack actually did run the runner as a
|
||||
Swarm-hosted container on beta and was deliberately reverted to the desktop
|
||||
for this reason — see the note at the top of `docker-stack.yml`.
|
||||
second runner alongside it — not putting a runner on prod or beta (vps2).**
|
||||
`container.docker_host: automount` gives job containers the *host's* Docker
|
||||
socket; on vps2 that would mean any CI job has root-equivalent access to both
|
||||
the prod and beta stacks running there. An earlier revision of this stack
|
||||
actually ran the runner as a Swarm-hosted container on the box Forgejo was
|
||||
pinned to and was deliberately reverted for this same class of reason — see
|
||||
the note at the top of `docker-stack.yml`.
|
||||
|
||||
## Custom CI job image (`ci-runner/`)
|
||||
|
||||
|
|
@ -155,8 +155,8 @@ docker push git.thermograph.org/emi/thermograph/ci-runner:vN
|
|||
|
||||
`register-lan-runner.sh`'s `LABELS` default points at the current tag, so
|
||||
fresh registrations pick it up automatically. The live runner is cut over by
|
||||
editing the `labels` array in `~/forgejo-runner/.runner` on the desktop (same
|
||||
runner id/token, no re-registration needed) and restarting the service —
|
||||
editing the `labels` array in `~/forgejo-runner/.runner` on the runner host
|
||||
(same runner id/token, no re-registration needed) and restarting the service —
|
||||
**verify a real job runs green under the new image before relying on it**,
|
||||
same way v1's break was caught. Only after that verification should the
|
||||
now-redundant `apt-get install docker.io` / `python3-yaml` steps be removed
|
||||
|
|
@ -165,7 +165,7 @@ still running on the stock `node:20-bookworm` image.
|
|||
|
||||
## Why Postgres here and not the Thermograph app's TimescaleDB
|
||||
|
||||
Separate instance, separate network (`forgejo_net`, not the app's compose
|
||||
Separate instance, separate network (`forgejo_net`, not the app's overlay
|
||||
network), separate volume. Forgejo is a distinct product with its own schema
|
||||
and its own backup/restore lifecycle — sharing a database with the app would
|
||||
couple two things that should be able to fail, migrate, and restore
|
||||
|
|
@ -177,7 +177,7 @@ independently.
|
|||
docker service ls # forgejo_db, forgejo_forgejo both Running, 1/1
|
||||
curl -I https://git.thermograph.org/ # 200, valid cert (Caddy's, not a new one)
|
||||
curl -I https://git.thermograph.org/v2/ # 403 from anywhere off the WireGuard mesh
|
||||
# On the desktop, after registering the runner:
|
||||
# On the runner host, after registering the runner:
|
||||
systemctl --user status forgejo-runner # active, both labels registered
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
# ends up as a confusing orphaned comment in production config with no
|
||||
# surrounding context (docker-stack.yml isn't visible from there).
|
||||
#
|
||||
# Target: /etc/caddy/Caddyfile on beta (the box `forgejo` is pinned to — see
|
||||
# docker-stack.yml). Reuses beta's existing Caddy instead of a second reverse
|
||||
# proxy/ACME flow: Forgejo publishes its web UI to 127.0.0.1:3080 only
|
||||
# (host-local), and this block is the only thing that ever talks to it.
|
||||
# Target: /etc/caddy/Caddyfile on vps1 (the box `forgejo` is pinned to — see
|
||||
# docker-stack.yml; this is the box also called "vps1", 75.119.132.91).
|
||||
# Reuses vps1's existing Caddy instead of a second reverse proxy/ACME flow:
|
||||
# Forgejo publishes its web UI to 127.0.0.1:3080 only (host-local), and this
|
||||
# block is the only thing that ever talks to it.
|
||||
#
|
||||
# Registry exposure (hazard #15, see docker-stack.yml's header comment):
|
||||
# git.thermograph.org/v2/* is the built-in OCI registry API. It's blocked
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
# else (web UI, git-over-HTTP, PR pages) stays public like the rest of the
|
||||
# site.
|
||||
#
|
||||
# Update the domain below to match whatever you actually pointed at beta's
|
||||
# Update the domain below to match whatever you actually pointed at vps1's
|
||||
# IP, if not git.thermograph.org.
|
||||
|
||||
# --- copy from here down into /etc/caddy/Caddyfile ---
|
||||
|
|
|
|||
|
|
@ -9,18 +9,18 @@
|
|||
# (deploy/forgejo/register-lan-runner.sh) — the same place the pre-Forgejo
|
||||
# GitHub self-hosted runner already lived, not a Swarm-scheduled container.
|
||||
#
|
||||
# No Traefik here. Forgejo is pinned to beta (node.labels.role == forge)
|
||||
# because that's what was chosen, but beta is ALSO today's live thermograph.org
|
||||
# No Traefik here. Forgejo is pinned to vps1 (node.labels.role == forge)
|
||||
# because that's what was chosen, and vps1 is ALSO the emigriffith.dev portfolio
|
||||
# host — Caddy already owns its ports 80/443 (see /etc/caddy/Caddyfile on that
|
||||
# box). A second reverse proxy binding those same ports would either fail to
|
||||
# start or fight Caddy. Instead: forgejo's web port publishes to
|
||||
# 127.0.0.1:3080 only (host-local, mode: host), and beta's existing Caddy gets
|
||||
# 127.0.0.1:3080 only (host-local, mode: host), and vps1's existing Caddy gets
|
||||
# a new site block reverse-proxying git.thermograph.org -> 127.0.0.1:3080,
|
||||
# same pattern as its thermograph.org block. TLS is Caddy's existing
|
||||
# automatic-HTTPS (HTTP-01), not a second ACME flow.
|
||||
# same pattern as its other blocks. TLS is Caddy's existing automatic-HTTPS
|
||||
# (HTTP-01), not a second ACME flow.
|
||||
#
|
||||
# Deploy from the manager node (prod), after all three nodes (prod, beta,
|
||||
# desktop) have joined the swarm and beta is labeled role=forge:
|
||||
# Deploy from the manager node (vps2, which runs prod), after all three nodes
|
||||
# (vps1, vps2, desktop) have joined the swarm and vps1 is labeled role=forge:
|
||||
#
|
||||
# docker stack deploy -c deploy/forgejo/docker-stack.yml forgejo
|
||||
#
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
# firewall the /v2/ registry API paths so only WireGuard-mesh clients can
|
||||
# reach them. That's implemented in the Caddy site block (see
|
||||
# deploy/forgejo/README.md), not here — nothing in this stack file is
|
||||
# registry-specific, the restriction lives entirely in Caddy's config on beta.
|
||||
# registry-specific, the restriction lives entirely in Caddy's config on vps1.
|
||||
|
||||
services:
|
||||
db:
|
||||
|
|
@ -96,9 +96,10 @@ services:
|
|||
FORGEJO__service__ENABLE_PASSWORD_SIGNIN_FORM: "false"
|
||||
FORGEJO__service__REGISTER_MANUAL_CONFIRM: "true"
|
||||
FORGEJO__service__DEFAULT_USER_IS_RESTRICTED: "true"
|
||||
# Outbound mail via prod's Postfix null client over the WireGuard mesh
|
||||
# (10.10.0.1:25 — mynetworks permits beta); self-signed cert on :25, so
|
||||
# trust it. Send-only; used for admin/approval and notification mail.
|
||||
# Outbound mail via vps2's Postfix null client (prod's box) over the
|
||||
# WireGuard mesh (10.10.0.1:25 — mynetworks permits vps1, 10.10.0.2,
|
||||
# where this Forgejo runs); self-signed cert on :25, so trust it.
|
||||
# Send-only; used for admin/approval and notification mail.
|
||||
FORGEJO__mailer__ENABLED: "true"
|
||||
FORGEJO__mailer__PROTOCOL: "smtp"
|
||||
FORGEJO__mailer__SMTP_ADDR: "10.10.0.1"
|
||||
|
|
@ -117,17 +118,17 @@ services:
|
|||
networks: [forgejo_net]
|
||||
ports:
|
||||
# SSH for git@ clones — published on whichever node the task lands on
|
||||
# (pinned to beta by the placement constraint below, so effectively
|
||||
# always beta's public IP, port 2222).
|
||||
# (pinned to vps1 by the placement constraint below, so effectively
|
||||
# always vps1's public IP, port 2222).
|
||||
- target: 2222
|
||||
published: 2222
|
||||
protocol: tcp
|
||||
mode: host
|
||||
# Web UI/API. Swarm's port schema has no host_ip scoping, so this binds
|
||||
# 0.0.0.0:3080 on beta — NOT actually localhost-only by itself. A
|
||||
# 0.0.0.0:3080 on vps1 — NOT actually localhost-only by itself. A
|
||||
# DOCKER-USER iptables rule (see deploy/forgejo/README.md) is what
|
||||
# actually restricts it, since Docker's own iptables rules bypass ufw
|
||||
# for published ports. beta's Caddy reverse-proxies to 127.0.0.1:3080.
|
||||
# for published ports. vps1's Caddy reverse-proxies to 127.0.0.1:3080.
|
||||
- target: 3000
|
||||
published: 3080
|
||||
protocol: tcp
|
||||
|
|
|
|||
|
|
@ -4,17 +4,26 @@
|
|||
# runner (see DEPLOY-DEV.md) — this replaces that runner, it doesn't add a
|
||||
# second one. Sudo-free, systemd --user, same pattern as the app service.
|
||||
#
|
||||
# This is THE runner (docs/runbooks/implementation-handoff.md Track B step 5
|
||||
# — canonical placement is the desktop, not a Swarm-hosted container), so it
|
||||
# registers with BOTH labels the workflows need, where one runner used to be
|
||||
# two: general CI/build/deploy.yml jobs (`docker`, containerized via this
|
||||
# machine's own already-installed Docker — no Docker-in-Docker sidecar needed,
|
||||
# unlike the Swarm-hosted design this replaces, since a real host with a real
|
||||
# Docker install needs no such indirection) and the LAN-deploy job
|
||||
# (`thermograph-lan`, bare/host-native — it writes to ~/thermograph-dev and
|
||||
# restarts a systemd --user service, which only works running directly on the
|
||||
# It registers with BOTH labels the workflows historically needed: general
|
||||
# CI/build/deploy jobs (`docker`, containerized via this machine's own
|
||||
# already-installed Docker — no Docker-in-Docker sidecar needed, since a real
|
||||
# host with a real Docker install needs no such indirection) and the LAN-deploy
|
||||
# job (`thermograph-lan`, bare/host-native — it wrote to ~/thermograph-dev and
|
||||
# restarted a systemd --user service, which only worked running directly on the
|
||||
# host, not inside a container).
|
||||
#
|
||||
# `thermograph-lan` IS NOW OBSOLETE. Dev moved off this machine to vps1 and is
|
||||
# deployed over SSH by the same `Deploy` workflow that ships beta and prod;
|
||||
# there is no host-native LAN deploy job left for that label to serve. Nothing
|
||||
# breaks by keeping it registered — no workflow requests it — but do not build
|
||||
# anything new on it.
|
||||
#
|
||||
# TODO(cutover): whether this machine keeps serving the `docker` label at all is
|
||||
# an open call. It is no longer the only runner (the Swarm-hosted one is
|
||||
# always-on), and the box's new job is AI model hosting plus flex Swarm-worker
|
||||
# capacity. Keeping it is fine — it is real CI capacity — but the estate no
|
||||
# longer depends on it, so decide deliberately rather than by inertia.
|
||||
#
|
||||
# bash deploy/forgejo/register-lan-runner.sh <forgejo_url> <registration_token>
|
||||
#
|
||||
# Get <registration_token> from the Forgejo web UI:
|
||||
|
|
|
|||
|
|
@ -184,6 +184,10 @@ credit; keep it in place.
|
|||
|
||||
## 9. Not on dev/beta
|
||||
|
||||
This overlay runs only on the self-hosting host (prod). Dev and beta leave
|
||||
`THERMOGRAPH_ARCHIVE_URL` unset and use the public Open-Meteo archive API — do
|
||||
not bring the overlay up there.
|
||||
This overlay runs only for prod specifically — not for every environment on
|
||||
prod's host. Beta now shares vps2 with prod, but that doesn't extend the
|
||||
self-hosted archive to it: beta is its own Swarm stack
|
||||
(`thermograph-beta-stack.yml`), which never sets `THERMOGRAPH_ARCHIVE_URL`, so
|
||||
it reaches the public Open-Meteo archive API like dev does. Dev leaves it
|
||||
unset for the same reason on vps1. Do not bring this overlay up for beta or
|
||||
dev, and don't assume co-residency with prod on vps2 changes that.
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-time bootstrap for the Thermograph LAN dev server on THIS machine.
|
||||
#
|
||||
# Sudo-free: the app runs as a Docker Compose stack (see deploy-dev.sh), and
|
||||
# `linger` keeps the runner/services running across logout/reboot. Re-runnable
|
||||
# (idempotent).
|
||||
#
|
||||
# bash deploy/provision-dev-lan.sh
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-$HOME/thermograph-dev}"
|
||||
REPO_URL="${REPO_URL:-http://10.10.0.2:3080/emi/thermograph-infra.git}"
|
||||
BRANCH="${BRANCH:-dev}"
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "==> Enabling linger so the service survives logout/reboot"
|
||||
loginctl enable-linger "$USER" \
|
||||
|| echo " (couldn't enable linger; the service still runs while you're logged in)"
|
||||
|
||||
echo "==> Cloning/refreshing $APP_DIR and starting the service"
|
||||
APP_DIR="$APP_DIR" REPO_URL="$REPO_URL" BRANCH="$BRANCH" bash "$here/deploy-dev.sh"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. The dev server runs as the 'thermograph-dev' systemd --user service.
|
||||
status: systemctl --user status thermograph-dev
|
||||
logs: journalctl --user -u thermograph-dev -f
|
||||
restart: systemctl --user restart thermograph-dev
|
||||
stop: systemctl --user stop thermograph-dev
|
||||
EOF
|
||||
79
infra/deploy/provision-dev.sh
Executable file
79
infra/deploy/provision-dev.sh
Executable file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-time bootstrap of the DEV environment on vps1.
|
||||
#
|
||||
# sudo bash infra/deploy/provision-dev.sh
|
||||
#
|
||||
# Replaces provision-dev-lan.sh, which bootstrapped dev on the operator's
|
||||
# desktop as a sudo-free systemd --user stack with `linger`. Dev is a normal
|
||||
# fleet environment now: a checkout at /opt/thermograph-dev, secrets rendered
|
||||
# from the SOPS vault at deploy time, deployed over SSH by CI like beta and
|
||||
# prod. The desktop hosts no Thermograph environment at all.
|
||||
#
|
||||
# What dev keeps that beta and prod do not:
|
||||
# - It renders dev.yaml ALONE, never layering common.yaml (the fleet's shared
|
||||
# production credentials). vps1 also runs Forgejo and its CI, and dev runs
|
||||
# whatever branch is in flight — see the long note in render-secrets.sh.
|
||||
# - It is MESH-ONLY: published on 10.10.0.2:8137 (wg0), never 0.0.0.0. No DNS
|
||||
# record, no Caddy site, no TLS. Anything that can reach it is already on
|
||||
# the WireGuard mesh.
|
||||
#
|
||||
# Idempotent; re-run it after changing the branch or repointing the remote.
|
||||
set -euo pipefail
|
||||
|
||||
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. "$SELF_DIR/env-topology.sh"
|
||||
thermograph_topology dev
|
||||
|
||||
REPO_URL="${REPO_URL:-http://10.10.0.2:3080/emi/thermograph.git}"
|
||||
APP_DIR="${APP_DIR:-$TG_APP_DIR}"
|
||||
BRANCH="${BRANCH:-$TG_BRANCH}"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "!! run this as root (it writes /opt and /etc/thermograph)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Checkout: $APP_DIR on $BRANCH"
|
||||
if [ -d "$APP_DIR/.git" ]; then
|
||||
git -C "$APP_DIR" remote set-url origin "$REPO_URL"
|
||||
git -C "$APP_DIR" fetch --prune origin "$BRANCH"
|
||||
git -C "$APP_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
|
||||
else
|
||||
git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
|
||||
fi
|
||||
|
||||
echo "==> Marking this checkout's environment"
|
||||
mkdir -p /etc/thermograph
|
||||
# The host marker still exists for by-hand runs. vps1 runs exactly one
|
||||
# environment, so a marker is sufficient here — unlike vps2, where beta and prod
|
||||
# share a box and THERMOGRAPH_ENV must be passed explicitly.
|
||||
printf 'dev\n' > /etc/thermograph/secrets-env
|
||||
# Dev is compose, not Swarm. env-topology.sh is what actually decides this; the
|
||||
# marker is only the fallback for a checkout that predates it.
|
||||
rm -f /etc/thermograph/deploy-mode
|
||||
|
||||
if [ ! -f /etc/thermograph/age.key ]; then
|
||||
cat >&2 <<'EOF'
|
||||
!! No age key at /etc/thermograph/age.key.
|
||||
!! Dev cannot render its vault without it, and deploy-dev.sh will fall back to
|
||||
!! the un-vaulted defaults. Copy the key over (0400 root:root), then re-run:
|
||||
!! install -m 0400 -o root -g root age.key /etc/thermograph/age.key
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. Dev is checked out at $APP_DIR on $BRANCH.
|
||||
|
||||
Deploy it:
|
||||
SERVICE=all BACKEND_IMAGE_TAG=sha-<a> FRONTEND_IMAGE_TAG=sha-<b> \\
|
||||
$APP_DIR/infra/deploy/deploy-dev.sh
|
||||
|
||||
Reach it (mesh only):
|
||||
http://${TG_BIND_ADDR}:8137
|
||||
|
||||
Confirm it is NOT publicly exposed — this must show ${TG_BIND_ADDR}:8137 and
|
||||
never 0.0.0.0:8137:
|
||||
ss -ltnp | grep 8137
|
||||
EOF
|
||||
|
|
@ -35,6 +35,12 @@
|
|||
# sudo MAIL_DOMAIN=thermograph.org bash deploy/provision-mail.sh
|
||||
# sudo MAIL_DOMAIN=thermograph.org RELAYHOST='[smtp.provider.com]:587' \
|
||||
# RELAY_USER=apikey RELAY_PASSWORD=secret bash deploy/provision-mail.sh
|
||||
#
|
||||
# MAIL_ENV picks which environment's deploy mode (env-topology.sh) sizes the
|
||||
# Docker mail gateway below -- see the "WHICH gateway" comment. Defaults to
|
||||
# prod; only matters if this box ever runs an environment in compose mode
|
||||
# (it doesn't today -- prod and beta are both Swarm on this box; compose-mode
|
||||
# dev lives on the other box entirely and doesn't run Postfix).
|
||||
set -euo pipefail
|
||||
|
||||
MAIL_DOMAIN="${MAIL_DOMAIN:-thermograph.org}"
|
||||
|
|
@ -43,6 +49,12 @@ RELAYHOST="${RELAYHOST:-}"
|
|||
RELAY_USER="${RELAY_USER:-}"
|
||||
RELAY_PASSWORD="${RELAY_PASSWORD:-}"
|
||||
|
||||
MAIL_ENV="${MAIL_ENV:-prod}"
|
||||
SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. "$SELF_DIR/env-topology.sh"
|
||||
thermograph_topology "$MAIL_ENV"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "run as root (sudo)" >&2
|
||||
exit 1
|
||||
|
|
@ -68,17 +80,21 @@ postconf -e "myorigin = ${MAIL_DOMAIN}"
|
|||
# docker-compose.yml). Set DOCKER_MAIL_GATEWAY="" for a pure loopback-only null
|
||||
# client (app running natively on the host, not in a container).
|
||||
#
|
||||
# WHICH gateway depends on the host's deploy mode: plain compose (beta, LAN)
|
||||
# uses the pinned compose bridge (172.19.0.1/172.19.0.0/16, the defaults);
|
||||
# Swarm-stack mode (prod) uses the docker_gwbridge gateway instead --
|
||||
# overlay tasks have no compose-bridge gateway -- so prod is provisioned with
|
||||
# DOCKER_MAIL_GATEWAY=172.18.0.1 DOCKER_MAIL_SUBNET=172.18.0.0/16 (plus its
|
||||
# MESH_MAIL_* listener below). Do NOT list an address that doesn't exist on
|
||||
# the host: Postfix's master fails to bind and takes ALL listeners down --
|
||||
# exactly what happened when the compose bridge (172.19.0.1) vanished at the
|
||||
# stack cutover while still listed in inet_interfaces. Also note: postfix on
|
||||
# this distro is an umbrella unit; restart `postfix@-`, not `postfix`, for
|
||||
# inet_interfaces changes to take effect.
|
||||
# WHICH gateway to listen on comes from MAIL_ENV's deploy mode (env-topology.sh,
|
||||
# TG_DEPLOY_MODE) -- not a hardcoded beta/prod split. Beta moved onto this SAME
|
||||
# box as prod and is Swarm too now, so "compose" no longer means "beta"; it
|
||||
# means dev, which lives on the other box entirely and never runs this script.
|
||||
# Compose mode uses the pinned compose bridge (172.19.0.1/172.19.0.0/16, the
|
||||
# defaults); stack mode uses the docker_gwbridge gateway instead -- overlay
|
||||
# tasks have no compose-bridge gateway -- so a stack-mode MAIL_ENV (prod or
|
||||
# beta; same box, same gateway) gets DOCKER_MAIL_GATEWAY=172.18.0.1
|
||||
# DOCKER_MAIL_SUBNET=172.18.0.0/16 (plus the MESH_MAIL_* listener below). Do
|
||||
# NOT list an address that doesn't exist on the host: Postfix's master fails
|
||||
# to bind and takes ALL listeners down -- exactly what happened when the
|
||||
# compose bridge (172.19.0.1) vanished at the stack cutover while still listed
|
||||
# in inet_interfaces. Also note: postfix on this distro is an umbrella unit;
|
||||
# restart `postfix@-`, not `postfix`, for inet_interfaces changes to take
|
||||
# effect.
|
||||
#
|
||||
# THE SAME TRAP FIRES AT BOOT, NOT JUST ON RENUMBERING (prod outage 2026-07-24).
|
||||
# A Docker bridge address does not exist until dockerd creates it, and the stock
|
||||
|
|
@ -92,15 +108,23 @@ postconf -e "myorigin = ${MAIL_DOMAIN}"
|
|||
# it orders postfix@ after docker.service and wg-quick@wg0.service and retries
|
||||
# on failure. If you add an address here that some other daemon creates, add
|
||||
# that daemon to the drop-in too.
|
||||
DOCKER_MAIL_GATEWAY="${DOCKER_MAIL_GATEWAY-172.19.0.1}"
|
||||
DOCKER_MAIL_SUBNET="${DOCKER_MAIL_SUBNET-172.19.0.0/16}"
|
||||
# Optional WireGuard-mesh listener: other mesh nodes (e.g. beta's Forgejo, whose
|
||||
if [ "$TG_DEPLOY_MODE" = stack ]; then
|
||||
DOCKER_MAIL_GATEWAY="${DOCKER_MAIL_GATEWAY-172.18.0.1}"
|
||||
DOCKER_MAIL_SUBNET="${DOCKER_MAIL_SUBNET-172.18.0.0/16}"
|
||||
else
|
||||
DOCKER_MAIL_GATEWAY="${DOCKER_MAIL_GATEWAY-172.19.0.1}"
|
||||
DOCKER_MAIL_SUBNET="${DOCKER_MAIL_SUBNET-172.19.0.0/16}"
|
||||
fi
|
||||
# Optional WireGuard-mesh listener: other mesh nodes (e.g. vps1's Forgejo, whose
|
||||
# mailer posts to 10.10.0.1:25 — see deploy/forgejo/docker-stack.yml) can relay
|
||||
# through this box. Prod runs with MESH_MAIL_LISTEN=10.10.0.1 and
|
||||
# MESH_MAIL_PEERS=10.10.0.2/32; both default OFF so a plain run stays a strict
|
||||
# null client. Without these, re-running this script on prod would silently drop
|
||||
# the mesh listener and break Forgejo's outbound mail — the live config was
|
||||
# originally hand-applied and this script is the source of truth for it now.
|
||||
# through this box. This host (vps2 -- prod AND beta) runs with
|
||||
# MESH_MAIL_LISTEN=10.10.0.1 and MESH_MAIL_PEERS=10.10.0.2/32; both default OFF
|
||||
# so a plain run stays a strict null client. 10.10.0.2 is vps1 (Forgejo,
|
||||
# Grafana, dev) -- mesh IPs did not move in the vps1/vps2 split, only which
|
||||
# environment runs where, so this is NOT "beta's" address. Without these,
|
||||
# re-running this script on vps2 would silently drop the mesh listener and
|
||||
# break Forgejo's outbound mail — the live config was originally hand-applied
|
||||
# and this script is the source of truth for it now.
|
||||
MESH_MAIL_LISTEN="${MESH_MAIL_LISTEN-}"
|
||||
MESH_MAIL_PEERS="${MESH_MAIL_PEERS-}"
|
||||
postconf -e "inet_protocols = ipv4"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
#!/usr/bin/env bash
|
||||
# Provision a host to render secrets from the SOPS vault at deploy time. Idempotent;
|
||||
# run once per box (prod/beta) as a sudo-capable user.
|
||||
# Provision a NAMED ENVIRONMENT to render secrets from the SOPS vault at deploy
|
||||
# time. Idempotent; run once per environment (not once per box) as a
|
||||
# sudo-capable user — vps2 carries two (prod AND beta), so it needs this run
|
||||
# TWICE, each pointing SECRETS_ENV_FILE at that environment's own marker.
|
||||
#
|
||||
# SECRETS_ENV=prod AGE_KEY_SRC=/path/to/age.key bash deploy/provision-secrets.sh
|
||||
# SECRETS_ENV=beta AGE_KEY_SRC=/path/to/age.key \
|
||||
# SECRETS_ENV_FILE=/etc/thermograph/beta-secrets-env bash deploy/provision-secrets.sh
|
||||
#
|
||||
# Installs `sops` + `age`, then installs the age PRIVATE key at /etc/thermograph/age.key
|
||||
# (0400) and writes /etc/thermograph/secrets-env. After this, deploy.sh renders
|
||||
# /etc/thermograph.env from deploy/secrets/*.yaml. See deploy/secrets/README.md.
|
||||
# Installs `sops` + `age` (idempotent, and shared across every environment on the
|
||||
# box — one age key decrypts every deploy/secrets/<env>.yaml, since they're all
|
||||
# encrypted to the same recipient), then installs the age PRIVATE key at
|
||||
# /etc/thermograph/age.key (0400) and writes SECRETS_ENV_FILE (default
|
||||
# /etc/thermograph/secrets-env — right for a single-environment host, dev
|
||||
# included). After this, deploy.sh renders that environment's own
|
||||
# /etc/thermograph*.env from deploy/secrets/*.yaml. See deploy/secrets/README.md.
|
||||
#
|
||||
# SECRETS_ENV_FILE only matters for a by-hand run with THERMOGRAPH_ENV unset:
|
||||
# env-topology.sh's thermograph_env_name() falls back to reading it to decide
|
||||
# which environment a bare `deploy.sh` means, and a host running two
|
||||
# environments (vps2) cannot answer that from one shared marker — a by-hand
|
||||
# deploy of beta there must export THERMOGRAPH_SECRETS_ENV_FILE to match
|
||||
# whatever path this script wrote. CI-driven deploys pass THERMOGRAPH_ENV
|
||||
# explicitly and never consult this file at all.
|
||||
set -euo pipefail
|
||||
|
||||
SECRETS_ENV="${SECRETS_ENV:?set SECRETS_ENV=prod|beta}"
|
||||
SECRETS_ENV="${SECRETS_ENV:?set SECRETS_ENV=prod|beta|dev}"
|
||||
AGE_KEY_SRC="${AGE_KEY_SRC:?set AGE_KEY_SRC=/path/to/the/age/private/key}"
|
||||
# Where to write the env marker. Default matches the original, single-environment
|
||||
# behavior; a SECOND environment on the same box (vps2's beta, alongside prod's
|
||||
# default marker) must pass a distinct path so provisioning one can never
|
||||
# overwrite the other's marker.
|
||||
SECRETS_ENV_FILE="${SECRETS_ENV_FILE:-/etc/thermograph/secrets-env}"
|
||||
SOPS_VERSION="${SOPS_VERSION:-v3.13.2}"
|
||||
AGE_VERSION="${AGE_VERSION:-v1.3.1}"
|
||||
BIN="${BIN:-/usr/local/bin}"
|
||||
|
|
@ -35,11 +56,16 @@ install_age() {
|
|||
install_sops
|
||||
install_age
|
||||
|
||||
echo "==> Installing age key -> /etc/thermograph/age.key (0400) and marker (${SECRETS_ENV})"
|
||||
sudo mkdir -p /etc/thermograph
|
||||
echo "==> Installing age key -> /etc/thermograph/age.key (0400) and marker ${SECRETS_ENV_FILE} (${SECRETS_ENV})"
|
||||
sudo mkdir -p /etc/thermograph "$(dirname "$SECRETS_ENV_FILE")"
|
||||
sudo install -m 0400 "$AGE_KEY_SRC" /etc/thermograph/age.key
|
||||
printf '%s\n' "$SECRETS_ENV" | sudo tee /etc/thermograph/secrets-env >/dev/null
|
||||
sudo chmod 0644 /etc/thermograph/secrets-env
|
||||
printf '%s\n' "$SECRETS_ENV" | sudo tee "$SECRETS_ENV_FILE" >/dev/null
|
||||
sudo chmod 0644 "$SECRETS_ENV_FILE"
|
||||
|
||||
echo "==> Done. Verify: sops --version && ls -l /etc/thermograph/"
|
||||
echo " Next: seed deploy/secrets/*.yaml, dry-run render, then deploy (see README)."
|
||||
if [ "$SECRETS_ENV_FILE" != /etc/thermograph/secrets-env ]; then
|
||||
echo " NOTE: non-default marker -- a by-hand deploy of ${SECRETS_ENV} on this box"
|
||||
echo " must export THERMOGRAPH_SECRETS_ENV_FILE=${SECRETS_ENV_FILE} (CI-driven"
|
||||
echo " deploys pass THERMOGRAPH_ENV explicitly and never consult this file)."
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -15,12 +15,21 @@
|
|||
# (prod|beta|dev). A host without them keeps whatever /etc/thermograph.env it already
|
||||
# has — so merging this is a safe no-op until a host is deliberately migrated. See
|
||||
# deploy/secrets/README.md.
|
||||
# render_thermograph_secrets <repo> [env_name] [out_file]
|
||||
#
|
||||
# env_name and out_file are explicit since vps2 began running two environments.
|
||||
# A host-wide marker file cannot name two environments, and one output path
|
||||
# cannot hold two renders: on vps2 prod renders prod.yaml to /etc/thermograph.env
|
||||
# while beta renders beta.yaml to /etc/thermograph-beta.env. Both arguments keep
|
||||
# their pre-split defaults (marker file, /etc/thermograph.env), so a
|
||||
# single-environment host and a by-hand run behave exactly as before.
|
||||
render_thermograph_secrets() {
|
||||
local repo="${1:-.}" # repo root holding deploy/secrets
|
||||
local key="${THERMOGRAPH_AGE_KEY:-/etc/thermograph/age.key}"
|
||||
local marker="${THERMOGRAPH_SECRETS_ENV_FILE:-/etc/thermograph/secrets-env}"
|
||||
local env_name
|
||||
env_name=$(cat "$marker" 2>/dev/null || true)
|
||||
local env_name="${2:-}"
|
||||
local out="${3:-/etc/thermograph.env}"
|
||||
[ -n "$env_name" ] || env_name=$(cat "$marker" 2>/dev/null || true)
|
||||
|
||||
# The per-host file is required; common.yaml is optional so the initial cutover can
|
||||
# seed each host as an exact copy of its live env (byte-identical render, no value
|
||||
|
|
@ -31,7 +40,7 @@ render_thermograph_secrets() {
|
|||
# (a) Not configured for SOPS at all — no env marker, or no age key. The LAN
|
||||
# dev box is legitimately in this state. Saying so and succeeding is right.
|
||||
if [ -z "$env_name" ] || [ ! -f "$key" ]; then
|
||||
echo "==> SOPS secrets not configured here; using existing /etc/thermograph.env"
|
||||
echo "==> SOPS secrets not configured here; using existing $out"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
|
@ -57,7 +66,7 @@ render_thermograph_secrets() {
|
|||
return 1
|
||||
fi
|
||||
|
||||
echo "==> Rendering /etc/thermograph.env from deploy/secrets (common + ${env_name})"
|
||||
echo "==> Rendering $out from deploy/secrets (common + ${env_name})"
|
||||
# The age private key is root-owned (0400). Read it directly if we can, else via
|
||||
# sudo into SOPS_AGE_KEY — so the key never has to be readable by the deploy user.
|
||||
# (The render needs sudo to write /etc/thermograph.env below anyway.)
|
||||
|
|
@ -119,12 +128,12 @@ render_thermograph_secrets() {
|
|||
# failed in-place `cat` write to status 0. Capture the status instead, so a
|
||||
# half-written /etc/thermograph.env fails loudly rather than deploying stale.
|
||||
local rc=0
|
||||
if [ -f /etc/thermograph.env ] && [ -w /etc/thermograph.env ]; then
|
||||
cat "$tmp" > /etc/thermograph.env || rc=1
|
||||
elif install -m 0640 "$tmp" /etc/thermograph.env 2>/dev/null; then :
|
||||
elif sudo install -m 0640 -o "$(id -un)" -g "$(id -gn)" "$tmp" /etc/thermograph.env 2>/dev/null; then :
|
||||
if [ -f "$out" ] && [ -w "$out" ]; then
|
||||
cat "$tmp" > "$out" || rc=1
|
||||
elif install -m 0640 "$tmp" "$out" 2>/dev/null; then :
|
||||
elif sudo install -m 0640 -o "$(id -un)" -g "$(id -gn)" "$tmp" "$out" 2>/dev/null; then :
|
||||
else
|
||||
echo "!! cannot write /etc/thermograph.env (need file write access or passwordless sudo)" >&2
|
||||
echo "!! cannot write $out (need file write access or passwordless sudo)" >&2
|
||||
rc=1
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ no per-host duplication.
|
|||
|------|-------|------------|
|
||||
| `common.yaml` | The 16 values identical on **prod and beta** — VAPID keypair, metrics token, IndexNow key, `REGISTRY_TOKEN`, S3 endpoint/bucket and both S3 keypairs, plus shared non-secret config. **Not layered under `dev`** — see below | ✅ |
|
||||
| `prod.yaml` | Prod's own — the three held-back credentials below, sizing (`APP_CPUS`, `DB_CPUS`, `DB_MEMORY`, `WORKERS`), `THERMOGRAPH_BASE_URL`, and the Discord + mail credentials that exist nowhere else | ✅ |
|
||||
| `beta.yaml` | Beta's own — the three held-back credentials, sizing, `THERMOGRAPH_BASE_URL` | ✅ |
|
||||
| `dev.yaml` | The LAN dev box's own — **self-contained**, 12 values, no production credential among them | ✅ |
|
||||
| `beta.yaml` | Beta's own — the three held-back credentials, `THERMOGRAPH_BASE_URL`, `APP_CPUS`/`WORKERS`, plus `THERMOGRAPH_MAIL_BACKEND=console` and `THERMOGRAPH_DISCORD_BOT=0`. Its `POSTGRES_PASSWORD` and `THERMOGRAPH_DATABASE_URL` name beta's **own role and database** (`thermograph_beta`) on the instance it shares with prod. `DB_CPUS`/`DB_MEMORY` were **removed**: beta no longer runs a database of its own, and leaving them would have implied it sized one — the shared instance is sized by `prod.yaml`'s values (see `deploy/stack/thermograph-stack.yml`'s `db` service) | ✅ |
|
||||
| `dev.yaml` | Dev's own (vps1) — **self-contained**, 12 values, no production credential among them | ✅ |
|
||||
| `centralis.prod.yaml` | **Centralis's** nine variables, rendered to `/etc/centralis.env` on prod. A different service, a different output file, a different renderer — see below | ✅ |
|
||||
| `example.yaml` | Format reference / CI fixture (fake values) | ✅ |
|
||||
| `../../.sops.yaml` | Which age recipient files are encrypted to (plaintext config) | — |
|
||||
|
|
@ -25,28 +25,55 @@ no per-host duplication.
|
|||
### Three credentials are deliberately NOT in `common.yaml`
|
||||
|
||||
`POSTGRES_PASSWORD`, `THERMOGRAPH_AUTH_SECRET` and `THERMOGRAPH_DATABASE_URL`
|
||||
hold **identical values on prod and beta today**, so by the mechanical rule they
|
||||
belong in `common.yaml`. They are kept per-host anyway.
|
||||
hold **different values on prod and beta today** (beta has its own database
|
||||
role/password on the shared instance, and its own auth secret), so unlike the
|
||||
rest of `common.yaml` they were never candidates for sharing on values alone.
|
||||
They stay per-environment for a reason that matters more now than it used to:
|
||||
|
||||
These are the credentials that let one environment act as another: with them, a
|
||||
foothold on beta is a foothold on prod's database and prod's session signing.
|
||||
Beta is the *more* exposed box — it serves public Forgejo and Grafana. They match
|
||||
only because beta was seeded from prod, not because the two are meant to be one
|
||||
system.
|
||||
**These are the credentials that let one environment act as another.** With
|
||||
matching values, a foothold in one environment's rendered env file is a
|
||||
foothold on the other's database and the other's session signing. That used
|
||||
to matter because beta and prod were separate boxes; it matters for a
|
||||
different reason now that they're co-resident on vps2 — keeping the
|
||||
credentials themselves separate is what stops co-location on one host from
|
||||
also becoming equivalence at the credential level. `deploy/db/provision-env-db.sh`
|
||||
enforces the same boundary from the database side: beta's role
|
||||
(`thermograph_beta`) is `NOSUPERUSER`/`NOCREATEDB`/`NOCREATEROLE`, owns only
|
||||
its own database, and `CONNECT` is revoked from `PUBLIC` on it — so even a
|
||||
leaked beta credential cannot reach prod's data, and the reverse.
|
||||
|
||||
Keeping them per-host costs nothing now (same values, one extra line each) and
|
||||
buys the ability to diverge: rotating prod's database password stops implying
|
||||
"and beta's too". Moving them into `common.yaml` would encode the equivalence as
|
||||
intentional and turn breaking it into a migration rather than an edit.
|
||||
**What actually separates beta and prod now, stated plainly:** they share a
|
||||
host by design (see `deploy/env-topology.sh`'s header), so a host-level
|
||||
compromise of vps2 is shared between them — separate SSH credentials no
|
||||
longer buy a host boundary between beta and prod, the way they once did when
|
||||
beta and prod were different machines. What remains is isolation at the
|
||||
**database** level (separate roles, separate databases, `CONNECT` revoked from
|
||||
`PUBLIC`) and the **file** level (separate checkouts, separate rendered env
|
||||
files, separate stack env files, separate loopback ports). Keeping
|
||||
`POSTGRES_PASSWORD`/`THERMOGRAPH_AUTH_SECRET`/`THERMOGRAPH_DATABASE_URL` out of
|
||||
a shared file is part of that file-level isolation: nothing on vps2 would ever
|
||||
hand you prod's auth secret merely because you have beta's.
|
||||
|
||||
If you ever *do* want them to differ, change one file. That is the whole point.
|
||||
**vps1 is the box that must never hold prod credentials.** It runs Forgejo,
|
||||
Forgejo's CI runner, Grafana, and the `dev` environment — which runs whatever
|
||||
branch is currently in flight, reviewed or not. That is the actual reason
|
||||
`dev` renders `dev.yaml` **alone** and never layers `common.yaml` (see below):
|
||||
not squeamishness about a dev box, but that vps1 is uniquely positioned to
|
||||
leak whatever it's handed, and `common.yaml` is the fleet's shared production
|
||||
credential set.
|
||||
|
||||
`dev.yaml` is the case that already differs: all three of its copies are its own,
|
||||
generated on the desktop. The rule earned its keep the first time it was used.
|
||||
Keeping the three held-back credentials per-environment costs nothing (one
|
||||
line each) and buys the ability for them to diverge further, or for a future
|
||||
environment to be added without inheriting anyone else's values by accident.
|
||||
|
||||
`dev.yaml` is the case that already differs completely: all three of its
|
||||
values are its own, generated on vps1 and never shared with prod or beta.
|
||||
|
||||
At deploy the renderer concatenates `common.yaml` then `<env>.yaml`, so a **host
|
||||
value wins** (env_file / `source` take the last occurrence of a duplicate key).
|
||||
`<env>` comes from `/etc/thermograph/secrets-env` on the box (`prod`/`beta`/`dev`).
|
||||
`<env>` comes from an explicit `THERMOGRAPH_ENV`/argument on vps2 (which runs
|
||||
two environments) or `/etc/thermograph/secrets-env` as a fallback elsewhere —
|
||||
see `deploy/env-topology.sh`.
|
||||
|
||||
### `dev` is vaulted, but renders `dev.yaml` **alone**
|
||||
|
||||
|
|
@ -65,17 +92,17 @@ is a **plaintext concatenation**, so layering it would put every one of those in
|
|||
`/etc/thermograph.env` on the dev box and, through `env_file:`, into the
|
||||
environment of every container in the dev stack.
|
||||
|
||||
The dev box is the operator's desktop. It is also the Forgejo CI runner, whose
|
||||
`docker`-labelled jobs get the host docker socket automounted, and the stack it
|
||||
runs is the `dev` branch — code that has not been through the gate that guards
|
||||
prod. Handing that the estate's read-write object-storage keys and the push
|
||||
signing key is a strictly larger blast radius than anything the vault buys back.
|
||||
An override in `dev.yaml` would not help: last-wins governs *consumers*, but the
|
||||
production value is still physically a line in the file.
|
||||
The dev box is **vps1** — the same box that runs Forgejo and its CI. Its
|
||||
`docker`-labelled runner jobs get the host docker socket automounted, and the
|
||||
stack it runs is the `dev` branch — code that has not been through the gate
|
||||
that guards prod. Handing that the estate's read-write object-storage keys and
|
||||
the push signing key is a strictly larger blast radius than anything the vault
|
||||
buys back. An override in `dev.yaml` would not help: last-wins governs
|
||||
*consumers*, but the production value is still physically a line in the file.
|
||||
|
||||
One value in `common.yaml` is also simply **wrong** for dev:
|
||||
`THERMOGRAPH_COOKIE_SECURE=1` is right for the TLS hosts and silently breaks login
|
||||
over dev's plain-HTTP LAN URL. `dev.yaml` sets `0`.
|
||||
over dev's plain-HTTP mesh-only URL. `dev.yaml` sets `0`.
|
||||
|
||||
So `dev.yaml` is self-contained. Where the value is shared-but-harmless
|
||||
(`PORT`, `THERMOGRAPH_BASE`, `TIMESCALEDB_TAG`) it carries its own copy — a
|
||||
|
|
@ -87,8 +114,8 @@ duplicated constant is the cheap half of this trade.
|
|||
|-----|-----|
|
||||
| `POSTGRES_PASSWORD`, `THERMOGRAPH_DATABASE_URL` | dev's own. Deliberately the weak, well-known `thermograph-dev` — the value dev's `pgdata` volume is already initialized with, and the DB is never published off the compose network. Kept per-host for the reason below, and *not* shared with prod |
|
||||
| `THERMOGRAPH_AUTH_SECRET` | dev's **own**, freshly generated. Fixes a live crash loop: the `daemon` service refuses to start without it (it derives the `/internal/*` token from it), so dev's daemon had been restarting every 60s. Prod's value must never be here — it signs sessions and verification links |
|
||||
| `THERMOGRAPH_BASE_URL` | `http://10.0.1.216:8137`. Unset, the app defaults to `https://thermograph.org`, so dev's IndexNow pings and Discord links claimed to be prod |
|
||||
| `THERMOGRAPH_COOKIE_SECURE=0` | plain HTTP on the LAN |
|
||||
| `THERMOGRAPH_BASE_URL` | `http://10.10.0.2:8137` (vps1's mesh address). Unset, the app defaults to `https://thermograph.org`, so dev's IndexNow pings and Discord links claimed to be prod |
|
||||
| `THERMOGRAPH_COOKIE_SECURE=0` | plain HTTP on the mesh-only URL |
|
||||
| `THERMOGRAPH_MAIL_BACKEND=console`, `THERMOGRAPH_DISCORD_BOT=0` | explicit fail-safes. Both are already the code defaults; stating them means dev cannot start mailing or open a Discord gateway by inheriting a value |
|
||||
| `PORT`, `THERMOGRAPH_BASE`, `TIMESCALEDB_TAG`, `DB_MEMORY`, `WORKERS` | non-secret config that would otherwise have come from `common.yaml` |
|
||||
|
||||
|
|
@ -104,8 +131,8 @@ duplicated constant is the cheap half of this trade.
|
|||
(`backend/indexnow.py`). Prod's key is what authenticates URL submissions *for
|
||||
thermograph.org*.
|
||||
- **`THERMOGRAPH_METRICS_TOKEN`.** With no token `/api/v2/metrics` is
|
||||
direct-loopback-only, which is exactly right on a LAN box; dev's Alloy agent
|
||||
ships logs, not metrics, so nothing scrapes it.
|
||||
direct-loopback-only, which is exactly right on a mesh-only box; dev's Alloy
|
||||
agent ships logs, not metrics, so nothing scrapes it.
|
||||
- **`REGISTRY_TOKEN`.** dev pulls with the persistent `docker login` credential
|
||||
already in the host's docker config, like the prod/beta CI paths. A vault copy
|
||||
would only duplicate a credential the box already has into a more readable file.
|
||||
|
|
@ -224,12 +251,17 @@ files), so layering would only push the VAPID private key, both S3 keypairs and
|
|||
- `/etc/thermograph/age.key` (`0400`) on each VPS that renders at deploy time.
|
||||
- **Back it up** in the password manager. The public recipient is in `.sops.yaml`.
|
||||
|
||||
On the **dev desktop** those two are the same file, on purpose. `render-secrets.sh`
|
||||
falls back to `sudo cat` for a root-owned key, the desktop has no passwordless sudo,
|
||||
and the Forgejo runner is a `systemd --user` service with no tty — a `/etc` copy
|
||||
would make every CI-triggered dev deploy hang on a prompt it cannot answer.
|
||||
`deploy-dev.sh` points `THERMOGRAPH_AGE_KEY` at the operator's keyring instead, so
|
||||
the box keeps **one** copy of the recovery root rather than two.
|
||||
On **vps1** (dev) those two can end up being the same file. `render-secrets.sh`
|
||||
falls back to `sudo cat` for a root-owned key, and `deploy-dev.sh` still assumes
|
||||
the account driving a CI-triggered dev deploy has no passwordless sudo and no
|
||||
tty to answer a `sudo` prompt (a `systemd --user` Forgejo-runner service) —
|
||||
inherited from the old desktop setup, where that was true of the whole box.
|
||||
Whether it's still true of vps1's CI-runner account specifically (as opposed
|
||||
to the `agent` login, which does have passwordless sudo there per `ACCESS.md`)
|
||||
wasn't re-verified for this pass; `deploy-dev.sh` points `THERMOGRAPH_AGE_KEY`
|
||||
at the operator's keyring as a fallback either way, so the box keeps **one**
|
||||
copy of the recovery root rather than two regardless of which account ends up
|
||||
mattering.
|
||||
|
||||
## Prerequisites (once per machine)
|
||||
|
||||
|
|
@ -246,8 +278,10 @@ go install github.com/getsops/sops/v3/cmd/sops@latest # if you have Go
|
|||
```sh
|
||||
sops deploy/secrets/common.yaml # opens DECRYPTED in $EDITOR; re-encrypts on save
|
||||
git commit -am "rotate <thing>" && git push forgejo
|
||||
# beta auto-deploys on push to main. prod (no CI) — one command:
|
||||
ssh agent@169.58.46.181 'cd /opt/thermograph && git pull && deploy/deploy.sh'
|
||||
# beta and prod both auto-deploy on push (main -> beta, release -> prod) via
|
||||
# the Deploy workflow. To force it by hand on vps2, say which environment:
|
||||
ssh agent@169.58.46.181 'cd /opt/thermograph && git pull && THERMOGRAPH_ENV=prod deploy/deploy.sh'
|
||||
ssh agent@169.58.46.181 'cd /opt/thermograph-beta && git pull && THERMOGRAPH_ENV=beta deploy/deploy.sh'
|
||||
```
|
||||
|
||||
A host-specific value (e.g. `POSTGRES_PASSWORD`) is the same, editing `prod.yaml` /
|
||||
|
|
@ -264,10 +298,13 @@ commit, and the next `deploy-dev.sh` run (merge to `dev`, or by hand) renders it
|
|||
|
||||
## Setting up a new dev machine
|
||||
|
||||
The desktop is hands-on: Centralis cannot SSH to it, so this is typed, not
|
||||
automated. `provision-secrets.sh` is **not** the right tool here — it installs the
|
||||
age key root-owned at `/etc/thermograph/age.key`, which the runner cannot read
|
||||
(above).
|
||||
Dev is provisioned like any fleet host now (`deploy/provision-dev.sh`, run as
|
||||
root on vps1), not the sudo-free desktop bootstrap this predates. The
|
||||
secrets-specific steps below are still worth doing by hand rather than
|
||||
folding into that script: `provision-secrets.sh` is **not** the right tool
|
||||
here — it installs the age key root-owned at `/etc/thermograph/age.key`,
|
||||
which the CI-runner-driven render may not be able to read (see the age-key
|
||||
note above).
|
||||
|
||||
```sh
|
||||
# 1. sops + age on PATH, and the age private key in the operator's keyring:
|
||||
|
|
@ -293,17 +330,17 @@ SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt \
|
|||
|
||||
# 5. Deploy. deploy-dev.sh refuses to run if render-secrets.sh in the checkout
|
||||
# cannot honour THERMOGRAPH_SECRETS_SKIP_COMMON, rather than leak.
|
||||
APP_DIR=~/thermograph-dev bash deploy/deploy-dev.sh
|
||||
APP_DIR=/opt/thermograph-dev bash deploy/deploy-dev.sh
|
||||
```
|
||||
|
||||
To take a dev box **back** off the vault, delete `/etc/thermograph/secrets-env`:
|
||||
presence detection turns rendering off and `deploy-dev.sh` falls back to its
|
||||
built-in `POSTGRES_PASSWORD` default.
|
||||
|
||||
A *different* dev machine (a second desktop, a laptop) needs its own values, not a
|
||||
copy of this one's: give it its own `<env>.yaml` and its own marker name, so the two
|
||||
can never be confused for one host — the same argument as the three held-back
|
||||
credentials above.
|
||||
A *different* dev machine (a laptop running `make dev-up` locally, or a future
|
||||
second dev host) needs its own values, not a copy of vps1's: give it its own
|
||||
`<env>.yaml` and its own marker name, so the two can never be confused for one
|
||||
host — the same argument as the three held-back credentials above.
|
||||
|
||||
## Add a new secret
|
||||
|
||||
|
|
@ -332,17 +369,24 @@ git commit -am "rotate age identity" && push + deploy
|
|||
|
||||
## First-time bootstrap (seed from the live boxes)
|
||||
|
||||
**Historical** — this ran once, before the vps1/vps2 split, when beta was its
|
||||
own box at `75.119.132.91`. That address is **vps1** now (dev + Forgejo +
|
||||
Grafana), not beta; re-running the beta line below as written would seed
|
||||
`beta.yaml` from the wrong host. Kept for the shape of the process, which is
|
||||
unchanged if a new environment is ever bootstrapped the same way — just point
|
||||
`seed-from-live.sh` at wherever that environment actually lives today.
|
||||
|
||||
Order matters — values must match the live env exactly so `AUTH_SECRET` / VAPID /
|
||||
`POSTGRES_PASSWORD` don't rotate unintentionally:
|
||||
|
||||
1. Install `sops`+`age` and drop `/etc/thermograph/age.key` (0400) +
|
||||
`/etc/thermograph/secrets-env` on prod & beta (`provision-secrets.sh`).
|
||||
`/etc/thermograph/secrets-env` on the target hosts (`provision-secrets.sh`).
|
||||
2. Seed each host's file from its live env with the helper (pulls over SSH, encrypts,
|
||||
and verifies the render round-trips — run it on your own machine, it reads live
|
||||
secrets):
|
||||
secrets), e.g. as originally run:
|
||||
```sh
|
||||
deploy/secrets/seed-from-live.sh prod agent@169.58.46.181
|
||||
deploy/secrets/seed-from-live.sh beta agent@75.119.132.91
|
||||
deploy/secrets/seed-from-live.sh beta agent@75.119.132.91 # beta's address AT THE TIME; now vps1's
|
||||
```
|
||||
That writes exact per-host copies (`prod.yaml`/`beta.yaml`); factor shared values
|
||||
into `common.yaml` later. Commit.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
APP_CPUS: ENC[AES256_GCM,data:Wg==,iv:9kB0WruqLCIkJp7i+t5kGB4qv8NzHwgTQlFmYwhTEXc=,tag:q6cKDI86YrFJjGRnEkdweA==,type:str]
|
||||
DB_CPUS: ENC[AES256_GCM,data:7w==,iv:xDOeZo3dJ5gnXIEGyOBGZm4RUTNyzinMkvxWoY/EbQs=,tag:ikmAjzeM/U4w5HDZ+C7mgA==,type:str]
|
||||
DB_MEMORY: ENC[AES256_GCM,data:apg=,iv:4+GHbccVBFUGtrP12a2oEya7Hz0KUhJAFzdwpqQJ2sc=,tag:X0h2nYza80c9PjeNgqX7Uw==,type:str]
|
||||
POSTGRES_PASSWORD: ENC[AES256_GCM,data:X5HmOvHVtm2mjeLZO7FaEWP0Qq767D0quycr2iI3/Mw=,iv:BQsjJaDPW9RxnMldE1rCU3yP5WBOXvc9VjnTim3kMZs=,tag:yfdi+T7lBbBK544bSvEZtQ==,type:str]
|
||||
THERMOGRAPH_AUTH_SECRET: ENC[AES256_GCM,data:YjgknnUhAU0gW6YuMCmlZYfVhOgye52MLhVF7nKMuc0ToGn0OTqqoiXN0A==,iv:Q9XCoKa5Y/7V9b72tktGZ12jgPumC4kJKjrtCM39ihw=,tag:3kEpMjLCEFQcpSir3N9f6w==,type:str]
|
||||
THERMOGRAPH_BASE_URL: ENC[AES256_GCM,data:bus0/ibfLohpsszHZKKoGe5P0/Su5i36Hk2nzg==,iv:QI3crdbipse62xdakCGxPIA4wmdq/T4KzZSvi/NE1Xg=,tag:/S4Bzk2aDoA6ahDlW7qG8w==,type:str]
|
||||
THERMOGRAPH_DATABASE_URL: ENC[AES256_GCM,data:xMd8m6VajAjtwQy8DDqTs4VbyASG8ua8FUZHfPmmEzZEb4cTtYILJ+z6LXhcyvdenDsPg2/GJx5BApvVAKSrWrnSzNWnp+pAs7x+VnNzZ2giZ9meiQ==,iv:MOU+SB/taEc/ExRc07ChF1ksOQiaDj7TPMQq+aV4Ltg=,tag:XP2h9KBGJE7JzripO7yjcg==,type:str]
|
||||
WORKERS: ENC[AES256_GCM,data:BQ==,iv:XYgQ+Sn9OnbUR8LVW+4r9vhnQQJZmliVomfyRmUqMsw=,tag:JzM3JsNR08z1pePSTl/dhw==,type:str]
|
||||
APP_CPUS: ENC[AES256_GCM,data:qg==,iv:T3eyF+9ssUCWDiFr8Wg0tRpgeV3BSLIjOSBE2i7WOlc=,tag:wisw0gQ6E8QCNom/Zd3mWQ==,type:str]
|
||||
POSTGRES_PASSWORD: ENC[AES256_GCM,data:r5ygtwgycLjjGXSehpLt/RpUkhLyZ4lHPt6KiqNd+ng=,iv:qgqfU1mtm7ZuqSbeUEB9cgT56bOAkc0UFb3BMxED+xg=,tag:WiEoVHrA96WNFROqmFyElA==,type:str]
|
||||
THERMOGRAPH_AUTH_SECRET: ENC[AES256_GCM,data:aZySO3pAVB24S7uCsblRtd65U+OkBatK2tmuzeALdlPsqRhMqRioXQLgMQ==,iv:5zxNwj39E/AOJXzV/AEX9w1n3LSnl9PnS3cp5wVRi2E=,tag:jQRGr2436E4qqBE53iltqQ==,type:str]
|
||||
THERMOGRAPH_BASE_URL: ENC[AES256_GCM,data:9IxftZqwSBG/1MY+jcALQeeTbIk+tJ/8GXGUgQ==,iv:EvsDUExWQHpidriMDDj0flM8Rz25iFycVPJLrK240+s=,tag:7rKr5KUDMe4J7U4OHUvPRg==,type:str]
|
||||
THERMOGRAPH_DATABASE_URL: ENC[AES256_GCM,data:SnD/achZHduuWwwrQYOseqw0mquV1B4k4c2eom81dqtib6SxGj1OabHYj0rJd+o9Y5sH+kTh+zkFHpBi0J7c5GEGzhBqLctt530uMlOstvu1Hh7qL+DareyBF+SHwG4=,iv:qPFUnfRpQ6gIfBx6phyl5RWsNgEFYdNqW8oVzDZgXU8=,tag:djaqFRpDetNoH7Ux1PmQ3Q==,type:str]
|
||||
WORKERS: ENC[AES256_GCM,data:gQ==,iv:jkwIzTpiEtEtbt7acP8DWRbl51baTFuQiw4oJxb3eyA=,tag:X2Gqy3icYRKrI6F99lnuyQ==,type:str]
|
||||
THERMOGRAPH_MAIL_BACKEND: ENC[AES256_GCM,data:OrchUc9tpw==,iv:SmAZ+USGesaCyaOdKopqKmPeG7y/WtDry9G3aDVgP/Q=,tag:EGh5foV6uClrIJCdkjP3Hg==,type:str]
|
||||
THERMOGRAPH_DISCORD_BOT: ENC[AES256_GCM,data:Yw==,iv:jsHid5lQ75shgfIJkrsLnlhmcJ1aJWuxkUyhmpg52Dc=,tag:UqtZjQpYwQH5uvEYC9mp7w==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFWHVZU2xSS1pmcy91S2w5
|
||||
MWNhQW90TDdQdDdJV1BHM2Y0a1d6aFJOS2hnCjRodHZlY0orODdpS3QwTVdTdXM5
|
||||
Z3RJOFhyR3AzbjljaExkQjN0YUJ2ckUKLS0tIGpua1Z2TDJrRzE3WFZlMWNpL1Ja
|
||||
VG45eVM0MldLejRNd0pweWNna3JYWlUKWuNU+6PqKlbr7F0ckrNxsMF2OyXh1fMu
|
||||
cLFBIQg/7vO7O7PJ0VIy0Ugfq6gj2Gv91qKJUGeOXOw3tv1Y6HHbjA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqdFZ3MWVCSDdvdnVLWlZH
|
||||
My9JMzF2TmhiSlJjbWRYbUIzSm0zb1dtdTI4CmI4N2o0YzR1NGNoTmI5OXBJM1Bj
|
||||
OVF3WWEvYmlWczR2dUJpYTROR2pvK2sKLS0tIDM5blFFdnZwRHpDOEZ6aHhyL0dn
|
||||
d1BDWVV6YXNQNWo3dWRqN2FVUC85UmMK8jjGvQxKDsnlr7i95Ar509nihDgRb/JW
|
||||
qzLM5fwMU16HOPMiAPd/zH3rUyKcZxMtITR6QLcuSNLByURg4ZMBnQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
|
||||
lastmodified: "2026-07-25T00:38:24Z"
|
||||
mac: ENC[AES256_GCM,data:lTpEmA8gweS+QlAakIziXxAEiHBUsNDk5lGwVG+uaXNhifIYWbu+QWkklP4wES9o3pu4TNCzFboRWkBbnzhW8HYRglkSChsmmFXDChz67IYlaveCFwczxffp39iypnV/EKaBCa8m1eDJ5kugzr2s7+blagCH0TwpNXBqZShyGNE=,iv:KBF6CreB4oTJGwNetTj27Grox30X3s3qEEYb1cJXmIE=,tag:YfRb5yAoY8K/TXspoZYdcA==,type:str]
|
||||
lastmodified: "2026-07-25T21:43:52Z"
|
||||
mac: ENC[AES256_GCM,data:/+kmyMP20n7fQRO2FdiHWaLl8MAfnMjtF8OE5HEUCLpRGet3s+IqYb8DajFIF9SCibW7aEqb6ir9m0KBx0IRW4TTsvK6JYFyRZKKtt4oiCzRaGwUMwhI75DE6xYdHxEbdAC6Q+qFUKGtRcIGhSX1LrlGEloSJggdEFSgfTlJLNg=,iv:UX1zMFw70ODiPolKSjEj8adKxa49Kf5dzfDA7ePtxmE=,tag:dA0OqIPTvXBlMPNH4oCNtg==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
PORT: ENC[AES256_GCM,data:0LjaWw==,iv:3e8Q71QFlMAhQStgoiVh/c2ELI3+7UkWseAFv8BnElU=,tag:/wYfKmqErJpsLBjg1gs4Tw==,type:str]
|
||||
THERMOGRAPH_BASE: ENC[AES256_GCM,data:Ww==,iv:e2yNIAAw4dtlahfbA+PCM/jyODKFwWFNfVpVJ1JoREc=,tag:QzoGwDgIwdA9OFAMIPej7Q==,type:str]
|
||||
TIMESCALEDB_TAG: ENC[AES256_GCM,data:7Oj7OoxoaG51whg=,iv:PPbfdn4DHMxTPA5FUqv+lEY29wQsVX+g0aVuWvraYrU=,tag:NAyJZlRXU1iKo4jwBctUtw==,type:str]
|
||||
DB_MEMORY: ENC[AES256_GCM,data:/3U=,iv:jFI/9NQaz8oIqBWAKydWUEEI7bI3SmLnsoBlVgfKw7I=,tag:oBoFANuTgZV0jXC9qR5IHQ==,type:str]
|
||||
WORKERS: ENC[AES256_GCM,data:VQ==,iv:+d84ByEU22XVQi43NcRn8cHmbW9XudJMj0guidsHxBQ=,tag:XM//7y0VQCwini4CGL7pSg==,type:str]
|
||||
THERMOGRAPH_BASE_URL: ENC[AES256_GCM,data:FK2kOaZG8ESwDi3VMOlo0XvBKvdADQ==,iv:c6GvCrAnAf+ZFh53xg3ZOlf934Ls+PyR/+CXRi933lE=,tag:cYLXvxP5hk7/6gmz/CCP0A==,type:str]
|
||||
THERMOGRAPH_COOKIE_SECURE: ENC[AES256_GCM,data:zg==,iv:sVr33CvZoZmn0LcHlP+FVA8esjGy0aKe1A2iPrrrT0I=,tag:3YneNF7m05fCRGfMgYzByA==,type:str]
|
||||
THERMOGRAPH_MAIL_BACKEND: ENC[AES256_GCM,data:pRBtOu6cTw==,iv:88QgyY6rwwp/6Y32r6Us1M+b7YpSLPfKIsCL2j/YuIY=,tag:FrchZZrMcCSeOFRWbePM4Q==,type:str]
|
||||
THERMOGRAPH_DISCORD_BOT: ENC[AES256_GCM,data:NQ==,iv:Zl888EtEoQQd8FdqGb4/N7gGMT4yEymQfWd2fZZCNVA=,tag:Xspr8/taeQwh1qHhfFbLfw==,type:str]
|
||||
POSTGRES_PASSWORD: ENC[AES256_GCM,data:l076hUutOAKw7+X6wboX,iv:9WVz3SVUikWREVjB6GsJ+EXWO3PvX+g+OaZNGN9f+jU=,tag:OkVuEqEbTSikpCNEySPX5A==,type:str]
|
||||
THERMOGRAPH_DATABASE_URL: ENC[AES256_GCM,data:kXCYmZXewa8JSY5iJd0cHDtsKrxyKEl5tSVwWeQEd8u7XU+/dn4BExT4RDB3vUWVFVoHXYIXedMnDO8h0QfDCxSI13s=,iv:s1bDjdH4E4T+kF126saOrKlx3AnKEkRWF68KEPcCUe0=,tag:nCzD9U/7g28bJmUXkiYh3Q==,type:str]
|
||||
THERMOGRAPH_AUTH_SECRET: ENC[AES256_GCM,data:EjwKGKh1od5vgvm8LLv1l6TXO7nshUwh5ihXDpETl+3ldtF8L4H3CWszsw==,iv:4mqwEXWpGpLYbIUotHj9Qf7FSD0ATwkxwhfuL7pG+Zc=,tag:0iLU0ee86H0CgmgPCCtatQ==,type:str]
|
||||
PORT: ENC[AES256_GCM,data:VCheDw==,iv:u9IC7psxzcsLuuWhU89fUJfVsmCI7lECbjxzKmC/nGM=,tag:ZTG/kG2vSNvUUlItAjsCHg==,type:str]
|
||||
THERMOGRAPH_BASE: ENC[AES256_GCM,data:Aw==,iv:Pcmbm6D126jncQnmBquZ/t6qMaaUF7UDnQV6q85CsrY=,tag:SGakFybX3TNKAWvFo9gsEw==,type:str]
|
||||
TIMESCALEDB_TAG: ENC[AES256_GCM,data:ghewNqRFuYJfBgE=,iv:q1GsQM3QZBW4A95U9NY90RR9WRmPV6miIxGLgLmPTps=,tag:eJARZNyqnJbHhtXXUI1AWw==,type:str]
|
||||
DB_MEMORY: ENC[AES256_GCM,data:k2U=,iv:ZRCjRUHbNvVrCdGWqmOoJBtsJLeLVSVm32szpQ23HN4=,tag:KE5jUadHZnKFPis0friNbg==,type:str]
|
||||
WORKERS: ENC[AES256_GCM,data:yQ==,iv:6VE9mjS7QLuOWazReqyt1DhAhamkvWSm92B2d/ZCZYo=,tag:hDl1/E11xwUyPVhFNhzOuQ==,type:str]
|
||||
THERMOGRAPH_BASE_URL: ENC[AES256_GCM,data:/sOgmM0eCf7dWldK0iZaDWE0W4aN,iv:dpiLeHLanO2zwtMmPMbsqR4aTVaXfT+V2Ux9K8Alh3w=,tag:PDd/L2nt4X750xCKjHnSPw==,type:str]
|
||||
THERMOGRAPH_COOKIE_SECURE: ENC[AES256_GCM,data:Bw==,iv:dsLcLu126A3d7dTqBgQY1SOvJfKOBT/ye4ucIB4w6B4=,tag:/G7Qvlzb6KKVVjqmn3d/Nw==,type:str]
|
||||
THERMOGRAPH_MAIL_BACKEND: ENC[AES256_GCM,data:ryWhCkSdxg==,iv:Lpl1CIjTINV8rF8BW5MHZD4Ji802i96zcyqF3xRMLcY=,tag:I0nbb6IP6LM/Y+KaHjBDpg==,type:str]
|
||||
THERMOGRAPH_DISCORD_BOT: ENC[AES256_GCM,data:Rw==,iv:o5tr5lQGXFkB6kTJ/dDARBWECDYRdofFlZ7v/HdMe5k=,tag:Lv97fH6u/nIKZlJr5HjbZw==,type:str]
|
||||
POSTGRES_PASSWORD: ENC[AES256_GCM,data:IBVel17GKtrW5yzqDjBi,iv:yEPSSYR/Ng9rjSvSl0veNRDPLayF0SvWZ1T8BSY1l50=,tag:hbB4Uo6YluQPgl9XyvgfKw==,type:str]
|
||||
THERMOGRAPH_DATABASE_URL: ENC[AES256_GCM,data:KjsSo+IglfY5MhtEsX3HJpExAtBwzmSu3E+O7Y22B2WuIrTKGDxG81WwvQGABYfKVel6kzzv8uKczq1qEtOwHRdMsRI=,iv:VPJNe1GrJa7BeAA/N/fqyBjBF+ciF8btE4j/TwrV3cA=,tag:tkyzkR1LxY8z28bjb0bJ7w==,type:str]
|
||||
THERMOGRAPH_AUTH_SECRET: ENC[AES256_GCM,data:ccEQxcNxMEn8/Gf2knQaVMSoBp7BRJ7zWhr9KmawaSMrwHGU7kADDWCoMw==,iv:nDpMHhP0ubjzz2LIY0QMGFLGP1M10zOIJb6pPgri0PI=,tag:+Z2j5XLdV+LuSK7hWf5r0Q==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzV0lrcUpjeU81TmJDRVpa
|
||||
elppeU1mMUlpc3lKSTVtU3MxUi83bzN2dVVRCmxZbDJFUkJQVjhCSmljNFpPaWVu
|
||||
dkNqTkJTd1ZEaHZsZitpTWlJdElCK28KLS0tIEdSTXN6S1Y2eE12U3o2RGxBNXNN
|
||||
bDBlYkNxMDVTVEFLYVIyVkhIRnNwOGMKv/TMt307XqvzBLCOCA5C4kXFV9iJeVBn
|
||||
7gyzb5MRbHbDoNAY/5ckU3341uWUXUQ+IrPvVt2snAdXIlWghm+HwA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyb2dCeVgrbGxGSnNpaUE3
|
||||
RlovN0RrejVmREY0ckNGaExpNWkvSjcyRW1RCjYvc1IrMWwvdmJGU2g5bU52Mmgw
|
||||
SVY1dnFHd0cyNkM1ZWhJaWlIZldzZlEKLS0tIGtLZDRSMS82UjRFRVdZU3J1ckMz
|
||||
T0g1WnRUUGFFRTVyMlF6WnZiSEoweEEKPLwcTNiE4s24J0kComnNJj5jXIBRBzMh
|
||||
PVktsQm9m1ojaG0hgCZsFaMOOnE5O0Vi41jgG7WWLSNZRaVggvRbJg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
|
||||
lastmodified: "2026-07-25T00:59:09Z"
|
||||
mac: ENC[AES256_GCM,data:Ga1lMooQrBhU+YasAbN3THZvjrct21KRbuxOoXf+72K5mrgPmqZyFDdB71B8dOUzmz4cqTPw3+jQx2s2ZrZCEiR3qmb897I8hWbTiFWnURSC7rjytxIe6kLhDBjCtso8ThKUMqNjAglqjkytHXljP7Cg4zCuGLEgczoakEJVB7s=,iv:NFHljXdG619XJMjy+1OuUknu+QaG5nH68fvo/zAf/TE=,tag:MyFf/edujKzypOwloVUGAA==,type:str]
|
||||
lastmodified: "2026-07-25T21:44:03Z"
|
||||
mac: ENC[AES256_GCM,data:ok66XdbCyLyU/2UxQ6Fo63S16r6VETT7Qk/KVJ2b4rrdd4hfOMMAvmUfFmBNkxclxBp+UMOlGJ3XpGRZRzcmWR9o1eXpFlussgDbWHU+KLoIVqAVkUsltVM9aDkg+3dl6/vInftWGw24LbfDh1WhYQ9+OwyKxZntj0sNHAShc8E=,iv:eBTeVtkAv6qcldT0x+zyO2yWVrCm0nhRUJjzaAfxLBM=,tag:BtgIWfkLSoZDcbJML9uO7g==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Seed deploy/secrets/<env>.yaml by encrypting a box's live /etc/thermograph.env
|
||||
# Seed deploy/secrets/<env>.yaml by encrypting an environment's live env file
|
||||
# ENTIRELY ON THE BOX. The plaintext never leaves the box; only the already-encrypted
|
||||
# ciphertext is written back here. Encryption uses the age PUBLIC key (safe to hardcode)
|
||||
# so the machine you run this from needs nothing but SSH access + the agent key — no
|
||||
|
|
@ -7,7 +7,13 @@
|
|||
#
|
||||
# Run from any checkout of this branch that can SSH to the target with the agent key:
|
||||
# deploy/secrets/seed-encrypt-on-host.sh prod agent@169.58.46.181
|
||||
# deploy/secrets/seed-encrypt-on-host.sh beta agent@75.119.132.91
|
||||
# deploy/secrets/seed-encrypt-on-host.sh beta agent@169.58.46.181
|
||||
# deploy/secrets/seed-encrypt-on-host.sh dev agent@75.119.132.91
|
||||
#
|
||||
# prod and beta share a box (vps2); they differ only by which env FILE is read
|
||||
# (/etc/thermograph.env vs /etc/thermograph-beta.env), resolved from
|
||||
# deploy/env-topology.sh. Hardcoding that path meant "seed beta" would encrypt
|
||||
# PROD's live secrets into beta.yaml.
|
||||
# Then commit + push the resulting deploy/secrets/<env>.yaml. Verify faithfulness with
|
||||
# the key-gaps skill after. See README.md.
|
||||
set -euo pipefail
|
||||
|
|
@ -17,14 +23,18 @@ ENV_NAME="${1:?usage: seed-encrypt-on-host.sh <env> <ssh-target> [ssh-key]}"
|
|||
SSH_TARGET="${2:?ssh target, e.g. agent@169.58.46.181}"
|
||||
SSH_KEY="${3:-$HOME/.ssh/thermograph_agent_ed25519}"
|
||||
OUT="deploy/secrets/${ENV_NAME}.yaml"
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. deploy/env-topology.sh
|
||||
thermograph_topology "$ENV_NAME"
|
||||
REMOTE_ENV_FILE="$TG_ENV_FILE"
|
||||
|
||||
# Public age recipient — NOT a secret (matches .sops.yaml). The private key never
|
||||
# leaves the operator's machine / the hosts' /etc/thermograph/age.key.
|
||||
AGE_PUB="age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2"
|
||||
SOPS_URL="https://github.com/getsops/sops/releases/download/v3.13.2/sops-v3.13.2.linux.amd64"
|
||||
|
||||
echo "==> Encrypting ${SSH_TARGET}:/etc/thermograph.env on-host -> ${OUT}"
|
||||
ssh -i "$SSH_KEY" "$SSH_TARGET" "AGE_PUB='$AGE_PUB' SOPS_URL='$SOPS_URL' bash -s" > "$OUT" <<'REMOTE'
|
||||
echo "==> Encrypting ${SSH_TARGET}:${REMOTE_ENV_FILE} on-host -> ${OUT}"
|
||||
ssh -i "$SSH_KEY" "$SSH_TARGET" "AGE_PUB='$AGE_PUB' SOPS_URL='$SOPS_URL' ENV_FILE='$REMOTE_ENV_FILE' bash -s" > "$OUT" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
exec 3>&1 # real stdout carries ONLY the ciphertext
|
||||
{ # setup noise -> stderr, so it can't corrupt the file
|
||||
|
|
@ -34,7 +44,9 @@ exec 3>&1 # real stdout carries ONLY the ciphertext
|
|||
fi
|
||||
} >&2
|
||||
tmp="$(mktemp)"; tmy="$(mktemp)"; trap 'rm -f "$tmp" "$tmy"' EXIT
|
||||
sudo cat /etc/thermograph.env > "$tmp" # plaintext stays on this box only
|
||||
# $ENV_FILE comes from the ssh command line above (env-topology.sh resolved it
|
||||
# for the named environment) — NOT hardcoded, because vps2 holds two of them.
|
||||
sudo cat "$ENV_FILE" > "$tmp" # plaintext stays on this box only
|
||||
python3 -c '
|
||||
import json, sys
|
||||
for line in open(sys.argv[1], encoding="utf-8"):
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
# Seed (or re-seed) an encrypted secrets file from a box's live /etc/thermograph.env.
|
||||
# Seed (or re-seed) an encrypted secrets file from an environment's live env file.
|
||||
# Run on the operator's machine — it reads production secrets, so it is deliberately
|
||||
# NOT something the agent runs for you. Requires: sops + age on PATH, the age private
|
||||
# key at ~/.config/sops/age/keys.txt (or SOPS_AGE_KEY_FILE), and SSH access to the box.
|
||||
#
|
||||
# deploy/secrets/seed-from-live.sh prod agent@169.58.46.181 ~/.ssh/thermograph_agent_ed25519
|
||||
# deploy/secrets/seed-from-live.sh beta agent@75.119.132.91 ~/.ssh/thermograph_agent_ed25519
|
||||
# deploy/secrets/seed-from-live.sh beta agent@169.58.46.181 ~/.ssh/thermograph_agent_ed25519
|
||||
# deploy/secrets/seed-from-live.sh dev agent@75.119.132.91 ~/.ssh/thermograph_agent_ed25519
|
||||
#
|
||||
# Note prod and beta share a box (vps2) and therefore differ only by which env
|
||||
# FILE is read: /etc/thermograph.env vs /etc/thermograph-beta.env. That path is
|
||||
# resolved from deploy/env-topology.sh rather than hardcoded — hardcoding it
|
||||
# meant "seed beta" would read PROD's live secrets and write them into
|
||||
# beta.yaml, which is how one environment silently ends up holding another's
|
||||
# credentials.
|
||||
#
|
||||
# Writes deploy/secrets/<env>.yaml ENCRYPTED (exact copy of the live env, so the
|
||||
# deploy render is faithful), then verifies the render round-trips to the same
|
||||
|
|
@ -21,13 +29,17 @@ ENV_NAME="${1:?usage: seed-from-live.sh <env> <ssh-target> [ssh-key]}"
|
|||
SSH_TARGET="${2:?ssh target, e.g. agent@169.58.46.181}"
|
||||
SSH_KEY="${3:-$HOME/.ssh/thermograph_agent_ed25519}"
|
||||
OUT="deploy/secrets/${ENV_NAME}.yaml"
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. deploy/env-topology.sh
|
||||
thermograph_topology "$ENV_NAME"
|
||||
REMOTE_ENV_FILE="$TG_ENV_FILE"
|
||||
command -v sops >/dev/null || { echo "!! sops not on PATH" >&2; exit 1; }
|
||||
|
||||
tmp="$(mktemp)"; trap 'rm -f "$tmp" "$tmp.env"' EXIT
|
||||
umask 077
|
||||
|
||||
echo "==> Pulling ${SSH_TARGET}:/etc/thermograph.env (sudo)"
|
||||
ssh -i "$SSH_KEY" "$SSH_TARGET" 'sudo cat /etc/thermograph.env' > "$tmp"
|
||||
echo "==> Pulling ${SSH_TARGET}:${REMOTE_ENV_FILE} (sudo)"
|
||||
ssh -i "$SSH_KEY" "$SSH_TARGET" "sudo cat $REMOTE_ENV_FILE" > "$tmp"
|
||||
n=$(grep -cE '^[A-Za-z_][A-Za-z0-9_]*=' "$tmp" || true)
|
||||
[ "$n" -gt 0 ] || { echo "!! no KEY=VALUE lines read (permission? path?)" >&2; exit 1; }
|
||||
echo " ${n} vars"
|
||||
|
|
@ -47,7 +59,7 @@ for line in open(src, encoding="utf-8"):
|
|||
continue
|
||||
pairs[k] = v
|
||||
with open(dst, "w", encoding="utf-8") as fh:
|
||||
fh.write(f"# SOPS-encrypted — {env} host secrets, seeded from the live /etc/thermograph.env.\n")
|
||||
fh.write(f"# SOPS-encrypted — {env} host secrets, seeded from that environment’s live env file.\n")
|
||||
for k, v in pairs.items():
|
||||
fh.write(f"{k}: {json.dumps(v)}\n") # JSON-escaped scalar is valid YAML
|
||||
print(f" {len(pairs)} keys")
|
||||
|
|
|
|||
|
|
@ -18,8 +18,20 @@
|
|||
# rehearsal on the same host that cannot touch live data or ports.
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/thermograph}"
|
||||
SERVICE="${SERVICE:-all}"
|
||||
|
||||
# Two stacks now run on vps2 (prod and beta), so nothing here may assume "the"
|
||||
# stack: the name, the stack file, the loopback ports, the env file, the
|
||||
# database role and the service-name prefix all come from env-topology.sh,
|
||||
# keyed by the environment deploy.sh already resolved and exported.
|
||||
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. "$SELF_DIR/../env-topology.sh"
|
||||
ENV_NAME=$(thermograph_env_name)
|
||||
[ -n "$ENV_NAME" ] || ENV_NAME=prod
|
||||
thermograph_topology "$ENV_NAME"
|
||||
|
||||
APP_DIR="${APP_DIR:-$TG_APP_DIR}"
|
||||
cd "$APP_DIR"
|
||||
|
||||
case "$SERVICE" in
|
||||
|
|
@ -27,10 +39,26 @@ case "$SERVICE" in
|
|||
*) echo "!! SERVICE must be backend|frontend|all, got '$SERVICE'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Which stack-file services this environment's roll targets. Prod's are bare
|
||||
# (web, worker, ...); beta's carry a `beta-` prefix so its short DNS names never
|
||||
# collide with prod's on the shared overlay — see env-topology.sh.
|
||||
SVC_WEB="${TG_SVC_PREFIX}web"
|
||||
SVC_WORKER="${TG_SVC_PREFIX}worker"
|
||||
SVC_FRONTEND="${TG_SVC_PREFIX}frontend"
|
||||
SVC_LAKE="${TG_SVC_PREFIX}lake"
|
||||
SVC_DAEMON="${TG_SVC_PREFIX}daemon"
|
||||
|
||||
STACK_FILE="$APP_DIR/infra/$TG_STACK_FILE"
|
||||
ENV_FILE="$TG_ENV_FILE"
|
||||
STACK_ENV_FILE="$TG_STACK_ENV_FILE"
|
||||
|
||||
if [ "${STACK_TEST:-0}" = "1" ]; then
|
||||
# Test mode rehearses PROD's stack only; it is not a second-environment path.
|
||||
STACK_NAME="thermograph-test"
|
||||
LB_NAME="thermograph-test-lb"
|
||||
LB_HTTP_PORT=18137; LB_FE_PORT=18080
|
||||
DATA_NETWORK="thermograph-test_internal"
|
||||
DB_SERVICE="thermograph-test_db"
|
||||
export PGDATA_VOLUME="thermograph-test_pgdata"
|
||||
export APPDATA_VOLUME="thermograph-test_appdata"
|
||||
export APPLOGS_VOLUME="thermograph-test_applogs"
|
||||
|
|
@ -38,29 +66,42 @@ if [ "${STACK_TEST:-0}" = "1" ]; then
|
|||
docker volume create "$APPDATA_VOLUME" >/dev/null
|
||||
docker volume create "$APPLOGS_VOLUME" >/dev/null
|
||||
else
|
||||
STACK_NAME="${STACK_NAME:-thermograph}"
|
||||
LB_NAME="thermograph-lb"
|
||||
LB_HTTP_PORT=8137; LB_FE_PORT=8080
|
||||
STACK_NAME="${STACK_NAME:-$TG_STACK_NAME}"
|
||||
LB_NAME="$TG_LB_NAME"
|
||||
LB_HTTP_PORT="$TG_LB_HTTP_PORT"; LB_FE_PORT="$TG_LB_FE_PORT"
|
||||
# Where the database is. Prod owns it inside its own overlay; beta reaches
|
||||
# the same service across that overlay, which its stack declares external.
|
||||
DATA_NETWORK="$TG_DATA_NETWORK"
|
||||
DB_SERVICE="$TG_DB_SERVICE"
|
||||
fi
|
||||
export STACK_NAME
|
||||
echo "==> Environment: $ENV_NAME (stack $STACK_NAME on $TG_HOST, checkout $APP_DIR)"
|
||||
|
||||
# --- secrets ------------------------------------------------------------------
|
||||
# Render (SOPS) + source /etc/thermograph.env exactly like deploy.sh, then
|
||||
# install the uid-10001-readable copy the tasks' env-entrypoint shim sources.
|
||||
# 10001 = the app images' `thermograph` user; the file is 0400 to that uid.
|
||||
#
|
||||
# Per-environment paths throughout: on vps2 prod uses /etc/thermograph.env +
|
||||
# /etc/thermograph/stack.env while beta uses /etc/thermograph-beta.env +
|
||||
# /etc/thermograph/beta-stack.env. Sharing either file would give beta prod's
|
||||
# database credentials and vice versa.
|
||||
if [ -f "$APP_DIR/infra/deploy/render-secrets.sh" ]; then
|
||||
# shellcheck source=infra/deploy/render-secrets.sh
|
||||
. "$APP_DIR/infra/deploy/render-secrets.sh"
|
||||
render_thermograph_secrets "$APP_DIR/infra"
|
||||
render_thermograph_secrets "$APP_DIR/infra" "$ENV_NAME" "$ENV_FILE"
|
||||
fi
|
||||
# /etc/thermograph.env is rendered at deploy time from the SOPS vault — it
|
||||
# cannot exist at lint time, so don't ask shellcheck to follow it.
|
||||
# $ENV_FILE is rendered at deploy time from the SOPS vault — it cannot exist at
|
||||
# lint time, so don't ask shellcheck to follow it.
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/thermograph.env 2>/dev/null || true
|
||||
. "$ENV_FILE" 2>/dev/null || true
|
||||
set +a
|
||||
sudo install -o 10001 -g 0 -m 0400 /etc/thermograph.env /etc/thermograph/stack.env \
|
||||
|| install -o 10001 -g 0 -m 0400 /etc/thermograph.env /etc/thermograph/stack.env
|
||||
sudo install -o 10001 -g 0 -m 0400 "$ENV_FILE" "$STACK_ENV_FILE" \
|
||||
|| install -o 10001 -g 0 -m 0400 "$ENV_FILE" "$STACK_ENV_FILE"
|
||||
# The stack file bind-mounts this path into every task; export it so the
|
||||
# `volumes:` interpolation resolves to the right environment's copy.
|
||||
export STACK_ENV_FILE
|
||||
|
||||
# --- image tags -----------------------------------------------------------------
|
||||
# Same persisted-tags contract as deploy.sh: incoming env wins, the file
|
||||
|
|
@ -90,7 +131,14 @@ FRONTEND_IMAGE="$REGISTRY_HOST/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:
|
|||
# Hazard #7: the db image under an existing volume must never drift. Resolve
|
||||
# the digest-pinned ref from whatever is running (stack task or compose
|
||||
# container), falling back to the local latest-pg18's digest on first bring-up.
|
||||
if [ -z "${TIMESCALEDB_IMAGE:-}" ]; then
|
||||
#
|
||||
# Only the environment that OWNS the database needs this — i.e. the one whose
|
||||
# own stack declares the db service. Beta's stack declares none (it uses prod's
|
||||
# instance), so resolving an image pin there answers a question beta's stack
|
||||
# file never asks.
|
||||
OWNS_DB=0
|
||||
[ "$DB_SERVICE" = "${STACK_NAME}_db" ] && OWNS_DB=1
|
||||
if [ -z "${TIMESCALEDB_IMAGE:-}" ] && [ "$OWNS_DB" = 1 ]; then
|
||||
cid=$(docker ps -q --filter "label=com.docker.swarm.service.name=${STACK_NAME}_db" | head -1)
|
||||
[ -z "$cid" ] && cid=$(docker ps -q --filter "name=thermograph-db-1" | head -1)
|
||||
if [ -n "$cid" ]; then
|
||||
|
|
@ -102,7 +150,11 @@ if [ -z "${TIMESCALEDB_IMAGE:-}" ]; then
|
|||
[ -n "$TIMESCALEDB_IMAGE" ] || TIMESCALEDB_IMAGE="$img"
|
||||
fi
|
||||
export TIMESCALEDB_IMAGE
|
||||
echo "==> Images: web/worker=$BACKEND_IMAGE frontend=$FRONTEND_IMAGE db=$TIMESCALEDB_IMAGE"
|
||||
if [ "$OWNS_DB" = 1 ]; then
|
||||
echo "==> Images: web/worker=$BACKEND_IMAGE frontend=$FRONTEND_IMAGE db=$TIMESCALEDB_IMAGE"
|
||||
else
|
||||
echo "==> Images: web/worker=$BACKEND_IMAGE frontend=$FRONTEND_IMAGE (db: shared $DB_SERVICE)"
|
||||
fi
|
||||
|
||||
# --- registry ------------------------------------------------------------------
|
||||
if [ -n "${REGISTRY_TOKEN:-}" ]; then
|
||||
|
|
@ -124,15 +176,22 @@ done
|
|||
# in the stack). Runs on the stack's overlay so `db` resolves. First-ever
|
||||
# deploy: the network doesn't exist yet — create it exactly as the stack will
|
||||
# (attachable overlay) so the name is adopted, then migrate, then deploy.
|
||||
NET="${STACK_NAME}_internal"
|
||||
# Never pre-create $NET: docker stack deploy must own it (a pre-existing
|
||||
# unlabeled network makes it fail with "already exists"). On first deploy the
|
||||
# migrate runs AFTER stack deploy instead (FIRST_DEPLOY_MIGRATE below).
|
||||
#
|
||||
# The migrate task joins the network the DATABASE is on, which for beta is
|
||||
# prod's overlay rather than beta's own — and it connects as this environment's
|
||||
# own role to this environment's own database (beta: thermograph_beta on both
|
||||
# counts), so a beta migration can never touch prod's schema.
|
||||
NET="$DATA_NETWORK"
|
||||
MIGRATE_URL="postgresql+asyncpg://${TG_DB_USER}:${POSTGRES_PASSWORD}@db:5432/${TG_DB_NAME}"
|
||||
# Never pre-create $NET when this stack owns it: docker stack deploy must own it
|
||||
# (a pre-existing unlabeled network makes it fail with "already exists"). On
|
||||
# first deploy the migrate runs AFTER stack deploy instead (FIRST_DEPLOY_MIGRATE
|
||||
# below). Beta's data network is prod's and always already exists.
|
||||
if [ "$SERVICE" = "backend" ] || [ "$SERVICE" = "all" ]; then
|
||||
if docker service inspect "${STACK_NAME}_db" >/dev/null 2>&1; then
|
||||
echo "==> One-shot migrate ($BACKEND_IMAGE)"
|
||||
if docker service inspect "$DB_SERVICE" >/dev/null 2>&1; then
|
||||
echo "==> One-shot migrate ($BACKEND_IMAGE -> ${TG_DB_NAME})"
|
||||
docker run --rm --network "$NET" \
|
||||
-e THERMOGRAPH_DATABASE_URL="postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph" \
|
||||
-e THERMOGRAPH_DATABASE_URL="$MIGRATE_URL" \
|
||||
--entrypoint /app/deploy/entrypoint.sh "$BACKEND_IMAGE" migrate
|
||||
else
|
||||
echo "==> First deploy: db not up yet; replicas will be rolled after stack deploy runs migrate below"
|
||||
|
|
@ -141,38 +200,38 @@ fi
|
|||
|
||||
# --- deploy ----------------------------------------------------------------------
|
||||
FIRST_DEPLOY_MIGRATE=0
|
||||
docker service inspect "${STACK_NAME}_db" >/dev/null 2>&1 || FIRST_DEPLOY_MIGRATE=1
|
||||
if [ "$SERVICE" = "all" ] || ! docker service inspect "${STACK_NAME}_web" >/dev/null 2>&1; then
|
||||
docker service inspect "$DB_SERVICE" >/dev/null 2>&1 || FIRST_DEPLOY_MIGRATE=1
|
||||
if [ "$SERVICE" = "all" ] || ! docker service inspect "${STACK_NAME}_${SVC_WEB}" >/dev/null 2>&1; then
|
||||
echo "==> docker stack deploy ($STACK_NAME)"
|
||||
docker stack deploy --with-registry-auth -c "$APP_DIR/infra/deploy/stack/thermograph-stack.yml" "$STACK_NAME"
|
||||
docker stack deploy --with-registry-auth -c "$STACK_FILE" "$STACK_NAME"
|
||||
# First-ever deploy ran no migrate above (db didn't exist): wait for db,
|
||||
# migrate, then force web/worker to restart cleanly against the schema.
|
||||
if [ "${FIRST_DEPLOY_MIGRATE:-0}" = "1" ]; then
|
||||
echo "==> Waiting for db, then first-boot migrate"
|
||||
for i in $(seq 1 60); do
|
||||
cid=$(docker ps -q --filter "label=com.docker.swarm.service.name=${STACK_NAME}_db" | head -1)
|
||||
[ -n "$cid" ] && docker exec "$cid" pg_isready -U thermograph -d thermograph >/dev/null 2>&1 && break
|
||||
cid=$(docker ps -q --filter "label=com.docker.swarm.service.name=${DB_SERVICE}" | head -1)
|
||||
[ -n "$cid" ] && docker exec "$cid" pg_isready -U "$TG_DB_USER" -d "$TG_DB_NAME" >/dev/null 2>&1 && break
|
||||
sleep 5
|
||||
done
|
||||
docker run --rm --network "$NET" \
|
||||
-e THERMOGRAPH_DATABASE_URL="postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD}@db:5432/thermograph" \
|
||||
-e THERMOGRAPH_DATABASE_URL="$MIGRATE_URL" \
|
||||
--entrypoint /app/deploy/entrypoint.sh "$BACKEND_IMAGE" migrate
|
||||
docker service update --force --detach=false "${STACK_NAME}_web"
|
||||
docker service update --force --detach=false "${STACK_NAME}_worker"
|
||||
docker service update --force --detach=false "${STACK_NAME}_${SVC_WEB}"
|
||||
docker service update --force --detach=false "${STACK_NAME}_${SVC_WORKER}"
|
||||
fi
|
||||
else
|
||||
case "$SERVICE" in
|
||||
backend)
|
||||
echo "==> Rolling web + worker + lake to $BACKEND_IMAGE"
|
||||
docker service update --with-registry-auth --detach=false --image "$BACKEND_IMAGE" "${STACK_NAME}_web"
|
||||
docker service update --with-registry-auth --detach=false --image "$BACKEND_IMAGE" "${STACK_NAME}_worker"
|
||||
echo "==> Rolling $SVC_WEB + $SVC_WORKER + $SVC_LAKE to $BACKEND_IMAGE"
|
||||
docker service update --with-registry-auth --detach=false --image "$BACKEND_IMAGE" "${STACK_NAME}_${SVC_WEB}"
|
||||
docker service update --with-registry-auth --detach=false --image "$BACKEND_IMAGE" "${STACK_NAME}_${SVC_WORKER}"
|
||||
# lake and daemon ship in the same image; a stack file predating either
|
||||
# has no service yet — the next SERVICE=all stack deploy creates it, so
|
||||
# don't fail here. The daemon especially must roll with web: they share
|
||||
# the /internal/* contract, and a version skew between them is exactly
|
||||
# what pinning one BACKEND_IMAGE_TAG exists to prevent (seen live: the
|
||||
# first post-creation backend roll left the daemon a release behind).
|
||||
for extra in lake daemon; do
|
||||
for extra in "$SVC_LAKE" "$SVC_DAEMON"; do
|
||||
if docker service inspect "${STACK_NAME}_${extra}" >/dev/null 2>&1; then
|
||||
docker service update --with-registry-auth --detach=false --image "$BACKEND_IMAGE" "${STACK_NAME}_${extra}"
|
||||
else
|
||||
|
|
@ -181,8 +240,8 @@ else
|
|||
done
|
||||
;;
|
||||
frontend)
|
||||
echo "==> Rolling frontend to $FRONTEND_IMAGE"
|
||||
docker service update --with-registry-auth --detach=false --image "$FRONTEND_IMAGE" "${STACK_NAME}_frontend"
|
||||
echo "==> Rolling $SVC_FRONTEND to $FRONTEND_IMAGE"
|
||||
docker service update --with-registry-auth --detach=false --image "$FRONTEND_IMAGE" "${STACK_NAME}_${SVC_FRONTEND}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
|
@ -192,13 +251,20 @@ fi
|
|||
# 0.0.0.0) on the attachable overlay, proxying to the service VIPs. Recreated
|
||||
# only when missing/dead — its config rarely changes; `docker rm -f $LB_NAME`
|
||||
# to force a refresh after editing lb/Caddyfile.
|
||||
#
|
||||
# Note the network: the LB proxies to THIS stack's own service VIPs, so it joins
|
||||
# this stack's own overlay — which for beta is beta's `internal`, not the shared
|
||||
# data network its tasks also sit on. And its config is per-environment, because
|
||||
# beta's services are named beta-web/beta-frontend.
|
||||
LB_NETWORK="${STACK_NAME}_internal"
|
||||
LB_CONFIG="$APP_DIR/infra/${TG_LB_CONFIG:-deploy/stack/lb/Caddyfile}"
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$LB_NAME"; then
|
||||
docker rm -f "$LB_NAME" >/dev/null 2>&1 || true
|
||||
echo "==> Starting loopback LB bridge $LB_NAME (127.0.0.1:$LB_HTTP_PORT, :$LB_FE_PORT)"
|
||||
docker run -d --name "$LB_NAME" --restart unless-stopped \
|
||||
--network "$NET" \
|
||||
--network "$LB_NETWORK" \
|
||||
-p "127.0.0.1:${LB_HTTP_PORT}:8137" -p "127.0.0.1:${LB_FE_PORT}:8080" \
|
||||
-v "$APP_DIR/infra/deploy/stack/lb/Caddyfile:/etc/caddy/Caddyfile:ro" \
|
||||
-v "$LB_CONFIG:/etc/caddy/Caddyfile:ro" \
|
||||
caddy:2-alpine >/dev/null
|
||||
fi
|
||||
|
||||
|
|
@ -233,8 +299,14 @@ docker images --format '{{.Repository}}:{{.Tag}}' \
|
|||
|
||||
# Post-deploy warm + IndexNow, via any web task (skip in test mode: no data,
|
||||
# and the warmer would burn upstream quota against an empty cache).
|
||||
if [ "${STACK_TEST:-0}" != "1" ] && { [ "$SERVICE" = backend ] || [ "$SERVICE" = all ]; }; then
|
||||
wcid=$(docker ps -q --filter "label=com.docker.swarm.service.name=${STACK_NAME}_web" | head -1)
|
||||
#
|
||||
# TG_POST_DEPLOY gates this per environment: prod does both, beta does neither
|
||||
# (IndexNow from beta would ask search engines to index beta.thermograph.org,
|
||||
# and the warm would spend the shared upstream archive quota on a rehearsal
|
||||
# cache). See env-topology.sh.
|
||||
if [ "${STACK_TEST:-0}" != "1" ] && [ "${TG_POST_DEPLOY:-1}" = 1 ] \
|
||||
&& { [ "$SERVICE" = backend ] || [ "$SERVICE" = all ]; }; then
|
||||
wcid=$(docker ps -q --filter "label=com.docker.swarm.service.name=${STACK_NAME}_${SVC_WEB}" | head -1)
|
||||
if [ -n "$wcid" ]; then
|
||||
echo "==> Warming city archives (detached) + IndexNow"
|
||||
docker exec -d "$wcid" sh -c 'python warm_cities.py --pace 2 >> /app/logs/warm-cities.log 2>&1' || true
|
||||
|
|
|
|||
34
infra/deploy/stack/lb/Caddyfile.beta
Normal file
34
infra/deploy/stack/lb/Caddyfile.beta
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Beta's loopback LB bridge config — the beta counterpart of ./Caddyfile.
|
||||
#
|
||||
# Two differences from prod's, both load-bearing:
|
||||
#
|
||||
# 1. It proxies to beta-web / beta-frontend, not web / frontend. Beta's Swarm
|
||||
# services carry that prefix so their short DNS names cannot collide with
|
||||
# prod's on the shared overlay (see thermograph-beta-stack.yml).
|
||||
# 2. deploy-stack.sh publishes this container on 127.0.0.1:8237 and :8180,
|
||||
# not 8137/8180 — prod's LB already owns 8137/8080 on this host. The
|
||||
# LISTEN ports below stay 8137/8080: those are inside the container, and
|
||||
# the host mapping is what differs. Keeping the container ports identical
|
||||
# to prod's means the two configs differ only in the upstream names.
|
||||
#
|
||||
# The host Caddy on vps2 terminates TLS for beta.thermograph.org and proxies to
|
||||
# 127.0.0.1:8237 / :8180 — see deploy/Caddyfile.
|
||||
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
}
|
||||
|
||||
:8137 {
|
||||
reverse_proxy beta-web:8137 {
|
||||
# Fail fast to the client if the VIP has no healthy task; Swarm's own
|
||||
# task healthchecks handle ejecting dead replicas from the VIP.
|
||||
lb_try_duration 5s
|
||||
}
|
||||
}
|
||||
|
||||
:8080 {
|
||||
reverse_proxy beta-frontend:8080 {
|
||||
lb_try_duration 5s
|
||||
}
|
||||
}
|
||||
278
infra/deploy/stack/thermograph-beta-stack.yml
Normal file
278
infra/deploy/stack/thermograph-beta-stack.yml
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
# Docker Swarm stack for BETA, co-resident with prod on vps2.
|
||||
#
|
||||
# Deployed by the same deploy/stack/deploy-stack.sh as prod, which picks this
|
||||
# file (and beta's ports, env file, LB and DB role) out of deploy/env-topology.sh
|
||||
# when THERMOGRAPH_ENV=beta. Beta moved here from its own box so that a beta
|
||||
# green light is evidence about prod: same orchestrator, same host kernel, same
|
||||
# Postgres build, same Caddy, same mail path, same mesh position.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY THIS IS A SEPARATE FILE AND NOT AN OVERLAY ON thermograph-stack.yml
|
||||
# ---------------------------------------------------------------------------
|
||||
# `docker stack deploy` accepts multiple -c files and MERGES them. Merging can
|
||||
# add and override, but it cannot REMOVE a service — and the single most
|
||||
# important fact about beta is a removal: it has no `db`. It uses prod's. An
|
||||
# overlay would therefore still create a second Postgres, which is the exact
|
||||
# thing this design exists to avoid. The same goes for the two autoscalers,
|
||||
# which beta deliberately does not run.
|
||||
#
|
||||
# The cost is a file that must be kept roughly in step with prod's by hand.
|
||||
# Keep them in step for anything that affects whether the APP works (env vars,
|
||||
# entrypoints, healthchecks, the migrate contract). Do NOT keep them in step on
|
||||
# scale, replicas or resource limits — those differ on purpose (below).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE THREE THINGS THAT MAKE CO-RESIDENCY SAFE
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. SERVICE NAMES ARE PREFIXED (beta-web, not web). Swarm registers a service's
|
||||
# short name as a DNS alias on every network it joins. Beta's tasks share the
|
||||
# `data` network with prod's, so two services both called `web` would make
|
||||
# `web` ambiguous — prod's frontend could resolve a beta task, and vice
|
||||
# versa. The prefix removes the collision without touching prod's stack file.
|
||||
#
|
||||
# 2. THE DATABASE IS SHARED, THE DATA IS NOT. Beta connects to prod's `db`
|
||||
# service as the role `thermograph_beta`, to the database `thermograph_beta`.
|
||||
# That role owns only its own database (see deploy/db/provision-env-db.sh),
|
||||
# so a beta deploy running an unmerged branch — or a migration that goes
|
||||
# wrong — cannot read or write production data. One server is a capacity
|
||||
# decision, not a trust decision.
|
||||
#
|
||||
# 3. NOTHING ELSE IS SHARED BY ACCIDENT. Separate checkout (/opt/thermograph-beta),
|
||||
# separate rendered env file (/etc/thermograph-beta.env), separate stack env
|
||||
# (/etc/thermograph/beta-stack.env), separate volumes, separate loopback
|
||||
# ports (8237/8180 — prod owns 8137/8080), separate LB container, separate
|
||||
# deploy lock and image-tag file. Every one of those is derived in
|
||||
# env-topology.sh rather than repeated here by hand.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHAT BETA DELIBERATELY DOES NOT DO
|
||||
# ---------------------------------------------------------------------------
|
||||
# - No autoscaling: fixed 1 replica per service. Beta exists to answer "does
|
||||
# this code work", not "does it scale"; a second replica would only add a
|
||||
# variable prod's rehearsal doesn't need, on a host prod is also using.
|
||||
# - No IndexNow ping and no city-archive warm (deploy-stack.sh gates both on
|
||||
# TG_POST_DEPLOY). Pinging IndexNow from beta asks Bing/DuckDuckGo/Yandex to
|
||||
# index beta.thermograph.org; the warm spends the shared upstream archive
|
||||
# quota to fill a cache only a rehearsal reads.
|
||||
# - No real mail and no Discord gateway. Both are governed by beta's vault
|
||||
# (THERMOGRAPH_MAIL_BACKEND=console, THERMOGRAPH_DISCORD_BOT=0) rather than
|
||||
# pinned here, so the operator can opt in with `sops edit` if a release ever
|
||||
# genuinely needs to rehearse them. Discord in particular allows ONE gateway
|
||||
# connection per bot token — beta and prod must never both hold one.
|
||||
|
||||
services:
|
||||
beta-web:
|
||||
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
|
||||
entrypoint: ["/host/env-entrypoint.sh"]
|
||||
environment:
|
||||
# Beta's OWN role and OWN database on the shared instance. `db` resolves
|
||||
# across the external `data` network to prod's db service.
|
||||
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta
|
||||
THERMOGRAPH_BASE: /
|
||||
PORT: 8137
|
||||
THERMOGRAPH_SERVICE_ROLE: backend
|
||||
THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://beta-frontend:8080
|
||||
WORKERS: ${BETA_WEB_WORKERS:-2}
|
||||
THERMOGRAPH_DATA_DIR: /state
|
||||
# Never the notifier/scheduler — that is beta-worker's job, exactly as in
|
||||
# prod, so the two files stay honest about which process owns what.
|
||||
THERMOGRAPH_ROLE: web
|
||||
# Migrations run as the one-shot task in deploy-stack.sh.
|
||||
RUN_MIGRATIONS: "0"
|
||||
# Overlay tasks reach the HOST's Postfix via the docker_gwbridge gateway.
|
||||
# Same host, same Postfix as prod — but see the mail note in the header:
|
||||
# beta's vault selects the console backend, so nothing is actually sent.
|
||||
THERMOGRAPH_SMTP_HOST: ${STACK_SMTP_HOST:-172.18.0.1}
|
||||
THERMOGRAPH_LAKE_URL: http://beta-lake:8141
|
||||
volumes:
|
||||
- appdata:/state
|
||||
- applogs:/app/logs
|
||||
- /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro
|
||||
- /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
|
||||
networks:
|
||||
- internal
|
||||
- data
|
||||
deploy:
|
||||
replicas: 1
|
||||
# vps2 is the Swarm manager and every volume here is local to it. The
|
||||
# desktop is a worker on this mesh and must never be scheduled the app.
|
||||
placement:
|
||||
constraints: ["node.role == manager"]
|
||||
resources:
|
||||
limits:
|
||||
cpus: "${BETA_WEB_CPUS:-2}"
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
update_config:
|
||||
order: start-first
|
||||
failure_action: rollback
|
||||
|
||||
beta-worker:
|
||||
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
|
||||
entrypoint: ["/host/env-entrypoint.sh"]
|
||||
environment:
|
||||
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph_beta:${POSTGRES_PASSWORD}@db:5432/thermograph_beta
|
||||
THERMOGRAPH_BASE: /
|
||||
PORT: 8137
|
||||
THERMOGRAPH_SERVICE_ROLE: backend
|
||||
THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://beta-frontend:8080
|
||||
WORKERS: "1"
|
||||
THERMOGRAPH_DATA_DIR: /state
|
||||
THERMOGRAPH_ROLE: worker
|
||||
# The advisory lock is taken in beta's OWN database, so it can never
|
||||
# contend with prod's worker despite the shared server.
|
||||
THERMOGRAPH_SINGLETON_PG: "1"
|
||||
RUN_MIGRATIONS: "0"
|
||||
THERMOGRAPH_SMTP_HOST: ${STACK_SMTP_HOST:-172.18.0.1}
|
||||
THERMOGRAPH_LAKE_URL: http://beta-lake:8141
|
||||
volumes:
|
||||
- appdata:/state
|
||||
- applogs:/app/logs
|
||||
- /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro
|
||||
- /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
|
||||
networks:
|
||||
- internal
|
||||
- data
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints: ["node.role == manager"]
|
||||
resources:
|
||||
limits:
|
||||
cpus: "${BETA_WORKER_CPUS:-1}"
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
beta-lake:
|
||||
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
|
||||
entrypoint: ["/host/env-entrypoint.sh"]
|
||||
environment:
|
||||
THERMOGRAPH_ROLE: lake
|
||||
PORT: 8141
|
||||
THERMOGRAPH_SERVICE_ROLE: backend
|
||||
WORKERS: "1"
|
||||
THERMOGRAPH_LAKE_CACHE: /state/lake-cache
|
||||
volumes:
|
||||
# Beta's own cache volume. Deliberately not prod's: they are read caches
|
||||
# of the same bucket, but sharing a volume across two stacks would couple
|
||||
# their lifecycles for no gain.
|
||||
- lakecache:/state
|
||||
- /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro
|
||||
- /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
|
||||
# No `data` network: the lake reads object storage, never Postgres.
|
||||
networks:
|
||||
- internal
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints: ["node.role == manager"]
|
||||
resources:
|
||||
limits:
|
||||
cpus: "${BETA_LAKE_CPUS:-1}"
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
update_config:
|
||||
order: start-first
|
||||
failure_action: rollback
|
||||
|
||||
beta-daemon:
|
||||
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG:?required}
|
||||
# Same reasoning as prod's daemon: NOT env-entrypoint.sh, because that shim
|
||||
# execs the image's own entrypoint (Alembic + uvicorn) and migrations belong
|
||||
# to the one-shot task. Source the host-rendered env and exec the binary.
|
||||
#
|
||||
# Note the ordering consequence, which is load-bearing here: this sources
|
||||
# the env file AFTER the `environment:` block is applied, so a key present
|
||||
# in /etc/thermograph/beta-stack.env WINS over one set below. That is why
|
||||
# THERMOGRAPH_DISCORD_BOT=0 lives in beta's vault and not in this file — a
|
||||
# value set here would be silently overridden if the vault ever set one.
|
||||
entrypoint:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- 'set -a; [ -f /host/thermograph.env ] && . /host/thermograph.env; set +a; exec /usr/local/bin/thermograph-daemon'
|
||||
environment:
|
||||
THERMOGRAPH_API_BASE_INTERNAL: http://beta-web:8137
|
||||
volumes:
|
||||
- /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
|
||||
# The image HEALTHCHECK curls /healthz on ${PORT}; the daemon serves
|
||||
# nothing, so without this override Swarm restarts it forever.
|
||||
healthcheck:
|
||||
disable: true
|
||||
networks:
|
||||
- internal
|
||||
deploy:
|
||||
# EXACTLY 1, and in beta's case the Discord gateway is off entirely
|
||||
# (vault: THERMOGRAPH_DISCORD_BOT=0) because prod's daemon holds the only
|
||||
# permitted gateway connection for that bot token.
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints: ["node.role == manager"]
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.5"
|
||||
memory: 128m
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
update_config:
|
||||
order: stop-first
|
||||
failure_action: rollback
|
||||
|
||||
beta-frontend:
|
||||
image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required}
|
||||
entrypoint: ["/host/env-entrypoint.sh"]
|
||||
# REQUIRED: overriding `entrypoint:` with no `command:` drops the image's
|
||||
# CMD entirely, and env-entrypoint.sh's fallback (`exec uvicorn app:app`)
|
||||
# does not exist in this Go image — the task would exit 127 every deploy.
|
||||
command: ["/usr/local/bin/thermograph-frontend"]
|
||||
environment:
|
||||
THERMOGRAPH_BASE: /
|
||||
PORT: 8080
|
||||
THERMOGRAPH_SERVICE_ROLE: frontend
|
||||
THERMOGRAPH_API_BASE_INTERNAL: http://beta-web:8137
|
||||
volumes:
|
||||
- /opt/thermograph-beta/infra/deploy/stack/env-entrypoint.sh:/host/env-entrypoint.sh:ro
|
||||
- /etc/thermograph/beta-stack.env:/host/thermograph.env:ro
|
||||
networks:
|
||||
- internal
|
||||
deploy:
|
||||
replicas: 1
|
||||
placement:
|
||||
constraints: ["node.role == manager"]
|
||||
resources:
|
||||
limits:
|
||||
cpus: "${BETA_FRONTEND_CPUS:-1}"
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
update_config:
|
||||
order: start-first
|
||||
failure_action: rollback
|
||||
|
||||
networks:
|
||||
# Beta's own east-west network: beta-web <-> beta-frontend <-> beta-lake, and
|
||||
# the loopback LB bridge joins it (hence attachable). Keeping this separate
|
||||
# from prod's overlay means beta's ordinary traffic never touches it.
|
||||
internal:
|
||||
driver: overlay
|
||||
attachable: true
|
||||
|
||||
# Prod's overlay, joined ONLY to reach the shared `db` service. Declared
|
||||
# external because prod's stack owns it: `docker stack deploy` of THIS file
|
||||
# must never create, modify or (on `docker stack rm thermograph-beta`) remove
|
||||
# the network prod's database is on.
|
||||
#
|
||||
# Consequence worth knowing before you tear anything down: `docker stack rm
|
||||
# thermograph` would take this network with it and beta would lose its
|
||||
# database link until prod is redeployed.
|
||||
data:
|
||||
external: true
|
||||
name: thermograph_internal
|
||||
|
||||
volumes:
|
||||
# Beta's own, created by this stack under the thermograph-beta_ prefix. Unlike
|
||||
# prod's (which are `external` because they were inherited from the compose
|
||||
# era and hold live data), these can be recreated: beta's appdata is a parquet
|
||||
# cache plus derived files, and its DATABASE — the part that matters — lives
|
||||
# on the shared instance, not here.
|
||||
appdata: {}
|
||||
applogs: {}
|
||||
lakecache: {}
|
||||
|
|
@ -1,36 +1,41 @@
|
|||
# 3-node Docker Swarm (prod + beta + desktop), for hosting Forgejo
|
||||
# 3-node Docker Swarm (vps2 + vps1 + desktop)
|
||||
|
||||
This Swarm cluster's only job is to run Forgejo (`deploy/forgejo/`) — it does
|
||||
**not** orchestrate the Thermograph app itself, which stays on the
|
||||
Terraform-managed `docker compose` deploys on prod/beta independently (see
|
||||
`terraform/README.md`). Keeping those separate means nothing here can strand
|
||||
or interfere with the app's already-working, single-writer Postgres/TimescaleDB
|
||||
deploys.
|
||||
This Swarm mesh's only **Swarm-scheduled** workload is Forgejo
|
||||
(`deploy/forgejo/`), pinned to vps1. It does **not** orchestrate the
|
||||
Thermograph app the same way — prod and beta each run as their own `docker
|
||||
stack deploy` (`deploy/stack/thermograph-stack.yml` /
|
||||
`thermograph-beta-stack.yml`) that happens to land on this same manager node
|
||||
(vps2) because their volumes are local to it today. Keeping Forgejo's stack
|
||||
and the app stacks conceptually separate means nothing here can strand or
|
||||
interfere with the app's single-writer Postgres/TimescaleDB.
|
||||
|
||||
This is the canonical topology from
|
||||
`thermograph-docs/runbooks/implementation-handoff.md` (Track B steps 2-3) — three nodes,
|
||||
not two. An earlier revision of this doc/scripts covered just prod+beta;
|
||||
the desktop (this LAN dev machine) joins too.
|
||||
not two. The desktop (formerly the LAN dev machine) joins as a worker for flex
|
||||
capacity and AI-model hosting; it hosts no Thermograph environment.
|
||||
|
||||
**Nodes:**
|
||||
- **manager** — prod, the new 48 GB / 12-core box (more headroom).
|
||||
- **worker** — beta, the old VPS (`75.119.132.91`).
|
||||
- **worker** — desktop, this LAN dev machine (also runs the Forgejo Actions
|
||||
runner as a plain systemd service — see `deploy/forgejo/README.md` — not as
|
||||
a Swarm-scheduled container).
|
||||
- **manager** — vps2 (`169.58.46.181`), the box with headroom for prod's and
|
||||
beta's Swarm stacks and their local volumes.
|
||||
- **worker** — vps1 (`75.119.132.91`), pinned to run Forgejo
|
||||
(`node.labels.role == forge`). Also runs Grafana/Loki/Alloy and the dev
|
||||
environment, both outside this Swarm cluster (plain Docker/compose on the
|
||||
same host's daemon, not Swarm-scheduled).
|
||||
- **worker** — desktop (this machine), flex capacity plus AI-model hosting. No
|
||||
Thermograph environment runs here.
|
||||
|
||||
One manager, not more: Raft needs 3 nodes for real quorum-based HA, and this
|
||||
cluster only has 3 nodes total, so making even one more of them a manager
|
||||
would still fall short of real HA while adding split-brain risk. If the
|
||||
manager (prod) goes down, the workers keep running whatever was already
|
||||
scheduled on them (Forgejo, pinned to beta) but the cluster can't reschedule
|
||||
anything until prod's back — acceptable for a small cluster whose only job is
|
||||
CI/CD.
|
||||
manager (vps2) goes down, the workers keep running whatever was already
|
||||
scheduled on them (Forgejo, pinned to vps1) but the cluster can't reschedule
|
||||
anything until vps2's back — acceptable for a small cluster whose only
|
||||
Swarm-scheduled job is CI/CD.
|
||||
|
||||
## Order of operations
|
||||
|
||||
1. **Agent access first** (`deploy/provision-agent-access.sh`) on prod and
|
||||
beta — everything below on those two boxes is run through that access. The
|
||||
1. **Agent access first** (`deploy/provision-agent-access.sh`) on vps1 and
|
||||
vps2 — everything below on those two boxes is run through that access. The
|
||||
desktop is wherever you're already working from; no separate access step
|
||||
needed there.
|
||||
2. **WireGuard mesh** (`setup-wireguard.sh <my_wg_ip> <peers_file>`) — run on
|
||||
|
|
@ -38,19 +43,21 @@ CI/CD.
|
|||
the two-pass key-exchange dance (pubkeys aren't known until every node has
|
||||
run it once). Verify with `ping <peer_wg_ip>` to each of the other two
|
||||
before continuing.
|
||||
3. **Swarm init** (`init-swarm.sh <manager_wg_ip>`) on the manager (prod) only.
|
||||
3. **Swarm init** (`init-swarm.sh <manager_wg_ip>`) on the manager (vps2) only.
|
||||
4. **Swarm join** (`join-swarm.sh <manager_wg_ip> <token>`) on **each** of the
|
||||
two workers (beta, desktop) — same token for both.
|
||||
two workers (vps1, desktop) — same token for both.
|
||||
5. **Firewall lockdown** (`firewall-swarm.sh`) on **all three** nodes — closes
|
||||
2377/7946/4789 to everything except the WireGuard interface. Do this
|
||||
*after* joining is confirmed working on all three, not before (locking the
|
||||
ports first would make the join itself fail).
|
||||
6. **Label beta** (`label-forge-node.sh <beta-node-name>`) on the manager —
|
||||
`docker node ls` shows each node's name/ID. Only beta gets `role=forge`;
|
||||
the desktop and prod don't need a Swarm label for anything in this setup.
|
||||
6. **Label vps1** (`label-forge-node.sh <vps1-node-name>`) on the manager —
|
||||
`docker node ls` shows each node's name/ID. Only vps1 gets `role=forge`;
|
||||
the desktop and vps2 don't need a Swarm label for anything in this setup.
|
||||
7. Deploy Forgejo: see `deploy/forgejo/README.md`.
|
||||
8. Register the Actions runner **on the desktop** (not through Swarm):
|
||||
`deploy/forgejo/register-lan-runner.sh`.
|
||||
8. Register the Actions runner: `deploy/forgejo/register-lan-runner.sh`. See
|
||||
that document (and `DEPLOY-DEV.md`) for where it actually runs today — its
|
||||
own header comment predates the vps1/vps2 rename and still describes "the
|
||||
desktop" as the canonical placement.
|
||||
|
||||
## Why WireGuard instead of relying on Swarm's built-in TLS alone
|
||||
|
||||
|
|
@ -65,19 +72,19 @@ than trusting the public internet (or the desktop's home network) directly.
|
|||
## Verifying
|
||||
|
||||
```bash
|
||||
# On the manager:
|
||||
# On the manager (vps2):
|
||||
docker node ls # all three nodes Ready
|
||||
docker node inspect <beta-node> --format '{{.Spec.Labels}}' # role:forge
|
||||
docker node inspect <vps1-node> --format '{{.Spec.Labels}}' # role:forge
|
||||
|
||||
# From a FOURTH machine outside the mesh entirely, confirm the Swarm ports
|
||||
# are NOT reachable on either VPS's public IP (the desktop has no public IP
|
||||
# to check this way):
|
||||
nc -zv -w2 <prod_or_beta_public_ip> 2377 # should fail/timeout
|
||||
nc -zvu -w2 <prod_or_beta_public_ip> 4789 # should fail/timeout
|
||||
nc -zv -w2 <vps1_or_vps2_public_ip> 2377 # should fail/timeout
|
||||
nc -zvu -w2 <vps1_or_vps2_public_ip> 4789 # should fail/timeout
|
||||
```
|
||||
|
||||
## Adding a node label back out (undo)
|
||||
|
||||
```bash
|
||||
docker node update --label-rm role <beta-node>
|
||||
docker node update --label-rm role <vps1-node>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
#!/usr/bin/env bash
|
||||
# Locks the Swarm ports (2377 control, 7946 gossip, 4789 overlay VXLAN) to the
|
||||
# WireGuard interface only — they must never be reachable from the public
|
||||
# internet. Run on BOTH boxes after joining the swarm. Existing app-facing
|
||||
# rules (80/443, SSH, etc.) are untouched.
|
||||
# internet. Run on ALL THREE nodes (vps2, vps1, desktop) after joining the
|
||||
# swarm. Existing app-facing rules (80/443, SSH, etc.) are untouched.
|
||||
set -euo pipefail
|
||||
|
||||
WG_IFACE="${WG_IFACE:-wg0}"
|
||||
|
|
@ -34,8 +34,10 @@ if ! ufw status | grep -q "^Status: active"; then
|
|||
echo "ufw is currently INACTIVE on this node — 'ufw enable' switches its"
|
||||
echo "default policy to deny-incoming for EVERYTHING, not just the Swarm"
|
||||
echo "ports above. Before enabling, explicitly allow every port this node"
|
||||
echo "already serves publicly (SSH at minimum; on beta specifically, also"
|
||||
echo "80/tcp and 443/tcp for the live thermograph.org Caddy) — check"
|
||||
echo "'ss -tlnp' for what's actually listening first. Enabling ufw without"
|
||||
echo "doing this WILL drop live traffic the moment it activates."
|
||||
echo "already serves publicly (SSH at minimum; on vps2 specifically, also"
|
||||
echo "80/tcp and 443/tcp for the live thermograph.org/beta.thermograph.org"
|
||||
echo "Caddy; on vps1, 80/tcp and 443/tcp for git.thermograph.org,"
|
||||
echo "dashboard.thermograph.org and emigriffith.dev) — check 'ss -tlnp' for"
|
||||
echo "what's actually listening first. Enabling ufw without doing this WILL"
|
||||
echo "drop live traffic the moment it activates."
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run ONCE, on the manager node only (prod — the new 48 GB box). Initializes
|
||||
# the Swarm advertising the WireGuard address, so cluster traffic never
|
||||
# touches the public interface. Run setup-wireguard.sh on all THREE nodes
|
||||
# first (prod, beta, and the desktop — see
|
||||
# Run ONCE, on the manager node only (vps2 — the box with headroom for prod's
|
||||
# and beta's Swarm stacks and their local volumes). Initializes the Swarm
|
||||
# advertising the WireGuard address, so cluster traffic never touches the
|
||||
# public interface. Run setup-wireguard.sh on all THREE nodes first (vps2,
|
||||
# vps1, and the desktop — see
|
||||
# docs/runbooks/implementation-handoff.md Track B steps 2-3).
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ echo "==> docker swarm init, advertising ${MY_WG_IP} (the WireGuard address, not
|
|||
docker swarm init --advertise-addr "$MY_WG_IP" --listen-addr "${MY_WG_IP}:2377"
|
||||
|
||||
echo
|
||||
echo "==> Worker join command (run this on EACH of the two workers — beta and"
|
||||
echo "==> Worker join command (run this on EACH of the two workers — vps1 and"
|
||||
echo " the desktop; the same token works for both):"
|
||||
TOKEN="$(docker swarm join-token -q worker)"
|
||||
echo " docker swarm join --token ${TOKEN} ${MY_WG_IP}:2377"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run ONCE on EACH worker node (beta, and the desktop — not the manager,
|
||||
# prod). Joins the Swarm initialized by init-swarm.sh, over the WireGuard
|
||||
# Run ONCE on EACH worker node (vps1, and the desktop — not the manager,
|
||||
# vps2). Joins the Swarm initialized by init-swarm.sh, over the WireGuard
|
||||
# tunnel. The join token is the same for both workers.
|
||||
set -euo pipefail
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run ONCE, on the manager node, after both boxes have joined the swarm.
|
||||
# Labels the worker (beta) so the Forgejo stack's placement constraint
|
||||
# Run ONCE, on the manager node (vps2), after both boxes have joined the
|
||||
# swarm. Labels the worker (vps1) so the Forgejo stack's placement constraint
|
||||
# (node.labels.role == forge) schedules there and nowhere else.
|
||||
set -euo pipefail
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# Sets up this node's side of a full-mesh WireGuard tunnel across all of
|
||||
# prod, beta, and the desktop (three nodes, not two — see
|
||||
# vps2, vps1, and the desktop (three nodes, not two — see
|
||||
# docs/runbooks/implementation-handoff.md Track B step 2). Docker Swarm's
|
||||
# control plane (2377/tcp) is TLS-encrypted by default, but the overlay data
|
||||
# plane (VXLAN, 4789/udp) is NOT — and it should never face the public
|
||||
|
|
@ -14,8 +14,8 @@
|
|||
# --- Peer list format --------------------------------------------------
|
||||
# A text file, one line per OTHER node (not including the one you're running
|
||||
# on), each line: <wg_ip> <public_ip> <pubkey-or-dash>
|
||||
# 10.10.0.1 169.58.46.181 <prod's pubkey, or - if not yet known>
|
||||
# 10.10.0.2 75.119.132.91 <beta's pubkey, or ->
|
||||
# 10.10.0.1 169.58.46.181 <vps2's pubkey, or - if not yet known>
|
||||
# 10.10.0.2 75.119.132.91 <vps1's pubkey, or ->
|
||||
# 10.10.0.3 <desktop's public/reachable IP or a DDNS name> <desktop's pubkey, or ->
|
||||
#
|
||||
# First pass on any node: peer pubkeys you don't have yet are "-". Run this
|
||||
|
|
|
|||
|
|
@ -46,8 +46,9 @@ POSTGRES_PASSWORD=change-me
|
|||
# an empty value is honored as-is and would break the fallback to the public API.
|
||||
#THERMOGRAPH_ARCHIVE_URL=http://open-meteo-api:8080/v1/archive
|
||||
|
||||
# Mark the session cookie Secure — required behind Caddy's HTTPS. Set to 1 in prod;
|
||||
# leave unset only for plain-HTTP LAN dev (a Secure cookie is never sent over HTTP).
|
||||
# Mark the session cookie Secure — required behind Caddy's HTTPS. Set to 1 for
|
||||
# prod and beta (both TLS-fronted); leave unset only for dev's plain-HTTP
|
||||
# mesh-only URL (a Secure cookie is never sent over HTTP).
|
||||
THERMOGRAPH_COOKIE_SECURE=1
|
||||
|
||||
# Pin these too (see their own sections below), so container restarts don't rotate
|
||||
|
|
@ -58,11 +59,14 @@ THERMOGRAPH_COOKIE_SECURE=1
|
|||
|
||||
# Number of uvicorn worker processes. More than 1 stops a single slow upstream fetch
|
||||
# (e.g. a cache-miss weather lookup) from blocking every other request — the cause of
|
||||
# past brief outages. Prod runs 4 (the compose app service also defaults to WORKERS=4);
|
||||
# leave unset (defaults to 1) on a small box. Workers elect one leader for the
|
||||
# subscription notifier via a lockfile (THERMOGRAPH_SINGLETON_LOCK, set by the compose
|
||||
# app service to /app/data/notifier.lock) so its timer-driven upstream sweep runs once,
|
||||
# not once per worker. ~200 MB RAM per worker.
|
||||
# past brief outages. Prod's Swarm `web` service defaults to 4 (WEB_WORKERS, also the
|
||||
# autoscaler's per-replica count); beta's defaults to 2 (BETA_WEB_WORKERS) — a
|
||||
# rehearsal environment, not a decision that beta needs less headroom per se. The
|
||||
# base compose file (dev) also defaults to 4. Leave unset (defaults to 1) on a small
|
||||
# box. Workers elect one leader for the subscription notifier via a lockfile
|
||||
# (THERMOGRAPH_SINGLETON_LOCK, set by the compose/stack app service to
|
||||
# /app/data/notifier.lock) so its timer-driven upstream sweep runs once, not once per
|
||||
# worker. ~200 MB RAM per worker.
|
||||
WORKERS=4
|
||||
|
||||
# THERMOGRAPH_SINGLETON_LOCK arbitrates workers on ONE host. Under multi-host Swarm,
|
||||
|
|
|
|||
|
|
@ -13,11 +13,21 @@
|
|||
# by deploy-dev.sh. This overlay must NOT reintroduce `build:`; it only relaxes
|
||||
# resource caps and LAN-exposes a port, same as the monorepo overlay did.
|
||||
#
|
||||
# Differences from the prod stack (unchanged intent from the monorepo overlay):
|
||||
# 1. backend is published on ALL interfaces (0.0.0.0:8137), not loopback, so
|
||||
# phones and other devices on the Wi-Fi can reach the dev server directly --
|
||||
# dev has no Caddy in front (prod does, which is why the base file binds
|
||||
# 127.0.0.1 only).
|
||||
# Differences from the prod stack:
|
||||
# 1. backend is published on ${DEV_BIND_ADDR}, defaulting to LOOPBACK.
|
||||
#
|
||||
# This used to be a flat 0.0.0.0:8137, from when dev ran on the operator's
|
||||
# LAN box and the point was for phones on the Wi-Fi to reach it. Dev now
|
||||
# runs on vps1, a public VPS, where 0.0.0.0 would publish whatever
|
||||
# unreviewed branch is in flight to the entire internet — with no Caddy, no
|
||||
# TLS and no auth in front of it.
|
||||
#
|
||||
# So the default is 127.0.0.1 (safe anywhere, including a laptop running
|
||||
# `make dev-up`), and deploy.sh sets DEV_BIND_ADDR to the environment's mesh
|
||||
# address from deploy/env-topology.sh — 10.10.0.2 on vps1. Dev is then
|
||||
# reachable at http://10.10.0.2:8137 from anything on the WireGuard mesh and
|
||||
# from nowhere else. There is deliberately no dev DNS record and no Caddy
|
||||
# site block for it.
|
||||
# 2. frontend's port publish is dropped entirely -- dev has no Caddy to reach it
|
||||
# directly, so it stays compose-internal-only, reached solely through
|
||||
# backend's own reverse-proxy fallback (THERMOGRAPH_FRONTEND_BASE_INTERNAL,
|
||||
|
|
@ -27,18 +37,21 @@
|
|||
# prod's backend=4 / frontend=2 / db=2 allocation. `!reset` drops the base
|
||||
# value (both the top-level `cpus:` and the Swarm-style `deploy.resources`
|
||||
# block the base file carries for parity).
|
||||
# 4. backend/daemon/lake get a second env_file entry pointing at a copy of the
|
||||
# render under $APP_DIR (deploy-dev.sh sets THERMOGRAPH_SECRETS_ENV_FILE_MIRROR
|
||||
# to produce it). This box's Docker is the snap package, confined to $HOME --
|
||||
# it can't see /etc/thermograph.env at all (not a permissions error, it just
|
||||
# doesn't exist as far as snap-confined Docker is concerned), so the base
|
||||
# file's env_file entry silently loads nothing here. compose appends env_file
|
||||
# lists across overlays (last-wins on duplicate keys), so this is additive:
|
||||
# prod/beta never set the mirror var, so they only ever get the base entry.
|
||||
# 4. backend/daemon/lake get a second env_file entry pointing at an OPTIONAL
|
||||
# copy of the render under $APP_DIR (`required: false`, so it is a no-op
|
||||
# when absent). It exists for snap-packaged Docker, which is confined to
|
||||
# $HOME and cannot see /etc/thermograph.env at all — not a permissions
|
||||
# error; the file simply does not exist as far as snap-confined Docker is
|
||||
# concerned, so the base file's env_file entry silently loads nothing.
|
||||
# deploy-dev.sh now produces the mirror ONLY when it detects snap Docker,
|
||||
# which vps1 (ordinary apt Docker) is not — there, dev reads
|
||||
# /etc/thermograph.env like beta and prod do, and no plaintext copy of the
|
||||
# render is written into the checkout. compose appends env_file lists across
|
||||
# overlays (last-wins on duplicate keys), so this stays additive.
|
||||
services:
|
||||
backend:
|
||||
ports: !override
|
||||
- "8137:8137"
|
||||
- "${DEV_BIND_ADDR:-127.0.0.1}:8137:8137"
|
||||
cpus: !reset null
|
||||
deploy: !reset null
|
||||
env_file:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@ already shipped (`infra/ops/dbq.sh`) — **mirror it**. This document is the
|
|||
contract, the environment facts you'll trip over, and how the result gets
|
||||
verified.
|
||||
|
||||
**Historical note:** `infra/ops/iceberg.sh` now exists, originally built
|
||||
against the environment facts below as they stood before the vps1/vps2 split
|
||||
— `beta` at `75.119.132.91` and `prod` at `169.58.46.181`, each its own box.
|
||||
`iceberg.sh` (and `dbq.sh`) have since been updated to resolve their SSH
|
||||
target and host facts from `deploy/env-topology.sh` instead of a hardcoded
|
||||
table, so they already reflect the current split (vps1 = `75.119.132.91` =
|
||||
dev + Forgejo + Grafana; vps2 = `169.58.46.181` = prod AND beta). This
|
||||
document is left as a historical record of the original design contract, not
|
||||
a live description of where each environment's engine runs — see
|
||||
`infra/ops/README.md` for the current behavior.
|
||||
|
||||
## Definition of done
|
||||
|
||||
`infra/ops/iceberg.sh` exists and behaves like `dbq.sh`:
|
||||
|
|
@ -36,6 +47,9 @@ new network exposure and no interactive steps.
|
|||
|
||||
## Environment facts you need
|
||||
|
||||
**As originally written** (pre vps1/vps2 split; kept for history — see the
|
||||
note at the top of this document):
|
||||
|
||||
- Monorepo `emi/thermograph` (domain dirs: `backend/ frontend/ infra/
|
||||
observability/`). Ops tooling lives in `infra/ops/`.
|
||||
- Environments: **dev** = LAN compose stack on the dev machine; **beta** =
|
||||
|
|
@ -53,6 +67,24 @@ new network exposure and no interactive steps.
|
|||
services. Untested; verify before relying on it.
|
||||
- WireGuard mesh: prod `10.10.0.1`, beta `10.10.0.2`, dev `10.10.0.3`.
|
||||
- `rclone` and `age` are already installed on prod and beta.
|
||||
|
||||
**Current facts, post vps1/vps2 split** (the boxes and mesh IPs above did not
|
||||
move — only which environment runs where, and what the boxes are called):
|
||||
|
||||
- **vps2** = `169.58.46.181` = mesh `10.10.0.1` = the box called "prod" above.
|
||||
Runs **both** prod and beta now, as two separate Docker Swarm stacks — beta
|
||||
is a Swarm service too today, not a compose exception, so the tunnelling
|
||||
constraint above applies equally to it: `ssh -L` is impossible for beta now
|
||||
as well, not just prod.
|
||||
- **vps1** = `75.119.132.91` = mesh `10.10.0.2` = the box called "beta" above.
|
||||
Runs Forgejo, Grafana/Loki/Alloy, and **dev** (its own Postgres container,
|
||||
reached over SSH like any fleet host — not the LAN-box-local case the
|
||||
original facts describe).
|
||||
- **desktop** = mesh `10.10.0.3` = "the dev machine" above. Hosts no
|
||||
Thermograph environment any more; it's a Swarm worker for flex capacity and
|
||||
AI-model hosting. The "dev machine is a Swarm worker in the prod cluster"
|
||||
fact still holds structurally (the desktop is still a worker node), but
|
||||
nothing about Iceberg or the app database routes through it any more.
|
||||
- Reference implementation to copy: **`infra/ops/dbq.sh`** + `infra/ops/README.md`.
|
||||
|
||||
## Storage — almost certainly your warehouse
|
||||
|
|
|
|||
|
|
@ -11,34 +11,56 @@ infra/ops/dbq.sh beta -c '\dt'
|
|||
echo "select 1" | infra/ops/dbq.sh prod -f -
|
||||
```
|
||||
|
||||
Environments: `dev` (LAN compose stack on the dev machine), `beta`
|
||||
(75.119.132.91), `prod` (169.58.46.181). Extra args pass straight through to
|
||||
Environments: `dev` (own compose stack + own Postgres container, on vps1,
|
||||
`75.119.132.91`), `beta` and `prod` (both on vps2, `169.58.46.181` — separate
|
||||
roles/databases on the ONE shared TimescaleDB instance there). Host, SSH
|
||||
target and container filter all come from `deploy/env-topology.sh` now, never
|
||||
a hardcoded table — `dbq.sh` SSHes to vps1 for `dev` and to vps2 for
|
||||
`beta`/`prod`, and beta (which has no db container of its own) resolves
|
||||
prod's shared `thermograph_db` task. Extra args pass straight through to
|
||||
`psql` (`-tA`, `-x`, `--csv`, `-f`, …); stdin is forwarded.
|
||||
|
||||
**Known gap:** beta has no read-only role of its own yet — `deploy/db/provision-env-db.sh`
|
||||
only provisions the environment's *owning* role (`thermograph_beta`), so a
|
||||
`dbq.sh beta` query connects as `thermograph_beta` (which can write) rather
|
||||
than a read-only role, unlike `dev`/`prod`. See `dbq.sh`'s own header for the
|
||||
tracked follow-up (a `thermograph_beta_ro` role).
|
||||
|
||||
### Why it execs into the container instead of using a connection string
|
||||
|
||||
None of the databases are exposed over TCP — each listens only on its private
|
||||
docker network. Prod's is a Swarm **overlay** (`10.0.2.0/24`) that the host
|
||||
cannot route to, so `ssh -L` works for beta but is *impossible* for prod;
|
||||
publishing 5432 would mean new ufw rules plus a Swarm endpoint change on
|
||||
production. Running `psql` inside the db container works identically in all
|
||||
three environments with no ports, no tunnels, and no infra changes.
|
||||
docker network. Prod's and beta's is a Swarm **overlay** (`thermograph_internal`)
|
||||
that the vps2 host itself cannot route to, so `ssh -L` is impossible for
|
||||
either of them — treat beta exactly like prod here, now that it's a Swarm
|
||||
service too, not the compose-network exception it used to be. Publishing 5432
|
||||
would mean new ufw rules plus a Swarm endpoint change on a live environment.
|
||||
Running `psql` inside the db container works identically in all three
|
||||
environments with no ports, no tunnels, and no infra changes.
|
||||
|
||||
The prod container name is a Swarm task name that changes on every redeploy, so
|
||||
it is resolved at call time via `docker ps --filter name=…`, never hardcoded.
|
||||
|
||||
### Safety
|
||||
|
||||
Queries connect as **`thermograph_ro`** — a `NOSUPERUSER` role granted only
|
||||
`pg_read_all_data`. Read-only is enforced by Postgres, not by convention:
|
||||
`dev` and `prod` connect as **`thermograph_ro`** — a `NOSUPERUSER` role granted
|
||||
only `pg_read_all_data`. Read-only is enforced by Postgres, not by convention:
|
||||
|
||||
```
|
||||
$ infra/ops/dbq.sh prod -c "create table t(i int)"
|
||||
ERROR: permission denied for schema public
|
||||
```
|
||||
|
||||
**`beta` is the exception, today:** `deploy/db/provision-env-db.sh` only ever
|
||||
provisions the environment's *owning* role (`thermograph_beta` —
|
||||
`NOSUPERUSER`/`NOCREATEDB`/`NOCREATEROLE`, but it owns its own database and
|
||||
schema) and revokes `CONNECT` from `PUBLIC` on `thermograph_beta`, so
|
||||
`thermograph_ro` was never granted `CONNECT` there. A `dbq.sh beta` query
|
||||
therefore connects as `thermograph_beta` until a beta-scoped read-only role
|
||||
exists (tracked in `dbq.sh`'s own header as a TODO) — it can write to beta's
|
||||
database, though never to prod's.
|
||||
|
||||
The app's own `thermograph` role is a superuser and is deliberately **not** used
|
||||
by this tool. If the role is ever missing (fresh database), recreate it with:
|
||||
by this tool. If `thermograph_ro` is ever missing (fresh instance), recreate it with:
|
||||
|
||||
```sql
|
||||
CREATE ROLE thermograph_ro LOGIN;
|
||||
|
|
@ -68,15 +90,18 @@ The lake is **one shared warehouse** in Contabo object storage
|
|||
same credentials — so unlike Postgres there is no per-env database to reach,
|
||||
and the environments differ only in *where the engine runs*: a throwaway
|
||||
`docker run` of a small DuckDB image (duckdb CLI + `httpfs`/`iceberg`
|
||||
extensions pre-installed) on the target box — locally for LAN dev, over SSH
|
||||
for beta/prod. The image is built on the host the first time it's needed from
|
||||
a Dockerfile embedded in the script; its tag is a hash of that Dockerfile, so
|
||||
editing the script rolls every host forward on the next call.
|
||||
extensions pre-installed) on the target box — vps1 for `dev`, vps2 for
|
||||
`beta`/`prod`, resolved from `deploy/env-topology.sh` rather than a hardcoded
|
||||
table (beta and prod are two Swarm stacks on the same vps2 box now). The
|
||||
image is built on the host the first time it's needed from a Dockerfile
|
||||
embedded in the script; its tag is a hash of that Dockerfile, so editing the
|
||||
script rolls every host forward on the next call.
|
||||
|
||||
A container per query means no daemon, no listening port, no tunnel (prod's
|
||||
overlay cannot be tunnelled), no firewall or Swarm changes — `sudo ss -ltnp`
|
||||
is identical before and after a query. It also never execs into an app
|
||||
container and resolves no container names, so prod redeploys can't break it.
|
||||
A container per query means no daemon, no listening port, no tunnel (beta's
|
||||
and prod's Swarm overlay cannot be tunnelled), no firewall or Swarm changes —
|
||||
`sudo ss -ltnp` is identical before and after a query. It also never execs
|
||||
into an app container and resolves no container names, so a redeploy can't
|
||||
break it.
|
||||
|
||||
There is no catalog service to run or expose: an Iceberg table's current
|
||||
state is fully described by the newest metadata JSON under
|
||||
|
|
|
|||
|
|
@ -7,26 +7,43 @@
|
|||
# echo "select 1" | infra/ops/dbq.sh prod -f -
|
||||
#
|
||||
# Why exec-into-the-container instead of a connection string:
|
||||
# none of the databases are exposed over TCP. Each listens only on its private
|
||||
# docker network -- and prod's is a Swarm *overlay* (10.0.2.0/24) that the host
|
||||
# itself cannot route to, so `ssh -L` works for beta but is impossible for prod.
|
||||
# Publishing 5432 would mean new firewall + Swarm endpoint changes on production.
|
||||
# Running psql *inside* the db container works identically in all three
|
||||
# environments with no ports, no tunnels and no infra changes -- locally for LAN
|
||||
# dev, over SSH for beta/prod.
|
||||
# none of the databases are exposed over TCP. Dev's listens only on its own
|
||||
# private docker network on vps1; prod and beta share ONE instance on prod's
|
||||
# Swarm *overlay* (10.0.2.0/24) on vps2, which the host itself cannot route
|
||||
# to, so `ssh -L` is impossible for either. Publishing 5432 would mean new
|
||||
# firewall + Swarm endpoint changes on production. Running psql *inside* the
|
||||
# db container works identically in all three environments with no ports, no
|
||||
# tunnels and no infra changes -- over SSH to vps1 for dev, to vps2 for
|
||||
# beta/prod.
|
||||
#
|
||||
# Safety: always connects as `thermograph_ro`, a NOSUPERUSER role granted only
|
||||
# pg_read_all_data. Read-only is enforced by Postgres itself, not by convention,
|
||||
# so a stray INSERT/DDL fails with "permission denied" even against prod. (The
|
||||
# app's own `thermograph` role is a superuser -- deliberately not used here.)
|
||||
# Host, SSH target and container filter all come from env-topology.sh, never
|
||||
# a hardcoded table -- beta and prod are two Swarm stacks on the SAME box
|
||||
# (vps2) now, and beta has no db container of its own: it reaches prod's
|
||||
# `thermograph_db` task, the one shared TimescaleDB instance.
|
||||
#
|
||||
# Safety: every environment connects as a READ-ONLY role, never as the role the
|
||||
# app itself uses -- a stray INSERT/DDL fails with "permission denied" even
|
||||
# against prod. The role is the environment's own owning role with an `_ro`
|
||||
# suffix, which resolves to the long-standing `thermograph_ro` for prod and dev
|
||||
# and to `thermograph_beta_ro` for beta.
|
||||
#
|
||||
# That suffix convention is why beta needed no special case. It would have been
|
||||
# easy to give beta one: `CONNECT` is revoked from `PUBLIC` on
|
||||
# `thermograph_beta`, so prod's `thermograph_ro` cannot reach it, and the
|
||||
# tempting shortcut was to let beta queries connect as `thermograph_beta` --
|
||||
# the role that OWNS beta's database. That would have quietly made ad-hoc
|
||||
# queries read-write on beta alone. deploy/db/provision-env-db.sh provisions the
|
||||
# `_ro` role for every environment instead.
|
||||
#
|
||||
# Any extra arguments are passed straight through to psql, so -tA, -c, -f, -x,
|
||||
# --csv etc. all work. Stdin is forwarded, so `-f -` reads piped SQL.
|
||||
set -euo pipefail
|
||||
|
||||
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. "$SELF_DIR/../deploy/env-topology.sh"
|
||||
|
||||
KEYFILE="${THERMOGRAPH_AGENT_KEY:-$HOME/.ssh/thermograph_agent_ed25519}"
|
||||
DB_USER="${THERMOGRAPH_DB_QUERY_USER:-thermograph_ro}"
|
||||
DB_NAME="${THERMOGRAPH_DB_NAME:-thermograph}"
|
||||
|
||||
usage() {
|
||||
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//' >&2
|
||||
|
|
@ -39,13 +56,33 @@ env_name="${1:-}"
|
|||
shift
|
||||
|
||||
case "$env_name" in
|
||||
dev) filter=thermograph-dev-db ; ssh_target= ;;
|
||||
beta) filter=thermograph-db-1 ; ssh_target=agent@75.119.132.91 ;;
|
||||
prod) filter=thermograph_db ; ssh_target=agent@169.58.46.181 ;;
|
||||
dev|beta|prod) ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "dbq: unknown environment '$env_name' (want: dev|beta|prod)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
thermograph_topology "$env_name"
|
||||
|
||||
ssh_target="$TG_SSH_TARGET"
|
||||
|
||||
# Container filter: a Swarm task name (shared across prod and beta -- beta has
|
||||
# no db task of its own) or a compose container name, per the environment's
|
||||
# deploy mode.
|
||||
if [ "$TG_DEPLOY_MODE" = stack ]; then
|
||||
filter="$TG_DB_SERVICE"
|
||||
else
|
||||
filter="${TG_COMPOSE_PROJECT}-${TG_DB_SERVICE}"
|
||||
fi
|
||||
|
||||
DB_NAME="${THERMOGRAPH_DB_NAME:-$TG_DB_NAME}"
|
||||
if [ -n "${THERMOGRAPH_DB_QUERY_USER:-}" ]; then
|
||||
DB_USER="$THERMOGRAPH_DB_QUERY_USER"
|
||||
else
|
||||
# thermograph_ro for prod/dev, thermograph_beta_ro for beta -- provisioned by
|
||||
# deploy/db/provision-env-db.sh. Never the owning role.
|
||||
DB_USER="${TG_DB_USER}_ro"
|
||||
fi
|
||||
|
||||
[ $# -gt 0 ] || usage
|
||||
|
||||
# Quote the psql arguments so they survive the remote shell intact.
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@
|
|||
# Why an ephemeral container instead of a query service: the lake is one shared
|
||||
# warehouse in Contabo object storage (s3://era5-thermograph/iceberg), readable
|
||||
# from every environment, so the environments differ only in WHERE the engine
|
||||
# runs -- a throwaway `docker run` of a small DuckDB image on the target box.
|
||||
# No daemon, no listening port, no tunnel (prod's overlay cannot be tunnelled),
|
||||
# and no container names to resolve, so prod redeploys cannot break it.
|
||||
# runs -- a throwaway `docker run` of a small DuckDB image on the target box
|
||||
# (vps1 for dev, vps2 for beta/prod -- the SSH target comes from
|
||||
# env-topology.sh, never a hardcoded table; beta and prod are two Swarm stacks
|
||||
# on the SAME vps2 box now). No daemon, no listening port, no tunnel (prod's
|
||||
# overlay cannot be tunnelled), and no container names to resolve, so prod
|
||||
# redeploys cannot break it.
|
||||
#
|
||||
# There is no catalog service: a table's current state is its newest metadata
|
||||
# JSON under <warehouse>/<table>/metadata/, resolved at call time and exposed
|
||||
|
|
@ -34,6 +37,10 @@
|
|||
# read as piped SQL. A single bare argument is treated as `-c` SQL.
|
||||
set -euo pipefail
|
||||
|
||||
SELF_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
# shellcheck source=infra/deploy/env-topology.sh
|
||||
. "$SELF_DIR/../deploy/env-topology.sh"
|
||||
|
||||
KEYFILE="${THERMOGRAPH_AGENT_KEY:-$HOME/.ssh/thermograph_agent_ed25519}"
|
||||
|
||||
usage() {
|
||||
|
|
@ -47,13 +54,17 @@ env_name="${1:-}"
|
|||
shift
|
||||
|
||||
case "$env_name" in
|
||||
dev) ssh_target= ;;
|
||||
beta) ssh_target=agent@75.119.132.91 ;;
|
||||
prod) ssh_target=agent@169.58.46.181 ;;
|
||||
dev|beta|prod) ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "iceberg: unknown environment '$env_name' (want: dev|beta|prod)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# SSH target comes from env-topology.sh, not a hardcoded table: beta and prod
|
||||
# are two Swarm stacks on the SAME box (vps2) now, so a stale beta entry here
|
||||
# would silently point a beta query at the wrong host.
|
||||
thermograph_topology "$env_name"
|
||||
ssh_target="$TG_SSH_TARGET"
|
||||
|
||||
[ $# -gt 0 ] || usage
|
||||
|
||||
# The engine image: duckdb CLI with the httpfs + iceberg extensions installed
|
||||
|
|
|
|||
|
|
@ -10,27 +10,46 @@ scaffold" below) — scaffold-only today, no live resources until you opt in.
|
|||
|
||||
## What it manages
|
||||
|
||||
One reusable module (`modules/thermograph-host`) is instantiated per host via
|
||||
`for_each` (`main.tf`'s `local.all_hosts`, merging `var.hosts` — SSH-managed,
|
||||
already-existing boxes — with any `var.gcp_hosts` Terraform created itself).
|
||||
This config manages **two VPS hosts** today:
|
||||
One reusable module (`modules/thermograph-host`) is instantiated per
|
||||
**(host, environment)** pair via `for_each` (`main.tf`'s `local.all_hosts`,
|
||||
merging `var.hosts` — SSH-managed, already-existing boxes — with any
|
||||
`var.gcp_hosts` Terraform created itself). `var.hosts` is keyed by HOST, not by
|
||||
environment, because **vps2 alone runs two environments** (prod and beta) as
|
||||
separate Docker Swarm stacks on the same box — see the `hosts` variable's file
|
||||
header in `variables.tf` for why that stopped being "one entry = one
|
||||
environment" and what still needs to change in `main.tf` to consume the new
|
||||
shape (flagged there as a `TODO(cutover)`). This config manages **two VPS
|
||||
hosts**, three environments, today:
|
||||
|
||||
| key | role | VPS | branch | domain | notes |
|
||||
|--------|--------|-------------------------|-----------|-------------------|------------------------------------|
|
||||
| `prod` | prod | NEW 48 GB / 12-core box (`169.58.46.181`) | `release` (of the APP repos — see `backend_image_tag` / `frontend_image_tag`) | `thermograph.org` | Caddy TLS; sized up (8/8/4/16g) |
|
||||
| `beta` | beta | old box `75.119.132.91` | `main` (of the APP repo) | `beta.thermograph.org` | Caddy TLS; also hosts Forgejo |
|
||||
| host | environment | VPS | branch | domain | notes |
|
||||
|--------|-------------|--------------------------------------------|--------|------------------------|----------------------------------------------|
|
||||
| `vps1` | `dev` | `75.119.132.91` | `dev` (of the APP repos — see `backend_image_tag` / `frontend_image_tag`) | none (mesh-only) | Also hosts Forgejo, Grafana/Loki/Alloy, emigriffith.dev — not Terraform-managed |
|
||||
| `vps2` | `prod` | 48 GB / 12-core box (`169.58.46.181`) | `main` | `thermograph.org` | Caddy TLS (this module owns `/etc/caddy/Caddyfile`); sized up (8/8/4/16g) |
|
||||
| `vps2` | `beta` | the SAME box as prod (`169.58.46.181`) | `main` | none (see below) | Separate Swarm stack + checkout, shares prod's TimescaleDB instance on a separate database/role |
|
||||
|
||||
Each host's own checkout on disk (`app_dir`, `git_branch`) is **this infra
|
||||
repo**, not the app repo — the "branch" column above is which app-repo tag a
|
||||
host is meant to track conceptually; the actual pinned versions are
|
||||
`var.hosts[*].backend_image_tag` / `frontend_image_tag` (e.g. `"sha-<12 hex>"` each —
|
||||
the app is two separately-published images, `emi/thermograph-backend/app` and
|
||||
`emi/thermograph-frontend/app`), since the host has no app checkout to derive a tag
|
||||
from. The LAN dev server is **out of scope here** — it
|
||||
builds from source via the app repo's `deploy/deploy-dev.sh` (a self-hosted
|
||||
Forgejo Actions runner), not Terraform.
|
||||
Only ONE environment per host may set a `domain`: the module installs a full
|
||||
`/etc/caddy/Caddyfile`, and a second `terraform apply` with its own `domain` set
|
||||
would silently overwrite the first's site instead of adding to it (there is no
|
||||
merge). Prod claims that slot on vps2 today, so beta's public reverse-proxy (if
|
||||
and when `beta.thermograph.org` is exposed) has to be a site block in vps2's
|
||||
shared, hand-maintained Caddy config instead — the same pattern
|
||||
`deploy/forgejo/docker-stack.yml` already uses to put `git.thermograph.org` in
|
||||
front of Forgejo's loopback port on vps1, alongside that box's own Caddy-fronted
|
||||
`emigriffith.dev`.
|
||||
|
||||
Per host, over SSH provisioners, Terraform:
|
||||
Each environment's own checkout on disk (`app_dir`, `git_branch`) is **this
|
||||
infra repo**, not the app repo — the "branch" column above is which app-repo
|
||||
tag an environment is meant to track conceptually; the actual pinned versions
|
||||
are `var.hosts[*].environments[*].backend_image_tag` / `frontend_image_tag`
|
||||
(e.g. `"sha-<12 hex>"` each — the app is two separately-published images,
|
||||
`emi/thermograph-backend/app` and `emi/thermograph-frontend/app`), since the
|
||||
host has no app checkout to derive a tag from. `app_dir` is required with no
|
||||
default specifically so vps2's two environments can never collide on the same
|
||||
checkout path (prod: `/opt/thermograph`, beta: `/opt/thermograph-beta`). The
|
||||
LAN-laptop dev rehearsal (`make dev-up`) is **out of scope here** — it's a
|
||||
local-only compose overlay, not this Terraform-managed `dev` environment.
|
||||
|
||||
Per (host, environment), over SSH provisioners, Terraform:
|
||||
|
||||
- installs Docker + the compose plugin if missing;
|
||||
- configures a `ufw` firewall (22/80/443 always; on a host with **no** domain it also
|
||||
|
|
@ -104,7 +123,11 @@ terraform plan
|
|||
terraform apply
|
||||
```
|
||||
|
||||
Target one host with `-target='module.host["beta"]'` if you want to apply to just one.
|
||||
Target one host with `-target='module.host["prod"]'` if you want to apply to just one
|
||||
(the module's `for_each` key changes once `main.tf` is updated to flatten `var.hosts`'s
|
||||
new per-host `environments` map — see the `TODO(cutover)` on the `hosts` variable in
|
||||
`variables.tf` — at which point the key for beta becomes something like
|
||||
`module.host["vps2-beta"]`, not `module.host["beta"]`).
|
||||
|
||||
## Secrets
|
||||
|
||||
|
|
@ -150,11 +173,15 @@ Terraform brings the stack up — don't let Terraform recreate containers mid-mi
|
|||
|
||||
## Assumptions / notes
|
||||
|
||||
- **beta has no public domain by default.** With `compose_files = ["docker-compose.yml"]`
|
||||
the app binds `127.0.0.1:8137` (loopback), so opening the port in `ufw` alone does not
|
||||
expose it. Reach beta via an SSH tunnel, or set `domain = "beta.thermograph.org"` (adds
|
||||
Caddy TLS) — or add the `0.0.0.0`-publishing dev overlay to `compose_files` — to make
|
||||
it reachable. `COOKIE_SECURE` is auto-set to `0` when there's no domain (a Secure
|
||||
- **beta has no public domain here, and can't without a different Caddy strategy.**
|
||||
With `compose_files = ["docker-compose.yml"]` the app binds `127.0.0.1:8137`
|
||||
(loopback), so opening the port in `ufw` alone does not expose it — and setting
|
||||
`domain` on beta the way prod does would make this module overwrite the SAME
|
||||
`/etc/caddy/Caddyfile` prod's apply just installed (see the table above). Reach
|
||||
beta via an SSH tunnel, or front it with a site block in vps2's own
|
||||
hand-maintained Caddy config (outside Terraform), the same way Forgejo's
|
||||
`git.thermograph.org` reaches Forgejo's loopback port on vps1.
|
||||
`COOKIE_SECURE` is auto-set to `0` when there's no domain (a Secure
|
||||
cookie is never sent over plain HTTP) and `1` behind Caddy TLS.
|
||||
- The rendered Caddyfile only reverse-proxies the app. The repo's `deploy/Caddyfile`
|
||||
additionally serves the `emigriffith.dev` portfolio and legacy redirects; those are
|
||||
|
|
|
|||
|
|
@ -44,13 +44,39 @@ module "gcp_vm" {
|
|||
}
|
||||
|
||||
locals {
|
||||
# Every host this config manages, SSH-only (var.hosts) plus GCP-created (whose
|
||||
# `host` is filled in from the VM Terraform just created) — one unified map so a
|
||||
# single `module.host` for_each below handles both without duplicating any
|
||||
# provisioning logic. GCP hosts always use a named size tier (simpler than
|
||||
# ONE MODULE INSTANCE PER (HOST, ENVIRONMENT) PAIR, not per host.
|
||||
#
|
||||
# var.hosts is keyed by machine (vps1, vps2) and each machine carries an
|
||||
# `environments` map, because vps2 runs prod AND beta. The module below
|
||||
# provisions an ENVIRONMENT — a checkout, a branch, image tags, a Caddy site,
|
||||
# container sizing — so it needs one instance per environment, with the two
|
||||
# instances on vps2 sharing that machine's SSH identity.
|
||||
#
|
||||
# Flattened to keys like "vps2-prod" and "vps2-beta". Keeping them distinct
|
||||
# module instances is what makes `app_dir` (required, no default in
|
||||
# variables.tf) do its job: two environments on one box get two explicitly
|
||||
# different checkouts, so neither apply can `git reset --hard` the other's
|
||||
# tree.
|
||||
ssh_environments = merge([
|
||||
for hname, h in var.hosts : {
|
||||
for ename, e in h.environments :
|
||||
"${hname}-${ename}" => merge(e, {
|
||||
host = h.host
|
||||
ssh_user = h.ssh_user
|
||||
ssh_private_key_path = h.ssh_private_key_path
|
||||
})
|
||||
}
|
||||
]...)
|
||||
|
||||
# Every environment this config manages, SSH-only (flattened above) plus
|
||||
# GCP-created (whose `host` is filled in from the VM Terraform just created) —
|
||||
# one unified map so a single `module.host` for_each below handles both without
|
||||
# duplicating any provisioning logic. GCP hosts stay one-entry-per-machine:
|
||||
# nothing creates two environments on a created VM today, and the variable
|
||||
# keeps its flat shape. They always use a named size tier (simpler than
|
||||
# exposing the four raw sizing fields on that variable too).
|
||||
all_hosts = merge(
|
||||
var.hosts,
|
||||
local.ssh_environments,
|
||||
{
|
||||
for name, h in var.gcp_hosts : name => merge(h, {
|
||||
host = module.gcp_vm[name].external_ip
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ variable "ssh_private_key_path" {
|
|||
}
|
||||
|
||||
variable "role" {
|
||||
description = "\"prod\" | \"dev\" — informational."
|
||||
description = "\"prod\" | \"beta\" | \"dev\" — informational. One module instance is one (host, environment) pair, so vps2 (which runs both prod and beta) gets two instances, each with its own role/app_dir/image tags -- see the root module's `hosts` variable."
|
||||
type = string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ output "prod_url" {
|
|||
}
|
||||
|
||||
output "hosts" {
|
||||
description = "Per-host summary: the address managed and its role."
|
||||
description = "Per-environment summary, keyed \"<host>-<environment>\" (vps2 carries two): the machine managed and the role running on it."
|
||||
value = {
|
||||
for name, mod in module.host : name => {
|
||||
host = mod.host
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Copy to terraform.tfvars and fill in real IPs + credentials.
|
||||
# Copy to terraform.tfvars and fill in real credentials.
|
||||
# cp terraform.tfvars.example terraform.tfvars
|
||||
# terraform.tfvars is gitignored (repo_url may carry a credential, and om_rclone_conf
|
||||
# always does — both land in local state). NEVER commit real values.
|
||||
#
|
||||
|
||||
# App secrets (POSTGRES_PASSWORD, THERMOGRAPH_AUTH_SECRET, VAPID keys,
|
||||
# REGISTRY_TOKEN, Discord/SMTP creds, ...) are NOT here anymore -- they live in
|
||||
# the SOPS+age vault (../deploy/secrets/*.yaml), rendered at deploy time. See
|
||||
|
|
@ -11,54 +11,99 @@
|
|||
# ---------------------------------------------------------------------------------
|
||||
# Hosts
|
||||
# ---------------------------------------------------------------------------------
|
||||
# Two VPS hosts. (The `dev` branch deploys to the LAN dev server via the app repo's
|
||||
# deploy/deploy-dev.sh — that box is NOT managed by Terraform.)
|
||||
# Two VPS boxes, keyed by HOST (vps1, vps2) — NOT by environment, because vps2
|
||||
# alone carries two (prod AND beta). Each host lists its SSH identity once, then
|
||||
# an `environments` map for whatever runs on it. (The `dev` branch deploys to
|
||||
# vps1 the same way in reality, but the LAN-laptop `make dev-up` rehearsal is NOT
|
||||
# managed by Terraform at all.)
|
||||
hosts = {
|
||||
# Production: the NEW 48 GB / 12-core VPS serving thermograph.org.
|
||||
prod = {
|
||||
host = "REPLACE_WITH_NEW_VPS_IP" # <-- the new prod VPS IP/hostname
|
||||
ssh_user = "agent"
|
||||
ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519"
|
||||
role = "prod"
|
||||
git_branch = "main" # this INFRA repo's branch -- see *_image_tag for the app versions
|
||||
backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG" # e.g. "sha-abcdef012345" -- from thermograph-backend build-push.yml
|
||||
frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG" # e.g. "sha-012345abcdef" -- from thermograph-frontend build-push.yml
|
||||
domain = "thermograph.org" # Caddy TLS in front, app on loopback
|
||||
compose_files = ["docker-compose.yml"]
|
||||
app_dir = "/opt/thermograph"
|
||||
# "large" is the named size tier for this box (locals.sizes in main.tf) — same
|
||||
# numbers as hand-picking workers=8/app_cpus=8/db_cpus=4/db_memory="16g" below,
|
||||
# via the shortcut. The Postgres internal budget scales from db_memory
|
||||
# automatically (deploy/db/init/20-tuning.sh); no separate tuning edit.
|
||||
size = "large"
|
||||
# Self-host the ERA5 archive here: layers docker-compose.openmeteo.yml and
|
||||
# provisions the rclone mount of the object-storage bucket (om_* vars below).
|
||||
openmeteo = true
|
||||
om_data_dir = "/mnt/om-archive"
|
||||
}
|
||||
|
||||
# Beta / testing: the OLD VPS, repurposed.
|
||||
beta = {
|
||||
# vps1: Forgejo (git+CI+registry), Grafana/Loki/Alloy, emigriffith.dev, and the
|
||||
# `dev` environment. One environment today; the shape still nests it so a
|
||||
# second one (there is none planned) would never have to fight this host's
|
||||
# SSH identity.
|
||||
vps1 = {
|
||||
host = "75.119.132.91"
|
||||
ssh_user = "agent"
|
||||
ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519"
|
||||
role = "beta"
|
||||
git_branch = "main"
|
||||
backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG"
|
||||
frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG"
|
||||
# No public domain by default: no Caddy/TLS, firewall opens the app port. NOTE:
|
||||
# with compose_files = ["docker-compose.yml"] the app binds 127.0.0.1 only, so
|
||||
# until you either set a domain (e.g. "beta.thermograph.org", which fronts it with
|
||||
# Caddy) or add the 0.0.0.0-publishing dev overlay, reach it via an SSH tunnel.
|
||||
domain = ""
|
||||
compose_files = ["docker-compose.yml"]
|
||||
app_dir = "/opt/thermograph"
|
||||
# Explicit numbers, not a size tier — both styles work on any host; a tier is
|
||||
# purely an opt-in shortcut (see prod's `size = "large"` above).
|
||||
workers = 4
|
||||
app_cpus = 4
|
||||
db_cpus = 2
|
||||
db_memory = "8g"
|
||||
|
||||
environments = {
|
||||
dev = {
|
||||
role = "dev"
|
||||
git_branch = "dev" # the only environment that tracks `dev`; beta/prod track `main`
|
||||
backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG"
|
||||
frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG"
|
||||
# Mesh-only: no public domain, no Caddy/TLS. Reachable at
|
||||
# http://10.10.0.2:8137 from the WireGuard mesh only.
|
||||
domain = ""
|
||||
compose_files = ["docker-compose.yml"]
|
||||
app_dir = "/opt/thermograph-dev"
|
||||
workers = 4
|
||||
app_cpus = 4
|
||||
db_cpus = 2
|
||||
db_memory = "8g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# vps2: prod (thermograph.org) AND beta (beta.thermograph.org) as two separate
|
||||
# Swarm stacks on the SAME 48 GB / 12-core box — plus Centralis, Postfix and
|
||||
# the backups (not Terraform-managed). ONE shared TimescaleDB instance serves
|
||||
# both app_dirs below, on separate databases/roles (deploy/db/provision-env-db.sh);
|
||||
# each environment's db_cpus/db_memory here sizes only that environment's own
|
||||
# app-container caps, not a second database.
|
||||
vps2 = {
|
||||
host = "169.58.46.181"
|
||||
ssh_user = "agent"
|
||||
ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519"
|
||||
|
||||
environments = {
|
||||
prod = {
|
||||
role = "prod"
|
||||
git_branch = "main" # this INFRA repo's branch -- see *_image_tag for the app versions
|
||||
backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG" # e.g. "sha-abcdef012345" -- from thermograph-backend build-push.yml
|
||||
frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG" # e.g. "sha-012345abcdef" -- from thermograph-frontend build-push.yml
|
||||
domain = "thermograph.org" # Caddy TLS in front, app on loopback
|
||||
compose_files = ["docker-compose.yml"]
|
||||
# MUST differ from beta's app_dir below -- see variables.tf's file header.
|
||||
app_dir = "/opt/thermograph"
|
||||
# "large" is the named size tier for this box (locals.sizes in main.tf) — same
|
||||
# numbers as hand-picking workers=8/app_cpus=8/db_cpus=4/db_memory="16g" below,
|
||||
# via the shortcut. The Postgres internal budget scales from db_memory
|
||||
# automatically (deploy/db/init/20-tuning.sh); no separate tuning edit.
|
||||
size = "large"
|
||||
# Self-host the ERA5 archive here: layers docker-compose.openmeteo.yml and
|
||||
# provisions the rclone mount of the object-storage bucket (om_* vars below).
|
||||
openmeteo = true
|
||||
om_data_dir = "/mnt/om-archive"
|
||||
}
|
||||
|
||||
beta = {
|
||||
role = "beta"
|
||||
git_branch = "main"
|
||||
backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG"
|
||||
frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG"
|
||||
# "" ON PURPOSE, unlike prod above: modules/thermograph-host installs a
|
||||
# FULL /etc/caddy/Caddyfile per environment with a domain set, and only
|
||||
# ONE environment on a box can own that file. Prod already claims it on
|
||||
# this host, so beta.thermograph.org's public reverse-proxy has to be a
|
||||
# site block in vps2's shared, hand-maintained Caddy instead (the same
|
||||
# pattern deploy/forgejo/docker-stack.yml uses for git.thermograph.org
|
||||
# on vps1) -- not something this module can render for a second
|
||||
# environment on the same host.
|
||||
domain = ""
|
||||
compose_files = ["docker-compose.yml"]
|
||||
# MUST differ from prod's app_dir above -- a SECOND checkout on the same
|
||||
# box, so a `git reset --hard` in one deploy can never yank the tree out
|
||||
# from under the other's running deploy.
|
||||
app_dir = "/opt/thermograph-beta"
|
||||
# Explicit numbers, not a size tier — both styles work on any environment; a
|
||||
# tier is purely an opt-in shortcut (see prod's `size = "large"` above).
|
||||
workers = 4
|
||||
app_cpus = 4
|
||||
db_cpus = 2
|
||||
db_memory = "8g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# UAT: an ephemeral, single-node environment — same images/topology shape as
|
||||
|
|
@ -68,20 +113,27 @@ hosts = {
|
|||
# host = "REPLACE_WITH_UAT_VM_IP"
|
||||
# ssh_user = "agent"
|
||||
# ssh_private_key_path = "~/.ssh/thermograph_agent_ed25519"
|
||||
# role = "uat"
|
||||
# git_branch = "main"
|
||||
# backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG"
|
||||
# frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG"
|
||||
# domain = ""
|
||||
# compose_files = ["docker-compose.yml"]
|
||||
# app_dir = "/opt/thermograph"
|
||||
# size = "nano"
|
||||
# environments = {
|
||||
# uat = {
|
||||
# role = "uat"
|
||||
# git_branch = "main"
|
||||
# backend_image_tag = "REPLACE_WITH_BACKEND_IMAGE_TAG"
|
||||
# frontend_image_tag = "REPLACE_WITH_FRONTEND_IMAGE_TAG"
|
||||
# domain = ""
|
||||
# compose_files = ["docker-compose.yml"]
|
||||
# app_dir = "/opt/thermograph"
|
||||
# size = "nano"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
}
|
||||
|
||||
# GCP-created hosts — SCAFFOLD ONLY, empty by default. Populate an entry to have
|
||||
# Terraform actually create a GCP Compute Engine VM (see modules/gcp-host); until
|
||||
# then no google_* resource is planned and no GCP credentials are needed. Example:
|
||||
# GCP-created hosts — SCAFFOLD ONLY, empty by default, and still keyed one entry
|
||||
# per ENVIRONMENT (see variables.tf's TODO(cutover) note on gcp_hosts — these are
|
||||
# hypothetical single-purpose boxes, not vps1/vps2, so they don't need the
|
||||
# host/environment nesting above). Populate an entry to have Terraform actually
|
||||
# create a GCP Compute Engine VM (see modules/gcp-host); until then no google_*
|
||||
# resource is planned and no GCP credentials are needed. Example:
|
||||
# gcp_hosts = {
|
||||
# gcp-uat = {
|
||||
# project = "REPLACE_WITH_GCP_PROJECT_ID"
|
||||
|
|
@ -108,7 +160,7 @@ hosts = {
|
|||
# repo_url = "https://deploy:REPLACE_WITH_TOKEN@git.thermograph.org/emi/thermograph-infra.git"
|
||||
|
||||
# ---------------------------------------------------------------------------------
|
||||
# Self-hosted Open-Meteo archive (only used by hosts with openmeteo = true) --------
|
||||
# Self-hosted Open-Meteo archive (only used by environments with openmeteo = true)
|
||||
# ---------------------------------------------------------------------------------
|
||||
# The ERA5 .om archive lives in an object-storage bucket, rclone-mounted on the host.
|
||||
# om_rclone_conf holds bucket credentials (sensitive; lands in state — keep out of git).
|
||||
|
|
|
|||
|
|
@ -1,69 +1,109 @@
|
|||
# ---------------------------------------------------------------------------------
|
||||
# Hosts
|
||||
# ---------------------------------------------------------------------------------
|
||||
# One entry per VPS. The same module is instantiated for each (see main.tf's
|
||||
# for_each). This config manages TWO hosts: prod (new 48 GB / 12-core VPS, branch
|
||||
# `release`, thermograph.org) and beta (the old VPS 75.119.132.91, branch `main`,
|
||||
# no public domain by default). The `dev` branch deploys to the LAN dev server via
|
||||
# deploy/deploy-dev.sh (a self-hosted runner, in the app repo) and is NOT managed here.
|
||||
# Keyed by HOST (vps1, vps2), NOT by environment: vps2 alone runs TWO environments
|
||||
# (prod AND beta) as separate Swarm stacks on the SAME box, so "one entry = one
|
||||
# environment" broke the moment that became true — two entries that both defaulted
|
||||
# `app_dir` to "/opt/thermograph" would let a prod apply and a beta apply stomp the
|
||||
# identical checkout. Each host now carries its SSH identity ONCE (host IP,
|
||||
# ssh_user, key) plus an `environments` map of everything that varies per
|
||||
# environment running on it — checkout path, git branch, image tags, domain,
|
||||
# sizing. `app_dir` is REQUIRED with no default for exactly this reason: two
|
||||
# environments on one host must be given two explicitly DIFFERENT paths (see
|
||||
# terraform.tfvars.example), so their checkouts — and a `git reset --hard` in one
|
||||
# of them — can never collide.
|
||||
#
|
||||
# A host with `domain = ""` gets no Caddy/TLS: the app port is opened on the firewall
|
||||
# and the app is reached directly (beta does this by default; set a domain to front
|
||||
# it with Caddy TLS).
|
||||
# vps1 (75.119.132.91): Forgejo (git+CI+registry), Grafana/Loki/Alloy,
|
||||
# emigriffith.dev, and the `dev` environment (its own Postgres, mesh-only, no
|
||||
# public DNS/Caddy site). vps2 (169.58.46.181): `prod` (thermograph.org) and
|
||||
# `beta` (beta.thermograph.org) — Centralis, Postfix and the backups also live
|
||||
# here — sharing the ONE TimescaleDB instance both app environments use, on
|
||||
# separate databases/roles (deploy/db/provision-env-db.sh); this variable's
|
||||
# per-environment db_cpus/db_memory size only that environment's app-container
|
||||
# caps, not a second database (see deploy/db/init/20-tuning.sh). The LAN dev
|
||||
# branch (`make dev-up`, a laptop-local rehearsal) deploys nowhere via Terraform
|
||||
# and is NOT managed here.
|
||||
#
|
||||
# Resource sizing (workers / app_cpus / db_cpus / db_memory) defaults to the historical
|
||||
# 4 / 4 / 2 / 8g budget (right for beta). The prod box is 48 GB / 12 cores — raise these
|
||||
# there (the example uses app_cpus 8, db_cpus 4, db_memory "16g"). The Postgres internal
|
||||
# budget scales from db_memory automatically (deploy/db/init/20-tuning.sh), so no
|
||||
# separate tuning edit is needed to exploit the RAM.
|
||||
# An environment with `domain = ""` gets no Caddy/TLS: its app port is opened on
|
||||
# the firewall and it's reached directly.
|
||||
#
|
||||
# Resource sizing (workers / app_cpus / db_cpus / db_memory), per environment,
|
||||
# defaults to the historical 4 / 4 / 2 / 8g budget (right for beta). vps2 is 48 GB
|
||||
# / 12 cores — raise these for prod (the example uses app_cpus 8, db_cpus 4,
|
||||
# db_memory "16g", via the "large" size tier). The Postgres internal budget scales
|
||||
# from db_memory automatically (deploy/db/init/20-tuning.sh), so no separate
|
||||
# tuning edit is needed to exploit the RAM.
|
||||
#
|
||||
# TODO(cutover): main.tf's `module.host` for_each (via `local.all_hosts`) still
|
||||
# expects ONE flat entry per key, reading `each.value.host`/`.role`/`.app_dir`/…
|
||||
# directly. It needs a flattening step ahead of that for_each — one derived entry
|
||||
# per (host, environment) pair, keyed e.g. "<host>-<environment>" ("vps2-prod" and
|
||||
# "vps2-beta" as two separate module instances sharing vps2's host/ssh_user/
|
||||
# ssh_private_key_path but each with its own app_dir/role/image tags/sizing).
|
||||
# This variable's new shape is not yet wired into main.tf's module block.
|
||||
variable "hosts" {
|
||||
description = "Map of hosts to manage, keyed by a short name (e.g. \"prod\", \"beta\")."
|
||||
description = "Map of VPS boxes to manage, keyed by a short host name (\"vps1\", \"vps2\") — NOT by environment. SSH identity is per-host; everything environment-specific lives in that host's `environments` map."
|
||||
type = map(object({
|
||||
host = string # IP or hostname to SSH to
|
||||
ssh_user = optional(string, "deploy") # SSH login user
|
||||
ssh_private_key_path = string # path to the private key for that user
|
||||
role = string # "prod" | "beta" (informational + outputs)
|
||||
git_branch = string # this INFRA repo's branch the checkout is reset to
|
||||
# Which app images to run, e.g. "sha-<12 hex>" (each matching build-push.yml's tag
|
||||
# for the commit that app repo built) or a semver tag on a release push. The app is
|
||||
# TWO separately-published images now — emi/thermograph-backend/app and
|
||||
# emi/thermograph-frontend/app — pinned independently. Required, no default: the
|
||||
# host's checkout is this infra repo, not the app repos, so there's no "current
|
||||
# commit" to derive a tag from; both must be explicit. Bump these (via a normal
|
||||
# tfvars edit + apply) whenever an app repo ships a commit you want this host
|
||||
# running; the infra repo's own git_branch is independent and rarely needs to change.
|
||||
backend_image_tag = string
|
||||
frontend_image_tag = string
|
||||
domain = optional(string, "") # public domain; "" => no Caddy/TLS
|
||||
compose_files = optional(list(string), ["docker-compose.yml"])
|
||||
app_dir = optional(string, "/opt/thermograph") # checkout path on the host
|
||||
workers = optional(number, 4) # uvicorn workers (WORKERS)
|
||||
app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS)
|
||||
db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS)
|
||||
db_memory = optional(string, "8g") # db container memory cap (DB_MEMORY)
|
||||
# A named size tier (see locals.sizes in main.tf: nano/small/medium/large) —
|
||||
# when set, overrides the four fields above with the tier's preset. Leave
|
||||
# null (default) to keep hand-picking workers/app_cpus/db_cpus/db_memory,
|
||||
# as prod/beta already do below.
|
||||
size = optional(string, null)
|
||||
# The floating tag matches today's behavior everywhere until you pin it. Pin to
|
||||
# an exact minor (SELECT extversion FROM pg_extension WHERE extname='timescaledb'
|
||||
# on the live DB) before any host of this stack could ever replicate with
|
||||
# another — a floating tag risks mismatched extension minors, which blocks a
|
||||
# physical replica (hop-1 cutover runbook hazard #7). Use the SAME tag everywhere.
|
||||
timescaledb_tag = optional(string, "latest-pg18")
|
||||
# Self-host the ERA5 archive (docker-compose.openmeteo.yml + a host rclone mount
|
||||
# of the object-storage bucket). Only the self-hosting host (prod) sets true.
|
||||
openmeteo = optional(bool, false)
|
||||
om_data_dir = optional(string, "/mnt/om-archive") # host rclone mount point (OM_DATA_DIR)
|
||||
# One entry per environment running on this host, keyed by environment name
|
||||
# ("prod" | "beta" | "dev"). A host normally carries one; vps2 carries two.
|
||||
environments = map(object({
|
||||
role = string # "prod" | "beta" | "dev" (informational + outputs; should match the map key above)
|
||||
git_branch = string # this INFRA repo's branch the checkout is reset to
|
||||
# Which app images to run, e.g. "sha-<12 hex>" (each matching build-push.yml's tag
|
||||
# for the commit that app repo built) or a semver tag on a release push. The app is
|
||||
# TWO separately-published images now — emi/thermograph-backend/app and
|
||||
# emi/thermograph-frontend/app — pinned independently. Required, no default: the
|
||||
# host's checkout is this infra repo, not the app repos, so there's no "current
|
||||
# commit" to derive a tag from; both must be explicit. Bump these (via a normal
|
||||
# tfvars edit + apply) whenever an app repo ships a commit you want this
|
||||
# environment running; the infra repo's own git_branch is independent and rarely
|
||||
# needs to change.
|
||||
backend_image_tag = string
|
||||
frontend_image_tag = string
|
||||
domain = optional(string, "") # public domain for this environment; "" => no Caddy/TLS
|
||||
compose_files = optional(list(string), ["docker-compose.yml"])
|
||||
# REQUIRED, no default — see the file header: two environments on one host
|
||||
# must never be able to default to the same checkout path.
|
||||
app_dir = string # checkout path on the host
|
||||
workers = optional(number, 4) # uvicorn workers (WORKERS)
|
||||
app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS)
|
||||
db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS)
|
||||
db_memory = optional(string, "8g") # db container memory cap (DB_MEMORY)
|
||||
# A named size tier (see locals.sizes in main.tf: nano/small/medium/large) —
|
||||
# when set, overrides the four fields above with the tier's preset. Leave
|
||||
# null (default) to keep hand-picking workers/app_cpus/db_cpus/db_memory,
|
||||
# as prod/beta already do below.
|
||||
size = optional(string, null)
|
||||
# The floating tag matches today's behavior everywhere until you pin it. Pin to
|
||||
# an exact minor (SELECT extversion FROM pg_extension WHERE extname='timescaledb'
|
||||
# on the live DB) before any host of this stack could ever replicate with
|
||||
# another — a floating tag risks mismatched extension minors, which blocks a
|
||||
# physical replica (hop-1 cutover runbook hazard #7). Use the SAME tag everywhere.
|
||||
timescaledb_tag = optional(string, "latest-pg18")
|
||||
# Self-host the ERA5 archive (docker-compose.openmeteo.yml + a host rclone mount
|
||||
# of the object-storage bucket). Only the self-hosting environment (prod) sets true.
|
||||
openmeteo = optional(bool, false)
|
||||
om_data_dir = optional(string, "/mnt/om-archive") # host rclone mount point (OM_DATA_DIR)
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
# GCP-created hosts, keyed the same way as `hosts`. Default {} => zero GCP resources
|
||||
# planned and the google provider is never actually invoked (see versions.tf and
|
||||
# modules/gcp-host). Populate an entry to have Terraform create the VM itself; its
|
||||
# output IP then feeds into the SAME thermograph-host module every SSH-managed host
|
||||
# uses (main.tf), so provisioning logic is never duplicated between providers.
|
||||
# GCP-created hosts, keyed the same way `hosts` used to be — one entry per
|
||||
# environment, since these are hypothetical single-purpose boxes (e.g. a future
|
||||
# UAT VM), not vps1/vps2, so they don't need the host/environment split above.
|
||||
# Default {} => zero GCP resources planned and the google provider is never
|
||||
# actually invoked (see versions.tf and modules/gcp-host). Populate an entry to
|
||||
# have Terraform create the VM itself; its output IP then feeds into the SAME
|
||||
# thermograph-host module every SSH-managed host uses (main.tf), so provisioning
|
||||
# logic is never duplicated between providers.
|
||||
# TODO(cutover): main.tf's `local.all_hosts = merge(var.hosts, ...)` currently
|
||||
# merges this flat shape with `var.hosts` into one map for `module.host`'s
|
||||
# for_each. Once `var.hosts` is nested (above), that merge needs to target the
|
||||
# same flattened (host, environment) shape this variable already has — e.g. by
|
||||
# treating each `gcp_hosts` entry as its own single-environment host.
|
||||
variable "gcp_hosts" {
|
||||
description = "Map of hosts for Terraform to CREATE on GCP (Compute Engine), keyed the same way as `hosts`. Empty by default -- no live GCP resources exist yet; this is a scaffold for future use. See modules/gcp-host."
|
||||
type = map(object({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
# Copy to .env on the monitoring host (beta) before `docker compose up`.
|
||||
# Copy to .env on the monitoring host (vps1) before `docker compose up`.
|
||||
# .env is gitignored — never commit real secrets.
|
||||
#
|
||||
# vps1 is NOT the beta environment — that runs on vps2 now, as its own Swarm
|
||||
# stack. Don't confuse the two when reading older docs that say "beta" here.
|
||||
|
||||
# The public hostname beta's Caddy serves Grafana at (see caddy-grafana.conf).
|
||||
# The public hostname vps1's Caddy serves Grafana at (see caddy-grafana.conf).
|
||||
GRAFANA_DOMAIN=dashboard.thermograph.org
|
||||
|
||||
# Break-glass local admin (Google SSO below is the primary login). Keep this
|
||||
|
|
@ -22,8 +25,8 @@ GOOGLE_CLIENT_SECRET=
|
|||
# --- Alerting (Discord) ----------------------------------------------------------
|
||||
# Where every alert in grafana/provisioning/alerting/ is delivered: a webhook on
|
||||
# the private #ops-alerts channel (Owners category) of the Thermograph.org
|
||||
# Discord. NOT email — beta's Grafana relays SMTP through prod's Postfix, so
|
||||
# email alerts die exactly when prod does.
|
||||
# Discord. NOT email — vps1's Grafana relays SMTP through vps2's Postfix, so
|
||||
# email alerts die exactly when prod (on vps2) does.
|
||||
#
|
||||
# To mint one: Discord -> #ops-alerts -> Edit Channel -> Integrations ->
|
||||
# Webhooks -> New Webhook -> Copy Webhook URL. Treat it like a password; anyone
|
||||
|
|
|
|||
|
|
@ -1,28 +1,37 @@
|
|||
# observability/ — agent instructions
|
||||
|
||||
The **logging stack** for the fleet: Loki + Grafana on beta, with a Grafana Alloy
|
||||
agent on every node (prod, beta, dev) shipping container and app logs over the
|
||||
WireGuard mesh. Grafana is fronted by beta's Caddy at
|
||||
**`dashboard.thermograph.org`** (Google SSO, pre-provisioned users only) — use
|
||||
that hostname everywhere, never `grafana.thermograph.org`.
|
||||
The **logging stack** for the fleet: Loki + Grafana on **vps1** — the monitoring
|
||||
host, mesh `10.10.0.2`, and NOT the beta *environment* (that runs on vps2 now;
|
||||
any doc that still says "beta" meaning this box is stale) — with a Grafana
|
||||
Alloy agent on every node shipping container and app logs over the WireGuard
|
||||
mesh. vps1 also hosts Forgejo, the portfolio site, and the **dev** environment
|
||||
(mesh-only, no public DNS). vps2 runs **prod and beta** as two Swarm stacks.
|
||||
Grafana is fronted by vps1's Caddy at **`dashboard.thermograph.org`** (Google
|
||||
SSO, pre-provisioned users only) — use that hostname everywhere, never
|
||||
`grafana.thermograph.org`.
|
||||
|
||||
This is operational config, not application code: there is **no build** and no
|
||||
deploy automation. It ships by hand — `docker compose up -d` on beta for
|
||||
deploy automation. It ships by hand — `docker compose up -d` on vps1 for
|
||||
Loki+Grafana, and `alloy/docker-compose.agent.yml` on each node. Read the root
|
||||
`CLAUDE.md` first; the branch model there applies here too.
|
||||
|
||||
## Layout
|
||||
|
||||
- `docker-compose.yml` — the Loki + Grafana stack (beta).
|
||||
- `docker-compose.yml` — the Loki + Grafana stack (vps1).
|
||||
- `loki/config.yml` — mesh-only, filesystem storage.
|
||||
- `grafana/provisioning/` — datasource + dashboard provider, auto-loaded at
|
||||
startup. `grafana/dashboards/*.json` — the dashboards.
|
||||
- `grafana/provisioning/alerting/` — alert rules, the Discord contact point, and
|
||||
the notification policy.
|
||||
- `alloy/config.alloy` + `alloy/docker-compose.agent.yml` — the per-node shipper.
|
||||
Node name comes from `ALLOY_NODE`, set per host.
|
||||
- `caddy-grafana.conf` — the beta Caddy vhost, kept here for reference.
|
||||
- `.env.example` — copy to `.env` on beta. `.env` is gitignored; never commit
|
||||
- `alloy/config.alloy` + `alloy/docker-compose.agent.yml` (+
|
||||
`alloy/docker-compose.agent.beta.yml`, a vps2-only overlay adding beta's
|
||||
`applogs` mount) — the per-node shipper. `ALLOY_NODE` is the MACHINE
|
||||
(`vps1`|`vps2`|`desktop`) — that's a semantic change from when the fleet had
|
||||
one environment per node; `host` (`prod`|`beta`|`dev`), the ENVIRONMENT
|
||||
label every dashboard/alert slices on, is now derived per source instead,
|
||||
since vps2 alone carries two of them.
|
||||
- `caddy-grafana.conf` — the vps1 Caddy vhost, kept here for reference.
|
||||
- `.env.example` — copy to `.env` on vps1. `.env` is gitignored; never commit
|
||||
OAuth secrets or the admin password.
|
||||
|
||||
## Rules
|
||||
|
|
@ -40,8 +49,8 @@ Loki+Grafana, and `alloy/docker-compose.agent.yml` on each node. Read the root
|
|||
literal Discord webhook URL in the repo is a hard failure. A rule pointing at a
|
||||
missing refId is valid YAML, provisions cleanly, and then never fires — that
|
||||
silent no-op is what this check exists to prevent.
|
||||
- **Alerts go to Discord `#ops-alerts`, never email.** Beta's Grafana relays SMTP
|
||||
through prod's Postfix, so email dies exactly when prod does.
|
||||
- **Alerts go to Discord `#ops-alerts`, never email.** vps1's Grafana relays
|
||||
SMTP through vps2's Postfix, so email dies exactly when prod (on vps2) does.
|
||||
- **Every rule is LogQL** — there is no Prometheus anywhere in the fleet.
|
||||
Thresholds were derived from real Loki data and the working is in the comments
|
||||
beside each rule; re-derive before changing a number rather than guessing.
|
||||
|
|
|
|||
|
|
@ -7,41 +7,61 @@ over the private WireGuard mesh. This replaces the old in-repo
|
|||
persistent, queryable, fleet-wide log store and UI.
|
||||
|
||||
```
|
||||
prod (10.10.0.1) beta (10.10.0.2) desktop / LAN dev (10.10.0.3)
|
||||
┌────────────┐ ┌──────────────────┐ ┌────────────┐
|
||||
│ Alloy agent│──wg0──┐ │ Loki ◀── Alloy │ ┌──wg0│ Alloy agent│
|
||||
└────────────┘ └───▶│ Grafana (Caddy) │◀──┘ └────────────┘
|
||||
docker+caddy+app └──────────────────┘ docker+caddy+app
|
||||
logs central store + UI logs
|
||||
vps1 (10.10.0.2) vps2 (10.10.0.1) desktop (10.10.0.3)
|
||||
┌───────────────────────┐ ┌───────────────────────┐ ┌──────────────┐
|
||||
│ Loki ◀───── Alloy │◀──wg0───┤ Alloy agent │◀─wg0──┤ Alloy agent │
|
||||
│ Grafana (Caddy) │ │ prod + beta (Swarm) │ │ (no app │
|
||||
│ dev (compose) │◀──wg0───┤ Postfix, backups │ │ stack) │
|
||||
│ Forgejo, portfolio │ └───────────────────────┘ └──────────────┘
|
||||
└───────────────────────┘ docker+caddy+app logs ships no app logs
|
||||
central store + UI + dev
|
||||
```
|
||||
|
||||
- **vps1** is the monitoring host: Loki, Grafana, Forgejo, the portfolio site,
|
||||
and the **dev** environment (its own compose project, mesh-only, no public
|
||||
DNS). It does **not** run the beta *environment* — that now lives on vps2 — so
|
||||
any older doc that says "beta" meaning *this box* is stale and needs reading
|
||||
as vps1.
|
||||
- **vps2** runs **prod and beta** as two separate Docker Swarm stacks
|
||||
(`thermograph`, `thermograph-beta`), plus Postfix and backups. One shared
|
||||
TimescaleDB serves both, on separate roles/databases.
|
||||
- **desktop** hosts no Thermograph environment now (AI-model hosting + flex
|
||||
Swarm-worker capacity instead), so it ships no app logs.
|
||||
- **Loki** stores logs (filesystem, 30-day retention). Listens on the mesh IP
|
||||
`10.10.0.2:3100` — never public.
|
||||
- **Grafana** is the UI, fronted by beta's Caddy at `dashboard.thermograph.org`,
|
||||
- **Grafana** is the UI, fronted by vps1's Caddy at `dashboard.thermograph.org`,
|
||||
login via **Google SSO** (with a break-glass local admin); Loki is not exposed.
|
||||
- **Alloy** runs on each node and ships three sources to Loki: every Docker
|
||||
container's stdout/stderr, Caddy's host access logs, and the app's structured
|
||||
JSON logs (`errors`/`access`/`audit` `*.jsonl`, parsed so `level`/`tag`/`phase`
|
||||
become labels). Every line is tagged with its node (`host = prod|beta|dev`).
|
||||
become labels). Every line now carries two distinct labels: `host`
|
||||
(`prod`|`beta`|`dev`) is the ENVIRONMENT, derived *per source* (a container's
|
||||
stack prefix, the Caddy access-log filename, or which `applogs` volume a JSON
|
||||
line came out of) — because vps2 alone carries two environments, and one
|
||||
node-wide value would have stamped every beta container and log line as
|
||||
`prod`. `node` (`vps1`|`vps2`|`desktop`) is the MACHINE, stamped externally on
|
||||
everything an agent ships. See `alloy/config.alloy` for exactly how `host` is
|
||||
derived per source.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|------|------|
|
||||
| `docker-compose.yml` | the central Loki + Grafana stack (runs on beta) |
|
||||
| `docker-compose.yml` | the central Loki + Grafana stack (runs on vps1) |
|
||||
| `loki/config.yml` | Loki config (filesystem, retention) |
|
||||
| `grafana/provisioning/` | auto-wired Loki datasource + dashboard provider |
|
||||
| `grafana/provisioning/alerting/` | contact point, notification policy and alert rules — see [Alerting](#alerting) |
|
||||
| `grafana/dashboards/thermograph-logs.json` | the fleet-logs dashboard |
|
||||
| `alloy/config.alloy` | the per-node collector config |
|
||||
| `alloy/docker-compose.agent.yml` | runs the Alloy agent on a node |
|
||||
| `caddy-grafana.conf` | beta Caddy site block for the Grafana UI |
|
||||
| `alloy/docker-compose.agent.beta.yml` | overlay adding beta's `applogs` mount — vps2 only |
|
||||
| `caddy-grafana.conf` | vps1 Caddy site block for the Grafana UI |
|
||||
|
||||
## Deploy
|
||||
|
||||
> ### ⚠️ Merging is not deploying
|
||||
>
|
||||
> `/opt/observability` on beta (and prod) is a checkout of the **archived**
|
||||
> `/opt/observability` on vps1 (and vps2) is a checkout of the **archived**
|
||||
> `emi/thermograph-observability` repo. It can never `git pull` again — the
|
||||
> content now lives here, in the mono repo, under `observability/`. A previous
|
||||
> observability PR merged and had **zero live effect** for exactly this reason.
|
||||
|
|
@ -49,22 +69,23 @@ persistent, queryable, fleet-wide log store and UI.
|
|||
> Until someone re-points those checkouts at the mono repo, every change here
|
||||
> ships by hand: back up the live file with a UTC-timestamped suffix, `scp` the
|
||||
> new one up, `sudo cp` it into place, restart the specific container. Use
|
||||
> `sudo docker restart <container>` — **not `docker compose`**, which on prod
|
||||
> demands a `GF_SECURITY_ADMIN_PASSWORD` it has no `.env` to interpolate from.
|
||||
> (Beta's stack does have an `.env`, so compose works there — and is required
|
||||
> `sudo docker restart <container>` — **not `docker compose`**, which on vps2
|
||||
> demands a `GF_SECURITY_ADMIN_PASSWORD` it has no `.env` to interpolate from
|
||||
> (vps2 only runs the Alloy agent — there's no Grafana there to configure).
|
||||
> (vps1's stack does have an `.env`, so compose works there — and is required
|
||||
> when a change adds a new environment variable, since `docker restart` alone
|
||||
> will not pick one up.)
|
||||
|
||||
### 1. Central stack (on beta)
|
||||
### 1. Central stack (on vps1)
|
||||
|
||||
```bash
|
||||
# on beta (75.119.132.91):
|
||||
# on vps1 (75.119.132.91):
|
||||
git clone <this repo> thermograph-observability && cd thermograph-observability
|
||||
cp .env.example .env # set GF_SECURITY_ADMIN_PASSWORD
|
||||
docker compose up -d # Loki on 10.10.0.2:3100 + 127.0.0.1:3100; Grafana on 127.0.0.1:3000
|
||||
```
|
||||
|
||||
Expose the Grafana UI: point `dashboard.thermograph.org` at beta, append
|
||||
Expose the Grafana UI: point `dashboard.thermograph.org` at vps1, append
|
||||
`caddy-grafana.conf` to `/etc/caddy/Caddyfile`, `systemctl reload caddy`.
|
||||
|
||||
### Google SSO (Grafana + Forgejo)
|
||||
|
|
@ -80,7 +101,7 @@ OAuth 2.0 Client (type: *Web application*) and register both redirect URIs:
|
|||
(`allow_sign_up=false`), so provision your email as an admin once:
|
||||
|
||||
```bash
|
||||
# on beta, after the stack is up (uses the break-glass admin creds from .env):
|
||||
# on vps1, after the stack is up (uses the break-glass admin creds from .env):
|
||||
curl -s -u admin:"$GF_SECURITY_ADMIN_PASSWORD" -H 'Content-Type: application/json' \
|
||||
-X POST http://127.0.0.1:3000/api/admin/users \
|
||||
-d '{"name":"you","login":"you@gmail.com","email":"you@gmail.com","password":"'"$(openssl rand -base64 24)"'"}'
|
||||
|
|
@ -97,28 +118,50 @@ docker exec -u git <forgejo-container> forgejo admin auth add-oauth \
|
|||
--auto-discover-url https://accounts.google.com/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
### 2. Alloy agent (on every node — prod, beta, desktop)
|
||||
### 2. Alloy agent (on vps1 and vps2)
|
||||
|
||||
`ALLOY_NODE` names the MACHINE now, not the environment — vps2 alone runs two
|
||||
environments (prod and beta), so a node name can no longer stand in for a
|
||||
`host` value. `ALLOY_ENV` supplies the default environment label for anything
|
||||
on that node not attributable to a specific stack (Forgejo, Grafana, the
|
||||
portfolio site). `APPLOGS_VOLUME` is the node's primary `applogs` volume; vps2
|
||||
additionally needs `BETA_APPLOGS_VOLUME` and a second compose file, since it
|
||||
carries both environments' structured logs side by side.
|
||||
|
||||
Every node pushes to Loki over the mesh IP — a containerized agent can't reach
|
||||
the host's `127.0.0.1`, but it can reach vps1's wg0 address at
|
||||
`10.10.0.2:3100`.
|
||||
|
||||
```bash
|
||||
cd thermograph-observability/alloy
|
||||
# Every node (beta included) pushes to Loki over the mesh IP — a containerized
|
||||
# agent can't reach the host's 127.0.0.1, but it can reach beta's wg0 address:
|
||||
# beta: LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push ALLOY_NODE=beta
|
||||
# prod: LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push ALLOY_NODE=prod
|
||||
# desktop: LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push ALLOY_NODE=dev
|
||||
ALLOY_NODE=prod LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push \
|
||||
|
||||
# vps1 (dev + Forgejo + the monitoring stack itself):
|
||||
ALLOY_NODE=vps1 ALLOY_ENV=dev APPLOGS_VOLUME=thermograph-dev_applogs \
|
||||
LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push \
|
||||
docker compose -f docker-compose.agent.yml up -d
|
||||
|
||||
# vps2 (prod + beta) — note the SECOND file, adding beta's log volume:
|
||||
ALLOY_NODE=vps2 ALLOY_ENV=prod APPLOGS_VOLUME=thermograph_applogs \
|
||||
BETA_APPLOGS_VOLUME=thermograph-beta_applogs \
|
||||
LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push \
|
||||
docker compose -f docker-compose.agent.yml -f docker-compose.agent.beta.yml up -d
|
||||
```
|
||||
|
||||
On the LAN dev node the app's log volume is `thermograph-dev_applogs` (not
|
||||
`thermograph_applogs`) — edit the two references in `docker-compose.agent.yml`
|
||||
there. The mesh must already be up (see the main repo's `deploy/swarm/` /
|
||||
`INFRA.md`); Swarm is **not** required — the agents talk plain HTTP over wg0.
|
||||
The mesh must already be up (see the main repo's `deploy/swarm/` / `INFRA.md`);
|
||||
Swarm is **not** required for the agent itself — it talks plain HTTP over wg0.
|
||||
|
||||
<!-- TODO(cutover): desktop no longer runs a Thermograph app stack and ships no
|
||||
app logs, but it isn't clear whether it still runs an Alloy agent at all
|
||||
(for its AI-model / flex-worker container logs) or has been dropped from
|
||||
the fleet entirely. `ALLOY_NODE=desktop` is still a valid value in
|
||||
alloy/config.alloy and docker-compose.agent.yml, but no desktop deploy
|
||||
command is given here — confirm and fill in once decided. -->
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# From any mesh node, confirm Loki is receiving from every host:
|
||||
# From any mesh node, confirm Loki is receiving from every environment
|
||||
# (host = prod|beta|dev; a separate node/vps1|vps2|desktop label exists too):
|
||||
curl -s 'http://10.10.0.2:3100/loki/api/v1/label/host/values' # -> {"data":["beta","dev","prod"]}
|
||||
# Then open Grafana → the "Thermograph — Fleet Logs" dashboard.
|
||||
```
|
||||
|
|
@ -130,6 +173,13 @@ log volume by service, app error rate by tag, upstream 429 count, Caddy 5xx
|
|||
count, a per-node notifier-liveness indicator (from the heartbeat log), recent app errors, and a live all-container
|
||||
tail. Edit it in Grafana and re-export the JSON here to version a change.
|
||||
|
||||
<!-- TODO(cutover): the dashboard JSON isn't in this pass's scope, but check
|
||||
whether its "Node" selector still means `node` (the machine) now that the
|
||||
label has that meaning, or whether some panels actually want to filter by
|
||||
`host` (the environment) instead — vps2 mixes prod and beta under one
|
||||
node value, so a machine-only selector can no longer isolate one
|
||||
environment there. -->
|
||||
|
||||
## Alerting
|
||||
|
||||
Provisioned from `grafana/provisioning/alerting/`, mounted read-only into Grafana
|
||||
|
|
@ -146,8 +196,8 @@ Discord contact point. Deliberately **not email**:
|
|||
|
||||
- Grafana's factory default pointed at the literal string `<example@email.com>` —
|
||||
a paging path that had never delivered a message to anyone.
|
||||
- beta's Grafana relays SMTP through **prod's** Postfix at `10.10.0.1:25` (see
|
||||
`docker-compose.override.yml` on beta). Email alerts therefore travel through
|
||||
- vps1's Grafana relays SMTP through **vps2's** Postfix at `10.10.0.1:25` (see
|
||||
`docker-compose.override.yml` on vps1). Email alerts therefore travel through
|
||||
the box most likely to be on fire, and vanish exactly when they matter.
|
||||
- Discord is off-estate, works when prod is dead, and reaches a phone.
|
||||
|
||||
|
|
@ -157,7 +207,7 @@ Discord contact point. Deliberately **not email**:
|
|||
`#notes` / `#staff-chat` are for humans.
|
||||
|
||||
The webhook URL is a secret and is **not in this repo**: it comes from
|
||||
`DISCORD_ALERT_WEBHOOK_URL` in beta's gitignored `.env`, which compose passes to
|
||||
`DISCORD_ALERT_WEBHOOK_URL` in vps1's gitignored `.env`, which compose passes to
|
||||
Grafana and which Grafana interpolates when reading the provisioning file.
|
||||
Grafana refuses to start if it is unset.
|
||||
|
||||
|
|
@ -187,7 +237,7 @@ value is its absence**: nothing can report its own death, so if `#ops-alerts` ha
|
|||
been silent for more than a day, alerting is broken rather than the estate
|
||||
healthy. Set `isPaused: true` on it to opt out of that guarantee.
|
||||
|
||||
### Deploying an alerting change to beta
|
||||
### Deploying an alerting change to vps1
|
||||
|
||||
Grafana already mounts `./grafana/provisioning` read-only, so new files under
|
||||
`alerting/` need no compose change — but the **first** deploy does, because it
|
||||
|
|
@ -196,25 +246,26 @@ new environment variable.
|
|||
|
||||
```bash
|
||||
# run from the mono-repo root
|
||||
BETA="agent@75.119.132.91"
|
||||
VPS1="agent@75.119.132.91" # the monitoring host — NOT the beta environment,
|
||||
# which now runs on vps2; don't repoint this at it.
|
||||
SSH="ssh -i ~/.ssh/thermograph_agent_ed25519"
|
||||
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
|
||||
# 1. put the secret in beta's .env (once), then confirm it took:
|
||||
$SSH $BETA "grep -q '^DISCORD_ALERT_WEBHOOK_URL=' /opt/observability/.env && echo present || echo MISSING"
|
||||
# 1. put the secret in vps1's .env (once), then confirm it took:
|
||||
$SSH $VPS1 "grep -q '^DISCORD_ALERT_WEBHOOK_URL=' /opt/observability/.env && echo present || echo MISSING"
|
||||
|
||||
# 2. back up the live compose file, stage the new files, move them into place:
|
||||
$SSH $BETA "sudo cp -a /opt/observability/docker-compose.yml /opt/observability/docker-compose.yml.$TS \
|
||||
$SSH $VPS1 "sudo cp -a /opt/observability/docker-compose.yml /opt/observability/docker-compose.yml.$TS \
|
||||
&& rm -rf /tmp/obs-stage && mkdir -p /tmp/obs-stage/alerting"
|
||||
scp -i ~/.ssh/thermograph_agent_ed25519 observability/docker-compose.yml $BETA:/tmp/obs-stage/
|
||||
scp -i ~/.ssh/thermograph_agent_ed25519 observability/grafana/provisioning/alerting/*.yml $BETA:/tmp/obs-stage/alerting/
|
||||
$SSH $BETA "sudo mkdir -p /opt/observability/grafana/provisioning/alerting \
|
||||
scp -i ~/.ssh/thermograph_agent_ed25519 observability/docker-compose.yml $VPS1:/tmp/obs-stage/
|
||||
scp -i ~/.ssh/thermograph_agent_ed25519 observability/grafana/provisioning/alerting/*.yml $VPS1:/tmp/obs-stage/alerting/
|
||||
$SSH $VPS1 "sudo mkdir -p /opt/observability/grafana/provisioning/alerting \
|
||||
&& sudo cp /tmp/obs-stage/docker-compose.yml /opt/observability/ \
|
||||
&& sudo cp /tmp/obs-stage/alerting/*.yml /opt/observability/grafana/provisioning/alerting/"
|
||||
|
||||
# 3. recreate Grafana so it reads the new env var AND the new provisioning.
|
||||
# `docker restart` is NOT enough here — it will not pick up a new env var.
|
||||
$SSH $BETA "cd /opt/observability && docker compose up -d grafana"
|
||||
$SSH $VPS1 "cd /opt/observability && docker compose up -d grafana"
|
||||
```
|
||||
|
||||
Subsequent rule-only changes are just step 2 plus
|
||||
|
|
@ -227,13 +278,14 @@ new environment variable is involved.
|
|||
falls back to its default (email → `<example@email.com>`) root policy — i.e. no
|
||||
alerting, which is where this started.
|
||||
|
||||
Alerting lives on **beta only** — prod and dev run Alloy agents, not Grafana, so
|
||||
nothing in `alerting/` ever ships to them.
|
||||
Alerting lives on **vps1 only** — vps2 (prod + beta) and dev only run Alloy
|
||||
agents, not Grafana, so nothing in `alerting/` ever ships to them, even though
|
||||
dev happens to share a box with vps1's Grafana.
|
||||
|
||||
### Verify alerting
|
||||
|
||||
```bash
|
||||
# on beta, after deploying and restarting Grafana:
|
||||
# on vps1, after deploying and restarting Grafana:
|
||||
GFP=$(grep '^GF_SECURITY_ADMIN_PASSWORD=' /opt/observability/.env | cut -d= -f2-)
|
||||
WH=$(grep '^DISCORD_ALERT_WEBHOOK_URL=' /opt/observability/.env | cut -d= -f2-)
|
||||
# 1. all 12 rules loaded and healthy (health should be "ok", never "error"):
|
||||
|
|
@ -270,7 +322,7 @@ delete a receiver a policy still references). Either use the UI — Alerting →
|
|||
Contact points → `grafana-default-email` → Delete — or:
|
||||
|
||||
```bash
|
||||
# on beta. Reads the running Alertmanager config, drops the one receiver,
|
||||
# on vps1. Reads the running Alertmanager config, drops the one receiver,
|
||||
# writes it back. Verified against Grafana 11.6.1 (returns 202).
|
||||
GFP=$(grep '^GF_SECURITY_ADMIN_PASSWORD=' /opt/observability/.env | cut -d= -f2-)
|
||||
AM=localhost:3000/api/alertmanager/grafana/config/api/v1/alerts
|
||||
|
|
@ -289,7 +341,7 @@ This is cosmetic — leaving it costs nothing but a confusing row in the UI.
|
|||
|
||||
- **Loki is mesh-only** by design; only Grafana (with auth) is public.
|
||||
- Alloy reads the Docker socket read-only to discover containers.
|
||||
- Retention is 30 days on beta's disk — bump `loki/config.yml` if you want more.
|
||||
- Retention is 30 days on vps1's disk — bump `loki/config.yml` if you want more.
|
||||
- The app emits a `tag=heartbeat` line for the subscription notifier every
|
||||
~15m; the dashboard's "Notifier alive?" panel turns red if a node misses it.
|
||||
Extend the same `audit.log_heartbeat()` to any future background daemon.
|
||||
|
|
@ -319,6 +371,6 @@ This is cosmetic — leaving it costs nothing but a confusing row in the UI.
|
|||
is for, but it is a *manual* dead-man's switch: it relies on somebody noticing
|
||||
that the daily message stopped. A real external watchdog (an uptime pinger
|
||||
hitting a Grafana endpoint from off-estate) would close this properly.
|
||||
- **beta runs a `docker-compose.override.yml` that is not in this repo**, wiring
|
||||
Grafana's SMTP to prod's Postfix. It should be mirrored into the tracked
|
||||
- **vps1 runs a `docker-compose.override.yml` that is not in this repo**, wiring
|
||||
Grafana's SMTP to vps2's Postfix. It should be mirrored into the tracked
|
||||
compose file or deleted; alerting no longer depends on it.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,39 @@
|
|||
// Grafana Alloy — the log collector that runs on EVERY node (prod, beta, LAN
|
||||
// dev). It gathers three sources and ships them to the central Loki on beta:
|
||||
// Grafana Alloy — the log collector that runs on EVERY node (vps1, vps2). It
|
||||
// gathers three sources and ships them to the central Loki on vps1:
|
||||
//
|
||||
// 1. Every Docker container's stdout/stderr (app, db, and on beta forgejo)
|
||||
// via the Docker socket.
|
||||
// 1. Every Docker container's stdout/stderr (app, db, and on vps1 forgejo +
|
||||
// the monitoring stack itself) via the Docker socket.
|
||||
// 2. Caddy's host access logs (/var/log/caddy/*.log) — the reverse proxy runs
|
||||
// on the host, not in a container, so its logs aren't in Docker.
|
||||
// 3. The app's structured JSON logs (errors/access/audit *.jsonl) from the
|
||||
// `applogs` Docker volume, parsed so `level`/`tag`/`phase` become labels.
|
||||
// `applogs` Docker volumes, parsed so `level`/`tag`/`phase` become labels.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// `host` IS THE ENVIRONMENT, AND IT IS NO LONGER THE NODE
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every query, dashboard and alert in this estate slices on host="prod" |
|
||||
// "beta" | "dev". That label used to be an EXTERNAL label — one value stamped
|
||||
// on everything the agent shipped — which was exact while one node ran exactly
|
||||
// one environment.
|
||||
//
|
||||
// vps2 now runs prod AND beta. A single external label there would have marked
|
||||
// every beta container, every beta access log and every beta JSON line as
|
||||
// `prod`: not a cosmetic problem, but wrong data feeding the prod alert rules,
|
||||
// and beta's own alerts (NotifierHeartbeatMissingBeta) firing on prod's
|
||||
// traffic or never firing at all.
|
||||
//
|
||||
// So `host` is now derived PER SOURCE — from the container's stack prefix, from
|
||||
// the access-log filename, from which log volume a JSON line came out of — and
|
||||
// ALLOY_ENV supplies the fallback for anything on the node that isn't
|
||||
// environment-specific (Forgejo, Grafana, Loki, the portfolio site). The node
|
||||
// itself is still labelled, as `node`, because "which machine" is a real
|
||||
// question too — it is just a different question from "which environment".
|
||||
//
|
||||
// Per-node settings come from the environment (see docker-compose.agent.yml):
|
||||
// ALLOY_NODE — this node's name label (prod | beta | dev)
|
||||
// ALLOY_NODE — the MACHINE this agent runs on (vps1 | vps2 | desktop)
|
||||
// ALLOY_ENV — the default environment label for logs on this node that
|
||||
// aren't attributable to a specific stack (vps1 -> dev,
|
||||
// vps2 -> prod)
|
||||
// LOKI_URL — where to push (http://10.10.0.2:3100/loki/api/v1/push over wg0)
|
||||
|
||||
livedebugging { enabled = false }
|
||||
|
|
@ -43,9 +67,48 @@ discovery.relabel "containers" {
|
|||
source_labels = ["__meta_docker_container_label_com_docker_compose_service"]
|
||||
target_label = "service"
|
||||
}
|
||||
|
||||
// --- environment attribution -------------------------------------------
|
||||
// Relabel rules are applied in order and a later write to the same target
|
||||
// wins, so this is "default, then override with anything more specific".
|
||||
//
|
||||
// The regexes are fully anchored (Prometheus relabel semantics), which is
|
||||
// what keeps `/thermograph_web.1.x` and `/thermograph-beta_beta-web.1.x`
|
||||
// from matching each other: prod's stack prefix ends in an underscore,
|
||||
// beta's in `-beta_`.
|
||||
rule {
|
||||
source_labels = ["__meta_docker_container_name"]
|
||||
regex = ".*"
|
||||
target_label = "host"
|
||||
replacement = sys.env("ALLOY_ENV")
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__meta_docker_container_name"]
|
||||
regex = "/thermograph_.*"
|
||||
target_label = "host"
|
||||
replacement = "prod"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__meta_docker_container_name"]
|
||||
regex = "/thermograph-beta_.*"
|
||||
target_label = "host"
|
||||
replacement = "beta"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__meta_docker_container_name"]
|
||||
regex = "/thermograph-dev[-_].*"
|
||||
target_label = "host"
|
||||
replacement = "dev"
|
||||
}
|
||||
|
||||
// Drop noise/duplicate containers, per environment. Beta's equivalents are
|
||||
// listed explicitly: its LB is `thermograph-beta-lb` (which does NOT match
|
||||
// `thermograph-lb.*`) and its worker is `thermograph-beta_beta-worker`, so
|
||||
// omitting them would have quietly re-admitted exactly the healthz-poll
|
||||
// noise the prod entries exist to exclude.
|
||||
rule {
|
||||
source_labels = ["container"]
|
||||
regex = "(alloy|autoscaler|thermograph-test_.*|thermograph-lb|thermograph_worker).*"
|
||||
regex = "(alloy|autoscaler|thermograph-test_.*|thermograph-lb|thermograph-beta-lb|thermograph_worker|thermograph-beta_beta-worker).*"
|
||||
action = "drop"
|
||||
}
|
||||
}
|
||||
|
|
@ -94,11 +157,48 @@ loki.process "caddy" {
|
|||
expression = "(?i)(semrushbot|claudebot|ahrefsbot|yandexbot|bytespider|mj12bot|petalbot)"
|
||||
drop_counter_reason = "crawler"
|
||||
}
|
||||
|
||||
// Attribute each access log to an environment BY FILENAME. On vps2 one
|
||||
// Caddy fronts both thermograph.org and beta.thermograph.org, so a single
|
||||
// node-wide label would file every beta request under prod.
|
||||
//
|
||||
// By filename rather than by parsing `request.host` out of the JSON: the
|
||||
// site block that writes the file is the same place the hostname is
|
||||
// declared (see infra/deploy/Caddyfile.vps2), one label costs nothing to
|
||||
// evaluate, and it keeps working for a non-JSON log format.
|
||||
stage.static_labels {
|
||||
values = { host = sys.env("ALLOY_ENV") }
|
||||
}
|
||||
stage.match {
|
||||
selector = "{filename=~\".*/beta[.]log\"}"
|
||||
|
||||
stage.static_labels {
|
||||
values = { host = "beta" }
|
||||
}
|
||||
}
|
||||
stage.match {
|
||||
selector = "{filename=~\".*/thermograph[.]log\"}"
|
||||
|
||||
stage.static_labels {
|
||||
values = { host = "prod" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. App structured JSON logs (errors / access / audit) -----------------------
|
||||
// Mounted read-only from the app's `applogs` volume at /applogs (see the agent
|
||||
// compose). Lift `level`/`tag`/`phase` out of the JSON so they're queryable.
|
||||
// Mounted read-only from the app's `applogs` volumes (see the agent compose).
|
||||
// Lift `level`/`tag`/`phase` out of the JSON so they're queryable.
|
||||
//
|
||||
// TWO MOUNTS, NOT ONE. Each environment has its own applogs volume, and on vps2
|
||||
// both exist side by side:
|
||||
//
|
||||
// /applogs this node's primary environment (vps2 -> prod, vps1 -> dev)
|
||||
// /applogs-beta beta's, mounted on vps2 only
|
||||
//
|
||||
// A single mount was structurally unable to carry both: whichever volume the
|
||||
// agent bound, the other environment's structured logs would never reach Loki
|
||||
// at all — silently, since a missing file source is not an error. Every alert
|
||||
// built on the JSON stream (the notifier heartbeats in particular) reads this.
|
||||
local.file_match "app_jsonl" {
|
||||
path_targets = [{ __path__ = "/applogs/**/*.jsonl", job = "app-json" }]
|
||||
sync_period = "1m"
|
||||
|
|
@ -122,7 +222,38 @@ loki.process "app_jsonl" {
|
|||
}
|
||||
// A record with no explicit level: an error-folder line is an error, else info.
|
||||
stage.static_labels {
|
||||
values = { source = "app" }
|
||||
values = { source = "app", host = sys.env("ALLOY_ENV") }
|
||||
}
|
||||
stage.labels {
|
||||
values = { level = "", tag = "", phase = "" }
|
||||
}
|
||||
}
|
||||
|
||||
// Beta's structured logs. Absent on vps1 (the mount simply isn't there), where
|
||||
// this matches nothing and costs nothing.
|
||||
local.file_match "app_jsonl_beta" {
|
||||
path_targets = [{ __path__ = "/applogs-beta/**/*.jsonl", job = "app-json" }]
|
||||
sync_period = "1m"
|
||||
}
|
||||
|
||||
loki.source.file "app_jsonl_beta" {
|
||||
targets = local.file_match.app_jsonl_beta.targets
|
||||
forward_to = [loki.process.app_jsonl_beta.receiver]
|
||||
|
||||
file_watch {
|
||||
min_poll_frequency = "2s"
|
||||
max_poll_frequency = "10s"
|
||||
}
|
||||
}
|
||||
|
||||
loki.process "app_jsonl_beta" {
|
||||
forward_to = [loki.write.central.receiver]
|
||||
|
||||
stage.json {
|
||||
expressions = { level = "level", tag = "tag", phase = "phase", status = "status" }
|
||||
}
|
||||
stage.static_labels {
|
||||
values = { source = "app", host = "beta" }
|
||||
}
|
||||
stage.labels {
|
||||
values = { level = "", tag = "", phase = "" }
|
||||
|
|
@ -134,7 +265,9 @@ loki.write "central" {
|
|||
endpoint {
|
||||
url = sys.env("LOKI_URL")
|
||||
}
|
||||
// Every line from this node is stamped with its node name, so one Grafana
|
||||
// view can slice prod vs beta vs dev.
|
||||
external_labels = { host = sys.env("ALLOY_NODE") }
|
||||
// `node` — the MACHINE, stamped on everything this agent ships. `host` is
|
||||
// deliberately NOT here: it is the ENVIRONMENT, set per source above,
|
||||
// because vps2 carries two of them and an external label cannot vary per
|
||||
// stream. Setting host here would override that work on every line.
|
||||
external_labels = { node = sys.env("ALLOY_NODE") }
|
||||
}
|
||||
|
|
|
|||
24
observability/alloy/docker-compose.agent.beta.yml
Normal file
24
observability/alloy/docker-compose.agent.beta.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Overlay for vps2 ONLY — the node that runs two environments.
|
||||
#
|
||||
# docker compose -f docker-compose.agent.yml -f docker-compose.agent.beta.yml up -d
|
||||
#
|
||||
# Adds beta's applogs volume at /applogs-beta, which config.alloy reads as a
|
||||
# separate source and labels host="beta". Everything else — the Docker socket,
|
||||
# the Caddy logs, the primary /applogs mount — comes from the base file.
|
||||
#
|
||||
# This is an overlay rather than another variable in the base file because the
|
||||
# /applogs-beta source is labelled beta UNCONDITIONALLY. On a node where beta
|
||||
# does not run, there is no correct volume to point it at: aiming it at the
|
||||
# node's own logs would republish them as beta, manufacturing data for an
|
||||
# environment that isn't there. "Absent" is the only honest configuration, and
|
||||
# a missing overlay expresses that exactly.
|
||||
|
||||
services:
|
||||
alloy:
|
||||
volumes:
|
||||
- beta_applogs:/applogs-beta:ro
|
||||
|
||||
volumes:
|
||||
beta_applogs:
|
||||
external: true
|
||||
name: ${BETA_APPLOGS_VOLUME:?set BETA_APPLOGS_VOLUME (thermograph-beta_applogs)}
|
||||
|
|
@ -1,22 +1,41 @@
|
|||
# The Alloy log-collector agent. Runs on EACH node (prod, beta, LAN dev) — one
|
||||
# per node — reading that node's Docker containers, Caddy logs, and the app's
|
||||
# JSON logs, and shipping them to the central Loki on beta over the mesh.
|
||||
# The Alloy log-collector agent. Runs on EACH node (vps1, vps2) — one per node
|
||||
# — reading that node's Docker containers, Caddy logs, and the app's JSON logs,
|
||||
# and shipping them to the central Loki on vps1 over the mesh.
|
||||
#
|
||||
# Per-node config comes from the environment (an .env file next to this, or
|
||||
# exported before `up`):
|
||||
# ALLOY_NODE this node's label: prod | beta | dev
|
||||
# ALLOY_NODE the MACHINE: vps1 | vps2 | desktop
|
||||
# ALLOY_ENV the default ENVIRONMENT label for logs on this node that
|
||||
# aren't attributable to a specific stack (Forgejo, Grafana,
|
||||
# the portfolio site): vps1 -> dev, vps2 -> prod
|
||||
# APPLOGS_VOLUME this node's primary applogs volume
|
||||
# LOKI_URL push endpoint. All nodes push over the mesh:
|
||||
# http://10.10.0.2:3100/loki/api/v1/push
|
||||
#
|
||||
# Deploy on a node:
|
||||
# ALLOY_NODE=prod LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push \
|
||||
# Deploy on vps1 (dev + Forgejo + the monitoring stack itself):
|
||||
# ALLOY_NODE=vps1 ALLOY_ENV=dev APPLOGS_VOLUME=thermograph-dev_applogs \
|
||||
# LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push \
|
||||
# docker compose -f docker-compose.agent.yml up -d
|
||||
#
|
||||
# The app's JSON logs are read from its `applogs` Docker volume, named
|
||||
# `<project>_applogs`. prod and beta run the app under the compose project
|
||||
# `thermograph`, so the external volume below is `thermograph_applogs`. The LAN
|
||||
# dev stack uses the project `thermograph-dev` — on that node, change the two
|
||||
# `thermograph_applogs` references below to `thermograph-dev_applogs`.
|
||||
# Deploy on vps2 (prod + beta) — note the SECOND file, which adds beta's log
|
||||
# volume:
|
||||
# ALLOY_NODE=vps2 ALLOY_ENV=prod APPLOGS_VOLUME=thermograph_applogs \
|
||||
# BETA_APPLOGS_VOLUME=thermograph-beta_applogs \
|
||||
# LOKI_URL=http://10.10.0.2:3100/loki/api/v1/push \
|
||||
# docker compose -f docker-compose.agent.yml -f docker-compose.agent.beta.yml up -d
|
||||
#
|
||||
# WHY THE VOLUME NAME IS A VARIABLE NOW, AND WHY BETA IS AN OVERLAY. The name
|
||||
# used to be the literal `thermograph_applogs`, with a comment telling you to
|
||||
# hand-edit two lines on the dev node. That was survivable while one node ran
|
||||
# one environment. vps2 runs two, each with its own applogs volume, and a single
|
||||
# hardcoded mount there would ship one environment's structured logs and
|
||||
# silently drop the other's.
|
||||
#
|
||||
# Beta's mount is a separate overlay file rather than a second variable in this
|
||||
# one because the second mount is labelled host="beta" unconditionally. Pointing
|
||||
# it at dev's volume on vps1 just to satisfy compose would ship every dev log
|
||||
# line a second time labelled as beta — inventing an environment's worth of
|
||||
# fake data on a box beta does not run on.
|
||||
|
||||
services:
|
||||
alloy:
|
||||
|
|
@ -27,7 +46,8 @@ services:
|
|||
- --storage.path=/var/lib/alloy/data
|
||||
- --server.http.listen-addr=0.0.0.0:12345
|
||||
environment:
|
||||
ALLOY_NODE: ${ALLOY_NODE:?set ALLOY_NODE (prod|beta|dev)}
|
||||
ALLOY_NODE: ${ALLOY_NODE:?set ALLOY_NODE (vps1|vps2|desktop) — the MACHINE}
|
||||
ALLOY_ENV: ${ALLOY_ENV:?set ALLOY_ENV (dev|beta|prod) — this node's default environment label}
|
||||
LOKI_URL: ${LOKI_URL:?set LOKI_URL}
|
||||
volumes:
|
||||
- ./config.alloy:/etc/alloy/config.alloy:ro
|
||||
|
|
@ -36,13 +56,16 @@ services:
|
|||
# Caddy's host access logs (runs on the host, not a container).
|
||||
- /var/log/caddy:/var/log/caddy:ro
|
||||
# The app's structured JSON logs, straight off its named volume.
|
||||
- thermograph_applogs:/applogs:ro
|
||||
- applogs:/applogs:ro
|
||||
- alloy_data:/var/lib/alloy/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# The app's existing log volume (created by the app's own stack). external:true
|
||||
# means compose references it, never creates or deletes it.
|
||||
thermograph_applogs:
|
||||
# means compose references it, never creates or deletes it. The NAME varies by
|
||||
# node — see the header — while the mount path stays /applogs, which is what
|
||||
# lets one config.alloy serve every node.
|
||||
applogs:
|
||||
external: true
|
||||
name: ${APPLOGS_VOLUME:?set APPLOGS_VOLUME (e.g. thermograph_applogs on vps2, thermograph-dev_applogs on vps1)}
|
||||
alloy_data: {}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# Append to beta's /etc/caddy/Caddyfile (beta is the monitoring host), then
|
||||
# `systemctl reload caddy`. Grafana publishes to 127.0.0.1:3000; this fronts it
|
||||
# with TLS, same pattern as beta's other site blocks. Login is Google SSO (with a
|
||||
# break-glass local admin); Loki (the log store, on 10.10.0.2:3100) is NOT proxied
|
||||
# here and stays mesh-only.
|
||||
# Append to vps1's /etc/caddy/Caddyfile (vps1 is the monitoring host — NOT the
|
||||
# beta environment, which runs on vps2 now), then `systemctl reload caddy`.
|
||||
# Grafana publishes to 127.0.0.1:3000; this fronts it with TLS, same pattern as
|
||||
# vps1's other site blocks. Login is Google SSO (with a break-glass local
|
||||
# admin); Loki (the log store, on 10.10.0.2:3100) is NOT proxied here and stays
|
||||
# mesh-only.
|
||||
#
|
||||
# dashboard.thermograph.org's A record must already point at beta (75.119.132.91).
|
||||
# dashboard.thermograph.org's A record must already point at vps1 (75.119.132.91).
|
||||
|
||||
dashboard.thermograph.org {
|
||||
encode zstd gzip
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
# The central observability stack: Loki (log store) + Grafana (UI). Runs on ONE
|
||||
# node — the monitoring host, `beta` (75.119.132.91 / mesh 10.10.0.2) — because
|
||||
# beta is an always-on VPS with a public Caddy already fronting it. Every node's
|
||||
# Alloy agent (see alloy/) ships logs here; agents on prod and the desktop reach
|
||||
# Loki over the WireGuard mesh, so Loki must listen on the mesh interface.
|
||||
# node — the monitoring host, `vps1` (75.119.132.91 / mesh 10.10.0.2) — because
|
||||
# vps1 is an always-on VPS with a public Caddy already fronting it. This is NOT
|
||||
# where the beta *environment* lives (that's vps2 now, alongside prod, as two
|
||||
# Swarm stacks) — don't let "beta" in older docs send you looking for Grafana
|
||||
# there. Every node's Alloy agent (see alloy/) ships logs here; agents on vps2
|
||||
# and desktop reach Loki over the WireGuard mesh, so Loki must listen on the
|
||||
# mesh interface.
|
||||
#
|
||||
# Deploy (on beta): docker compose up -d
|
||||
# Grafana is proxied by beta's Caddy at dashboard.thermograph.org (see README);
|
||||
# Deploy (on vps1): docker compose up -d
|
||||
# Grafana is proxied by vps1's Caddy at dashboard.thermograph.org (see README);
|
||||
# Loki is NOT public — it binds the mesh IP only, reachable to agents over wg0.
|
||||
|
||||
services:
|
||||
|
|
@ -19,7 +22,7 @@ services:
|
|||
# Mesh-only: agents on prod (10.10.0.1) and desktop (10.10.0.3) push here
|
||||
# over wg0. Never published on the public interface.
|
||||
- "10.10.0.2:3100:3100"
|
||||
# Also localhost, so the beta-local Alloy agent and curl checks can reach it.
|
||||
# Also localhost, so the vps1-local Alloy agent and curl checks can reach it.
|
||||
- "127.0.0.1:3100:3100"
|
||||
restart: unless-stopped
|
||||
|
||||
|
|
@ -61,7 +64,7 @@ services:
|
|||
# --- Alerting --------------------------------------------------------------
|
||||
# The Discord webhook that grafana/provisioning/alerting/contact-points.yml
|
||||
# interpolates as $DISCORD_ALERT_WEBHOOK_URL. It is a secret (holding it is
|
||||
# enough to post in the channel), so it lives only in beta's .env.
|
||||
# enough to post in the channel), so it lives only in vps1's .env.
|
||||
# Required, not defaulted: a Grafana that comes up with an empty webhook
|
||||
# looks perfectly healthy and pages nobody, which is the failure mode this
|
||||
# whole config exists to end. Better to refuse to start.
|
||||
|
|
@ -71,7 +74,7 @@ services:
|
|||
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
ports:
|
||||
# Host-local; beta's Caddy reverse-proxies to it. Not public directly.
|
||||
# Host-local; vps1's Caddy reverse-proxies to it. Not public directly.
|
||||
- "127.0.0.1:3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
|
|
|
|||
|
|
@ -3,21 +3,22 @@
|
|||
#
|
||||
# WHY NOT EMAIL. The Grafana factory default routes to the literal string
|
||||
# "<example@email.com>", i.e. nowhere. Fixing it by pointing at a real mailbox
|
||||
# would still be wrong: beta's Grafana relays SMTP through prod's Postfix at
|
||||
# 10.10.0.1:25 (see the live docker-compose.override.yml on beta), so email
|
||||
# would still be wrong: vps1's Grafana (the monitoring host — not the beta
|
||||
# environment, which runs on vps2) relays SMTP through vps2's Postfix at
|
||||
# 10.10.0.1:25 (see the live docker-compose.override.yml on vps1), so email
|
||||
# alerts travel *through the box most likely to be on fire* and are silently
|
||||
# lost whenever Postfix is down — which is exactly when you need them. Discord
|
||||
# is off-estate: it works when prod is dead, and it works from a phone.
|
||||
#
|
||||
# THE WEBHOOK URL IS A SECRET. Anyone holding it can post into the channel, so
|
||||
# it is NOT in this repo. It is read from the Grafana process environment, which
|
||||
# docker-compose.yml feeds from beta's gitignored .env (see .env.example).
|
||||
# docker-compose.yml feeds from vps1's gitignored .env (see .env.example).
|
||||
# Grafana expands $VAR / $__env{VAR} when it reads provisioning files; if the
|
||||
# variable is unset the contact point ends up with a literal "$DISCORD_..."
|
||||
# string and every notification fails — see README "Verify alerting".
|
||||
#
|
||||
# To rotate: make a new webhook on the #ops-alerts channel, replace the value in
|
||||
# beta's .env, `docker restart observability-grafana-1`, delete the old webhook
|
||||
# vps1's .env, `docker restart observability-grafana-1`, delete the old webhook
|
||||
# in Discord.
|
||||
apiVersion: 1
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@
|
|||
# label — only `container`, which includes a per-task suffix that changes on
|
||||
# every redeploy (thermograph_web.1.<taskid>). So prod services are matched as
|
||||
# container=~"thermograph_<name>.+" with job="docker". Do not use `service=`
|
||||
# here; it only works on the compose hosts (beta, dev).
|
||||
# here; it only works on dev, now the ONLY compose host left — beta is Swarm
|
||||
# too (stack `thermograph-beta`, since both prod and beta run on vps2), so a
|
||||
# beta-targeted rule needs its own regex against beta's prefixed container
|
||||
# names (thermograph-beta_beta-web.1.<taskid> etc.), not a bare `service=`
|
||||
# match either.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# NOT ALERTABLE, ON PURPOSE
|
||||
|
|
@ -69,6 +73,10 @@ groups:
|
|||
# DATA: 15-minute windows over 24h ranged 35 -> 301 lines, minimum 35,
|
||||
# and never once reached zero — including *during* the outage (72), when
|
||||
# Caddy was happily logging 503s. Zero is unambiguous.
|
||||
#
|
||||
# The description names vps1, not "beta": the mesh path that matters here
|
||||
# is the one to the MONITORING host, which is vps1. Beta is on vps2 and
|
||||
# has nothing to do with whether these logs arrive.
|
||||
- uid: tg_prod_edge_dark
|
||||
title: ProdEdgeDark
|
||||
condition: C
|
||||
|
|
@ -81,7 +89,8 @@ groups:
|
|||
Nothing has reached thermograph.org's reverse proxy in 15 minutes
|
||||
(healthy floor is 35 lines/15m, and it stayed at 72 even during the
|
||||
04:20Z outage). Either prod is down, Caddy is down, prod's Alloy
|
||||
agent stopped shipping, or the WireGuard mesh to beta is broken.
|
||||
agent stopped shipping, or the WireGuard mesh to vps1 (where Loki
|
||||
runs) is broken.
|
||||
This rule also fires on Loki NoData/errors, so it doubles as the
|
||||
"we have lost observability" alarm — if it is the only thing firing,
|
||||
suspect the pipeline before suspecting the app.
|
||||
|
|
@ -397,9 +406,10 @@ groups:
|
|||
# means two consecutive empty evaluations, so roughly 25 minutes of real
|
||||
# silence before it pages — about one and a half missed beats.
|
||||
#
|
||||
# dev is deliberately excluded: the LAN box had zero heartbeats for the
|
||||
# first 10.5h of the backtest window because it was simply off. Alerting
|
||||
# on dev would mean an alert that is firing more often than not.
|
||||
# dev is deliberately excluded: the dev box (mesh-only, on vps1) had zero
|
||||
# heartbeats for the first 10.5h of the backtest window because it was
|
||||
# simply off. Alerting on dev would mean an alert that is firing more
|
||||
# often than not.
|
||||
#
|
||||
# MUST stay filtered to daemon="subscription-notifier". The selector was
|
||||
# originally bare `tag="heartbeat"`, which was unambiguous while the
|
||||
|
|
@ -833,10 +843,10 @@ groups:
|
|||
|
||||
# -- 12 ----------------------------------------------------------------
|
||||
# Nothing in this file can tell you that Grafana itself has stopped, or
|
||||
# that beta is down, or that the Discord webhook was revoked — a monitoring
|
||||
# system cannot report its own death. So this rule fires permanently and
|
||||
# by design, throttled by the notification policy to exactly one Discord
|
||||
# message per 24 hours.
|
||||
# that vps1 (the monitoring host) is down, or that the Discord webhook was
|
||||
# revoked — a monitoring system cannot report its own death. So this rule
|
||||
# fires permanently and by design, throttled by the notification policy
|
||||
# to exactly one Discord message per 24 hours.
|
||||
#
|
||||
# HOW TO USE IT: if #ops-alerts has been silent for more than a day, the
|
||||
# alerting pipeline is broken, not the estate healthy. That one daily
|
||||
|
|
@ -844,6 +854,7 @@ groups:
|
|||
# rules are still capable of reaching you.
|
||||
#
|
||||
# To silence it (and lose that guarantee), set isPaused: true.
|
||||
#
|
||||
- uid: tg_alerting_watchdog
|
||||
title: AlertingWatchdog
|
||||
condition: C
|
||||
|
|
@ -854,13 +865,20 @@ groups:
|
|||
summary: 'Alerting pipeline is alive (daily heartbeat — not a fault)'
|
||||
description: >-
|
||||
This is the dead-man's switch and it is supposed to be firing. It
|
||||
proves Grafana on beta is evaluating rules, Loki is answering, and
|
||||
proves Grafana on vps1 is evaluating rules, Loki is answering, and
|
||||
the Discord webhook still works. Seeing it once a day is correct.
|
||||
NOT seeing it for over a day means alerting itself is down and no
|
||||
other rule can reach you — check observability-grafana-1 on beta.
|
||||
other rule can reach you — check observability-grafana-1 on vps1.
|
||||
labels:
|
||||
severity: watchdog
|
||||
host: beta
|
||||
# `node`, not `host`: every other rule's `host` names an ENVIRONMENT
|
||||
# (prod/beta/dev), and this rule is about the alerting pipeline
|
||||
# itself, which is a machine. It used to be filed `host: beta` back
|
||||
# when the monitoring box and the beta environment were the same
|
||||
# machine — they are not any more, and that label would now read as a
|
||||
# claim about beta. Routing is unaffected: notification-policies.yml
|
||||
# matches on `severity` only and uses `host` just for grouping.
|
||||
node: vps1
|
||||
class: meta
|
||||
isPaused: false
|
||||
data:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# Loki — the single log store for the whole Thermograph fleet (prod, beta, LAN
|
||||
# dev). Every node's Alloy agent pushes here over the WireGuard mesh; Grafana
|
||||
# reads from here. Single-binary, filesystem-backed: no object storage, no
|
||||
# clustering — right for a three-node hobby fleet, and everything lives on one
|
||||
# volume you can back up or blow away.
|
||||
# Loki — the single log store for the whole Thermograph fleet: prod and beta
|
||||
# (both on vps2, as separate Swarm stacks) and dev (vps1, mesh-only, its own
|
||||
# compose project). Every node's Alloy agent pushes here over the WireGuard
|
||||
# mesh; Grafana reads from here. Single-binary, filesystem-backed: no object
|
||||
# storage, no clustering — right for a three-node hobby fleet, and everything
|
||||
# lives on one volume you can back up or blow away.
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
|
|
|
|||
Loading…
Reference in a new issue