#!/usr/bin/env bash # Seed (or re-seed) an encrypted secrets file from an environment's live env file. # Run on the operator's machine — it reads production secrets, so it is deliberately # NOT something the agent runs for you. Requires: sops + age on PATH, the age private # key at ~/.config/sops/age/keys.txt (or SOPS_AGE_KEY_FILE), and SSH access to the box. # # deploy/secrets/seed-from-live.sh prod agent@169.58.46.181 ~/.ssh/thermograph_agent_ed25519 # deploy/secrets/seed-from-live.sh beta agent@169.58.46.181 ~/.ssh/thermograph_agent_ed25519 # deploy/secrets/seed-from-live.sh dev agent@75.119.132.91 ~/.ssh/thermograph_agent_ed25519 # # Note prod and beta share a box (vps2) and therefore differ only by which env # FILE is read: /etc/thermograph.env vs /etc/thermograph-beta.env. That path is # resolved from deploy/env-topology.sh rather than hardcoded — hardcoding it # meant "seed beta" would read PROD's live secrets and write them into # beta.yaml, which is how one environment silently ends up holding another's # credentials. # # Writes deploy/secrets/.yaml ENCRYPTED (exact copy of the live env, so the # deploy render is faithful), then verifies the render round-trips to the same # KEY=VALUE set. Never prints secret values. See README.md. set -euo pipefail # Anchor to the repo that holds this script, so it works when invoked by absolute path # from any cwd (a different checkout, your home dir, etc.) — OUT and .sops.yaml resolve # relative to here, not to wherever you happened to run it. cd "$(dirname "$(readlink -f "$0")")/../.." ENV_NAME="${1:?usage: seed-from-live.sh [ssh-key]}" SSH_TARGET="${2:?ssh target, e.g. agent@169.58.46.181}" SSH_KEY="${3:-$HOME/.ssh/thermograph_agent_ed25519}" OUT="deploy/secrets/${ENV_NAME}.yaml" # shellcheck source=infra/deploy/env-topology.sh . deploy/env-topology.sh thermograph_topology "$ENV_NAME" REMOTE_ENV_FILE="$TG_ENV_FILE" command -v sops >/dev/null || { echo "!! sops not on PATH" >&2; exit 1; } tmp="$(mktemp)"; trap 'rm -f "$tmp" "$tmp.env"' EXIT umask 077 echo "==> Pulling ${SSH_TARGET}:${REMOTE_ENV_FILE} (sudo)" ssh -i "$SSH_KEY" "$SSH_TARGET" "sudo cat $REMOTE_ENV_FILE" > "$tmp" n=$(grep -cE '^[A-Za-z_][A-Za-z0-9_]*=' "$tmp" || true) [ "$n" -gt 0 ] || { echo "!! no KEY=VALUE lines read (permission? path?)" >&2; exit 1; } echo " ${n} vars" echo "==> Writing ${OUT} (flat YAML) and encrypting" python3 - "$tmp" "$OUT" "$ENV_NAME" <<'PY' import json, sys src, dst, env = sys.argv[1:4] pairs = {} for line in open(src, encoding="utf-8"): s = line.rstrip("\n").strip() if not s or s.startswith("#") or "=" not in s: continue k, _, v = s.partition("=") k = k.strip() if not all(c.isalnum() or c == "_" for c in k) or not k[0].isalpha() and k[0] != "_": continue pairs[k] = v with open(dst, "w", encoding="utf-8") as fh: fh.write(f"# SOPS-encrypted — {env} host secrets, seeded from that environment’s live env file.\n") for k, v in pairs.items(): fh.write(f"{k}: {json.dumps(v)}\n") # JSON-escaped scalar is valid YAML print(f" {len(pairs)} keys") PY sops -e -i "$OUT" grep -q 'ENC\[AES256_GCM' "$OUT" || { echo "!! $OUT did not encrypt — refusing to keep it" >&2; rm -f "$OUT"; exit 1; } echo "==> Verifying the render round-trips to the same KEY=VALUE set" sops -d --input-type yaml --output-type dotenv "$OUT" > "$tmp.env" python3 - "$tmp" "$tmp.env" <<'PY' import sys def load(p): d = {} for line in open(p, encoding="utf-8"): s = line.rstrip("\n").strip() if not s or s.startswith("#") or "=" not in s: continue k, _, v = s.partition("=") d[k.strip()] = v return d live, rendered = load(sys.argv[1]), load(sys.argv[2]) missing = sorted(set(live) - set(rendered)) extra = sorted(set(rendered) - set(live)) diff = sorted(k for k in live if k in rendered and live[k] != rendered[k]) if missing or extra or diff: if missing: print(" !! keys lost in render:", missing) if extra: print(" !! keys added in render:", extra) if diff: print(" !! values changed (keys only):", diff) sys.exit(1) print(f" OK — {len(live)} keys render identically") PY echo "==> ${OUT} is ready. Review with: sops ${OUT} (or git diff --stat)" echo " Then commit + push; the agent can take it from there."