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]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-27 19:07:52 -05:00
1 parent 318f85de11
commit 216bea2a16
10 files changed
+203 -78

No files matched your search

+4 -2
View File
@@ -30,8 +30,10 @@ plan.log
*.junit.xml
coverage/
# Tofu remote-state secrets (R2 creds + encryption passphrase + account-specific backend cfg).
# The *.example templates ARE committed; the real files are not.
# RETIRED plaintext Tofu secret files. Superseded by the COMMITTED, SOPS/age-encrypted
# infra/tofu/secrets.sops.env + the wrapper infra/tofu/tf.sh (see REMOTE-STATE.md). These
# lines are KEPT as a safety net so any leftover plaintext copy on an operator's machine can
# NEVER be committed; delete the local files once you've migrated to the encrypted flow.
infra/tofu/backend.hcl
infra/tofu/.r2.env
+8
View File
@@ -34,3 +34,11 @@ creation_rules:
encrypted_regex: "^(data|stringData)$"
# same age PUBLIC recipient as above.
age: "age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg"
# OpenTofu runtime secrets (dotenv). Holds the R2 S3 keys, the state-encryption
# passphrase, and the Proxmox API token that tf.sh injects into the provider's
# ENVIRONMENT (never a tofu variable/state). Dotenv, so `sops exec-env` / tf.sh
# can export cleanly; NO encrypted_regex here → SOPS encrypts EVERY value (the
# keys stay cleartext for reviewable diffs). Encrypted to the same age recipient.
- path_regex: (^|/)infra/tofu/secrets\.sops\.env$
age: "age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg"
-24
View File
@@ -1,24 +0,0 @@
# Copy to `.r2.env` (gitignored), fill in, then: source .r2.env
# These are the ONLY secrets. They never enter git or tofu state.
# --- R2 S3 credentials (from Cloudflare R2 -> Manage API Tokens -> Object Read & Write) ---
export AWS_ACCESS_KEY_ID="<R2_ACCESS_KEY_ID>"
export AWS_SECRET_ACCESS_KEY="<R2_SECRET_ACCESS_KEY>"
# --- State-at-rest encryption (OpenTofu native, AES-GCM via PBKDF2 passphrase) ---
# CRITICAL: store this passphrase in your PASSWORD MANAGER. It is required to read the state on any
# machine and after any rebuild. It must be >= 16 characters (OpenTofu enforces a minimum).
export TF_ENCRYPTION='
key_provider "pbkdf2" "state" {
passphrase = "<STRONG_PASSPHRASE_MIN_16_CHARS>"
}
method "aes_gcm" "state" {
keys = key_provider.pbkdf2.state
}
state {
method = method.aes_gcm.state
}
plan {
method = method.aes_gcm.state
}
'
+14 -12
View File
@@ -64,24 +64,26 @@ All sizing/IPs/counts/placement are variables — see `variables.tf`.
## Auth (env only — never in tofu vars/state)
The provider reads these from the environment; the **API token is never a tofu
variable**, so it never enters state, vars, or plan output:
variable**, so it never enters state, vars, or plan output. You do NOT export these by hand —
they live encrypted in `secrets.sops.env` and the `tf.sh` wrapper decrypts + exports them
(and sets `TF_VAR_proxmox_endpoint` for scheme normalization) at runtime:
```
export PROXMOX_VE_ENDPOINT="https://192.168.2.102:8006/"
export PROXMOX_VE_API_TOKEN="user@realm!tokenid=SECRET"
export PROXMOX_VE_INSECURE="true"
export TF_VAR_proxmox_endpoint="$PROXMOX_VE_ENDPOINT" # optional; enables normalization
PROXMOX_VE_ENDPOINT=https://192.168.2.102:8006/
PROXMOX_VE_API_TOKEN=user@realm!tokenid=SECRET
PROXMOX_VE_INSECURE=true
```
See `REMOTE-STATE.md` for the encrypted-secrets + wrapper flow.
## Usage (operator — NOT run here)
All secrets come from the encrypted `secrets.sops.env` via the `tf.sh` wrapper, run inside the
dev container (which has `sops` + the age key):
```
cd infra/tofu
cp terraform.tfvars.example terraform.tfvars # set ssh_authorized_key
# source the operator env file (exports the PROXMOX_VE_* above)
tofu init
tofu plan
tofu apply
cp infra/tofu/terraform.tfvars.example infra/tofu/terraform.tfvars # set ssh_authorized_key
./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
# hand the node IPs to Ansible:
tofu output -raw ansible_inventory > ../ansible/inventory/hosts.ini
./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh output -raw ansible_inventory > infra/ansible/inventory/hosts.ini
```
## Hand-off contract
+51 -21
View File
@@ -18,22 +18,38 @@ rest** (OpenTofu native AES-GCM) so R2 only ever holds ciphertext.
that bucket. Copy the **Access Key ID** and **Secret Access Key** (shown once).
3. Note your **Account ID** (32 hex chars, in the dashboard URL / the R2 S3 endpoint).
### 2. Fill the two gitignored files (secrets stay out of git and out of chat)
### 2. Fill in the encrypted secrets file (edited in place; ciphertext is committed)
All five secrets live in ONE committed, SOPS/age-encrypted dotenv, `infra/tofu/secrets.sops.env`.
Edit it inside the dev container (it has `sops` + the age key). `sops` decrypts to your `$EDITOR`
and re-encrypts on save — plaintext never touches disk:
```bash
cd infra/tofu
cp backend.hcl.example backend.hcl # set bucket/key + endpoints.s3 = https://<ACCOUNT_ID>.r2.cloudflarestorage.com
cp .r2.env.example .r2.env # set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY + a strong TF_ENCRYPTION passphrase
./infra/devcontainer/dev.sh run sops infra/tofu/secrets.sops.env
```
**Store the encryption passphrase in your password manager.** It is required to read state on any
machine and after any rebuild. Also record the R2 keys there. (Both files are in `.gitignore`.)
Replace every `REPLACE-ME`:
- `PROXMOX_VE_API_TOKEN` — `user@realm!tokenid=secret` (injected into the provider's env by tf.sh;
never a tofu variable, so it never lands in state).
- `PROXMOX_VE_ENDPOINT` / `PROXMOX_VE_INSECURE` — already set to `https://192.168.2.102:8006/` / `true`.
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — the R2 Object-Read/Write token from step 1.
- `TF_STATE_PASSPHRASE` — the state-at-rest passphrase (≥ 16 chars). **tf.sh builds the multi-line
`TF_ENCRYPTION` blob from this one scalar.**
> ⚠️ **The passphrase and R2 keys MUST be the SAME values that already encrypt the live R2 state.**
> The state in R2 is already AES-GCM-encrypted with the current passphrase; a *different*
> `TF_STATE_PASSPHRASE` makes the existing state UNREADABLE (and wrong R2 keys can't reach it at
> all). Reuse your existing values from your password manager — do NOT invent new ones here.
The non-secret R2 bucket/key/endpoint live in the COMMITTED `infra/tofu/backend.r2.hcl` (an R2
account id is not a credential). Still keep the passphrase + R2 keys in your password manager as
the authoritative backup. The old plaintext `.r2.env` / `backend.hcl` are retired — delete any
local copies (they stay in `.gitignore` only as a safety net).
### 3. Migrate the existing local state -> R2 (one shot, moves state only — touches NO infra)
> The R2 state has ALREADY been migrated and encrypted. This step is historical — a fresh machine
> adopting the existing encrypted state does NOT run it; it just does the Day-2 flow below.
```bash
cd infra/tofu
cp terraform.tfstate terraform.tfstate.premigrate.bak # extra belt-and-suspenders backup
source .r2.env
tofu init -backend-config=backend.hcl -migrate-state # answer "yes" to copy local -> R2
tofu plan # MUST report "No changes." — proves parity
cp infra/tofu/terraform.tfstate infra/tofu/terraform.tfstate.premigrate.bak # belt-and-suspenders
./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init -migrate-state # answer "yes"
./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh plan # MUST say "No changes."
```
If `plan` shows no changes, the migration is correct. If it wants to create/destroy nodes, STOP —
the state did not carry over; re-check backend.hcl/creds and do not apply.
@@ -48,20 +64,34 @@ the state did not carry over; re-check backend.hcl/creds and do not apply.
> R2 state does NOT need this — the encrypt-only `.r2.env.example` is correct as-is.
## Day-2 use (any machine)
Everything goes through the `tf.sh` wrapper inside the dev container. It decrypts
`secrets.sops.env`, exports `PROXMOX_VE_*` / `AWS_*`, builds `TF_ENCRYPTION` from
`TF_STATE_PASSPHRASE`, and passes `backend.r2.hcl` to `init`. NO manual secret entry, no plaintext
env file:
```bash
cd infra/tofu
source .r2.env # R2 creds + TF_ENCRYPTION passphrase
tofu init -backend-config=backend.hcl
tofu plan
./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init
./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh plan # expect "No changes."
./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh apply
```
That's the whole "work from a different computer" story: clone the repo, create the two local files
from the templates (creds + passphrase from your password manager), `init`, done.
To CHANGE a secret, edit the encrypted file in place (re-encrypts on save):
```bash
./infra/devcontainer/dev.sh run sops infra/tofu/secrets.sops.env
```
That's the whole "work from a different computer" story: clone the repo, have the age PRIVATE key in
`~/.config/sops/age/keys.txt` (the dev container mounts it), then `tf.sh init` / `plan`. The secrets
ride along in git as ciphertext; only the age key lives outside the repo.
## DR: rebuild from nothing
Because state is in R2 (not the cluster), a destroyed cluster is recoverable: on a fresh box, install
OpenTofu, create `backend.hcl` + `.r2.env`, `tofu init -backend-config=backend.hcl`, then `tofu apply`
to rebuild the VMs against the preserved state. R2 itself should ALSO be covered by the off-cluster
backup target (OQ-BKP) so state survives even an R2/account loss.
Because state is in R2 (not the cluster) AND the tofu secrets are committed (encrypted), a destroyed
cluster is recoverable from just **the repo + the age private key**. On a fresh box: install the dev
container toolchain, drop the age key at `~/.config/sops/age/keys.txt`, then
`./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init` and `... tf.sh apply` to rebuild the VMs
against the preserved state. Two things must survive off-cluster and off-repo: the **age private
key** (in `flux-system/sops-age` in a live cluster; also keep an offline copy) and the **R2 state
bucket** itself (covered by the off-cluster backup target OQ-BKP so state survives an R2/account loss).
The `TF_STATE_PASSPHRASE` and R2 keys are recoverable from the encrypted `secrets.sops.env` as long
as you hold the age key — keep them in your password manager too, belt and suspenders.
## Follow-up (tracked)
- **Locking.** OpenTofu 1.9.0 has no `use_lockfile`; state locking is currently OFF (safe for a single
-12
View File
@@ -1,12 +0,0 @@
# Copy to `backend.hcl` (gitignored) and fill in from your Cloudflare R2 bucket + API token.
# Passed to tofu via: source .r2.env && tofu init -backend-config=backend.hcl -migrate-state
#
# The R2 <ACCOUNT_ID> is the 32-hex string in your Cloudflare dashboard URL / R2 endpoint. It is
# not a credential, but is kept out of git here as defense-in-depth.
bucket = "homelab-tofu-state"
key = "k3s-dev/terraform.tfstate"
endpoints = {
s3 = "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
}
+17
View File
@@ -0,0 +1,17 @@
# Committed R2 backend config — NON-SECRET account-specific bits, passed to
# `tofu init -backend-config=` by tf.sh. The S3 keys and the state-encryption
# passphrase are NOT here — they come from secrets.sops.env via tf.sh.
#
# The endpoint contains the R2 account id (the 32-hex string). An account id is
# NOT a credential (it does nothing without the R2 API keys), so committing it is
# fine and keeps the "work from any machine" story to: clone repo + age key.
#
# The rest of the backend "s3" block (region, skip_* flags, encryption) lives in
# backend.tf; this file supplies only bucket/key/endpoints.
bucket = "homelab-tofu-state"
key = "k3s-dev/terraform.tfstate"
endpoints = {
s3 = "https://7ce452a1b3d6c86daf2c020edcb82d37.r2.cloudflarestorage.com"
}
+10 -7
View File
@@ -6,13 +6,16 @@
# gone, `tofu apply` to rebuild. R2 sits entirely outside that chain: a full cluster rebuild can
# still read state. It also survives total-site loss (off-site) and gives multi-machine access.
#
# SECRETS: the account-specific bucket/endpoint come from a gitignored backend.hcl; the S3 keys
# come from the environment (.r2.env: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY). State bytes are
# encrypted at rest via the TF_ENCRYPTION env blob (see REMOTE-STATE.md) — the passphrase lives ONLY
# in your password manager and the gitignored .r2.env, never in git or in the state itself.
# SECRETS: driven by infra/tofu/tf.sh, which decrypts the COMMITTED, SOPS/age-encrypted
# secrets.sops.env and exports AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (R2 S3 keys) plus builds
# the TF_ENCRYPTION blob from the single TF_STATE_PASSPHRASE. The non-secret bucket/key/endpoint
# live in the committed backend.r2.hcl (an R2 account id is not a credential). State bytes are
# encrypted at rest via TF_ENCRYPTION (see REMOTE-STATE.md) — the passphrase lives ONLY inside the
# encrypted secrets.sops.env (and your password manager), never in git plaintext or in the state.
#
# INIT (partial config — non-secret bits here, secret bits from the gitignored file):
# cd infra/tofu && source .r2.env && tofu init -backend-config=backend.hcl -migrate-state
# INIT (run via the wrapper, inside the dev container — non-secret bits from backend.r2.hcl,
# secret bits decrypted from secrets.sops.env):
# ./infra/devcontainer/dev.sh run ./infra/tofu/tf.sh init
#
# NOTE: this `terraform {}` block is MERGED with the one in versions.tf (required_version +
# required_providers). OpenTofu allows multiple terraform blocks across files; `backend` appears once.
@@ -27,7 +30,7 @@ terraform {
skip_metadata_api_check = true
skip_s3_checksum = true # R2 rejects AWS trailing (streaming) checksums.
# bucket, key, endpoints{ s3 } -> backend.hcl (gitignored; keeps the R2 account id out of git).
# bucket, key, endpoints{ s3 } -> committed backend.r2.hcl, passed by tf.sh via -backend-config.
#
# use_lockfile = true -> ENABLE after bumping OpenTofu to >= 1.10. Native S3 conditional-write
# locking (no DynamoDB); works on R2 via If-None-Match. 1.9.0 has no lockfile support, so state
+24
View File
@@ -0,0 +1,24 @@
#ENC[AES256_GCM,data:phVXkjO3EXMu1N0PjbJc3ezD9Fv6OjYXV8SR7kVJkCU5mNegSsSxqla5yK2pnkXe/2bKl4zH2jHNc1Mcjwrkxbq2rbdCl87HkDYU4GQ=,iv:T+NCH5piQKyu33pC+VDmkGKFDw8BFyKU0SGyXU0Oyc0=,tag:9rTfz7kjbG14I542CJ20JQ==,type:comment]
#ENC[AES256_GCM,data:ouvXF4JpcjNKh19m7pmbsMGaYCJnt8g8j3u6KOS5qqLwPUQXWpap5/iMFOJ99Zg7P5Mr++pFBcsDHehaLdmk3zRPejAn2hiZdrU0uDBPHrNOs5ivAV/xplWbeU5RoQ==,iv:RHIswt1NJ5SFx4zJCy3lLHHW29kh+5EXM9SMXTafzQY=,tag:Y7B+MSE1reAkvdLf5rG73Q==,type:comment]
#ENC[AES256_GCM,data:N8YDuSbxFKEGYqBAj/lcJiq1sVXkc6QHyv+PAPeflCqSkuJnYxRK5PmUdvrUlJ5UQ2JtgZSpLPQeunGYkXTHUKxQKn30HGU/vH7PnFUaaGcdQio=,iv:dzllLfLcFUsXyXRiUkWOzVq4ByCxuQyOthIuGuAvc50=,tag:Bnu2MKNAQBUf2CYVeM/ouw==,type:comment]
#ENC[AES256_GCM,data:S+H2PEFoMYlHFqG3BJNbT/iQxa38pZ1vgtG1mF/1Oq+UOF7jOiD8NSxayUnu2wuzqWrFkQtDCNRGPwiogq+nyeTQTkMJgm9iTKOkgFKn9gL06A==,iv:X61ZbHvs5yMcu7oyJvZgvUXeQ8IjjM3/BsZZ4oveRts=,tag:2xGsmutQCr4DOJWJm1cOHw==,type:comment]
#ENC[AES256_GCM,data:4LI7KckpBHs01cebeZjjqs8/WZbKKalPnx8aj77wT3JDtPifdAE0N1/4CpJYGOKJsJdm0sYm/3sh+dZ+F/PGfk3lfkTsKobvjS8Gs6WVOrQhJkq2LucBWw==,iv:8nJsWdKo57NsNIb0slM7OHakMu0u1sVpECRHE9BWVno=,tag:9xuw36Cd/Ex33FhYwGWfBA==,type:comment]
#
#ENC[AES256_GCM,data:Ne1QnjtM2NxPEgjHCgKTU6tand+4yaZWFTq3qxiNFcOucJVL134ycsE+41B7NlEz0+UztRXJ+Zz1/wpcIBQsswDfw1pVP+t3lGl7Mq+GcoElB/E=,iv:esdC6pJPkvirFm3Bt5c1q719o6fbWHMR617j86D9WCk=,tag:hfkQyK4Pf+mhMhAmiT4mhQ==,type:comment]
#ENC[AES256_GCM,data:olZoi67DYWBRnKv3SEcp9AY7J+jcw57LPZdff4tjgrTdPHos9uyE+s0JCWDAGVrKLu6JFvEIifKIaGz59FLo+qv1HHP6gWXhpxTS0RFTNAYwn/GVl5eUFzLWJmN8Fqw=,iv:K7V/q1De9D5hCKXXTkFfHSaHPtP/INWwdZ8V1tupMtQ=,tag:VPXiDQgMjdgXuswXRmbHRA==,type:comment]
PROXMOX_VE_API_TOKEN=ENC[AES256_GCM,data:F55qeDT3HBgfYA==,iv:UdBvzph1/5FFj5UpoJimHyvtZVWvBOyohn+V2HuXZFo=,tag:VBhe8TePhdGyfQVOJH/i9g==,type:str]
PROXMOX_VE_ENDPOINT=ENC[AES256_GCM,data:XJ1I+RMh6YBasYkJSFEgTMrHpGnooDiCCGx/,iv:4SCkmvxCkKRBP0YxzrpQ4bUEdlPvnwOqVh5S+jO5pqo=,tag:fmIg/5r2VKn/YTjVubwk1A==,type:str]
PROXMOX_VE_INSECURE=ENC[AES256_GCM,data:6VjGNw==,iv:gkR7QY5tNacpnquR7vVg14zJwjNMfR3iCkvI+T7g5d4=,tag:TvZCbaejUu+50sa3n+b4XQ==,type:str]
#ENC[AES256_GCM,data:wXUDqRFrcgE1vdKCXK/hq5ZerZ+qp/Dp48iP4sJBgVrIleEL8NEerk1WahuBXg7DZLm1r0yAZz7bACUHZST0x/Y=,iv:trlf6jEWST/ur2AobLuTxXAk4qIkoM+EHe542+aHZGY=,tag:mlr6H0E6OM6KcW1zntiQvg==,type:comment]
AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:lPyvLMNcKBptwA==,iv:zihVqD9XGE3gFbzyHd3wWTDLTVgZJRaHxsHIcef7M3A=,tag:fuVnjVRahoo1viSzoCqROA==,type:str]
AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:upXXeOauT56tKg==,iv:Rfj8HyjrMygqLLRMnuxzJGqDVzbUppPUYz1WQiJqPdI=,tag:y+8zcTTbDeOk/tx2dWQl0Q==,type:str]
#ENC[AES256_GCM,data:ztI5Cl8qRqpgnAw5e/fODKXJ3f8lmsbY9EXMIEaIQQBkyAlaFAEmbYUbb/1MgFhEGyMHFE6DROLAITBBf8nvIf8OYmsaufcvQeM=,iv:l8Ve0/cAvM6BzJkMeIw/0JFKZsB/4lrKYp6LZDz3BAQ=,tag:Ycbmwau8Slp8gvP6DOlXPw==,type:comment]
#ENC[AES256_GCM,data:rI8MK9Nuc58hX8zBEtqITCcLu5/4pBKmLKWbYE/NzfGN9VB1K2lDv7P5CHFyHN20BVWiUx0nOmXzPZCJ/3NecNr0pJBBn2psrHK8BgZkRDE+yKU=,iv:fDCzQy05zUCoYI82cxxRZkvcpBT4xDHsjNaTfAkosjo=,tag:G7yoxa/Zpt/8ylvaLB3OHQ==,type:comment]
#ENC[AES256_GCM,data:VdqDxRFlnn85hTj4Oz00+noc5nV9Yp19wKxk3Ql+eKDtMtm2ftLIow4vjuR3YIwGJTi04nkFdgkzmuOrOJBlkbhAmSjvVKi/0QxxHUhDAcslNvH8,iv:Fwo89vAPfPZXXk8y1Xn3yaxBKnGJgR4Y0eaYUOLmOjM=,tag:hvc/WJmbgD2QsCser+djlw==,type:comment]
TF_STATE_PASSPHRASE=ENC[AES256_GCM,data:Uw0sZEqe2dpSxw==,iv:0edbfCL73a+pMF2KYAv9bZbNA+PgHO53+o8HeCfanAI=,tag:qHLfOttXK9b56/e6Py0mpg==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXS1Q1dHdNUkdEN2tWWDNj\ncHR6RVZ5ckhYS0VkL1FqNGtNSWREb1hCNDNrCkVCMjFJU2JOV3I0MTI0Z3JHT2J2\nMUJTbDFoWHJyM1FjK05BSHhIOHVIK00KLS0tIG4vemVTNVhFajhSQ1dkY252Ly9q\nTGl2ZFV1ek5QQTBtcVZ6SnBZbXdERUUKKyz3ZNRrXn9OWWpV++fURpNHEuiRohko\nXJL496ln+4eJclnFDj6FJzqJJggKrd4F7zLb/XiEkDynP6fE6qXIQQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
sops_lastmodified=2026-08-28T00:03:49Z
sops_mac=ENC[AES256_GCM,data:LpNDyo0OGvMUXNBk1CN3a14P9EbDRGewXqmX/x50Japg+LDXW+ca/rgVGEhBO7YfCg9bsN3xWB8ZALj7HZsfAS8gf3UAYYENP6djs9em7/ZmExieTgVY1eq9LTkHVAuJo5ylPtOinr5jP56a0v7xKSXT08TKEVPOsmJ362ZIqfo=,iv:Jg1jYq2wkbZUnH2HvT/Kfoc4Shpo5qwIG10U+EU0r+I=,tag:Q9Ruy22FhQ/eZrcilw90KA==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.9.4
+75
View File
@@ -0,0 +1,75 @@
#!/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 "$@"