thermograph/infra/openbao/seed-from-sops.sh

211 lines
8.3 KiB
Bash
Raw Normal View History

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
# Seed OpenBao from the SOPS vault. One-shot, idempotent, run BY THE OPERATOR.
#
# infra/openbao/seed-from-sops.sh --dry-run # key names only, writes nothing
# infra/openbao/seed-from-sops.sh --env dev # seed one environment
# infra/openbao/seed-from-sops.sh --all # seed everything
#
# ============================================================================
# THIS SCRIPT READS EVERY PRODUCTION SECRET IN THE ESTATE IN PLAINTEXT.
# ============================================================================
# infra/CLAUDE.md and deploy/secrets/README.md both say that
# deploy/secrets/seed-from-live.sh "reads production secrets and is explicitly NOT for
# an agent to run". This script is in the same category and inherits the same rule:
# run it yourself, from your own terminal, with your own age key.
#
# It never prints a value. Every diagnostic is key NAMES and counts, so the output is
# safe to paste into an issue. --dry-run makes that guarantee mechanical: it resolves
# and validates everything, then reports what it WOULD write without contacting
# OpenBao at all.
#
# Why seed from SOPS rather than re-entering values by hand: the safety property of
# this entire migration is that the OpenBao render can be diffed BYTE-FOR-BYTE against
# the SOPS render (see verify-parity.sh). That only works if the values are identical.
# Re-typing 61 values would silently rotate whichever ones were mistyped, and the
# self-generating ones (AUTH_SECRET, the VAPID pair) fail SILENTLY when wrong — the app
# mints a replacement, comes up green, and invalidates every session and push
# subscription. So: copy exactly now, rotate deliberately later.
set -euo pipefail
MOUNT="${THERMOGRAPH_BAO_MOUNT:-thermograph}"
AGE_KEY="${SOPS_AGE_KEY_FILE:-$HOME/.config/sops/age/keys.txt}"
DRY_RUN=0
TARGETS=()
# Resolve the repo's infra/ directory from this script's own location, so the script
# works from any cwd. This is the same mistake terraform's remote-exec makes
# (main.tf:256 passes app_dir where app_dir/infra is wanted), so it is worth being
# explicit rather than clever.
SELF_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
INFRA_DIR="$(cd -- "$SELF_DIR/.." && pwd)"
VAULT_DIR="$INFRA_DIR/deploy/secrets"
usage() {
cat >&2 <<'EOF'
usage: seed-from-sops.sh [--dry-run] (--all | --env <name> ...)
--dry-run validate and report key names/counts; contact OpenBao not at all
--all seed common, dev, beta, prod and centralis.prod
--env <name> seed one of: common | dev | beta | prod | centralis.prod
Environment:
SOPS_AGE_KEY_FILE age private key (default ~/.config/sops/age/keys.txt)
BAO_ADDR OpenBao address (default https://10.10.0.1:8200)
BAO_TOKEN an operator token with write on <mount>/data/*
THERMOGRAPH_BAO_MOUNT KV v2 mount name (default thermograph)
EOF
exit 2
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--all) TARGETS=(common dev beta prod centralis.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
# The KV v2 path for a given vault file. common.yaml and centralis.prod.yaml are
# special-cased; everything else is an environment overlay.
#
# Keeping `common` at its own top-level path rather than under env/ is what makes the
# dev policy expressible: tg-host-dev.hcl denies exactly one path, and no wildcard
# over env/* can accidentally re-include it.
bao_path_for() {
case "$1" in
common) printf 'common\n' ;;
centralis.prod) printf 'centralis/prod\n' ;;
dev|beta|prod) printf 'env/%s\n' "$1" ;;
*) echo "!! unknown vault target: $1" >&2; return 1 ;;
esac
}
[ -f "$AGE_KEY" ] || { echo "!! age key not found at $AGE_KEY" >&2; exit 1; }
command -v sops >/dev/null 2>&1 || { echo "!! sops not on PATH" >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "!! python3 not on PATH" >&2; exit 1; }
if [ "$DRY_RUN" = 0 ]; then
command -v bao >/dev/null 2>&1 || { echo "!! bao not on PATH" >&2; exit 1; }
[ -n "${BAO_TOKEN:-}" ] || { echo "!! BAO_TOKEN is not set" >&2; exit 1; }
export BAO_ADDR="${BAO_ADDR:-https://10.10.0.1:8200}"
fi
rc=0
for target in "${TARGETS[@]}"; do
src="$VAULT_DIR/${target}.yaml"
if [ ! -f "$src" ]; then
echo "!! no vault file at $src" >&2
rc=1
continue
fi
path="$(bao_path_for "$target")" || { rc=1; continue; }
# Decrypt to JSON, not dotenv. JSON is lossless: the dotenv writer flattens a value
# containing a newline into a literal \n that is indistinguishable from a backslash
# followed by n (render-secrets.sh:198 documents this). Seeding through dotenv would
# bake that ambiguity into OpenBao permanently.
plain_json="$(SOPS_AGE_KEY_FILE="$AGE_KEY" \
sops -d --input-type yaml --output-type json "$src" 2>/dev/null)" || {
echo "!! failed to decrypt $src" >&2
rc=1
continue
}
# Validate and reshape into the flat string map KV v2 wants. Refuses the same unsafe
# values render-secrets-openbao.sh refuses, so an unrenderable value is caught at
# SEED time — before anything depends on it — rather than at deploy time.
openbao: render Centralis from the vault Centralis could not migrate at all. `seed-from-sops.sh --all` refused centralis.prod with `CENTRALIS_TOKENS: contains a shell metacharacter`, and render-secrets-openbao.sh had no Centralis path whatsoever — so the seeded data would have had nothing to read it. Both halves come from applying the app stack's rules to a file that does not share its shape. /etc/thermograph.env is raw unquoted KEY=value. /etc/centralis.env is consumed by exactly one thing and that thing is a shell: sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up' CENTRALIS_TOKENS is JSON, so it can never satisfy a no-metacharacters rule, and rendering it unquoted is precisely the 2026-07-24 failure: bash strips the quotes, Centralis fails closed on the malformed registry and serves a single `shared` identity, indistinguishable from the file never being written. So: - seed-from-sops.sh validates against the renderer that will consume the path. The dotenv rules still apply to common and env/*; centralis/* is exempt because it is single-quoted downstream. - render-secrets-openbao.sh gains thermograph_render_openbao_centralis, mirroring render_centralis_secrets: fetch as JSON, emit POSIX single-quoted assignments, then PROVE the file by sourcing it under `env -i` with `set -a` and comparing every value back before anything touches the destination. Mirrored rather than shared, per the existing note above thermograph_render_openbao — consolidate when the SOPS path is deleted. - render_centralis_secrets gains the same early-return backend dispatch the app path already uses, so the new function is reachable. Without it the SOPS path was the only path regardless of TG_SECRETS_BACKEND. Verified against a synthetic registry containing a JSON token map, an apostrophe, and `$(...)`/backtick/backslash/double-quote payloads: 5 keys round-trip byte-for-byte through `set -a; . file`, CENTRALIS_TOKENS parses to the right subjects, and the command substitution does not execute. The negative case is covered too — fed the unquoted 2026-07-24 shape, the round-trip check reports the value as changed (by length, never content) and refuses to write. Also corrects the claim in render-secrets-openbao.sh that the SOPS path never tripped the metacharacter hazard "because every current value happens to be alphanumeric". CENTRALIS_TOKENS is the counter-example; it never tripped those paths because it was never in them.
2026-07-31 02:55:17 +00:00
# Which renderer will consume this path decides which values are legal, so the
# validation has to match it. The app stack's /etc/thermograph.env is raw, UNQUOTED
# KEY=value (render-secrets-openbao.sh:14-18 freezes that shape), so a metacharacter
# there is a live expansion hazard. /etc/centralis.env is POSIX single-quoted by
# render_centralis_secrets and its OpenBao counterpart, where every byte inside
# '...' is literal — so applying the dotenv rules to it rejects data that renders
# perfectly well. CENTRALIS_TOKENS is JSON and can never satisfy them.
quoted=0
case "$target" in centralis.*) quoted=1 ;; esac
reshaped="$(THERMOGRAPH_SEED_JSON="$plain_json" THERMOGRAPH_SEED_QUOTED="$quoted" python3 - <<'PY'
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
import json, os, re, sys
doc = json.loads(os.environ["THERMOGRAPH_SEED_JSON"])
if not isinstance(doc, dict) or not doc:
sys.stderr.write("!! vault did not decrypt to a non-empty object\n")
sys.exit(1)
openbao: render Centralis from the vault Centralis could not migrate at all. `seed-from-sops.sh --all` refused centralis.prod with `CENTRALIS_TOKENS: contains a shell metacharacter`, and render-secrets-openbao.sh had no Centralis path whatsoever — so the seeded data would have had nothing to read it. Both halves come from applying the app stack's rules to a file that does not share its shape. /etc/thermograph.env is raw unquoted KEY=value. /etc/centralis.env is consumed by exactly one thing and that thing is a shell: sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up' CENTRALIS_TOKENS is JSON, so it can never satisfy a no-metacharacters rule, and rendering it unquoted is precisely the 2026-07-24 failure: bash strips the quotes, Centralis fails closed on the malformed registry and serves a single `shared` identity, indistinguishable from the file never being written. So: - seed-from-sops.sh validates against the renderer that will consume the path. The dotenv rules still apply to common and env/*; centralis/* is exempt because it is single-quoted downstream. - render-secrets-openbao.sh gains thermograph_render_openbao_centralis, mirroring render_centralis_secrets: fetch as JSON, emit POSIX single-quoted assignments, then PROVE the file by sourcing it under `env -i` with `set -a` and comparing every value back before anything touches the destination. Mirrored rather than shared, per the existing note above thermograph_render_openbao — consolidate when the SOPS path is deleted. - render_centralis_secrets gains the same early-return backend dispatch the app path already uses, so the new function is reachable. Without it the SOPS path was the only path regardless of TG_SECRETS_BACKEND. Verified against a synthetic registry containing a JSON token map, an apostrophe, and `$(...)`/backtick/backslash/double-quote payloads: 5 keys round-trip byte-for-byte through `set -a; . file`, CENTRALIS_TOKENS parses to the right subjects, and the command substitution does not execute. The negative case is covered too — fed the unquoted 2026-07-24 shape, the round-trip check reports the value as changed (by length, never content) and refuses to write. Also corrects the claim in render-secrets-openbao.sh that the SOPS path never tripped the metacharacter hazard "because every current value happens to be alphanumeric". CENTRALIS_TOKENS is the counter-example; it never tripped those paths because it was never in them.
2026-07-31 02:55:17 +00:00
# 1 => destined for a single-quoted env file; 0 => raw dotenv.
QUOTED = os.environ.get("THERMOGRAPH_SEED_QUOTED") == "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
KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
UNSAFE = set("`$\\\"'")
out, bad = {}, []
for key, val in doc.items():
if key == "sops":
continue
if not KEY_RE.match(key):
bad.append(f"{key}: not a valid env var name")
continue
if val is None:
val = ""
if not isinstance(val, str):
val = json.dumps(val, separators=(",", ":"))
openbao: render Centralis from the vault Centralis could not migrate at all. `seed-from-sops.sh --all` refused centralis.prod with `CENTRALIS_TOKENS: contains a shell metacharacter`, and render-secrets-openbao.sh had no Centralis path whatsoever — so the seeded data would have had nothing to read it. Both halves come from applying the app stack's rules to a file that does not share its shape. /etc/thermograph.env is raw unquoted KEY=value. /etc/centralis.env is consumed by exactly one thing and that thing is a shell: sudo bash -c 'set -a && . /etc/centralis.env && set +a && docker compose up' CENTRALIS_TOKENS is JSON, so it can never satisfy a no-metacharacters rule, and rendering it unquoted is precisely the 2026-07-24 failure: bash strips the quotes, Centralis fails closed on the malformed registry and serves a single `shared` identity, indistinguishable from the file never being written. So: - seed-from-sops.sh validates against the renderer that will consume the path. The dotenv rules still apply to common and env/*; centralis/* is exempt because it is single-quoted downstream. - render-secrets-openbao.sh gains thermograph_render_openbao_centralis, mirroring render_centralis_secrets: fetch as JSON, emit POSIX single-quoted assignments, then PROVE the file by sourcing it under `env -i` with `set -a` and comparing every value back before anything touches the destination. Mirrored rather than shared, per the existing note above thermograph_render_openbao — consolidate when the SOPS path is deleted. - render_centralis_secrets gains the same early-return backend dispatch the app path already uses, so the new function is reachable. Without it the SOPS path was the only path regardless of TG_SECRETS_BACKEND. Verified against a synthetic registry containing a JSON token map, an apostrophe, and `$(...)`/backtick/backslash/double-quote payloads: 5 keys round-trip byte-for-byte through `set -a; . file`, CENTRALIS_TOKENS parses to the right subjects, and the command substitution does not execute. The negative case is covered too — fed the unquoted 2026-07-24 shape, the round-trip check reports the value as changed (by length, never content) and refuses to write. Also corrects the claim in render-secrets-openbao.sh that the SOPS path never tripped the metacharacter hazard "because every current value happens to be alphanumeric". CENTRALIS_TOKENS is the counter-example; it never tripped those paths because it was never in them.
2026-07-31 02:55:17 +00:00
if not QUOTED:
if "\n" in val or "\r" in val:
bad.append(f"{key}: contains a newline")
continue
if UNSAFE & set(val):
bad.append(f"{key}: contains a shell metacharacter (` $ \\ \" ')")
continue
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
out[key] = val
if bad:
sys.stderr.write("!! unsafe values, refusing:\n")
for line in bad:
sys.stderr.write(f"!! {line}\n")
sys.exit(1)
# stdout line 1: space-separated key NAMES, for the operator-visible log.
# stdout line 2+: the JSON payload, fed to `bao kv put` on stdin.
sys.stdout.write(" ".join(sorted(out)) + "\n")
sys.stdout.write(json.dumps(out) + "\n")
PY
)" || { echo "!! validation failed for $target" >&2; rc=1; continue; }
names="$(printf '%s\n' "$reshaped" | sed -n '1p')"
payload="$(printf '%s\n' "$reshaped" | sed -n '2p')"
count="$(printf '%s\n' "$names" | wc -w | tr -d ' ')"
if [ "$DRY_RUN" = 1 ]; then
echo "== ${target}.yaml -> ${MOUNT}/${path} (${count} keys, DRY RUN, nothing written)"
printf ' %s\n' "$names"
continue
fi
# `bao kv put -` reads a JSON object from stdin, so no value ever appears in argv
# (where it would be visible in /proc and in the shell's history).
if printf '%s' "$payload" | bao kv put -mount="$MOUNT" "$path" - >/dev/null; then
echo "== ${target}.yaml -> ${MOUNT}/${path} (${count} keys written)"
else
echo "!! failed writing ${MOUNT}/${path}" >&2
rc=1
fi
done
if [ "$rc" = 0 ] && [ "$DRY_RUN" = 0 ]; then
cat <<EOF
Seeded. NOTHING is cut over yet — SOPS is still authoritative for every
environment, because TG_SECRETS_BACKEND defaults to sops in env-topology.sh.
Next: prove equivalence before flipping anything.
infra/openbao/verify-parity.sh --env dev
EOF
fi
exit "$rc"