thermograph/infra/openbao/verify-parity.sh
Emi Griffith 6bd07d33cb 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-29 21:35:20 -07:00

158 lines
6.3 KiB
Bash
Executable file

#!/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.
Exit status: 0 = every requested environment is at parity; 1 = a mismatch.
Suitable as a CI / cron gate.
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
# shellcheck source=/dev/null
. "$INFRA_DIR/deploy/render-secrets-openbao.sh"
overall=0
for env_name in "${TARGETS[@]}"; do
echo "== parity check: $env_name"
# dev renders WITHOUT common on both backends. On the SOPS side that is a caller
# flag; on the OpenBao side the policy also forbids it. Setting the flag here keeps
# the comparison apples-to-apples — and 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=0
[ "$env_name" = dev ] && skip_common=1
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
if [ "$overall" = 0 ]; then
echo
echo "PARITY OK for: ${TARGETS[*]}"
echo "Safe to consider flipping TG_SECRETS_BACKEND for these environments."
echo "Recommended gate before prod: 7 consecutive green runs on all three."
else
echo
echo "PARITY FAILED — do NOT flip TG_SECRETS_BACKEND." >&2
fi
exit "$overall"