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:
1 parent
318f85de11
commit
216bea2a16
10 files changed
+203
-78
No files matched your search
+4
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable
+75
@@ -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 "$@"
|
||||
Reference in new issue
Block a user