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.
278 lines
12 KiB
YAML
278 lines
12 KiB
YAML
# 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: {}
|