All checks were successful
secrets-guard / encrypted (pull_request) Successful in 4s
PR build (required check) / changes (pull_request) Successful in 7s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 9s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
shell-lint / shellcheck (push) Successful in 6s
Sync infra to hosts / sync-dev (push) Successful in 5s
Sync infra to hosts / sync-centralis (push) Has been skipped
secrets-guard / encrypted (push) Successful in 4s
--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)
222 lines
9.9 KiB
Bash
Executable file
222 lines
9.9 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.
|
|
|
|
--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.
|
|
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
|
|
|
|
# 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"
|
|
# shellcheck source=/dev/null
|
|
. "$INFRA_DIR/deploy/render-secrets-openbao.sh"
|
|
|
|
overall=0
|
|
checked=0
|
|
for env_name in "${TARGETS[@]}"; do
|
|
# Per-environment topology: TG_BAO_APPROLE (beta's differs, because beta shares a
|
|
# 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.
|
|
if ! thermograph_topology "$env_name"; then
|
|
echo "!! unknown environment: $env_name" >&2
|
|
overall=1; continue
|
|
fi
|
|
|
|
# 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))
|
|
|
|
# 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. 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}"
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
if [ "$overall" = 0 ]; then
|
|
echo
|
|
echo "PARITY OK — ${checked} environment(s) checked on this host."
|
|
echo "Safe to consider flipping TG_SECRETS_BACKEND for these environments."
|
|
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."
|
|
else
|
|
echo
|
|
echo "PARITY FAILED — do NOT flip TG_SECRETS_BACKEND." >&2
|
|
fi
|
|
exit "$overall"
|