All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 5s
secrets-guard / encrypted (push) Successful in 4s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 1s
Sync infra to hosts / sync-prod (push) Has been skipped
shell-lint / shellcheck (push) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / changes (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 7s
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.
210 lines
8.3 KiB
Bash
Executable file
210 lines
8.3 KiB
Bash
Executable file
#!/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.
|
|
# 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'
|
|
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)
|
|
|
|
# 1 => destined for a single-quoted env file; 0 => raw dotenv.
|
|
QUOTED = os.environ.get("THERMOGRAPH_SEED_QUOTED") == "1"
|
|
|
|
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=(",", ":"))
|
|
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
|
|
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"
|