Files
jakman85andClaude Opus 4.8 216bea2a16 tofu: move secrets into committed SOPS/age file + tf.sh wrapper
Replace the gitignored plaintext infra/tofu/.r2.env and backend.hcl with a
single committed, SOPS/age-encrypted dotenv (secrets.sops.env) plus a thin
wrapper (tf.sh), so tofu runs with no manual secret entry and secrets live
encrypted in git like the repo's other secrets.

- .sops.yaml: creation rule encrypting infra/tofu/secrets.sops.env (all values)
  to the existing age recipient.
- secrets.sops.env: encrypted, PLACEHOLDER values only. Holds R2 S3 keys, the
  state-encryption passphrase (TF_STATE_PASSPHRASE scalar), and the Proxmox API
  token/endpoint. Operator fills real values via `sops`.
- tf.sh: sops exec-env decrypts the dotenv, exports PROXMOX_VE_*/AWS_*, builds
  the multi-line TF_ENCRYPTION HCL from TF_STATE_PASSPHRASE, and runs tofu
  (injecting -backend-config=backend.r2.hcl on init). Proxmox token stays env-only,
  never a tofu variable/state.
- backend.r2.hcl: committed non-secret R2 bucket/key/endpoint (account id is not
  a credential), passed to `tofu init`.
- Retire .r2.env/backend.hcl(.example): drop the committed templates; keep the
  gitignore lines as a safety net for leftover local plaintext.
- Update backend.tf/README/REMOTE-STATE for the new day-2 flow and DR story
  (repo + age key is sufficient).

Verified: placeholder file round-trips sops -e/-d with the repo age key; tf.sh
exports the expected VAR NAMES and builds valid TF_ENCRYPTION HCL (stub tofu, no
real plan/secrets).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-27 19:07:52 -05:00

76 lines
3.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# infra/tofu/tf.sh — run OpenTofu with EVERY secret sourced from the SOPS/age-encrypted
# secrets.sops.env. No plaintext .r2.env, no manual secret entry, secrets live encrypted
# in git like the repo's other secrets.
#
# MUST run inside the dev container (it has sops + tofu, and the age PRIVATE key mounted
# from ~/.config/sops/age/keys.txt):
#
# ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init
# ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh plan
# ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh apply
#
# What it does:
# 1. `sops exec-env` decrypts secrets.sops.env and puts the dotenv keys in the ENV
# (exec-env parses the dotenv itself, so values with shell-special chars — ! ( ) ; —
# are set safely, unlike `source`). It then re-execs THIS script (phase 2).
# 2. Phase 2 exports PROXMOX_VE_* / AWS_* (already in env) + TF_VAR_proxmox_endpoint,
# builds the multi-line TF_ENCRYPTION HCL from the single TF_STATE_PASSPHRASE scalar
# (same pbkdf2 → aes_gcm → state/plan structure the old .r2.env hardcoded), and runs
# `tofu`. For `init` it appends `-backend-config=backend.r2.hcl` (the non-secret R2
# bucket/key/endpoint); other subcommands are passed through unchanged.
#
# The Proxmox API token is injected ONLY as PROXMOX_VE_API_TOKEN (provider reads it from
# the env) — it is NEVER a tofu variable, so it never enters state, vars, or plan output.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SECRETS="${HERE}/secrets.sops.env"
BACKEND="${HERE}/backend.r2.hcl"
# ── Phase 1: decrypt secrets into the environment, then re-exec self ──────────────────
if [[ "${_TF_SH_SECRETS_LOADED:-}" != "1" ]]; then
command -v sops >/dev/null 2>&1 || {
echo "tf.sh: 'sops' not found — run me inside the dev container:" >&2
echo " ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh $*" >&2
exit 127
}
[[ -f "$SECRETS" ]] || { echo "tf.sh: missing $SECRETS" >&2; exit 1; }
export _TF_SH_SECRETS_LOADED=1
# Re-exec THIS script (with its args) under sops exec-env so the dotenv values are in
# the process environment. printf %q keeps args intact through exec-env's shell.
exec sops exec-env "$SECRETS" "$(printf '%q ' "${BASH_SOURCE[0]}" "$@")"
fi
# ── Phase 2: secrets are now in the environment ───────────────────────────────────────
: "${TF_STATE_PASSPHRASE:?tf.sh: TF_STATE_PASSPHRASE not set (fill in secrets.sops.env)}"
: "${PROXMOX_VE_ENDPOINT:?tf.sh: PROXMOX_VE_ENDPOINT not set (fill in secrets.sops.env)}"
# Enable versions.tf endpoint scheme-normalization (harmless if already has https://).
export TF_VAR_proxmox_endpoint="${PROXMOX_VE_ENDPOINT}"
# Build the state-at-rest encryption blob from the single passphrase scalar.
export TF_ENCRYPTION="
key_provider \"pbkdf2\" \"state\" {
passphrase = \"${TF_STATE_PASSPHRASE}\"
}
method \"aes_gcm\" \"state\" {
keys = key_provider.pbkdf2.state
}
state {
method = method.aes_gcm.state
}
plan {
method = method.aes_gcm.state
}
"
cd "$HERE"
# `-backend-config` is only valid for `init`; inject it there, pass everything else through.
if [[ "${1:-}" == "init" ]]; then
shift
exec tofu init -backend-config="$BACKEND" "$@"
fi
exec tofu "$@"