openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Prove that the OpenBao render is EQUIVALENT to the SOPS render, without printing a
|
|
|
|
|
# single secret value.
|
|
|
|
|
#
|
|
|
|
|
# infra/openbao/verify-parity.sh --env dev
|
|
|
|
|
# infra/openbao/verify-parity.sh --all
|
|
|
|
|
#
|
|
|
|
|
# This is the gate for every cutover. The migration's entire safety argument is that
|
|
|
|
|
# the two backends produce the same KEY=value set, so flipping TG_SECRETS_BACKEND
|
|
|
|
|
# cannot change what any service sees. That argument has to be TESTED, not asserted —
|
|
|
|
|
# especially because the failure modes on the other side are silent:
|
|
|
|
|
#
|
|
|
|
|
# * a missing THERMOGRAPH_AUTH_SECRET makes the app mint a random one per worker
|
|
|
|
|
# (accounts/users.py:33), so logins start working intermittently and nothing logs;
|
|
|
|
|
# * a half-rendered VAPID pair makes push.py generate a NEW keypair and then delete
|
|
|
|
|
# every existing subscription row as "gone" (push.py:180-184);
|
|
|
|
|
# * a wrong POSTGRES_PASSWORD makes the data layer swallow the error and refetch from
|
|
|
|
|
# Open-Meteo, spending third-party quota, while /healthz stays green.
|
|
|
|
|
#
|
|
|
|
|
# None of those announce themselves. A byte-level diff beforehand is the only thing
|
|
|
|
|
# standing between a flag flip and one of them.
|
|
|
|
|
#
|
|
|
|
|
# OUTPUT DISCIPLINE: only key NAMES and counts are ever printed. Where values differ,
|
|
|
|
|
# the key name is reported and the values are not — the same rule
|
|
|
|
|
# deploy/secrets/dry-run-render.sh already follows.
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
SELF_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
|
INFRA_DIR="$(cd -- "$SELF_DIR/.." && pwd)"
|
|
|
|
|
TARGETS=()
|
|
|
|
|
|
|
|
|
|
usage() {
|
|
|
|
|
cat >&2 <<'EOF'
|
|
|
|
|
usage: verify-parity.sh (--all | --env <dev|beta|prod> ...)
|
|
|
|
|
|
|
|
|
|
Renders each environment through BOTH backends and compares the resulting
|
|
|
|
|
KEY=value sets. Prints key names and counts only, never values.
|
|
|
|
|
|
openbao: make verify-parity --all host-aware
--all expanded to (dev beta prod) and then tried to check all three from
wherever it was run. No host carries all three: dev is on vps1, beta and prod
are on vps2. So --all could not succeed anywhere, and the way it failed was
misleading rather than obvious -- on vps1, prod resolves TG_BAO_APPROLE to
/etc/thermograph/openbao-approle, which on THAT box holds dev's credential, so
prod's check authenticated as tg-dev and took a 403 from tg-host-prod.hcl.
That reads like a policy bug. It is "wrong box".
--all now means "every environment that lives on THIS host". Residency is
decided by the environment's own address being bound here (TG_SSH_HOST against
the local interfaces), and skips are announced rather than silent.
Residency is deliberately NOT decided by TG_APP_DIR existing. That was the
first attempt and it was wrong: vps1 still carries a stale /opt/thermograph
from before the estate split (d5357d0, #106), so prod looked resident there and
got checked anyway. Verified against both live hosts. A leftover directory is
exactly the kind of thing that outlives the arrangement it belonged to.
Zero environments checked is now a FAILURE. Previously a run that skipped
everything would exit 0, and a green run is precisely what the 7-day cutover
gate counts -- "nothing was wrong" and "nothing was examined" must not look
alike. The success line reports how many were checked and says plainly that a
green run on one host says nothing about the other.
Live-verified on both boxes:
vps1 --all -> dev PASS, beta and prod skipped, exit 0
vps2 --all -> dev skipped, beta and prod checked (both currently failing on
the three known stale common.yaml keys)
2026-08-01 16:18:05 +00:00
|
|
|
--all means "every environment that lives on THIS host", not all three: dev is
|
|
|
|
|
on vps1, beta and prod are on vps2, so no single box can check them all. An
|
|
|
|
|
environment with no checkout here is skipped out loud. Skipping every one of
|
|
|
|
|
them is an error, not a pass.
|
|
|
|
|
|
|
|
|
|
Exit status: 0 = every environment checked here is at parity; 1 = a mismatch,
|
|
|
|
|
or nothing was checkable. Suitable as a CI / cron gate.
|
openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
EOF
|
|
|
|
|
exit 2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
|
|
|
|
--all) TARGETS=(dev beta prod); shift ;;
|
|
|
|
|
--env) [ $# -ge 2 ] || usage; TARGETS+=("$2"); shift 2 ;;
|
|
|
|
|
-h|--help) usage ;;
|
|
|
|
|
*) echo "!! unknown argument: $1" >&2; usage ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
[ ${#TARGETS[@]} -gt 0 ] || usage
|
|
|
|
|
|
openbao: make the parity gate actually work, and run it nightly
Three fixes, all on the path between "the migration looks ready" and "the
migration is ready".
verify-parity.sh never sourced env-topology.sh, so TG_BAO_APPROLE was unset and
render-secrets-openbao.sh fell through to the bare default -- prod's credential
-- for every environment. On vps2 that made `--env beta` authenticate as
tg-prod and take a 403 from tg-host-prod.hcl on thermograph/data/env/beta.
`--all` failed the same way, taking dev with it. deploy.sh and
deploy-stack.sh always sourced it; only the verifier did not, which is the
worst place for the omission: the tool whose job is to notice divergence was
itself diverging from the path it verifies. It now calls thermograph_topology
per environment and takes TG_SKIP_COMMON from there rather than re-deriving it.
infra-sync.yml renders all three env files on every push touching infra/**, and
none of its three jobs sourced env-topology.sh either. TG_SECRETS_BACKEND was
therefore unset and render-secrets.sh:44 defaulted to sops. Flipping the
selector would have changed deploy.sh and deploy-stack.sh but not this
workflow, which would have kept re-rendering from SOPS -- silent while parity
holds, and a hard failure the moment the SOPS files are retired. The one-line
cutover the README describes was never sufficient on its own.
ops-cron.yml gains a secrets-parity job: prod and beta from vps2, dev from
vps1, nightly, failing loudly on a mismatch. README.md called this the gate for
a cutover; nothing implemented it. A gate that exists only in prose gets
satisfied by assertion. It grants CI no vault access -- the host renders, CI
only asks it to.
First measured run, immediately after the verifier fix:
dev 12 keys PASS, byte-identical
beta 24 keys FAIL, 3 values differ
prod 32 keys FAIL, 3 values differ
The same three keys on both: THERMOGRAPH_S3_SECRET_KEY,
THERMOGRAPH_LAKE_S3_SECRET_KEY, THERMOGRAPH_VAPID_CONTACT. All three live in
common.yaml, seeded 2026-07-31 02:30Z -- before the Contabo rotation landed.
dev passes because dev never layers common. One `seed-from-sops.sh --env
common` fixes beta and prod together, and the 7-day clock starts after that.
2026-08-01 15:36:35 +00:00
|
|
|
# env-topology.sh FIRST, and this is not cosmetic. It is the only thing that sets
|
|
|
|
|
# TG_BAO_APPROLE, and without it render-secrets-openbao.sh:41 falls through to the
|
|
|
|
|
# bare default — prod's credential — for every environment. On vps2 that made
|
|
|
|
|
# `--env beta` authenticate as tg-prod and take a 403 from tg-host-prod.hcl on
|
|
|
|
|
# thermograph/data/env/beta, so the gate could not verify the one environment whose
|
|
|
|
|
# isolation it exists to prove. `--all` failed the same way, and dev with it.
|
|
|
|
|
#
|
|
|
|
|
# deploy.sh and deploy-stack.sh always sourced this; only the verifier did not,
|
|
|
|
|
# which is the worst place for the omission to be: the tool whose whole job is to
|
|
|
|
|
# notice a divergence was itself diverging from the path it verifies.
|
|
|
|
|
#
|
|
|
|
|
# shellcheck source=/dev/null
|
|
|
|
|
. "$INFRA_DIR/deploy/env-topology.sh"
|
openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
# shellcheck source=/dev/null
|
|
|
|
|
. "$INFRA_DIR/deploy/render-secrets-openbao.sh"
|
|
|
|
|
|
|
|
|
|
overall=0
|
openbao: make verify-parity --all host-aware
--all expanded to (dev beta prod) and then tried to check all three from
wherever it was run. No host carries all three: dev is on vps1, beta and prod
are on vps2. So --all could not succeed anywhere, and the way it failed was
misleading rather than obvious -- on vps1, prod resolves TG_BAO_APPROLE to
/etc/thermograph/openbao-approle, which on THAT box holds dev's credential, so
prod's check authenticated as tg-dev and took a 403 from tg-host-prod.hcl.
That reads like a policy bug. It is "wrong box".
--all now means "every environment that lives on THIS host". Residency is
decided by the environment's own address being bound here (TG_SSH_HOST against
the local interfaces), and skips are announced rather than silent.
Residency is deliberately NOT decided by TG_APP_DIR existing. That was the
first attempt and it was wrong: vps1 still carries a stale /opt/thermograph
from before the estate split (d5357d0, #106), so prod looked resident there and
got checked anyway. Verified against both live hosts. A leftover directory is
exactly the kind of thing that outlives the arrangement it belonged to.
Zero environments checked is now a FAILURE. Previously a run that skipped
everything would exit 0, and a green run is precisely what the 7-day cutover
gate counts -- "nothing was wrong" and "nothing was examined" must not look
alike. The success line reports how many were checked and says plainly that a
green run on one host says nothing about the other.
Live-verified on both boxes:
vps1 --all -> dev PASS, beta and prod skipped, exit 0
vps2 --all -> dev skipped, beta and prod checked (both currently failing on
the three known stale common.yaml keys)
2026-08-01 16:18:05 +00:00
|
|
|
checked=0
|
openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
for env_name in "${TARGETS[@]}"; do
|
openbao: make the parity gate actually work, and run it nightly
Three fixes, all on the path between "the migration looks ready" and "the
migration is ready".
verify-parity.sh never sourced env-topology.sh, so TG_BAO_APPROLE was unset and
render-secrets-openbao.sh fell through to the bare default -- prod's credential
-- for every environment. On vps2 that made `--env beta` authenticate as
tg-prod and take a 403 from tg-host-prod.hcl on thermograph/data/env/beta.
`--all` failed the same way, taking dev with it. deploy.sh and
deploy-stack.sh always sourced it; only the verifier did not, which is the
worst place for the omission: the tool whose job is to notice divergence was
itself diverging from the path it verifies. It now calls thermograph_topology
per environment and takes TG_SKIP_COMMON from there rather than re-deriving it.
infra-sync.yml renders all three env files on every push touching infra/**, and
none of its three jobs sourced env-topology.sh either. TG_SECRETS_BACKEND was
therefore unset and render-secrets.sh:44 defaulted to sops. Flipping the
selector would have changed deploy.sh and deploy-stack.sh but not this
workflow, which would have kept re-rendering from SOPS -- silent while parity
holds, and a hard failure the moment the SOPS files are retired. The one-line
cutover the README describes was never sufficient on its own.
ops-cron.yml gains a secrets-parity job: prod and beta from vps2, dev from
vps1, nightly, failing loudly on a mismatch. README.md called this the gate for
a cutover; nothing implemented it. A gate that exists only in prose gets
satisfied by assertion. It grants CI no vault access -- the host renders, CI
only asks it to.
First measured run, immediately after the verifier fix:
dev 12 keys PASS, byte-identical
beta 24 keys FAIL, 3 values differ
prod 32 keys FAIL, 3 values differ
The same three keys on both: THERMOGRAPH_S3_SECRET_KEY,
THERMOGRAPH_LAKE_S3_SECRET_KEY, THERMOGRAPH_VAPID_CONTACT. All three live in
common.yaml, seeded 2026-07-31 02:30Z -- before the Contabo rotation landed.
dev passes because dev never layers common. One `seed-from-sops.sh --env
common` fixes beta and prod together, and the 7-day clock starts after that.
2026-08-01 15:36:35 +00:00
|
|
|
# Per-environment topology: TG_BAO_APPROLE (beta's differs, because beta shares a
|
openbao: make verify-parity --all host-aware
--all expanded to (dev beta prod) and then tried to check all three from
wherever it was run. No host carries all three: dev is on vps1, beta and prod
are on vps2. So --all could not succeed anywhere, and the way it failed was
misleading rather than obvious -- on vps1, prod resolves TG_BAO_APPROLE to
/etc/thermograph/openbao-approle, which on THAT box holds dev's credential, so
prod's check authenticated as tg-dev and took a 403 from tg-host-prod.hcl.
That reads like a policy bug. It is "wrong box".
--all now means "every environment that lives on THIS host". Residency is
decided by the environment's own address being bound here (TG_SSH_HOST against
the local interfaces), and skips are announced rather than silent.
Residency is deliberately NOT decided by TG_APP_DIR existing. That was the
first attempt and it was wrong: vps1 still carries a stale /opt/thermograph
from before the estate split (d5357d0, #106), so prod looked resident there and
got checked anyway. Verified against both live hosts. A leftover directory is
exactly the kind of thing that outlives the arrangement it belonged to.
Zero environments checked is now a FAILURE. Previously a run that skipped
everything would exit 0, and a green run is precisely what the 7-day cutover
gate counts -- "nothing was wrong" and "nothing was examined" must not look
alike. The success line reports how many were checked and says plainly that a
green run on one host says nothing about the other.
Live-verified on both boxes:
vps1 --all -> dev PASS, beta and prod skipped, exit 0
vps2 --all -> dev skipped, beta and prod checked (both currently failing on
the three known stale common.yaml keys)
2026-08-01 16:18:05 +00:00
|
|
|
# filesystem with prod), TG_SKIP_COMMON and TG_APP_DIR. Called before the header
|
|
|
|
|
# line because --all walks three environments and may skip some of them.
|
openbao: make the parity gate actually work, and run it nightly
Three fixes, all on the path between "the migration looks ready" and "the
migration is ready".
verify-parity.sh never sourced env-topology.sh, so TG_BAO_APPROLE was unset and
render-secrets-openbao.sh fell through to the bare default -- prod's credential
-- for every environment. On vps2 that made `--env beta` authenticate as
tg-prod and take a 403 from tg-host-prod.hcl on thermograph/data/env/beta.
`--all` failed the same way, taking dev with it. deploy.sh and
deploy-stack.sh always sourced it; only the verifier did not, which is the
worst place for the omission: the tool whose job is to notice divergence was
itself diverging from the path it verifies. It now calls thermograph_topology
per environment and takes TG_SKIP_COMMON from there rather than re-deriving it.
infra-sync.yml renders all three env files on every push touching infra/**, and
none of its three jobs sourced env-topology.sh either. TG_SECRETS_BACKEND was
therefore unset and render-secrets.sh:44 defaulted to sops. Flipping the
selector would have changed deploy.sh and deploy-stack.sh but not this
workflow, which would have kept re-rendering from SOPS -- silent while parity
holds, and a hard failure the moment the SOPS files are retired. The one-line
cutover the README describes was never sufficient on its own.
ops-cron.yml gains a secrets-parity job: prod and beta from vps2, dev from
vps1, nightly, failing loudly on a mismatch. README.md called this the gate for
a cutover; nothing implemented it. A gate that exists only in prose gets
satisfied by assertion. It grants CI no vault access -- the host renders, CI
only asks it to.
First measured run, immediately after the verifier fix:
dev 12 keys PASS, byte-identical
beta 24 keys FAIL, 3 values differ
prod 32 keys FAIL, 3 values differ
The same three keys on both: THERMOGRAPH_S3_SECRET_KEY,
THERMOGRAPH_LAKE_S3_SECRET_KEY, THERMOGRAPH_VAPID_CONTACT. All three live in
common.yaml, seeded 2026-07-31 02:30Z -- before the Contabo rotation landed.
dev passes because dev never layers common. One `seed-from-sops.sh --env
common` fixes beta and prod together, and the 7-day clock starts after that.
2026-08-01 15:36:35 +00:00
|
|
|
if ! thermograph_topology "$env_name"; then
|
|
|
|
|
echo "!! unknown environment: $env_name" >&2
|
|
|
|
|
overall=1; continue
|
|
|
|
|
fi
|
|
|
|
|
|
openbao: make verify-parity --all host-aware
--all expanded to (dev beta prod) and then tried to check all three from
wherever it was run. No host carries all three: dev is on vps1, beta and prod
are on vps2. So --all could not succeed anywhere, and the way it failed was
misleading rather than obvious -- on vps1, prod resolves TG_BAO_APPROLE to
/etc/thermograph/openbao-approle, which on THAT box holds dev's credential, so
prod's check authenticated as tg-dev and took a 403 from tg-host-prod.hcl.
That reads like a policy bug. It is "wrong box".
--all now means "every environment that lives on THIS host". Residency is
decided by the environment's own address being bound here (TG_SSH_HOST against
the local interfaces), and skips are announced rather than silent.
Residency is deliberately NOT decided by TG_APP_DIR existing. That was the
first attempt and it was wrong: vps1 still carries a stale /opt/thermograph
from before the estate split (d5357d0, #106), so prod looked resident there and
got checked anyway. Verified against both live hosts. A leftover directory is
exactly the kind of thing that outlives the arrangement it belonged to.
Zero environments checked is now a FAILURE. Previously a run that skipped
everything would exit 0, and a green run is precisely what the 7-day cutover
gate counts -- "nothing was wrong" and "nothing was examined" must not look
alike. The success line reports how many were checked and says plainly that a
green run on one host says nothing about the other.
Live-verified on both boxes:
vps1 --all -> dev PASS, beta and prod skipped, exit 0
vps2 --all -> dev skipped, beta and prod checked (both currently failing on
the three known stale common.yaml keys)
2026-08-01 16:18:05 +00:00
|
|
|
# NO ENVIRONMENT CAN BE CHECKED FROM A HOST IT DOES NOT LIVE ON, and --all used to
|
|
|
|
|
# pretend otherwise. dev is on vps1; beta and prod are on vps2. Running --all
|
|
|
|
|
# anywhere therefore guaranteed a failure on whichever environments are elsewhere,
|
|
|
|
|
# and the failure was actively misleading: on vps1, prod resolves TG_BAO_APPROLE to
|
|
|
|
|
# /etc/thermograph/openbao-approle, which on THAT box holds dev's credential, so
|
|
|
|
|
# prod's check authenticated as tg-dev and took a 403. Fails closed, but reads like
|
|
|
|
|
# a policy bug rather than "wrong box".
|
|
|
|
|
#
|
|
|
|
|
# Identity comes from the ENVIRONMENT'S OWN ADDRESS being bound here, not from its
|
|
|
|
|
# checkout existing. The first version of this check tested for TG_APP_DIR and was
|
|
|
|
|
# wrong: vps1 still carries a stale /opt/thermograph from before the estate split,
|
|
|
|
|
# so prod looked resident there and was checked anyway. A leftover directory is
|
|
|
|
|
# exactly the kind of thing that outlives the arrangement it belonged to.
|
|
|
|
|
#
|
|
|
|
|
# Skipping is announced, never silent. A gate that quietly narrows what it checked
|
|
|
|
|
# is worse than one that fails, because the run still goes green.
|
|
|
|
|
if [ -n "${TG_SSH_HOST:-}" ] && command -v ip >/dev/null 2>&1; then
|
|
|
|
|
if ! ip -4 -o addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \
|
|
|
|
|
| grep -qxF "$TG_SSH_HOST"; then
|
|
|
|
|
echo "== parity check: $env_name — SKIPPED, lives on ${TG_HOST:-another host} (${TG_SSH_HOST}), not here"
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo "== parity check: $env_name"
|
|
|
|
|
checked=$((checked + 1))
|
|
|
|
|
|
openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
# dev renders WITHOUT common on both backends. On the SOPS side that is a caller
|
openbao: make the parity gate actually work, and run it nightly
Three fixes, all on the path between "the migration looks ready" and "the
migration is ready".
verify-parity.sh never sourced env-topology.sh, so TG_BAO_APPROLE was unset and
render-secrets-openbao.sh fell through to the bare default -- prod's credential
-- for every environment. On vps2 that made `--env beta` authenticate as
tg-prod and take a 403 from tg-host-prod.hcl on thermograph/data/env/beta.
`--all` failed the same way, taking dev with it. deploy.sh and
deploy-stack.sh always sourced it; only the verifier did not, which is the
worst place for the omission: the tool whose job is to notice divergence was
itself diverging from the path it verifies. It now calls thermograph_topology
per environment and takes TG_SKIP_COMMON from there rather than re-deriving it.
infra-sync.yml renders all three env files on every push touching infra/**, and
none of its three jobs sourced env-topology.sh either. TG_SECRETS_BACKEND was
therefore unset and render-secrets.sh:44 defaulted to sops. Flipping the
selector would have changed deploy.sh and deploy-stack.sh but not this
workflow, which would have kept re-rendering from SOPS -- silent while parity
holds, and a hard failure the moment the SOPS files are retired. The one-line
cutover the README describes was never sufficient on its own.
ops-cron.yml gains a secrets-parity job: prod and beta from vps2, dev from
vps1, nightly, failing loudly on a mismatch. README.md called this the gate for
a cutover; nothing implemented it. A gate that exists only in prose gets
satisfied by assertion. It grants CI no vault access -- the host renders, CI
only asks it to.
First measured run, immediately after the verifier fix:
dev 12 keys PASS, byte-identical
beta 24 keys FAIL, 3 values differ
prod 32 keys FAIL, 3 values differ
The same three keys on both: THERMOGRAPH_S3_SECRET_KEY,
THERMOGRAPH_LAKE_S3_SECRET_KEY, THERMOGRAPH_VAPID_CONTACT. All three live in
common.yaml, seeded 2026-07-31 02:30Z -- before the Contabo rotation landed.
dev passes because dev never layers common. One `seed-from-sops.sh --env
common` fixes beta and prod together, and the 7-day clock starts after that.
2026-08-01 15:36:35 +00:00
|
|
|
# flag; on the OpenBao side the policy also forbids it. Taking the flag from the
|
|
|
|
|
# topology rather than re-deriving it here keeps the comparison apples-to-apples
|
|
|
|
|
# AND keeps this script from drifting from deploy.sh:67, which reads the same
|
|
|
|
|
# variable. If the flag were wrong, the OpenBao side would fail closed with a 403
|
|
|
|
|
# rather than quietly diverge — which is exactly the improvement being verified.
|
|
|
|
|
skip_common="${TG_SKIP_COMMON:-0}"
|
openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
|
|
|
|
|
sops_out=$(mktemp); bao_out=$(mktemp)
|
|
|
|
|
# Both temp files hold PLAINTEXT SECRETS. Removed on every exit path below; not a
|
|
|
|
|
# trap, for the reason documented at render-secrets.sh:82-91 (a RETURN trap set in a
|
|
|
|
|
# sourced context persists into the caller's shell and re-fires on the next source).
|
|
|
|
|
cleanup() { rm -f "$sops_out" "$bao_out"; }
|
|
|
|
|
|
|
|
|
|
# --- SOPS side: common (unless dev) then <env>, appended, last-wins ---
|
|
|
|
|
key="${THERMOGRAPH_AGE_KEY:-/etc/thermograph/age.key}"
|
|
|
|
|
[ -r "$key" ] || key="${SOPS_AGE_KEY_FILE:-$HOME/.config/sops/age/keys.txt}"
|
|
|
|
|
if [ ! -r "$key" ]; then
|
|
|
|
|
echo "!! no readable age key (tried /etc/thermograph/age.key and \$SOPS_AGE_KEY_FILE)" >&2
|
|
|
|
|
cleanup; overall=1; continue
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ "$skip_common" = 0 ] && [ -f "$INFRA_DIR/deploy/secrets/common.yaml" ]; then
|
|
|
|
|
SOPS_AGE_KEY_FILE="$key" sops -d --input-type yaml --output-type dotenv \
|
|
|
|
|
"$INFRA_DIR/deploy/secrets/common.yaml" >> "$sops_out" 2>/dev/null || {
|
|
|
|
|
echo "!! SOPS decrypt failed: common.yaml" >&2; cleanup; overall=1; continue; }
|
|
|
|
|
fi
|
|
|
|
|
SOPS_AGE_KEY_FILE="$key" sops -d --input-type yaml --output-type dotenv \
|
|
|
|
|
"$INFRA_DIR/deploy/secrets/${env_name}.yaml" >> "$sops_out" 2>/dev/null || {
|
|
|
|
|
echo "!! SOPS decrypt failed: ${env_name}.yaml" >&2; cleanup; overall=1; continue; }
|
|
|
|
|
|
|
|
|
|
# --- OpenBao side ---
|
|
|
|
|
if ! THERMOGRAPH_SECRETS_SKIP_COMMON="$skip_common" \
|
|
|
|
|
thermograph_openbao_source "$env_name" > "$bao_out"; then
|
|
|
|
|
echo "!! OpenBao render failed for $env_name" >&2
|
|
|
|
|
cleanup; overall=1; continue
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# --- Compare ---
|
|
|
|
|
# The SOPS output can legitimately contain the SAME key twice (common then env), and
|
|
|
|
|
# every consumer takes the LAST occurrence — `env_file:` and `source` both do. So
|
|
|
|
|
# collapsing to last-wins is not a normalisation for convenience, it is what the
|
|
|
|
|
# consumers actually see. Comparing raw would report a false mismatch the moment a
|
|
|
|
|
# key is overridden.
|
|
|
|
|
norm() {
|
|
|
|
|
python3 -c '
|
|
|
|
|
import sys
|
|
|
|
|
seen = {}
|
|
|
|
|
order = []
|
|
|
|
|
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
|
|
|
|
line = line.rstrip("\n")
|
|
|
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
|
|
|
continue
|
|
|
|
|
k, _, v = line.partition("=")
|
|
|
|
|
if k not in seen:
|
|
|
|
|
order.append(k)
|
|
|
|
|
seen[k] = v # last occurrence wins
|
|
|
|
|
for k in sorted(order):
|
|
|
|
|
print(f"{k}={seen[k]}")
|
|
|
|
|
' "$1"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a=$(mktemp); b=$(mktemp)
|
|
|
|
|
norm "$sops_out" > "$a"; norm "$bao_out" > "$b"
|
|
|
|
|
|
|
|
|
|
n_sops=$(wc -l < "$a" | tr -d ' ')
|
|
|
|
|
n_bao=$(wc -l < "$b" | tr -d ' ')
|
|
|
|
|
|
|
|
|
|
if cmp -s "$a" "$b"; then
|
|
|
|
|
echo " PASS — ${n_sops} keys, byte-identical after last-wins collapse"
|
|
|
|
|
else
|
|
|
|
|
overall=1
|
|
|
|
|
echo " FAIL — sops=${n_sops} keys, openbao=${n_bao} keys"
|
|
|
|
|
# Report only NAMES. Three buckets, because the fix differs per bucket:
|
|
|
|
|
# only-in-sops -> the seed missed a key (re-run seed-from-sops.sh)
|
|
|
|
|
# only-in-bao -> OpenBao has something the vault does not (stale/extra write)
|
|
|
|
|
# value-differs -> the seed captured a different value; DO NOT flip the backend
|
|
|
|
|
comm -23 <(cut -d= -f1 "$a") <(cut -d= -f1 "$b") | sed 's/^/ only in SOPS: /'
|
|
|
|
|
comm -13 <(cut -d= -f1 "$a") <(cut -d= -f1 "$b") | sed 's/^/ only in OpenBao: /'
|
|
|
|
|
join -t= -j1 <(sort -t= -k1,1 "$a") <(sort -t= -k1,1 "$b") -o 0,1.2,2.2 2>/dev/null \
|
|
|
|
|
| awk -F= '$2 != $3 { print " value differs: " $1 }' | sort -u
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
rm -f "$a" "$b"
|
|
|
|
|
cleanup
|
|
|
|
|
done
|
|
|
|
|
|
openbao: make verify-parity --all host-aware
--all expanded to (dev beta prod) and then tried to check all three from
wherever it was run. No host carries all three: dev is on vps1, beta and prod
are on vps2. So --all could not succeed anywhere, and the way it failed was
misleading rather than obvious -- on vps1, prod resolves TG_BAO_APPROLE to
/etc/thermograph/openbao-approle, which on THAT box holds dev's credential, so
prod's check authenticated as tg-dev and took a 403 from tg-host-prod.hcl.
That reads like a policy bug. It is "wrong box".
--all now means "every environment that lives on THIS host". Residency is
decided by the environment's own address being bound here (TG_SSH_HOST against
the local interfaces), and skips are announced rather than silent.
Residency is deliberately NOT decided by TG_APP_DIR existing. That was the
first attempt and it was wrong: vps1 still carries a stale /opt/thermograph
from before the estate split (d5357d0, #106), so prod looked resident there and
got checked anyway. Verified against both live hosts. A leftover directory is
exactly the kind of thing that outlives the arrangement it belonged to.
Zero environments checked is now a FAILURE. Previously a run that skipped
everything would exit 0, and a green run is precisely what the 7-day cutover
gate counts -- "nothing was wrong" and "nothing was examined" must not look
alike. The success line reports how many were checked and says plainly that a
green run on one host says nothing about the other.
Live-verified on both boxes:
vps1 --all -> dev PASS, beta and prod skipped, exit 0
vps2 --all -> dev skipped, beta and prod checked (both currently failing on
the three known stale common.yaml keys)
2026-08-01 16:18:05 +00:00
|
|
|
# Zero environments checked is a FAILURE, not a pass. Otherwise a --all run on a
|
|
|
|
|
# host that carries none of them — a new box, a renamed checkout, a typo'd --env —
|
|
|
|
|
# exits 0 having verified nothing, and a green run is exactly what the 7-day gate
|
|
|
|
|
# counts. "Nothing was wrong" and "nothing was examined" must not look alike.
|
|
|
|
|
if [ "$checked" -eq 0 ]; then
|
|
|
|
|
echo
|
|
|
|
|
echo "!! nothing was checked: none of [${TARGETS[*]}] has a checkout on this host." >&2
|
|
|
|
|
echo "!! dev lives on vps1; beta and prod live on vps2. Run this where they are." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
if [ "$overall" = 0 ]; then
|
|
|
|
|
echo
|
openbao: make verify-parity --all host-aware
--all expanded to (dev beta prod) and then tried to check all three from
wherever it was run. No host carries all three: dev is on vps1, beta and prod
are on vps2. So --all could not succeed anywhere, and the way it failed was
misleading rather than obvious -- on vps1, prod resolves TG_BAO_APPROLE to
/etc/thermograph/openbao-approle, which on THAT box holds dev's credential, so
prod's check authenticated as tg-dev and took a 403 from tg-host-prod.hcl.
That reads like a policy bug. It is "wrong box".
--all now means "every environment that lives on THIS host". Residency is
decided by the environment's own address being bound here (TG_SSH_HOST against
the local interfaces), and skips are announced rather than silent.
Residency is deliberately NOT decided by TG_APP_DIR existing. That was the
first attempt and it was wrong: vps1 still carries a stale /opt/thermograph
from before the estate split (d5357d0, #106), so prod looked resident there and
got checked anyway. Verified against both live hosts. A leftover directory is
exactly the kind of thing that outlives the arrangement it belonged to.
Zero environments checked is now a FAILURE. Previously a run that skipped
everything would exit 0, and a green run is precisely what the 7-day cutover
gate counts -- "nothing was wrong" and "nothing was examined" must not look
alike. The success line reports how many were checked and says plainly that a
green run on one host says nothing about the other.
Live-verified on both boxes:
vps1 --all -> dev PASS, beta and prod skipped, exit 0
vps2 --all -> dev skipped, beta and prod checked (both currently failing on
the three known stale common.yaml keys)
2026-08-01 16:18:05 +00:00
|
|
|
echo "PARITY OK — ${checked} environment(s) checked on this host."
|
openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
echo "Safe to consider flipping TG_SECRETS_BACKEND for these environments."
|
openbao: make verify-parity --all host-aware
--all expanded to (dev beta prod) and then tried to check all three from
wherever it was run. No host carries all three: dev is on vps1, beta and prod
are on vps2. So --all could not succeed anywhere, and the way it failed was
misleading rather than obvious -- on vps1, prod resolves TG_BAO_APPROLE to
/etc/thermograph/openbao-approle, which on THAT box holds dev's credential, so
prod's check authenticated as tg-dev and took a 403 from tg-host-prod.hcl.
That reads like a policy bug. It is "wrong box".
--all now means "every environment that lives on THIS host". Residency is
decided by the environment's own address being bound here (TG_SSH_HOST against
the local interfaces), and skips are announced rather than silent.
Residency is deliberately NOT decided by TG_APP_DIR existing. That was the
first attempt and it was wrong: vps1 still carries a stale /opt/thermograph
from before the estate split (d5357d0, #106), so prod looked resident there and
got checked anyway. Verified against both live hosts. A leftover directory is
exactly the kind of thing that outlives the arrangement it belonged to.
Zero environments checked is now a FAILURE. Previously a run that skipped
everything would exit 0, and a green run is precisely what the 7-day cutover
gate counts -- "nothing was wrong" and "nothing was examined" must not look
alike. The success line reports how many were checked and says plainly that a
green run on one host says nothing about the other.
Live-verified on both boxes:
vps1 --all -> dev PASS, beta and prod skipped, exit 0
vps2 --all -> dev skipped, beta and prod checked (both currently failing on
the three known stale common.yaml keys)
2026-08-01 16:18:05 +00:00
|
|
|
echo "Gate before prod: 7 consecutive green runs covering ALL THREE environments."
|
|
|
|
|
echo "That means both hosts — a green run here says nothing about the other box."
|
openbao: add a dormant OpenBao backend alongside SOPS
Phase 1 of moving the secret store to OpenBao. Nothing is cut over:
TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS
remains authoritative until an environment is flipped in a reviewed PR.
The reason for the migration is one specific failure class. infra/CLAUDE.md
requires that vps1 never hold prod credentials, but that rule is currently
enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at
three call sites. A fourth call site, or one by-hand render, writes both S3
keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics
tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl
makes that a 403 instead: dev's identity cannot read the common path at all.
Shape:
- vps2 only, native systemd (not a container: a Swarm service is circular,
and a plain docker run is one `prune -a` from gone), raft storage (2.6.0
deprecated the file backend), static auto-unseal so a reboot cannot block
deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the
boot chain of the secret store).
- AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's
secret-id is owned by its own deploy user, which is a boundary that does
not exist today since prod and beta both reach the same root-owned age key.
- Render still produces the same raw unquoted dotenv: Centralis compares
/etc/thermograph.env textually and the Swarm entrypoint parses it by line.
The backend dispatch in render-secrets.sh returns early rather than
refactoring the shared write path, so the SOPS path stays byte-identical.
Verified by rendering dev (12 keys) and prod (32 keys) through it, matching
the live hosts. The duplicated write path is noted for consolidation once
prod has been on OpenBao for a release cycle.
The OpenBao renderer rejects values containing a newline or a shell
metacharacter. /etc/thermograph.env is sourced by bash in six places, so
such a value is a command-execution path on the deploy host; the SOPS path
has always had this hazard and avoids it only because every current value
happens to be alphanumeric.
Also records that the age keypair is NOT retired by this migration: it is
the backup encryption key for every off-box dump (ops-cron.yml:142,197,
30-day retention), so deleting it with the vault files would silently
destroy a month of database recoverability.
verify-parity.sh is the cutover gate — it renders both backends and diffs
them, reporting key names and counts only, never values.
2026-07-30 04:35:20 +00:00
|
|
|
else
|
|
|
|
|
echo
|
|
|
|
|
echo "PARITY FAILED — do NOT flip TG_SECRETS_BACKEND." >&2
|
|
|
|
|
fi
|
|
|
|
|
exit "$overall"
|