Merge pull request 'Promote main to release: vault consolidation, dev + Centralis vaults' (#79) from main into release
This commit is contained in:
commit
4fce9e1540
11 changed files with 1092 additions and 105 deletions
|
|
@ -47,6 +47,7 @@ old in-workflow `ci-cd.yml` (retired along with the rest of `.github/`).
|
|||
| `.forgejo/workflows/deploy-dev.yml` | build + deploy on pushes to `dev` (direct, or a PR merge) |
|
||||
| `.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/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 |
|
||||
|
|
@ -119,10 +120,54 @@ Bootstrap (or rebuild) it by hand:
|
|||
bash deploy/provision-dev-lan.sh
|
||||
```
|
||||
|
||||
Config: port/base path are baked into the unit from `deploy/deploy-dev.sh`
|
||||
(`PORT`, `THERMOGRAPH_BASE`). Change them there and redeploy. The dedicated
|
||||
checkout at `~/thermograph-dev` is separate from your working tree, so a deploy
|
||||
never touches uncommitted edits in `~/Code/Thermograph`.
|
||||
The dedicated checkout at `~/thermograph-dev` is separate from your working tree,
|
||||
so a deploy never touches uncommitted edits in `~/Code/Thermograph`.
|
||||
|
||||
## 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 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.
|
||||
|
||||
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
|
||||
`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
|
||||
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:
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
CI paths do.
|
||||
|
||||
## Before monitoring a PR to land
|
||||
|
||||
|
|
@ -168,5 +213,8 @@ 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`.
|
||||
|
||||
For a quick terminal check without Grafana, the app still serves raw counters at
|
||||
the gated `GET /api/v2/metrics` route (loopback-only; set
|
||||
`THERMOGRAPH_METRICS_TOKEN` to read it over an SSH tunnel).
|
||||
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`.)
|
||||
|
|
|
|||
|
|
@ -50,25 +50,82 @@ export COMPOSE_FILE="docker-compose.yml:docker-compose.dev.yml"
|
|||
# var wins over the compose-file `name:` key, preserving that split.
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-thermograph-dev}"
|
||||
|
||||
# deploy.sh needs POSTGRES_PASSWORD to interpolate the compose file (both to init
|
||||
# the db container and to build backend's THERMOGRAPH_DATABASE_URL) -- normally
|
||||
# supplied by /etc/thermograph.env via render-secrets.sh's SOPS render, but dev has
|
||||
# no entry in deploy/secrets/ (only common/beta/prod.yaml exist), so
|
||||
# render_thermograph_secrets degrades to a no-op here (see render-secrets.sh) and
|
||||
# /etc/thermograph.env likely doesn't exist on a fresh dev box either. Postgres is
|
||||
# loopback-only inside the compose network on dev (never published, no real
|
||||
# credentials on this box), so a fixed default is fine -- same value the monorepo's
|
||||
# deploy-dev.sh used historically. Override via the environment if you ever want a
|
||||
# different one; this only sets it when nothing else already has.
|
||||
# --- Secrets -----------------------------------------------------------------
|
||||
# dev renders from the SOPS vault the same way prod and beta do -- same files, same
|
||||
# tool, same edit/commit/deploy loop -- with two dev-only adjustments. Both are
|
||||
# expressed as environment that render-secrets.sh already parameterises, so nothing
|
||||
# about the rendering logic is forked. See deploy/secrets/README.md.
|
||||
|
||||
# (1) dev renders deploy/secrets/dev.yaml ALONE; it does NOT layer common.yaml.
|
||||
#
|
||||
# common.yaml is the internet-facing fleet's SHARED PRODUCTION credential set: the
|
||||
# registry token, both S3 keypairs (the bucket keypair is read-write, and that
|
||||
# bucket also holds prod's database backups), the VAPID private key that signs push
|
||||
# to real subscribers, the IndexNow key, the metrics token. Layering it here would
|
||||
# write all of them in plaintext to /etc/thermograph.env on the box that is also the
|
||||
# Forgejo CI runner, and hand them to every container in the dev stack through
|
||||
# `env_file:` -- that is, to whatever unreviewed branch dev happens to be running.
|
||||
# It would also silently apply THERMOGRAPH_COOKIE_SECURE=1, which is correct for the
|
||||
# TLS hosts and breaks login on dev's plain-HTTP LAN URL.
|
||||
#
|
||||
# dev.yaml is self-contained instead: it carries dev's own copy of everything dev
|
||||
# genuinely needs, and nothing else.
|
||||
export THERMOGRAPH_SECRETS_SKIP_COMMON=1
|
||||
|
||||
# (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.
|
||||
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"
|
||||
fi
|
||||
|
||||
# Fail closed rather than leak. If this box is provisioned to render (an env marker
|
||||
# AND a key) but the render-secrets.sh in this checkout predates
|
||||
# THERMOGRAPH_SECRETS_SKIP_COMMON, the render would quietly concatenate common.yaml
|
||||
# into dev's /etc/thermograph.env -- exactly the leak (1) exists to prevent, with a
|
||||
# success exit code. Checking a sibling script for the flag is blunt, but the two
|
||||
# files are versioned together in this same checkout, and the alternative failure
|
||||
# mode is unreviewed code running with the estate's object-storage keys. Remove this
|
||||
# once the flag is unconditionally present in render-secrets.sh.
|
||||
_render_sh="$(dirname "$0")/render-secrets.sh"
|
||||
_secrets_marker="${THERMOGRAPH_SECRETS_ENV_FILE:-/etc/thermograph/secrets-env}"
|
||||
_age_key="${THERMOGRAPH_AGE_KEY:-/etc/thermograph/age.key}"
|
||||
if [ -s "$_secrets_marker" ] && [ -f "$_age_key" ] && [ -f "$_render_sh" ] \
|
||||
&& ! grep -q 'THERMOGRAPH_SECRETS_SKIP_COMMON' "$_render_sh"; then
|
||||
echo "!! This box is provisioned to render SOPS secrets (env marker + age key)," >&2
|
||||
echo "!! but $_render_sh does not honour" >&2
|
||||
echo "!! THERMOGRAPH_SECRETS_SKIP_COMMON, so it would render common.yaml -- the" >&2
|
||||
echo "!! fleet's shared PRODUCTION credentials -- into /etc/thermograph.env on the" >&2
|
||||
echo "!! CI-runner box. Refusing to deploy." >&2
|
||||
echo "!! Fix: update deploy/render-secrets.sh, or remove $_secrets_marker to fall" >&2
|
||||
echo "!! back to the un-vaulted dev path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# deploy.sh needs POSTGRES_PASSWORD to interpolate the compose file (both to init the
|
||||
# db container and to build backend's THERMOGRAPH_DATABASE_URL). On a provisioned dev
|
||||
# box it arrives from dev.yaml via the render above -- deploy.sh sources
|
||||
# /etc/thermograph.env AFTER calling the renderer, so the vault value wins over this
|
||||
# line. This default is the fallback for the un-vaulted paths that remain: a fresh
|
||||
# box before provisioning, and a bare `make dev-up`. It is the same value dev.yaml
|
||||
# holds and the value dev's existing pgdata volume was initialized with -- keep the
|
||||
# two in step, and note that changing it needs an ALTER ROLE against the running
|
||||
# database as well (see deploy/secrets/README.md), not just an edit.
|
||||
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-thermograph-dev}"
|
||||
|
||||
# REGISTRY_TOKEN (for `docker login` in deploy.sh) is a REAL credential -- read
|
||||
# access to git.thermograph.org's registry -- and deliberately has NO default here.
|
||||
# Until dev gets its own deploy/secrets/dev.yaml entry (see deploy/secrets/README.md)
|
||||
# it must come from the calling environment: exported by hand for a manual run, or
|
||||
# injected by whatever eventually SSHes in to trigger a dev deploy (analogous to
|
||||
# how deploy.yml supplies it for prod/beta today). deploy.sh's own `docker login`
|
||||
# step fails loudly if it's unset or wrong -- nothing silently no-ops.
|
||||
# REGISTRY_TOKEN (for `docker login` in deploy.sh) is deliberately NOT in dev.yaml
|
||||
# and has no default here. dev pulls with the persistent `docker login` credential
|
||||
# already in this host's docker config -- the same arrangement the prod/beta CI
|
||||
# deploy paths use, and the reason deploy.sh's login step is conditional. Putting a
|
||||
# registry token in dev's vault would only add a second copy of a credential the box
|
||||
# already has, in a file more processes can read. Export it by hand for a one-off run
|
||||
# on a box that has never logged in; a genuine auth problem fails loudly at `pull`.
|
||||
|
||||
echo "==> LAN-dev deploy: APP_DIR=$APP_DIR BRANCH=$BRANCH COMPOSE_FILE=$COMPOSE_FILE"
|
||||
exec "$(dirname "$0")/deploy.sh" "$@"
|
||||
|
|
|
|||
|
|
@ -25,11 +25,33 @@ render_thermograph_secrets() {
|
|||
# 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
|
||||
# changes) and factor out shared secrets into common.yaml later.
|
||||
if [ -z "$env_name" ] || [ ! -f "$key" ] \
|
||||
|| [ ! -f "$repo/deploy/secrets/${env_name}.yaml" ]; then
|
||||
#
|
||||
# These are two different situations and used to share one `return 0`.
|
||||
#
|
||||
# (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"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# (b) Configured — there is a key and a named environment — but the vault for
|
||||
# that environment is not where we looked. That is a failure, and it used
|
||||
# to return 0: a silent no-op wearing a success code. A deploy would report
|
||||
# success having rendered nothing, and the host would quietly keep serving
|
||||
# whatever /etc/thermograph.env already held, including after a rotation.
|
||||
#
|
||||
# The likely cause is the caller passing the wrong root. This function
|
||||
# wants the directory CONTAINING deploy/secrets — on the hosts that is
|
||||
# /opt/thermograph/infra, not /opt/thermograph. Getting it wrong looked
|
||||
# exactly like "not configured here", which is why it went unnoticed.
|
||||
if [ ! -f "$repo/deploy/secrets/${env_name}.yaml" ]; then
|
||||
echo "!! this host is configured for SOPS (env='${env_name}', age key present)" >&2
|
||||
echo "!! but no vault was found at ${repo}/deploy/secrets/${env_name}.yaml" >&2
|
||||
echo "!! pass the directory containing deploy/secrets — on the hosts that is" >&2
|
||||
echo "!! /opt/thermograph/infra — or add ${env_name}.yaml to the vault." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! command -v sops >/dev/null 2>&1; then
|
||||
echo "!! sops not installed but this host is configured for SOPS secrets" >&2
|
||||
return 1
|
||||
|
|
@ -62,7 +84,21 @@ render_thermograph_secrets() {
|
|||
# Decrypt failures return 1 explicitly, so a partial env is never written and
|
||||
# correctness doesn't depend on the caller's shell options. common first, host
|
||||
# second, so a host value overrides a shared one (last-wins).
|
||||
if [ -f "$repo/deploy/secrets/common.yaml" ]; then
|
||||
#
|
||||
# THERMOGRAPH_SECRETS_SKIP_COMMON=1 renders the host file ALONE. deploy-dev.sh
|
||||
# sets it, and the reason is what common.yaml contains: eleven of its sixteen
|
||||
# values are live production credentials — both S3 keypairs (one is read-write
|
||||
# on the bucket holding prod's database backups), the VAPID private key that
|
||||
# signs Web Push to real subscribers, REGISTRY_TOKEN, the IndexNow and metrics
|
||||
# tokens. This render is a plaintext concatenation consumed via `env_file:`, so
|
||||
# layering common on dev would put every one of those into the environment of
|
||||
# every container on a desktop that is also the Forgejo CI runner, executing
|
||||
# unreviewed dev-branch code with the docker socket mounted.
|
||||
#
|
||||
# An override in dev.yaml would not fix it: last-wins governs *consumers*, but
|
||||
# the production value is still physically a line in the file.
|
||||
if [ -f "$repo/deploy/secrets/common.yaml" ] \
|
||||
&& [ "${THERMOGRAPH_SECRETS_SKIP_COMMON:-0}" != 1 ]; then
|
||||
env "${key_env[@]}" sops -d --input-type yaml --output-type dotenv \
|
||||
"$repo/deploy/secrets/common.yaml" >> "$tmp" || { rm -f "$tmp"; return 1; }
|
||||
fi
|
||||
|
|
@ -94,3 +130,285 @@ render_thermograph_secrets() {
|
|||
rm -f "$tmp"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Centralis — render /etc/centralis.env from deploy/secrets/centralis.<env>.yaml
|
||||
# ============================================================================
|
||||
#
|
||||
# Centralis (the estate's control plane, prod-only) keeps its own env file,
|
||||
# deliberately separate from the app stack's /etc/thermograph.env. This function
|
||||
# brings that file under the same vault, so its four credentials
|
||||
# (CENTRALIS_AUTH_TOKEN, CENTRALIS_TOKENS, CENTRALIS_FORGEJO_TOKEN,
|
||||
# DISCORD_TOKEN) stop being hand-edited on the box.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY THIS IS A SEPARATE FUNCTION, NOT AN ARGUMENT TO THE ONE ABOVE
|
||||
# ---------------------------------------------------------------------------
|
||||
# The two files have DIFFERENT OUTPUT CONTRACTS, and that is the whole reason
|
||||
# this migration is being done.
|
||||
#
|
||||
# /etc/thermograph.env is consumed by docker-compose `env_file:`, by the systemd
|
||||
# unit's EnvironmentFile=, and by the stack containers' `. /host/thermograph.env`.
|
||||
# It is written as raw `KEY=value` — sops' own dotenv output, unquoted.
|
||||
#
|
||||
# /etc/centralis.env is consumed by EXACTLY ONE thing, and it is a shell:
|
||||
#
|
||||
# sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up'
|
||||
#
|
||||
# `set -a; . file` runs every line through bash's full word-expansion pipeline:
|
||||
# quote removal, parameter expansion, command substitution, field splitting.
|
||||
# Raw dotenv output is therefore NOT a safe representation for this file, and
|
||||
# sops cannot make it one — `sops -d --output-type dotenv` emits values verbatim.
|
||||
# On a value of {"emi":"..."} that yields
|
||||
#
|
||||
# CENTRALIS_TOKENS={"emi":"..."} -> bash strips the quotes -> {emi:...}
|
||||
#
|
||||
# which is exactly the hand-edit that took the token registry out on 2026-07-24:
|
||||
# Centralis rejected the malformed JSON, failed closed as designed, and served a
|
||||
# single `shared` identity — indistinguishable from the file never having been
|
||||
# written. A value containing a space would truncate; one containing a backtick
|
||||
# or $( ) would EXECUTE. So this function does not reuse the dotenv path at all.
|
||||
# It decrypts to JSON (lossless, unambiguous — dotenv escapes a newline to a
|
||||
# literal \n and cannot be told apart from a literal backslash-n) and emits
|
||||
# POSIX single-quoted assignments, then PROVES the result by sourcing it in a
|
||||
# clean shell and comparing every value back against the plaintext before the
|
||||
# file is allowed anywhere near /etc.
|
||||
#
|
||||
# Making render_thermograph_secrets take an output format would put a flag on
|
||||
# the app stack's deploy path whose wrong value is a production outage, to save
|
||||
# a few lines. It also could not be changed to emit quoted values for BOTH
|
||||
# files: /etc/thermograph.env's consumers include Centralis's own secrets tools,
|
||||
# which compare rendered values against the live file textually, so quoting it
|
||||
# would report all 32 keys as value-changed. Two consumers, two formats.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY common.yaml IS NOT READ HERE
|
||||
# ---------------------------------------------------------------------------
|
||||
# Not an oversight. Centralis shares no key with the app stack (verified: zero
|
||||
# overlap between the two live env files). Concatenating common.yaml would put
|
||||
# the VAPID private key, both S3 keypairs and REGISTRY_TOKEN into
|
||||
# /etc/centralis.env and into the shell that runs Centralis's compose — 16
|
||||
# credentials handed to a service that wants none of them, for no benefit.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY THIS ONE FAILS LOUDLY WITH NO "not configured here" PATH
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_thermograph_secrets is called unconditionally by every deploy on every
|
||||
# box, so it needs the (a) branch for hosts that have not been migrated. This
|
||||
# function is called only by Centralis's own deploy, on the one host that runs
|
||||
# Centralis. A caller asking for it has already asserted it should happen, so
|
||||
# every missing prerequisite is an error. There is no silent no-op path, because
|
||||
# a silent no-op here means deploying against a stale hand-edited file — the
|
||||
# state this exists to end.
|
||||
render_centralis_secrets() {
|
||||
local repo="${1:-.}" # dir CONTAINING deploy/secrets
|
||||
local key="${THERMOGRAPH_AGE_KEY:-/etc/thermograph/age.key}"
|
||||
local marker="${THERMOGRAPH_SECRETS_ENV_FILE:-/etc/thermograph/secrets-env}"
|
||||
# Overridable so the pre-cutover dry run can render somewhere harmless and diff
|
||||
# it against the live file. Nothing in the deploy path sets it.
|
||||
local dest="${CENTRALIS_RENDER_DEST:-/etc/centralis.env}"
|
||||
local env_name vault
|
||||
env_name=$(cat "$marker" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$env_name" ]; then
|
||||
echo "!! no environment name at ${marker} — cannot tell which Centralis vault to render" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ ! -f "$key" ]; then
|
||||
echo "!! no age key at ${key}; this host cannot decrypt the vault" >&2
|
||||
return 1
|
||||
fi
|
||||
# Scoped by environment even though Centralis runs only on prod today. The
|
||||
# alternative (one unscoped centralis.yaml) would render prod's bearer tokens
|
||||
# onto any host that ran this, which is the wrong failure to leave lying around
|
||||
# for the day Centralis gains a second home. Adding one is then a new file,
|
||||
# not a redesign.
|
||||
vault="$repo/deploy/secrets/centralis.${env_name}.yaml"
|
||||
if [ ! -f "$vault" ]; then
|
||||
echo "!! this host is configured for SOPS (env='${env_name}', age key present)" >&2
|
||||
echo "!! but no Centralis vault was found at ${vault}" >&2
|
||||
echo "!! pass the directory containing deploy/secrets — on the hosts that is" >&2
|
||||
echo "!! /opt/thermograph/infra — or add centralis.${env_name}.yaml to the vault." >&2
|
||||
return 1
|
||||
fi
|
||||
command -v sops >/dev/null 2>&1 || {
|
||||
echo "!! sops not installed but this host is configured for SOPS secrets" >&2; return 1; }
|
||||
command -v python3 >/dev/null 2>&1 || {
|
||||
echo "!! python3 not installed; needed to quote values safely for a sourced file" >&2; return 1; }
|
||||
|
||||
echo "==> Rendering ${dest} from ${vault}"
|
||||
# Same age-key handling as render_thermograph_secrets: read the 0400 root key
|
||||
# directly when we can, else lift it through sudo into SOPS_AGE_KEY, so the key
|
||||
# never has to be made readable by the deploy user.
|
||||
local key_env=()
|
||||
if [ -r "$key" ]; then
|
||||
key_env=("SOPS_AGE_KEY_FILE=$key")
|
||||
else
|
||||
local keymat; keymat=$(sudo cat "$key" 2>/dev/null | grep '^AGE-SECRET-KEY-' || true)
|
||||
[ -n "$keymat" ] || { echo "!! cannot read age key at $key (need sudo)" >&2; return 1; }
|
||||
key_env=("SOPS_AGE_KEY=$keymat")
|
||||
fi
|
||||
|
||||
# ONE temp directory, 0700, holding plaintext — so cleanup is a single rm on
|
||||
# every path. Deliberately NOT a `trap ... RETURN`: this file is SOURCED, and a
|
||||
# RETURN trap set inside a sourced function persists into the caller's shell and
|
||||
# re-fires when its next `source` completes (see the note in the function above).
|
||||
local work rc=0
|
||||
work=$(mktemp -d) || { echo "!! mktemp -d failed" >&2; return 1; }
|
||||
chmod 700 "$work"
|
||||
|
||||
# The work happens in a subshell so that no failure path can skip the cleanup
|
||||
# below, and so umask/cwd changes cannot leak into deploy.sh.
|
||||
#
|
||||
# Every step carries its own `|| exit 1` rather than relying on `set -e`. That
|
||||
# is not belt-and-braces: `set -e` is DISABLED inside a compound command that
|
||||
# is the left operand of `||`, which this subshell is (`( ... ) || rc=1`, needed
|
||||
# so a failure here cannot abort a `set -e` caller mid-deploy). A `set -e` in
|
||||
# here reads as protection and provides none — caught by a test that fed the
|
||||
# renderer a nested mapping and watched it sail past the rejection.
|
||||
(
|
||||
umask 077
|
||||
|
||||
# JSON, not dotenv. Lossless and unambiguous — see the header.
|
||||
env "${key_env[@]}" sops -d --input-type yaml --output-type json "$vault" \
|
||||
> "$work/plain.json" || exit 1
|
||||
|
||||
python3 - "$work/plain.json" "$work/out.env" "$work/want.json" <<'EMIT' || exit 1
|
||||
import json, re, sys
|
||||
|
||||
src, out_env, want_json = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
||||
|
||||
|
||||
def shell_quote(v):
|
||||
"""POSIX single-quoting. Inside '...' every byte is literal except ' itself,
|
||||
which is closed, backslash-escaped and reopened. There is no escape sequence
|
||||
to get wrong and no character class to keep up to date: it is total, and
|
||||
total is the only property worth having for a file bash will expand."""
|
||||
return "'" + v.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def scalar(k, v):
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, bool): # before int: bool is an int in Python
|
||||
return "true" if v else "false"
|
||||
if isinstance(v, (int, float)):
|
||||
return json.dumps(v)
|
||||
if v is None:
|
||||
return ""
|
||||
sys.stderr.write(
|
||||
"!! %s decrypts to a %s, but an env file holds only scalars\n" % (k, type(v).__name__))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
data = json.load(open(src, encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
sys.stderr.write("!! the vault did not decrypt to a mapping\n")
|
||||
sys.exit(1)
|
||||
|
||||
want, lines = {}, []
|
||||
for k, v in data.items():
|
||||
if not NAME.match(k):
|
||||
sys.stderr.write("!! %r is not a usable shell variable name\n" % (k,))
|
||||
sys.exit(1)
|
||||
want[k] = scalar(k, v)
|
||||
lines.append("%s=%s\n" % (k, shell_quote(want[k])))
|
||||
|
||||
with open(out_env, "w", encoding="utf-8") as fh:
|
||||
fh.write("# Rendered by infra/deploy/render-secrets.sh from the SOPS vault.\n")
|
||||
fh.write("# DO NOT EDIT BY HAND: the next render overwrites this file, and a\n")
|
||||
fh.write("# hand-edit is how the token registry was lost on 2026-07-24.\n")
|
||||
fh.write("# Rotate by editing infra/deploy/secrets/centralis.<env>.yaml instead.\n")
|
||||
fh.writelines(lines)
|
||||
with open(want_json, "w", encoding="utf-8") as fh:
|
||||
json.dump(want, fh)
|
||||
sys.stderr.write(" %d keys quoted\n" % len(want))
|
||||
EMIT
|
||||
|
||||
# PROVE IT. Source the rendered file the way the deploy does — in a clean
|
||||
# environment, `set -a`, no inherited variables to mask a dropped key — and
|
||||
# read every value back out. This is the test that the file cannot pass while
|
||||
# carrying the 2026-07-24 bug, and it runs on every render, not once.
|
||||
# shellcheck disable=SC2016 # single quotes are the point: "$1" must be
|
||||
# expanded by the inner `bash -c`, against the argument passed after `_`,
|
||||
# not by this shell. Double-quoting would interpolate the OUTER $1 — the
|
||||
# function's own first argument, the repo root — and the check would then
|
||||
# verify the wrong file, or nothing at all, while still reporting success.
|
||||
env -i PATH="$PATH" bash -c \
|
||||
'set -a; . "$1"; set +a; exec python3 -c "
|
||||
import json, os, sys
|
||||
sys.stdout.write(json.dumps(dict(os.environ)))"' _ "$work/out.env" \
|
||||
> "$work/got.json" || exit 1
|
||||
|
||||
python3 - "$work/want.json" "$work/got.json" <<'VERIFY' || exit 1
|
||||
import json, sys
|
||||
|
||||
want = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
got = json.load(open(sys.argv[2], encoding="utf-8"))
|
||||
|
||||
bad = []
|
||||
for k, v in want.items():
|
||||
if k not in got:
|
||||
bad.append("%s: not set at all after sourcing" % k)
|
||||
elif got[k] != v:
|
||||
# NEVER print either value. The difference is the finding; the content is
|
||||
# not, and this runs inside a deploy log.
|
||||
bad.append("%s: survives sourcing as a DIFFERENT value (len %d -> %d)"
|
||||
% (k, len(v), len(got[k])))
|
||||
if bad:
|
||||
sys.stderr.write("!! the rendered file does not round-trip through bash:\n")
|
||||
for b in bad:
|
||||
sys.stderr.write("!! %s\n" % b)
|
||||
sys.exit(1)
|
||||
|
||||
# CENTRALIS_TOKENS is the reason for all of the above: JSON, full of double
|
||||
# quotes, in a file bash expands. Centralis fails CLOSED on malformed JSON — it
|
||||
# drops to the single `shared` identity, which looks exactly like the registry
|
||||
# having never been configured. Refuse to write a file that would do that,
|
||||
# rather than discovering it from a dashboard that is missing two people.
|
||||
raw = got.get("CENTRALIS_TOKENS", "")
|
||||
if raw:
|
||||
try:
|
||||
reg = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
sys.stderr.write("!! CENTRALIS_TOKENS is not valid JSON after sourcing: %s\n" % exc)
|
||||
sys.exit(1)
|
||||
if not isinstance(reg, dict) or not reg:
|
||||
sys.stderr.write("!! CENTRALIS_TOKENS must be a non-empty JSON object\n")
|
||||
sys.exit(1)
|
||||
if not all(isinstance(s, str) and isinstance(t, str) and t for s, t in reg.items()):
|
||||
sys.stderr.write("!! CENTRALIS_TOKENS must map subject -> non-empty token string\n")
|
||||
sys.exit(1)
|
||||
# Subjects are names on audit lines, not credentials. Printing them is the
|
||||
# whole point: it is how an operator sees at a glance that nobody was lost.
|
||||
sys.stderr.write(" CENTRALIS_TOKENS parses: %d subject(s) — %s\n"
|
||||
% (len(reg), ", ".join(sorted(reg))))
|
||||
|
||||
sys.stderr.write(" %d keys survive `set -a; . <file>` byte-for-byte\n" % len(want))
|
||||
VERIFY
|
||||
) || rc=1
|
||||
|
||||
# Only now, with a file that has been read back through bash and matched value
|
||||
# for value, does anything touch $dest.
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
# 0600, not 0640: this file is four bearer credentials for the estate's
|
||||
# control plane and has no group that needs it. Prefer an in-place write so
|
||||
# the existing owner/mode survive; fall back the same way the app renderer
|
||||
# does, chowning on the sudo path so a non-root deploy user can still source
|
||||
# what it just rendered.
|
||||
if [ -f "$dest" ] && [ -w "$dest" ]; then
|
||||
cat "$work/out.env" > "$dest" || rc=1
|
||||
elif install -m 0600 "$work/out.env" "$dest" 2>/dev/null; then :
|
||||
elif sudo install -m 0600 -o "$(id -un)" -g "$(id -gn)" "$work/out.env" "$dest" 2>/dev/null; then :
|
||||
else
|
||||
echo "!! cannot write ${dest} (need file write access or passwordless sudo)" >&2
|
||||
rc=1
|
||||
fi
|
||||
else
|
||||
echo "!! render aborted; ${dest} left untouched" >&2
|
||||
fi
|
||||
|
||||
rm -rf "$work"
|
||||
return "$rc"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,16 +14,206 @@ no per-host duplication.
|
|||
|
||||
| File | Holds | Encrypted? |
|
||||
|------|-------|------------|
|
||||
| `common.yaml` | Secrets shared by every host — Discord tokens/secret, VAPID keypair, `THERMOGRAPH_AUTH_SECRET`, metrics token, indexnow key, SMTP creds | ✅ |
|
||||
| `prod.yaml` | Prod-only overrides — `POSTGRES_PASSWORD`, `REGISTRY_TOKEN`, ERA5 lake bucket creds (`THERMOGRAPH_LAKE_S3_ACCESS_KEY` / `THERMOGRAPH_LAKE_S3_SECRET_KEY`, consumed by the `lake` Swarm service and `gen_era5_lake.py`) | ✅ |
|
||||
| `beta.yaml` | Beta-only overrides | ✅ |
|
||||
| `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 | ✅ |
|
||||
| `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) | — |
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
If you ever *do* want them to differ, change one file. That is the whole point.
|
||||
|
||||
`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.
|
||||
|
||||
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`).
|
||||
`dev` has no real secrets and is intentionally not wired in — see `deploy-dev.sh`.
|
||||
|
||||
### `dev` is vaulted, but renders `dev.yaml` **alone**
|
||||
|
||||
All three environments are managed here now. `dev` is the one that does **not**
|
||||
get `common.yaml` layered under it: `deploy-dev.sh` exports
|
||||
`THERMOGRAPH_SECRETS_SKIP_COMMON=1`, and the renderer skips the shared layer.
|
||||
|
||||
That is not squeamishness about a dev box — it is what `common.yaml` contains.
|
||||
Eleven of its sixteen values are live production credentials: `REGISTRY_TOKEN`,
|
||||
`THERMOGRAPH_S3_ACCESS_KEY`/`_SECRET_KEY` and
|
||||
`THERMOGRAPH_LAKE_S3_ACCESS_KEY`/`_SECRET_KEY` (one keypair, **read-write**, on the
|
||||
bucket that also holds prod's database backups — see `../../ops/README.md`),
|
||||
`THERMOGRAPH_VAPID_PRIVATE_KEY`/`_PUBLIC_KEY` (they sign Web Push to real
|
||||
subscribers), `THERMOGRAPH_INDEXNOW_KEY`, `THERMOGRAPH_METRICS_TOKEN`. The render
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
So `dev.yaml` is self-contained. Where the value is shared-but-harmless
|
||||
(`PORT`, `THERMOGRAPH_BASE`, `TIMESCALEDB_TAG`) it carries its own copy — a
|
||||
duplicated constant is the cheap half of this trade.
|
||||
|
||||
**What `dev.yaml` holds** (12 keys):
|
||||
|
||||
| Key | Why |
|
||||
|-----|-----|
|
||||
| `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_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` |
|
||||
|
||||
**Deliberately absent, and why nothing breaks:**
|
||||
|
||||
- **Both S3 keypairs.** Without them the `lake` service answers 503 and history
|
||||
reads fall through to the Open-Meteo archive — an accelerator, never a
|
||||
dependency (`docker-compose.yml`, `backend/data/climate.py`).
|
||||
- **VAPID keypair.** `backend/notifications/push.py` resolves env → file →
|
||||
generates once and persists to the `appdata` volume. Dev gets its own identity
|
||||
for free; prod's private key stays off the box.
|
||||
- **`THERMOGRAPH_INDEXNOW_KEY`.** Same env → file → self-generate ladder
|
||||
(`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.
|
||||
- **`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.
|
||||
- **All Discord and mail credentials.** Dev must not be able to act as the real
|
||||
bot or send real mail.
|
||||
- **`APP_CPUS`, `DB_CPUS`.** `docker-compose.dev.yml` `!reset`s both — dev runs
|
||||
uncapped, so setting them would be a lie. (`DB_MEMORY` *is* live: the `db`
|
||||
service's `mem_limit` is not reset.)
|
||||
|
||||
If dev ever needs one of these for real, it gets **its own** — a dev Discord app,
|
||||
a scoped read-only bucket key — never a copy of prod's.
|
||||
|
||||
## Centralis: its own file, its own renderer
|
||||
|
||||
`/etc/centralis.env` on prod is the control plane's env file — four real
|
||||
credentials (`CENTRALIS_AUTH_TOKEN`, `CENTRALIS_TOKENS`,
|
||||
`CENTRALIS_FORGEJO_TOKEN`, `DISCORD_TOKEN`) and five non-secret settings. It was
|
||||
**hand-edited** until 2026-07-24, when a hand-edit wrote `CENTRALIS_TOKENS` as
|
||||
bare JSON into a file that gets `source`d, bash stripped the double quotes,
|
||||
`{"emi":"…"}` arrived as `{emi:…}`, and Centralis — correctly failing closed on
|
||||
malformed JSON — dropped to the single `shared` identity. Nothing logged an
|
||||
error. It looked exactly like the file had never been written.
|
||||
|
||||
`centralis.prod.yaml` + `render_centralis_secrets()` in
|
||||
[`../render-secrets.sh`](../render-secrets.sh) end that, by the only durable
|
||||
route: **a human stops writing the file.**
|
||||
|
||||
Seed / verify / rotate:
|
||||
|
||||
```sh
|
||||
# One-time seed from the live box (plaintext never leaves prod):
|
||||
deploy/secrets/seed-centralis-on-host.sh prod agent@169.58.46.181
|
||||
|
||||
# Prove a render matches the live file before cutting over. Renders to a 0700
|
||||
# scratch dir on the host, sources both, compares — key NAMES and a verdict only:
|
||||
deploy/secrets/verify-centralis-render.sh prod agent@169.58.46.181
|
||||
|
||||
# Thereafter, rotation is edit -> commit -> merge to main -> redeploy Centralis:
|
||||
sops deploy/secrets/centralis.prod.yaml
|
||||
```
|
||||
|
||||
Merging to `main` is what puts the new ciphertext on prod (`infra-sync.yml`
|
||||
fast-forwards `/opt/thermograph`); the render happens when Centralis is
|
||||
redeployed. See Centralis's own `deploy/README.md` §2 and §5.
|
||||
|
||||
### Why `CENTRALIS_*` is not just added to `prod.yaml`
|
||||
|
||||
It is the obvious move, and it hands the estate's control-plane bearer token to
|
||||
every application container.
|
||||
|
||||
`/etc/thermograph.env` is loaded into the app stack through `env_file:` and
|
||||
through `. /host/thermograph.env` in the stack services' entrypoints. Every key
|
||||
in it is in the environment of every container in the stack. `CENTRALIS_TOKENS`
|
||||
and `CENTRALIS_AUTH_TOKEN` authenticate an endpoint that carries `sql_query`
|
||||
with `write:true` and `run_on_host` over production, and `CENTRALIS_FORGEJO_TOKEN`
|
||||
is repo-scope on the monorepo. Putting them in `prod.yaml` would mean that a
|
||||
read-anything bug in the web backend, or a leaked container env dump, is a
|
||||
foothold on the control plane. The two files are separate for a reason that
|
||||
predates the vault, and the vault is not a reason to merge them.
|
||||
|
||||
The converse — pointing Centralis's deploy at `/etc/thermograph.env` — has the
|
||||
same problem from the other side, plus 32 unrelated variables in the shell that
|
||||
runs its compose, plus a lifecycle coupling: every app deploy re-renders that
|
||||
file, and Centralis and the app stack are deliberately deployed on different
|
||||
cadences.
|
||||
|
||||
### Why this file is fully quoted and `/etc/thermograph.env` is not
|
||||
|
||||
Different consumers.
|
||||
|
||||
`/etc/thermograph.env` is read by docker-compose `env_file:` and by systemd
|
||||
`EnvironmentFile=`, which parse `KEY=value` literally. Raw `sops --output-type
|
||||
dotenv` is the right representation for it, and it must stay that way: Centralis's
|
||||
own `secrets_inventory` / `secrets_render` tools compare rendered values against
|
||||
that file **textually**, so quoting it would report all 32 keys as value-changed.
|
||||
|
||||
`/etc/centralis.env` is read by exactly one thing, and it is a shell:
|
||||
|
||||
```sh
|
||||
sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up -d --build'
|
||||
```
|
||||
|
||||
`set -a; . file` runs every line through bash's full expansion pipeline. Raw
|
||||
dotenv output is *unsafe* there and sops cannot make it safe — it emits values
|
||||
verbatim. So `render_centralis_secrets` decrypts to **JSON** (lossless; dotenv
|
||||
escapes a newline to a literal `\n` and cannot be told apart from a literal
|
||||
backslash-n) and writes POSIX single-quoted assignments, in which every byte is
|
||||
literal except `'` itself.
|
||||
|
||||
Then it **proves the file** before letting it near `/etc`: it sources the
|
||||
rendered file in a clean shell (`env -i`), reads every variable back, and
|
||||
compares to the plaintext it started from. If any value does not survive, or if
|
||||
`CENTRALIS_TOKENS` does not parse as a JSON object of subject → token, the
|
||||
render aborts and the existing file is left untouched. The 2026-07-24 bug cannot
|
||||
be written by this path, and cannot be written *through* it either.
|
||||
|
||||
### Scoped by environment even though Centralis is prod-only
|
||||
|
||||
The file is `centralis.prod.yaml`, not `centralis.yaml`, and the renderer takes
|
||||
the `prod` from `/etc/thermograph/secrets-env` like everything else here. An
|
||||
unscoped file would render prod's bearer tokens onto any host that ran the
|
||||
function. If Centralis ever gains a second home, that is a new file rather than
|
||||
a redesign — and the two boxes get different tokens, which is the point.
|
||||
|
||||
`centralis.prod.yaml` is **not** layered under `common.yaml`. Centralis shares
|
||||
no key with the app stack (verified: zero overlap between the two live env
|
||||
files), so layering would only push the VAPID private key, both S3 keypairs and
|
||||
`REGISTRY_TOKEN` into a file that wants none of them.
|
||||
|
||||
## The age key (the one thing that matters)
|
||||
|
||||
|
|
@ -31,9 +221,16 @@ value wins** (env_file / `source` take the last occurrence of a duplicate key).
|
|||
unrecoverable; leak it and every secret is compromised.
|
||||
- It is **never in the repo.** It lives at:
|
||||
- `~/.config/sops/age/keys.txt` on the operator's machine (for editing), and
|
||||
- `/etc/thermograph/age.key` (`0400`) on each host that renders at deploy time.
|
||||
- `/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.
|
||||
|
||||
## Prerequisites (once per machine)
|
||||
|
||||
Install `sops` + `age` (single static binaries). On a host, use
|
||||
|
|
@ -54,9 +251,59 @@ ssh agent@169.58.46.181 'cd /opt/thermograph && git pull && deploy/deploy.sh'
|
|||
```
|
||||
|
||||
A host-specific value (e.g. `POSTGRES_PASSWORD`) is the same, editing `prod.yaml` /
|
||||
`beta.yaml` instead. **`POSTGRES_PASSWORD` is special**: the database only reads it on
|
||||
a *fresh* volume, so also `ALTER ROLE thermograph PASSWORD '…'` inside the running
|
||||
Postgres to match — the env change alone won't re-key an initialized DB.
|
||||
`beta.yaml` / `dev.yaml` instead. **`POSTGRES_PASSWORD` is special**: the database
|
||||
only reads it on a *fresh* volume, so also `ALTER ROLE thermograph PASSWORD '…'`
|
||||
inside the running Postgres to match — the env change alone won't re-key an
|
||||
initialized DB. (That applies to dev too. `dev.yaml` deliberately carries the value
|
||||
dev's existing `pgdata` volume was initialized with, so nothing rotates the moment
|
||||
dev is provisioned; rotating it later is the two-step above, plus the matching
|
||||
default in `deploy-dev.sh`.)
|
||||
|
||||
Dev is edited and deployed exactly like the others — `sops deploy/secrets/dev.yaml`,
|
||||
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).
|
||||
|
||||
```sh
|
||||
# 1. sops + age on PATH, and the age private key in the operator's keyring:
|
||||
# ~/.config/sops/age/keys.txt (0600, restored from the password manager)
|
||||
sops --version && age --version
|
||||
|
||||
# 2. Name the environment. This marker is what switches rendering ON — until it
|
||||
# exists the box renders nothing, which is the safe default.
|
||||
sudo mkdir -p /etc/thermograph
|
||||
printf 'dev\n' | sudo tee /etc/thermograph/secrets-env >/dev/null
|
||||
sudo chmod 0644 /etc/thermograph/secrets-env
|
||||
|
||||
# 3. Pre-create the render target owned by the deploying user, so no deploy ever
|
||||
# needs sudo (the renderer writes in place when the file is writable — the same
|
||||
# path beta's non-root `deploy` user takes).
|
||||
sudo install -m 0600 -o "$USER" -g "$USER" /dev/null /etc/thermograph.env
|
||||
|
||||
# 4. Dry-run the render before letting a deploy do it, and read the key names back.
|
||||
# Nothing from common.yaml may appear here.
|
||||
THERMOGRAPH_SECRETS_SKIP_COMMON=1 \
|
||||
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt \
|
||||
sops -d --input-type yaml --output-type dotenv deploy/secrets/dev.yaml | sed 's/=.*/=/'
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Add a new secret
|
||||
|
||||
|
|
@ -64,6 +311,12 @@ Postgres to match — the env change alone won't re-key an initialized DB.
|
|||
2. Add the app reader (`os.environ.get("THERMOGRAPH_NEW_THING")`).
|
||||
3. Commit + deploy. It flows into `/etc/thermograph.env` automatically.
|
||||
|
||||
`common.yaml` reaches prod and beta only. If dev needs the new thing, decide
|
||||
which it is and act on it: harmless config → copy it into `dev.yaml`; a real
|
||||
credential → mint dev **its own** and put that in `dev.yaml`; neither → leave dev
|
||||
without it and make sure the code degrades rather than crashes. Do not reach for
|
||||
prod's copy.
|
||||
|
||||
## Rotate the age identity itself (re-key)
|
||||
|
||||
```sh
|
||||
|
|
@ -104,4 +357,10 @@ Order matters — values must match the live env exactly so `AUTH_SECRET` / VAPI
|
|||
- Rendering is **presence-detected**: a host without the age key + marker keeps its
|
||||
existing `/etc/thermograph.env`, so nothing breaks before a host is migrated.
|
||||
- Rendering happens **on the target box**, so CI/runners never see the age key or the
|
||||
plaintext.
|
||||
plaintext — **except on dev**, where the box *is* the runner. That is not a hole
|
||||
this vault can close; it is the reason `dev.yaml` holds nothing that would matter
|
||||
if the box were owned. Keep it that way: the day dev needs a real credential,
|
||||
mint it a scoped one of its own.
|
||||
- `deploy-dev.sh` **fails closed**: if the `render-secrets.sh` it finds cannot honour
|
||||
`THERMOGRAPH_SECRETS_SKIP_COMMON`, it aborts the deploy rather than render the
|
||||
shared production layer onto the dev box.
|
||||
|
|
|
|||
|
|
@ -1,39 +1,23 @@
|
|||
PORT: ENC[AES256_GCM,data:fQi1uQ==,iv:qedUPYIGaUh6xJKessGDssYNvLyUv1MwnBJDzhA0074=,tag:FHcxDJC94B2xNMDgwU+4VA==,type:str]
|
||||
POSTGRES_PASSWORD: ENC[AES256_GCM,data:SXlK1jZq8gRir51EbYWraa2ZRK4bmwCs2ThqNiKZDaU=,iv:8NwAyoMEsU6boXmCbLRENWBhcejvyy1ZlFRi1rCUK2Y=,tag:paNCYlJVVXYYdaHwmu9MkA==,type:str]
|
||||
THERMOGRAPH_DATABASE_URL: ENC[AES256_GCM,data:iSVBtlYKy7Yd+i0atzNUzMQN8LhNbc4dzSTW2celQsTSs/Cq7dNKurQdvJpmLvJIbUv7aw7u9bDQLckrF/PsPCw+AeQ/qAvSrop2+StVS+Jo6neU0A==,iv:4m7jx+JD2dnq6JlyT8phqnjrTBLtuiXTM0KP8BPOtkg=,tag:7G80BYRGZxrtwp7jN6TxnQ==,type:str]
|
||||
WORKERS: ENC[AES256_GCM,data:jQ==,iv:zXbYr35kE/oPVBwYVGjrbcOvFNyyn2YUY6LNRx04BAU=,tag:YjhQvhrY3HpfDVT9YdR/Yg==,type:str]
|
||||
APP_CPUS: ENC[AES256_GCM,data:Mw==,iv:YbELJMxO99xhHn3T/VoAlF2K+q70YqSX7lQe74e3ILw=,tag:MFXHhcTEhN8Z/Ay9rDbOUA==,type:str]
|
||||
DB_CPUS: ENC[AES256_GCM,data:Vw==,iv:EQQQapb30E7d7QVLbgDwsqM9RlHLi/6d015ptxDyW5o=,tag:C6X1bLZeRbiVzvZ6L29feg==,type:str]
|
||||
DB_MEMORY: ENC[AES256_GCM,data:IcM=,iv:ufsk06o8dCUQsy4zt5GfbKKFUV8r30h7CYlxrayAj84=,tag:0RfDuSUwyhB66GR16a256g==,type:str]
|
||||
TIMESCALEDB_TAG: ENC[AES256_GCM,data:LNRpLf3rZE32exI=,iv:8Ipv+YXoMJXAlvHd98Gdy3DQ9Vf9KyhQA8JIBO30cOk=,tag:CW71eYfQW7WXrtTiPFEwCQ==,type:str]
|
||||
THERMOGRAPH_BASE: ENC[AES256_GCM,data:MQ==,iv:9jP2QHwTTVJoYASWLEmB+YDnGcPHZVEromVemOtvars=,tag:HFamQ250lBShDwPryzBP/A==,type:str]
|
||||
THERMOGRAPH_BASE_URL: ENC[AES256_GCM,data:WJLcqKzwI0qOh7yb88wIgW9EzKN/uANOn/Dc+Q==,iv:5z2RSKIb+RJCCscUH5AC18rRa0x89RgIHiqu5xUUxSc=,tag:Niw093JTHoH7IjxF9QuR+A==,type:str]
|
||||
THERMOGRAPH_COOKIE_SECURE: ENC[AES256_GCM,data:qw==,iv:5wsDViGkCWZC0hNzANfPLVgbNrO5qTcVQ9q167EMN/k=,tag:lZfmR4uHgkPl45mul2bOcw==,type:str]
|
||||
THERMOGRAPH_AUTH_SECRET: ENC[AES256_GCM,data:9n0Yup3NxOO0NzUwNlA7aBvEFQJT8xIy31yIfUkaMoyHOlmzsj8JukjiDw==,iv:OE5S2fsLkQUL/bsinO6gHxcxr3fXBJXwpIEDWQXtWHI=,tag:WDqAe40LiTtl3nM2iuFLOQ==,type:str]
|
||||
THERMOGRAPH_METRICS_TOKEN: ENC[AES256_GCM,data:FgjpP9BZ8OakTG5j4hvP3RM5rGwqMQVSqoOA3QUUs/Y=,iv:qYIGtbFRLtjOuBPuKJo7NG4tYlvwJ+d2w0B0p6++wzU=,tag:WworrFhfAJC2nulIvK/fZg==,type:str]
|
||||
THERMOGRAPH_INDEXNOW_KEY: ENC[AES256_GCM,data:WFTDtzYb6nzLdsF11NarGCvm+B1WhmAOPNw6V8232/8=,iv:bsAO1Jq4h1paeNEJBg2fpkvQzBZSndEc7uf6aueMGfA=,tag:J2pHU9z6HASS9ggXE8oP4g==,type:str]
|
||||
THERMOGRAPH_VAPID_PRIVATE_KEY: ENC[AES256_GCM,data:7jpriHBwPlgA1n5CuXU3BXn6zo0r8/IcEv5tBHDKxQ6PBnX9wG2rBMNLTg==,iv:lk9hg+mLYgK0db321tri12kWR1dntjS6Kld8Wtcfr3c=,tag:LB3V+6X/pGugU2hun9/dCg==,type:str]
|
||||
THERMOGRAPH_VAPID_PUBLIC_KEY: ENC[AES256_GCM,data:05tQe7KaNQXWHzKQENrdk4X2hSUC50Z8TKBGzHurf4NuRYTba4FfAu2fj9ODFOWeV/j0LKk48zE8u7QfKz7pgrFuGccKQNmW9MTYrcGERQ8GlZh/n97l,iv:jy9IC+FKCD6Np/HlmZZDIfX8aMr7BfA/vL32DPFUzFU=,tag:w38/Q4MHqtldoE0pEI5WmQ==,type:str]
|
||||
THERMOGRAPH_VAPID_CONTACT: ENC[AES256_GCM,data:ANT1qBKnFUjyPp+NwF++jam69B8cWADVBtIcHdci7A==,iv:4tCQCQbWGnnlo/qKv9YBmmXlTfJkrSonT0M7y8UImIk=,tag:TLKQKzuM/M//Uq7gde+JUg==,type:str]
|
||||
REGISTRY_TOKEN: ENC[AES256_GCM,data:P7oFRZ8SZm6ZpJIMoH/xoNu9+sD084b6DUQWc3tSx4qGFPxXveDHmQ==,iv:B5J+qFgorUoJ3TK6TKWwXuNLs8mD+plaY4W8UaS/xEw=,tag:8CfR7DtJ5dd238n6YSyqNg==,type:str]
|
||||
THERMOGRAPH_LAKE_S3_ACCESS_KEY: ENC[AES256_GCM,data:83u4MJ5XjT9uxtBiJwfH+ICPUj0q1f7NMIXnqK2pjeA=,iv:QEq02qRPt0SO1RcsDZnOIe1V3mFu1bLxoaxGuv3Q8QU=,tag:HdF3hmPXCIOElGXmoy58vw==,type:str]
|
||||
THERMOGRAPH_LAKE_S3_SECRET_KEY: ENC[AES256_GCM,data:FlRe/NNxyFTLYrV3pBonTGC7Qvo9cxwAYuIDEo6tzcA=,iv:QqTpQ8UM3EiV4p4rJztjx3jdyAFJuqU+4rkpe7epfCk=,tag:W+sCbhE1sK9+Z9quOpoJyA==,type:str]
|
||||
THERMOGRAPH_S3_ENDPOINT: ENC[AES256_GCM,data:3ooQIJ9QtksXnQpM3xDZVV+DhtuIZEYjWfISs716,iv:n0Hd4Vm5zDlSJJ0UeH8dyItu39UYaSmlgYcLN1vDJuM=,tag:xqxl1dv72dS7tXjBRRA2gA==,type:str]
|
||||
THERMOGRAPH_S3_BUCKET: ENC[AES256_GCM,data:Q4fbMVujISo567VYdOgMkQ==,iv:sdHNp4bE+7BrrjSDWGCybhM1tbZhGzW/wmOuZOE9AhA=,tag:nVj3aO3DEwnjyPRdhjYuRw==,type:str]
|
||||
THERMOGRAPH_S3_ACCESS_KEY: ENC[AES256_GCM,data:Gdifczs+h4KQlJViCV+q9TR896fMajwllKiV/feN8wI=,iv:ekRpckoRI5SdqOKfx+85PAnA3J1PsZrX3tXMGLtOO7I=,tag:meTPyJCM83cfwX6sC25aEQ==,type:str]
|
||||
THERMOGRAPH_S3_SECRET_KEY: ENC[AES256_GCM,data:UsTtqDA1h2H1F8czE31mj/QfgdNb/GEg8HWQxrl6nCo=,iv:h3jO38fJ0gcih1qvnBWPX3AS8EWOErZbIQaC10lV70I=,tag:1NoN52vRiUHeu10Lzgai2A==,type:str]
|
||||
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]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDYThHTE9VS0ppWmR3djdE
|
||||
V3JreTBudFhqZTR1MEhZMXd3dzFTakRDWlZ3ClB6OUhSbFdjU2tmWVZXZlRtWExG
|
||||
dXhtTXFLV21DemUybXB4aWE4aWVSVlkKLS0tIHNEaVRGbWdPcmlZcG1UV2tFRFdh
|
||||
RVdSemxabXg2S2d0Y0tYeGh1MXZIOUUK2dJfuUkq5NZJPUrteMt1E+QvQZQNsnhr
|
||||
H5nrEF+WqfTizIbZ0rowqr8QxAbn6zcb/5+VZGjFIUP1qfXe2cI+5w==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFWHVZU2xSS1pmcy91S2w5
|
||||
MWNhQW90TDdQdDdJV1BHM2Y0a1d6aFJOS2hnCjRodHZlY0orODdpS3QwTVdTdXM5
|
||||
Z3RJOFhyR3AzbjljaExkQjN0YUJ2ckUKLS0tIGpua1Z2TDJrRzE3WFZlMWNpL1Ja
|
||||
VG45eVM0MldLejRNd0pweWNna3JYWlUKWuNU+6PqKlbr7F0ckrNxsMF2OyXh1fMu
|
||||
cLFBIQg/7vO7O7PJ0VIy0Ugfq6gj2Gv91qKJUGeOXOw3tv1Y6HHbjA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
|
||||
lastmodified: "2026-07-24T20:06:42Z"
|
||||
mac: ENC[AES256_GCM,data:2yk/ZeaTxefXMiRVbUfwiEpa76KvR6WZ/ySBONi66IQr6olCV7P+ZOpfZ49vN2HWy8XycCABjRYrxvpM9FJcOIz3srTWLrtc5Mv+hJ1gQedc57gItF0KuUBxpF4n0AuI+OFuLtB8GFxohFCk8/ZNAdGyhK+5RH95Y6aC58+2y9c=,iv:W5uBB+dkKKxAQDgsDb4uitVWrB0zgGh3Zg/5qWrs5OU=,tag:/mtl9OHGc2mwM+9d2cnFqg==,type:str]
|
||||
lastmodified: "2026-07-25T00:38:24Z"
|
||||
mac: ENC[AES256_GCM,data:lTpEmA8gweS+QlAakIziXxAEiHBUsNDk5lGwVG+uaXNhifIYWbu+QWkklP4wES9o3pu4TNCzFboRWkBbnzhW8HYRglkSChsmmFXDChz67IYlaveCFwczxffp39iypnV/EKaBCa8m1eDJ5kugzr2s7+blagCH0TwpNXBqZShyGNE=,iv:KBF6CreB4oTJGwNetTj27Grox30X3s3qEEYb1cJXmIE=,tag:YfRb5yAoY8K/TXspoZYdcA==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
|
|
|||
24
infra/deploy/secrets/centralis.prod.yaml
Normal file
24
infra/deploy/secrets/centralis.prod.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
CENTRALIS_AUTH_TOKEN: ENC[AES256_GCM,data:MoOrGKQoe0mFu0gk4koa0kX+I6SgcKSlDAh+motBvBy22kiWucumgrl9avCdepPP,iv:0Vn/8bld2+EZhcwEPepCnmn4ktXp63LJUrZ36uajZp8=,tag:cjM/AVlG21Mk36QB7TjfSw==,type:str]
|
||||
CENTRALIS_FORGEJO_TOKEN: ENC[AES256_GCM,data:p1KKmZZHhB3IO9QdWe2CYuN+rho+lZf3aeADT2sxZrD5SYeA05O1VA==,iv:93QFRj4tNU44wo6eCn3oCGmLP2tm571S4IhVCXpSQk8=,tag:/yH0O5Y25RfLBmKOWWJkZQ==,type:str]
|
||||
CENTRALIS_FORGEJO_URL: ENC[AES256_GCM,data:z3ZTFg/2F9ZeilolVXW2XlyjyQy9,iv:hX0lj94vXFG2lZCG3gykaE1oEjTsciREey7onyQgQsc=,tag:415VJUTErR9SG82mkS2jOg==,type:str]
|
||||
CENTRALIS_LOKI_URL: ENC[AES256_GCM,data:cFzoMoUYhxEVIF/K8JArTB49GRAo,iv:BTgmKBOsTdnWcvYOww4Zve5dJd+ZnFnBbWv3y5V2Vi4=,tag:kZ6LKLIVXk4UOZYbAr483w==,type:str]
|
||||
DISCORD_GUILD_ID: ENC[AES256_GCM,data:qUFkitxFHOZdA7h2xtNycUpdLA==,iv:RGfbTEdjWY4Wu9i8WQ0rAnDAwqLZZbTj/bsWavYEnjk=,tag:L2b2t+tLhGXpqmGcamBpNw==,type:str]
|
||||
CENTRALIS_TRANSCRIPT_CHANNEL: ENC[AES256_GCM,data:poxdL903opQV5waTCnO4wM3eIg==,iv:m5VWJpWXb49qak0wYoS1/W7S22+BAl+AFzjTY2Zu0a8=,tag:8iILm7mM0S/9/aAG1DywkQ==,type:str]
|
||||
CENTRALIS_VOICE_SUMMARY_CHANNEL: ENC[AES256_GCM,data:Ztvy9w8wpmEcROyRDJOosBJlWw==,iv:iugYisN38csVNIZamZ84mzXaPTABxU+I2yZ+CzL/ddQ=,tag:wNGJcoYN2obnoKY7uchYiw==,type:str]
|
||||
DISCORD_TOKEN: ENC[AES256_GCM,data:hdrjzSqwKmUdmdLl8dOMUPzLxfZf3H4Z6D9b+PIn/nzapQAPECihrxHZswayinYV3nbcZKj2Qh3Aw2jzaP0OpYM6HFdZmosA,iv:nE4utRtwNCDEWOKjTaobpXJHWkXY6LQE1EvHGH+tUH0=,tag:+3UC7N8B1WCCPzBzmB4yog==,type:str]
|
||||
CENTRALIS_TOKENS: ENC[AES256_GCM,data:nlDM6Y4rUuAiITo1iN/khDFiwCH8UlVA42SaB1d1a4CG7uVMZnl369MVTMWEXhe4GFOtXlSYgHt7XUMkVmCGQzfBwXed6VZcMPMD9XVkbdYz8+FtrOsf0KVQWvNOHQ6MnvOGMb/CVFBeo0A=,iv:OAw7hXOgAlUQWViCb/fkfqXaNEcyu7947ttbaqHYceM=,tag:sUoVL7Fac9c4dil/+bz7fw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGcitwT3BoSE9LWE5zejlh
|
||||
WlZPYnJUWTlycC9nZnJaK2d3dmFLQWhsS1M4Ckx0c0hPaXVnaUZra3dFdFVJbm93
|
||||
N29XNXZtVUN6d3FjNFB5UDNseXNwMDgKLS0tIGZ4ckI1ejEyc2w5alJNSXF3YklW
|
||||
cWZrUWdGd3BEdTYvZU5lV1BDcEE4Z0UK2Q/UGFJwBxLeSib1vJhfGAsR+RMLq144
|
||||
JEUSbfXvUHvuMyY94DhbXmh2WgqxWYQuvF9UrDLeDw14tNPIGoIpFg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
|
||||
lastmodified: "2026-07-25T01:02:49Z"
|
||||
mac: ENC[AES256_GCM,data:HsxO525FU/AfZw6Y5nTrfPIefl1InTV4M7e03hGsxZRHS2Fwsdqiyl/swI2oUFt3tfEyn0RwNB9cEeG/yo4UPPY80E5wnBvv0kaV0gfibBNwBRuIbPJZR/vXm6k8+60shcCOrtvpby4zUmtuk84kEK4mHWpVzh4xs5eYY37la6I=,iv:yR1Zq5TusAdzt7JnarXqRU3X896JMXJzrJw7p6tTzWs=,tag:lK9ph8OJyF/W1E4CLX8AGw==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
31
infra/deploy/secrets/common.yaml
Normal file
31
infra/deploy/secrets/common.yaml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
PORT: ENC[AES256_GCM,data:DygsjQ==,iv:9CQZ8qDFdUkfUDBdS5dpx79YqzXLzTS1KMafH8N8k8c=,tag:HtO18+gDgU+lfBhu6Zswmg==,type:str]
|
||||
REGISTRY_TOKEN: ENC[AES256_GCM,data:C7IHLfZvhNuzwy7TGl00u4gyj0YqEpScEaXW9VaE/uYMg51Wcr6oLg==,iv:BhBSppQ2xLRQm5u1DkFFAgB+EgKp7wYw2KlPDpdbUho=,tag:ZbCHYCY+EQg6AJwAr/J27Q==,type:str]
|
||||
THERMOGRAPH_BASE: ENC[AES256_GCM,data:sQ==,iv:Pop6S2qU0o2+2xMKWQD2P3Vjdh4rUafWF7o2/k8b6fw=,tag:NTJKCX7hH0shrVzmTAYrkQ==,type:str]
|
||||
THERMOGRAPH_COOKIE_SECURE: ENC[AES256_GCM,data:CA==,iv:TM6XiygrDQn2qps4lO6hY2ldjW8LWlO3D/R/DBPqvGQ=,tag:+thBT7fMzH+DigecgxF4CQ==,type:str]
|
||||
THERMOGRAPH_INDEXNOW_KEY: ENC[AES256_GCM,data:XZEMMJfmrSjc/sKEePaigTqEkh3ob/E25R25DvOAWwY=,iv:fqdaegzG1Na0zW+UgOh92RS4FP22pxStdD1/Jq5DRjs=,tag:/8ORtVkGUWjqgGXG/xZHmg==,type:str]
|
||||
THERMOGRAPH_LAKE_S3_ACCESS_KEY: ENC[AES256_GCM,data:5pL+8tZbs8TZ5ezJpkPUmMss0gLcte73lU+au/476Ws=,iv:ujU3G+rbPZQx/igg50GeIPQxqgd+szIsjc3AqGhGLBU=,tag:hrkgn2DPkmBGThlsdMplqg==,type:str]
|
||||
THERMOGRAPH_LAKE_S3_SECRET_KEY: ENC[AES256_GCM,data:XG+6wizXMVVFixhqLhg72HoKw9kEmH+Tty8jYMycvVk=,iv:tNz/WrkMYip2QyAr6bRy6PaIee56ZZ64BELBgvidVT0=,tag:HiQ55DGbeDAOSF0vpNWpWw==,type:str]
|
||||
THERMOGRAPH_METRICS_TOKEN: ENC[AES256_GCM,data:DmOZU3HWQTuoSvQMb7iPkClJbDH48NB3OcRIYRkqNz8=,iv:acgGh0w2mc5ZD7rB7m/GW3Awsx3RWK+02L6YkAv5Rw0=,tag:x3U9F0Dekw/r9euRMjVQAA==,type:str]
|
||||
THERMOGRAPH_S3_ACCESS_KEY: ENC[AES256_GCM,data:hTSSUIfOlY0GYI8gZ7FcOi7c89iye0Ym73l4M22zkNk=,iv:aPdbXKb4pOqigo9J3a7oM3qTip6HQcHUQDlAwg6XfoE=,tag:C4p+Ob/iSElNdF4m9E+M7A==,type:str]
|
||||
THERMOGRAPH_S3_BUCKET: ENC[AES256_GCM,data:vGJDaElN6JRUawlD55Vr5Q==,iv:4ZAQ5NXb9xuMN+qPspM17W6zm9NIDXLX9hTJ/etSL0E=,tag:Ss7xaUl+kOiVF7oDs9FiYw==,type:str]
|
||||
THERMOGRAPH_S3_ENDPOINT: ENC[AES256_GCM,data:PtZg52Uu70Ndx7L/dRTCZTmqv80NXiohOg+gfrzG,iv:hKQTy3Twd5uWwnS4UIs9oqT4NzpYUWQxWO1lUqrzpAc=,tag:kYwYp58fxkzRe5yQPSXa3g==,type:str]
|
||||
THERMOGRAPH_S3_SECRET_KEY: ENC[AES256_GCM,data:1xuZRaC4p+ZM/oFAUuGDZb6AAu2PbjFrHQY3zmczRpI=,iv:xzI24qlUOvYiThBLOue0odvopuxdHCwDlwI4JwiP9EU=,tag:yRS9GjGLBEYk/kZ6miFudg==,type:str]
|
||||
THERMOGRAPH_VAPID_CONTACT: ENC[AES256_GCM,data:N9mPLx6Mcgqm5TlgqIMFhNuZsBZxVPXf3LplO9k+VA==,iv:y5+MLI0zC5Kb6pRdORTMVJ1iMMoFrVRfv99mKWMRjp8=,tag:7VvKCLU+1TJtQcHWlDwybg==,type:str]
|
||||
THERMOGRAPH_VAPID_PRIVATE_KEY: ENC[AES256_GCM,data:hTxKlgPWeW4HGBf08SxdAdK+ce6T5Fl9f+5tSGV6WpS+za5EI3zsK8BgRw==,iv:l+omFaCyL2+PHWdchadkmED2gIAoj5T0u/Ltzaw8mok=,tag:AQ3wT/wD5JAouX1M+Tjjmw==,type:str]
|
||||
THERMOGRAPH_VAPID_PUBLIC_KEY: ENC[AES256_GCM,data:bgtqZFfTzYz5llh/YhAyGwGtRedK3/ze8SAWj8YdldY6s4nt2F3XeTANVO2Y9+NzvXc16PB3WWPnYEzCxnrG2KciIeYpOYZMoPpGldENPBLJtLa76Ej9,iv:HuZ7XtlXIt4wyJ79k3IYjHfWrQp7lnak4uCspyzS4BE=,tag:uPZvba8LVugDy76pKat6TA==,type:str]
|
||||
TIMESCALEDB_TAG: ENC[AES256_GCM,data:3mDU/k5fx0894+E=,iv:/iw3BZPF3mZ9M3bFNJSTzP9t15YZWCJwzoVXYL9Vj9o=,tag:Y4ez3+XfLJN1PtJyZlJj+g==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrdjgydDB2VWs3VjZ1TjUy
|
||||
dDQveXNKdGFkSDFrNXVMMmJKM2xtVThxWGswCjNxUjhvZ2NRYUJFQzcwaDFKL3dW
|
||||
OFZ0RjFBODBQcHJqYjBjSSsyZll2cTAKLS0tIHZlYjdqSVRKZEtmcHJKOU9UN010
|
||||
QWdpRENmTm5HaHRTNGJybTEyUkttN1kK7RTlbbT1u1EGE1IVykk576Tig2Bgm3Fh
|
||||
bncE6yn10QK5kVcF9dDvpQ6RbaG+ESpNZTnuhSL5PDqrKtjnsV0Iqw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
|
||||
lastmodified: "2026-07-25T00:38:24Z"
|
||||
mac: ENC[AES256_GCM,data:C12PnFE7MO0Ge5dCAHCcyXh650hFtRuexl5d3DL0IqTf8nLyNtRJzrPyl9whIs+S3HulsXh3xJOwwPdg6aTqdCfGSQ4Kp5qV+OHxH9lfpHs2yH+exa/eX+7Khpjk0OfvX5beC0GSPpYf9c01buaUTWcIh3pJXpDMdqr+aLC+Zd0=,iv:6VeaaEEkWU5rdTHy75rc2emb2u/HmOmLzO11D38ZXHc=,tag:iGRevzdtqZF9kyH0wxqG3w==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
27
infra/deploy/secrets/dev.yaml
Normal file
27
infra/deploy/secrets/dev.yaml
Normal file
|
|
@ -0,0 +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]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzV0lrcUpjeU81TmJDRVpa
|
||||
elppeU1mMUlpc3lKSTVtU3MxUi83bzN2dVVRCmxZbDJFUkJQVjhCSmljNFpPaWVu
|
||||
dkNqTkJTd1ZEaHZsZitpTWlJdElCK28KLS0tIEdSTXN6S1Y2eE12U3o2RGxBNXNN
|
||||
bDBlYkNxMDVTVEFLYVIyVkhIRnNwOGMKv/TMt307XqvzBLCOCA5C4kXFV9iJeVBn
|
||||
7gyzb5MRbHbDoNAY/5ckU3341uWUXUQ+IrPvVt2snAdXIlWghm+HwA==
|
||||
-----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]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
|
@ -1,47 +1,31 @@
|
|||
PORT: ENC[AES256_GCM,data:fIs4bw==,iv:IxgyETePv05oUAbuNrMYi02KFgR3P2nrH21m5BzubP0=,tag:dLvmH1eZ+GUR1SdouSZyOQ==,type:str]
|
||||
POSTGRES_PASSWORD: ENC[AES256_GCM,data:ozf8j0NeYoPezyvop7AQfecLbDrAF2Gm/rH6zy0m7tc=,iv:uvgkKEHEdsvTaF/JEuApwBGCoWVE5X6ISjk/8twerwk=,tag:2GIQY8BxGB2on+RpKMTGsA==,type:str]
|
||||
THERMOGRAPH_DATABASE_URL: ENC[AES256_GCM,data:tk7/lmdlleneechtHysVACO6kxpNv88xpMjzEEyOgOI18fZ5X6g50re/4A3HVBRCDajzFMiVi6RwkshuD14431cuSy+fUwG5YSmJ59EgVQdjk3TXJg==,iv:5S9mla2hWZdW53J4Hxtwh7Hm51yElZNwpnI+Y5J47Ws=,tag:X9bgD2hEHSN6v4BWMsXV6Q==,type:str]
|
||||
WORKERS: ENC[AES256_GCM,data:IQ==,iv:WSA3xRt9/OgWeaOx8LM17apYa8Ig/P5oLwx2F6yqwEM=,tag:2GhxzgvAGeF8QC1j7lJbqA==,type:str]
|
||||
APP_CPUS: ENC[AES256_GCM,data:+w==,iv:Xi5gGmV1/7GLOK2s6tmajyYOi73iiQ1MiSIjeVzeUec=,tag:oUP56tzWwsVy+B6BsI9IXw==,type:str]
|
||||
DB_CPUS: ENC[AES256_GCM,data:Yg==,iv:YJUkzGMZKd4ol1u3M1YisPd0RttCC6Q4GLzavrpxv7I=,tag:49RAdZFKlB3T50dL8mWtng==,type:str]
|
||||
DB_MEMORY: ENC[AES256_GCM,data:MzTb,iv:oT4Nuj2DBzOt2j/pXKGcWDxHEJspQyFNd56IfHxsg+M=,tag:PdnuD7CE13FE6cHs/Jzp8A==,type:str]
|
||||
TIMESCALEDB_TAG: ENC[AES256_GCM,data:M5c1teXwfm06bAw=,iv:NdmaDupUdOZIhtKx4AdILszGNau35PpgnkCEWR73SM4=,tag:SVtQbhKQJc33+40nwDOX6w==,type:str]
|
||||
THERMOGRAPH_BASE: ENC[AES256_GCM,data:qw==,iv:qWtU+SEPpTVsskhjLBtvgAw8jc2FmJJEkIdxt3ExNd8=,tag:al5lpHwgbzWS7MUD8pBXVg==,type:str]
|
||||
THERMOGRAPH_BASE_URL: ENC[AES256_GCM,data:IEPwvaGXhyJiM+TfbXUycoURU57uBMA=,iv:87pKtsL2hZVMRqHfwRP3nxTdG6iUpzbrUVCTZDTjR8w=,tag:1q3lOTpIn51+5RD4TV/Ssg==,type:str]
|
||||
THERMOGRAPH_COOKIE_SECURE: ENC[AES256_GCM,data:XA==,iv:a7Ck0qcUOmWLJMtWO8j7GlPRnx80lj0iZYnkAPl0JDU=,tag:BkPelQBxIus4pwLJVlcQPQ==,type:str]
|
||||
THERMOGRAPH_AUTH_SECRET: ENC[AES256_GCM,data:YVAX4BlmBzq7hcHQacVxDFLxKnd6C6QiEV7N9/MCjJNUlQonDX/+aydXpA==,iv:8PON0qfVH1LsXHOXUbogRJcTeosswQLVRlctKkscLAw=,tag:Lhnm5QGRJyVDbl5CIzBbPg==,type:str]
|
||||
THERMOGRAPH_METRICS_TOKEN: ENC[AES256_GCM,data:SggaGZF2wIFV3uiE1bnWWreyCuFXrtmvHi+Yp+tuF9E=,iv:QcgEAWNKRS3kiBfdjnK8UzG6XQ4q4vBoRgT2Uyw2NI4=,tag:ElAAWl0/OPONBTabag72TA==,type:str]
|
||||
THERMOGRAPH_INDEXNOW_KEY: ENC[AES256_GCM,data:UDCEfsWNX/4DwAkyo/QeDckFTBe7r/CT9/sEgfdzn88=,iv:TAY5BdkBq4KwtXhRazKiuTdnjE5UVcWFBRkAlkpnC08=,tag:knZ77X3fCfEPpN0eC+mP+g==,type:str]
|
||||
THERMOGRAPH_VAPID_PRIVATE_KEY: ENC[AES256_GCM,data:fOxls5noaPsKU23kNOoaTrYL1r68ggkLp3qxG7Kpc5W7VSGjBRlpvpNlOQ==,iv:sF2JMS8QyjvAjnfA/LjgCiwFJgWPZCs2ZtyHJrhstHI=,tag:wbwJl2whlzoaKh6OhDYu6Q==,type:str]
|
||||
THERMOGRAPH_VAPID_PUBLIC_KEY: ENC[AES256_GCM,data:IRz3U8C5vQ5n32uX5TI8IVmyWudnmprV1rJxMjl2kyVda5WK9tWkQ8qxq0XuRqgsc5vaLYNR/pfK/cJipBcFA7oR/zxsNzzyth49UzzTEVeTrkt4gPIj,iv:OCA5AcXjSoYItL09OHtYkJczOrkq1vS2D0V7msjXKhU=,tag:JqRPiWDLSisxZqYUcC6pNg==,type:str]
|
||||
THERMOGRAPH_VAPID_CONTACT: ENC[AES256_GCM,data:MmpxuSfiLpZg5RsxE9nl2Vz6J2HocgONf1Oe2atk4A==,iv:ijGCvfbts9ZhzXWR9mou8o6pQ1AkGdEoFQs+zZNTMpo=,tag:QmuRJoiIIAPwCbjRD/0S1w==,type:str]
|
||||
THERMOGRAPH_MAIL_BACKEND: ENC[AES256_GCM,data:etyPNQ==,iv:QnmEDt4aKYBBB/cp+tvvX7MqgVDGBBgqCO22M/GvEP8=,tag:STILjLmrxXrT8TEatf2phg==,type:str]
|
||||
THERMOGRAPH_SMTP_HOST: ENC[AES256_GCM,data:oiVnVJNwjFFdnA==,iv:PXKNKaGas3aTKpUk53WX5NJw/YS5Sxxv62RNZBlFd0M=,tag:sJMo0EVCVKZhj4pBjJGWRA==,type:str]
|
||||
THERMOGRAPH_SMTP_PORT: ENC[AES256_GCM,data:l3o=,iv:B2Qq+Eq+EL8F5R3dio/jxl65RGYtnouU9MChlXlnlaU=,tag:vLvd50WBMXVAoN+XHyltpQ==,type:str]
|
||||
THERMOGRAPH_DISCORD_CLIENT_SECRET: ENC[AES256_GCM,data:SugVMdKFZbudfN3l/ZbYZf9lLBVdNPrJ7p3keOM9NmE=,iv:Cc7m2t+49edItI74/bNB0/J/XF6fsG+efXVdBf9xj1Y=,tag:jgBGAXaMrmMh3Jkgh/zBMQ==,type:str]
|
||||
THERMOGRAPH_DISCORD_APP_ID: ENC[AES256_GCM,data:Gkk9f189A0pqGEMPFuqFKIxHAw==,iv:OMML3H845FEa3BLBphlkX7vWn04mpD6K4i0GwFBjgr4=,tag:V+GGCXgF1ZWuCEhScm2j8A==,type:str]
|
||||
THERMOGRAPH_DISCORD_BOT_TOKEN: ENC[AES256_GCM,data:NWuiv3jQFL0v0rB/O/Kt0i5y7eUoDw1pfrpF67zr4h7uN+MrvHau/0juaEEDurOuvwks+1jikZUrR1UX3warmi6sRZsFjRqp,iv:cuA1dvi5kNJ6/OM24xuQSTPjkW+FdfnMYqrePO+oyhE=,tag:Cq1uxz0PFYNUzJUsOWfSGw==,type:str]
|
||||
REGISTRY_TOKEN: ENC[AES256_GCM,data:HUDgFdmIKadUP79HN/ojKheWihTds1bR8+NxJnsY0G0PRU6QgGq3aA==,iv:XK/IBNpLljaPZYJddWDPLiykC7k0TcVKr82xa6bKfRQ=,tag:AfJBdXE2WkT6Fl61IuVWIA==,type:str]
|
||||
THERMOGRAPH_DISCORD_BOT: ENC[AES256_GCM,data:hw==,iv:Us8MoaQodVojvUfi5P+gdvL7QHMnT629GP4n/8F0rcA=,tag:fo4/zq3hTRyQPwLy9kE8IQ==,type:str]
|
||||
THERMOGRAPH_DISCORD_WEATHER_CHANNEL: ENC[AES256_GCM,data:OaCxv7LZ6Pj3UARlEKayilzT4Q==,iv:rdH2TvqtI1yedcHnAjn5ymbhaiz1F7lAY1mHSaBiBUY=,tag:BBKR/XtljAaoheQDDEMD8A==,type:str]
|
||||
THERMOGRAPH_LAKE_S3_ACCESS_KEY: ENC[AES256_GCM,data:KkaOozI9y/IJDJMPnSA6sxmUzdD/ao6RoYuDhTObuEg=,iv:zSC5ICu35a0A83OFTl2ft/8SIUeiXAJn9uWL4/q5IHk=,tag:dIkCNTmChd+/s39C7P8YqQ==,type:str]
|
||||
THERMOGRAPH_LAKE_S3_SECRET_KEY: ENC[AES256_GCM,data:z5Yh2FTxDCaLBbTuNSXEYMCbn6/abi08Ou+brzbbALI=,iv:s0bsnId0qiBBnAhAGVSO8WMFxpIjj43kkb/092Kwo+8=,tag:qv5dZOt6dyDHnIFHbX0yKg==,type:str]
|
||||
THERMOGRAPH_S3_ENDPOINT: ENC[AES256_GCM,data:Gs8UZiaXyDSQMVi6qHdjf46I+GHjbJxne/S6XDQ2,iv:fAnXNzLU+4sykVRDh8G35tloKkr/6de/Gn3IKx0Cn4w=,tag:UBKnOUqYO04tQPp6+8EeHA==,type:str]
|
||||
THERMOGRAPH_S3_BUCKET: ENC[AES256_GCM,data:FDIcNaBgJkhILXL1/5Bw/w==,iv:aHPhSd8XLs6aizu64fa51NLFpFQceK4+e0v4SygJ9QQ=,tag:Ppz4Orec54bpiacRr/hqEQ==,type:str]
|
||||
THERMOGRAPH_S3_ACCESS_KEY: ENC[AES256_GCM,data:sNZj3vQP2sR/TwvW3Xn+RYaX2wr2r5au99oyb/S4UNg=,iv:3qsuksTfLKsI/RBNMQtRfmYAnTMLpeASUEmZk1/+AFQ=,tag:OIvVtFDl9u2mSCwNd05Oiw==,type:str]
|
||||
THERMOGRAPH_S3_SECRET_KEY: ENC[AES256_GCM,data:BkjZCjpz0FMMl3XIzlH7BKVJkK0e1mhMx9ax8m4OEJw=,iv:JEgXkYHCgafPXKjJZFWifyVOYBtknmxmbWW37jc4tkM=,tag:jvKrdddwYDZYZL4A5Y+7kA==,type:str]
|
||||
APP_CPUS: ENC[AES256_GCM,data:Mw==,iv:VDl9fjeKCS6QKOxqEcnsUzm28hz2gi862U/MXdt48e4=,tag:pNOUUCcXnPXrGwrfV7mtPw==,type:str]
|
||||
DB_CPUS: ENC[AES256_GCM,data:8Q==,iv:pSM/YYBm8wC1BXKbKjys+zgBilZrm3nF+8X6QLDfnyM=,tag:hc5eZNmH6kFbcYmBaWt04Q==,type:str]
|
||||
DB_MEMORY: ENC[AES256_GCM,data:zXCT,iv:Z+Dc3x12wL2RCPj67vkQAb5/h6MlS2eU39MP8kgYDxs=,tag:izgtoFV/sTMcdg0SCpncZQ==,type:str]
|
||||
POSTGRES_PASSWORD: ENC[AES256_GCM,data:kRusxDXF8YvSQVNCD4iZlvt7pXFfFZe0hWLELxl5904=,iv:xzM54TsbVb+94Yp2oDB7Bi5R1a9mV7HxY+oOBEE4ANg=,tag:JRYqcpkAxsA8a/nvhRlODg==,type:str]
|
||||
THERMOGRAPH_AUTH_SECRET: ENC[AES256_GCM,data:8KnDLjhlE7Hv7G0CXuttmHjzBgqS38DZMpeRJOqYJhOJ4vjVNeWWyd9Ibg==,iv:MNiZRkWnV74+4ALNwcWyFbtyFOU/kuU5ZCH6OooF5X4=,tag:uZoZ4HwGxVYuWVkxOFNLFQ==,type:str]
|
||||
THERMOGRAPH_BASE_URL: ENC[AES256_GCM,data:m7fOK/MQEl41pzRIvqMrx1tqYjxt29k=,iv:qYdOs9gitsL9yz39bO3OvlKaHlMSYb0Qy7fHoBrikGE=,tag:4dEmR7twOmUosk/AfxOtqg==,type:str]
|
||||
THERMOGRAPH_DATABASE_URL: ENC[AES256_GCM,data:qZTguYgyrMxgI8thK5+16TP2X+Y89je7n92PcMm8Ha4v1Iig57I7hmhDL5m0HT0O6R2d1VR0LfhGa2G5cs9Vu/bwpqQKcy857w2hXjHegc6PB3gJ/g==,iv:ncj8a8WwLocqVloxvnIQLZginpXmyN1HOE9NOuh54nE=,tag:CL3lqG/Jdk4I8rccX/RASg==,type:str]
|
||||
THERMOGRAPH_DISCORD_APP_ID: ENC[AES256_GCM,data:uFKK3zDAtHezqUh+J7vTfDOZjA==,iv:IJDdNoTaM8EiXw9WweMI7Tw8yUvelUDCqILb/ybusv8=,tag:rasuzDgBwO2hPHRHWYpewQ==,type:str]
|
||||
THERMOGRAPH_DISCORD_BOT: ENC[AES256_GCM,data:sw==,iv:yCSFbOotnUWSGgsKwcbk4Kf6QDw8e6ap9WJCaf4R9y0=,tag:REJXFRuKYrkdsazbj/ZzBA==,type:str]
|
||||
THERMOGRAPH_DISCORD_BOT_TOKEN: ENC[AES256_GCM,data:qm8qSXUjnBh5/y3oEtWwxT63dJMta9x1cm7Fqoa+UrKffHrWgABirYnXdw30Xl8u/Xpozb9XTZjCXNOoLytJHHLcQ6pizK3h,iv:Z1btW0ISGgaNk8k+9vs0X8XFjWDnizysJmlytTbObrE=,tag:KmevCBpKUP65zAXb3pCpRg==,type:str]
|
||||
THERMOGRAPH_DISCORD_CLIENT_SECRET: ENC[AES256_GCM,data:dOhFSOHOzKyY6Eo5XxicTqeMs7uKMoBYSDko62U//cY=,iv:feppgBFYJqFCATsjoMDdtqImyRP+Fxlh3MGHZr69ARY=,tag:p0fMiqFxWKzBSyaTeYfqJw==,type:str]
|
||||
THERMOGRAPH_DISCORD_WEATHER_CHANNEL: ENC[AES256_GCM,data:L3WUamgkAd6KwX7f9hK4IquX4w==,iv:GUZMsF/nHevbjDaYuABALJJjnFQpAiv9pviKFAZ7zOg=,tag:D9Aprtq/uY56yCcWacyMeQ==,type:str]
|
||||
THERMOGRAPH_MAIL_BACKEND: ENC[AES256_GCM,data:sVvAiw==,iv:v7APCvTRHn2WVt6jR79aSuvAr71WOuW01XkxxNJd1VI=,tag:W9Mjv1ugYTTJIqhOwEYLKA==,type:str]
|
||||
THERMOGRAPH_SMTP_HOST: ENC[AES256_GCM,data:57g1wwzwH2ZpOw==,iv:oCE45LyGfMvOGkreX/lfbaE71bxULcDLRRv5trfptr0=,tag:pGU56sqExjkrsLCsP30tvw==,type:str]
|
||||
THERMOGRAPH_SMTP_PORT: ENC[AES256_GCM,data:L0M=,iv:jlq7qKwNO2CQsmjXm2YGar5vpVOouQrTPv7aCZqjZV8=,tag:VqYm/rz3XXjt1ZIAzcOk9g==,type:str]
|
||||
WORKERS: ENC[AES256_GCM,data:yg==,iv:cmix2WTi17ps925n5mzGC8jNTUXgaGMOsY8cv5b7K88=,tag:KZW4B4s/yRI6JhvMf+Sejw==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTK0ZVNC8welBOanMxRzFJ
|
||||
VUNNV1FmM1dIdkpiUDQvRFlIZ2JDc3hNOTJVCkNiTHdJRHlHYzFpeVBlbFRLSnN3
|
||||
VEc2NWF0RzZCZDFKaUJuK0p2VUpNTkUKLS0tIE04WmpiejBuRUM1NnE1WTNjWDdp
|
||||
NS9ITjkrSmhueXBLd1pQRDYxNzc5MUUKnP05qgyr7IrY8qWOl/OrkaanS+zGBlWA
|
||||
+tEJ8oBk9numWkpwHRCQj102ixXoFuEJkbjPHMQK98uCn6sVLrc8yA==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzTVBsWDFITXlsc3k1Z3B6
|
||||
ZHlZK0ZNaWhkTTJwcVMxY0NzdUNRdFdjSVVZCnVCdVMweW1yY2NEaks0M1lNNXMr
|
||||
Z1pYUDBCcnpsWVYxMzI5SlRvSXNTVzQKLS0tIHJpZUVsOVpqVXVmL251eE9qV01H
|
||||
THpLaGs0dUdKTi9zYlZ4aVhDM0pSV2MKntzANQ+MBmF0534dVqHceJtQPXEjFQMk
|
||||
bpO0CS4dNU9F0KRYpGrYCf1n6/+9W4dw3ZX/qLryzqNZD0cslVZm3Q==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1xx4dzs0dxlwvkv9sjuqzsphl7lfrxannkfken374yu2qvvcte9sqzktqt2
|
||||
lastmodified: "2026-07-24T20:06:42Z"
|
||||
mac: ENC[AES256_GCM,data:ne1vtQ3sTWEYGNme6cn84IKKJkXFkJKz+QEn3pi4vjwQYFa1hcdOSE/Sv42Tjx5FiQ+8tFbbwtuGrsVxcydqgxCqt5LiIMJiO4jdGVEaZsKYgshzzSGE1qn7fhPaz5welxiq5pNCddsDUmzVBBuKx8ddi4DCJk1JNSvIufMNvRU=,iv:sTSvy+IxfZHV6s4yZx8BZnVfPhaUR0pZIZrnMTjTvGw=,tag:4Xbf5pPDop2y5GcOaKfHNA==,type:str]
|
||||
lastmodified: "2026-07-25T00:38:24Z"
|
||||
mac: ENC[AES256_GCM,data:8eaV8ZKuJVwAic7MIpKd2dHXm4FYcqo9WsBOaolaJ9tBo9OOZhHG8VeYraeMGZ/KqTp3PMgek5aSN0zLGyY1yaqhj4ygocYLpfOHamkodcyvQvno2rOpe9VVQ/wg9GelpikYIA4zkK+hnZ5Xa7HidxNsBd7rfCMWFXOdjOsNzoM=,iv:+6VLa3B/H53+ilrELuTwHsXIxzpytyBMDaYNsK1ZmVQ=,tag:h2969P1BODB1Z1UXEoJUhw==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.13.2
|
||||
|
|
|
|||
99
infra/deploy/secrets/seed-centralis-on-host.sh
Executable file
99
infra/deploy/secrets/seed-centralis-on-host.sh
Executable file
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env bash
|
||||
# Seed deploy/secrets/centralis.<env>.yaml by encrypting prod's live
|
||||
# /etc/centralis.env ENTIRELY ON THE BOX. The plaintext never leaves the box;
|
||||
# only 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 — no sops, no age, no private key.
|
||||
#
|
||||
# deploy/secrets/seed-centralis-on-host.sh prod agent@169.58.46.181
|
||||
#
|
||||
# Then commit the resulting deploy/secrets/centralis.<env>.yaml.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHY THIS IS NOT seed-encrypt-on-host.sh WITH A DIFFERENT PATH
|
||||
# ---------------------------------------------------------------------------
|
||||
# `seed-encrypt-on-host.sh` captures the RAW TEXT to the right of the `=`. That
|
||||
# is correct for /etc/thermograph.env, which is machine-rendered and whose 32
|
||||
# values happen to be bare tokens, but it is WRONG for a hand-maintained,
|
||||
# shell-SOURCED file. /etc/centralis.env is consumed as
|
||||
#
|
||||
# set -a && . /etc/centralis.env && set +a && docker compose up
|
||||
#
|
||||
# so the value the program actually receives is the value AFTER bash's quote
|
||||
# removal. `CENTRALIS_TOKENS` is stored there single-quoted, because it is JSON
|
||||
# and contains double quotes. Capturing the raw text would put the surrounding
|
||||
# single quotes INSIDE the vault; the renderer would then quote them again, and
|
||||
# Centralis would receive `'{"emi":...}'` — literal quotes and all — and fail
|
||||
# its JSON parse. Fails closed, so it would look like the token registry had
|
||||
# been deleted. That is the same class of bug as the one this whole migration
|
||||
# exists to remove, arriving from the other direction.
|
||||
#
|
||||
# So this script asks BASH what the values are, by sourcing the file in a clean
|
||||
# environment (`env -i`) and reading the resulting variables. What goes into the
|
||||
# vault is exactly what Centralis receives today, by construction.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/../.."
|
||||
|
||||
ENV_NAME="${1:?usage: seed-centralis-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/centralis.${ENV_NAME}.yaml"
|
||||
|
||||
# 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/centralis.env on-host -> ${OUT}"
|
||||
ssh -i "$SSH_KEY" "$SSH_TARGET" "AGE_PUB='$AGE_PUB' SOPS_URL='$SOPS_URL' 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
|
||||
if ! command -v sops >/dev/null 2>&1; then
|
||||
sudo curl -fsSL "$SOPS_URL" -o /usr/local/bin/sops
|
||||
sudo chmod +x /usr/local/bin/sops
|
||||
fi
|
||||
} >&2
|
||||
umask 077
|
||||
tmp="$(mktemp)"; tmy="$(mktemp)"; tpy="$(mktemp)"
|
||||
trap 'rm -f "$tmp" "$tmy" "$tpy"' EXIT
|
||||
sudo cat /etc/centralis.env > "$tmp" # plaintext stays on this box only
|
||||
|
||||
# The declared assignment targets, in file order. `source` gives last-wins for a
|
||||
# repeated key and we read the post-source environment, so the de-dup here only
|
||||
# controls the ORDER of the emitted YAML, never which value is taken.
|
||||
names="$(grep -oE '^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=' "$tmp" \
|
||||
| sed -E 's/^[[:space:]]*(export[[:space:]]+)?//; s/=$//' | awk '!seen[$0]++')"
|
||||
[ -n "$names" ] || { echo "!! no KEY=VALUE lines in /etc/centralis.env" >&2; exit 1; }
|
||||
echo " capturing: $(echo "$names" | tr '\n' ' ')" >&2
|
||||
|
||||
cat > "$tpy" <<'PY'
|
||||
import json, os, sys
|
||||
for n in sys.argv[1:]:
|
||||
v = os.environ.get(n)
|
||||
if v is None:
|
||||
sys.stderr.write("!! %s vanished after sourcing\n" % n)
|
||||
sys.exit(1)
|
||||
print("%s: %s" % (n, json.dumps(v))) # JSON-escaped scalar = valid YAML
|
||||
PY
|
||||
|
||||
# Source in a CLEAN environment and read the values back, so what is stored is
|
||||
# exactly what `set -a && . /etc/centralis.env` hands to docker compose. PATH is
|
||||
# passed through only so python3 is findable. $names is deliberately unquoted:
|
||||
# it is a whitespace-separated list of identifiers by construction.
|
||||
# shellcheck disable=SC2086
|
||||
env -i PATH="$PATH" bash -c \
|
||||
'set -a; . "$1"; set +a; shift; exec python3 "$@"' _ "$tmp" "$tpy" $names > "$tmy"
|
||||
[ -s "$tmy" ] || { echo "!! captured nothing" >&2; exit 1; }
|
||||
|
||||
cd /tmp # no .sops.yaml here, so --age is honored
|
||||
sops -e --age "$AGE_PUB" --input-type yaml --output-type yaml "$tmy" >&3
|
||||
REMOTE
|
||||
|
||||
if grep -q 'ENC\[AES256_GCM' "$OUT"; then
|
||||
echo "==> OK: ${OUT} written and encrypted ($(grep -cE '^[A-Za-z_][A-Za-z0-9_]*: ' "$OUT") keys)"
|
||||
echo " Next: infra/deploy/secrets/verify-centralis-render.sh ${ENV_NAME} ${SSH_TARGET}"
|
||||
else
|
||||
echo "!! ${OUT} is not encrypted (the on-host step failed) — removing" >&2
|
||||
rm -f "$OUT"; exit 1
|
||||
fi
|
||||
156
infra/deploy/secrets/verify-centralis-render.sh
Executable file
156
infra/deploy/secrets/verify-centralis-render.sh
Executable file
|
|
@ -0,0 +1,156 @@
|
|||
#!/usr/bin/env bash
|
||||
# NON-DESTRUCTIVE go/no-go for the /etc/centralis.env cutover — the Centralis
|
||||
# analogue of dry-run-render.sh, and the proof obligation for the migration:
|
||||
# that rendering deploy/secrets/centralis.<env>.yaml produces a file whose
|
||||
# EFFECTIVE VALUES are identical to the live hand-maintained one.
|
||||
#
|
||||
# deploy/secrets/verify-centralis-render.sh prod agent@169.58.46.181
|
||||
#
|
||||
# What it does NOT do: write /etc/centralis.env, restart anything, or print a
|
||||
# single secret value. It renders to a 0700 scratch directory, sources both
|
||||
# files in a clean shell, compares the resulting variables, and reports key
|
||||
# NAMES and a verdict. The scratch directory is removed on every exit path.
|
||||
#
|
||||
# "Effective values" is the load-bearing phrase. /etc/centralis.env is consumed
|
||||
# by `set -a && . /etc/centralis.env`, so the only comparison that means
|
||||
# anything is between what BASH ends up with from each file — not between the
|
||||
# two files' text. The live file quotes CENTRALIS_TOKENS and the rendered one
|
||||
# quotes everything, so a textual diff would report a difference where there is
|
||||
# none, and (much worse) would report a match if the vault had captured the
|
||||
# quote characters as part of the value.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/../.."
|
||||
|
||||
ENV_NAME="${1:?usage: verify-centralis-render.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}"
|
||||
VAULT="deploy/secrets/centralis.${ENV_NAME}.yaml"
|
||||
[ -f "$VAULT" ] || { echo "!! no $VAULT — run seed-centralis-on-host.sh first" >&2; exit 1; }
|
||||
|
||||
SSH=(ssh -i "$SSH_KEY" "$SSH_TARGET")
|
||||
SCP=(scp -q -i "$SSH_KEY")
|
||||
|
||||
# shellcheck disable=SC2016 # single quotes are required: this whole string is
|
||||
# executed by the REMOTE shell, so $d and the command substitution must survive
|
||||
# the trip intact. Double-quoting would run mktemp locally and then send a path
|
||||
# that does not exist on the host.
|
||||
scratch="$("${SSH[@]}" 'd=$(mktemp -d) && chmod 700 "$d" && mkdir -p "$d/deploy/secrets" && echo "$d"')"
|
||||
[ -n "$scratch" ] || { echo "!! could not create a scratch dir on the host" >&2; exit 1; }
|
||||
cleanup() { "${SSH[@]}" "rm -rf -- '$scratch'" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Only ciphertext and a shell script cross the wire. Nothing decrypted ever
|
||||
# leaves the box; the render happens there, with the host's own age key.
|
||||
"${SCP[@]}" deploy/render-secrets.sh "${SSH_TARGET}:${scratch}/deploy/render-secrets.sh"
|
||||
"${SCP[@]}" "$VAULT" "${SSH_TARGET}:${scratch}/deploy/secrets/centralis.${ENV_NAME}.yaml"
|
||||
|
||||
"${SSH[@]}" "SCRATCH='$scratch' bash -s" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
. "$SCRATCH/deploy/render-secrets.sh"
|
||||
CENTRALIS_RENDER_DEST="$SCRATCH/rendered.env" render_centralis_secrets "$SCRATCH" >&2
|
||||
|
||||
sudo cat /etc/centralis.env > "$SCRATCH/live.env"
|
||||
|
||||
# Ask bash what each file means, in a clean environment so no inherited variable
|
||||
# can stand in for one the file failed to set.
|
||||
dump() {
|
||||
env -i PATH="$PATH" bash -c \
|
||||
'set -a; . "$1"; set +a; exec python3 -c "
|
||||
import json, os, sys
|
||||
sys.stdout.write(json.dumps(dict(os.environ)))"' _ "$1"
|
||||
}
|
||||
dump "$SCRATCH/live.env" > "$SCRATCH/live.json"
|
||||
dump "$SCRATCH/rendered.env" > "$SCRATCH/rend.json"
|
||||
|
||||
# The names each file actually assigns — so the shell's own PATH/PWD/SHLVL/_ are
|
||||
# not mistaken for content.
|
||||
names() {
|
||||
grep -oE '^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=' "$1" \
|
||||
| sed -E 's/^[[:space:]]*(export[[:space:]]+)?//; s/=$//' | sort -u
|
||||
}
|
||||
names "$SCRATCH/live.env" > "$SCRATCH/live.names"
|
||||
names "$SCRATCH/rendered.env" > "$SCRATCH/rend.names"
|
||||
|
||||
python3 - "$SCRATCH" <<'PY'
|
||||
import json, os, sys
|
||||
|
||||
d = sys.argv[1]
|
||||
load = lambda n: json.load(open(os.path.join(d, n), encoding="utf-8"))
|
||||
names = lambda n: [l.strip() for l in open(os.path.join(d, n), encoding="utf-8") if l.strip()]
|
||||
|
||||
live, rend = load("live.json"), load("rend.json")
|
||||
ln, rn = set(names("live.names")), set(names("rend.names"))
|
||||
|
||||
lost, added = sorted(ln - rn), sorted(rn - ln)
|
||||
changed = sorted(k for k in ln & rn if live.get(k) != rend.get(k))
|
||||
|
||||
print()
|
||||
print(" live /etc/centralis.env : %d keys" % len(ln))
|
||||
print(" rendered from the vault : %d keys" % len(rn))
|
||||
print()
|
||||
for k in sorted(ln & rn):
|
||||
print(" %-34s %s" % (k, "same" if live.get(k) == rend.get(k) else "DIFFERENT"))
|
||||
|
||||
ok = not (lost or added or changed)
|
||||
print()
|
||||
if lost:
|
||||
print(" MISSING from the render :", lost)
|
||||
if added:
|
||||
print(" ADDED by the render :", added)
|
||||
if changed:
|
||||
print(" VALUE CHANGED :", changed)
|
||||
|
||||
# The registry, by subject name. Values are never touched; `sorted(reg)` is a
|
||||
# list of audit-log subjects, which is what the operator needs to see.
|
||||
def subjects(env):
|
||||
raw = env.get("CENTRALIS_TOKENS", "")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
reg = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
return "UNPARSEABLE (%s)" % exc
|
||||
return sorted(reg) if isinstance(reg, dict) else "not a JSON object"
|
||||
|
||||
sl, sr = subjects(live), subjects(rend)
|
||||
print(" CENTRALIS_TOKENS subjects live: %s" % (sl,))
|
||||
print(" rendered: %s" % (sr,))
|
||||
if sl != sr:
|
||||
ok = False
|
||||
print(" !! the token registry does not survive the migration")
|
||||
|
||||
print()
|
||||
print(" VERDICT: %s" % ("PASS — the render is value-identical to the live file"
|
||||
if ok else "FAIL — do NOT cut over"))
|
||||
sys.exit(0 if ok else 1)
|
||||
PY
|
||||
|
||||
# End-to-end: the two env files must produce the SAME docker compose model. This
|
||||
# is the claim that actually matters — not "the files agree" but "the container
|
||||
# Centralis would be started with is the same one it is running now". Compared
|
||||
# by digest, on the box; the rendered model is full of secrets and is never
|
||||
# printed, shipped, or kept.
|
||||
compose=/opt/centralis/deploy/docker-compose.yml
|
||||
if [ -f "$compose" ] && command -v docker >/dev/null 2>&1; then
|
||||
model() {
|
||||
env -i PATH="$PATH" HOME="$HOME" bash -c \
|
||||
'set -a; . "$1"; set +a; cd /opt/centralis && \
|
||||
docker compose -f deploy/docker-compose.yml config' _ "$1" 2>/dev/null \
|
||||
| sha256sum | cut -d" " -f1
|
||||
}
|
||||
a="$(model "$SCRATCH/live.env")"
|
||||
b="$(model "$SCRATCH/rendered.env")"
|
||||
if [ -z "$a" ] || [ "$a" = "$(printf '' | sha256sum | cut -d' ' -f1)" ]; then
|
||||
echo " compose model : could not be built from the LIVE env — check by hand"
|
||||
elif [ "$a" = "$b" ]; then
|
||||
echo " compose model : IDENTICAL (docker compose config digests match)"
|
||||
else
|
||||
echo " compose model : DIFFERENT — do NOT cut over"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo " compose model : skipped (no /opt/centralis or no docker here)"
|
||||
fi
|
||||
REMOTE
|
||||
Loading…
Reference in a new issue