bootstrap/sops (age-keygen refusing in-repo, .sops.yaml, install-age-key.sh glue installing the sops-age Secret) + bootstrap/flux (bootstrap.sh: flux install -> apply -k clusters/dev; decryption/url patches). PM wiring: place .sops.yaml at repo root (SOPS walks to root); inline the 3 flux patches into clusters/dev (avoids kustomize ../ load-restriction). clusters/dev builds with decryption enabled; full CI gate PASS. No key material; nothing executed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
3.5 KiB
Installing the age private key as the sops-age Secret (out-of-band)
Phase 1, T-1.4 · ADR-0007 (SOPS+age) · R-SEC-01 · spec docs/07 "Secrets management".
This is the one sanctioned non-GitOps action in the secrets model (ADR-0007 Consequences,
docs/07): the age private key is bootstrapped into the cluster out-of-band as a Kubernetes
Secret that Flux's kustomize-controller reads to decrypt SOPS at reconcile time. The key is never
committed (.gitignore guards age.key, *.key); no key material appears in git, PRs, or logs
(AGENTS.md §5).
AUTHOR-ONLY runbook. Nothing here has been executed. The operator runs these steps once, after the age key exists (
age-keygen.sh) and Flux controllers are installed (../flux/bootstrap.sh).
Prerequisites
age-keygen.shhas been run → private key at~/.config/sops/age/keys.txt(outside the repo).- The
flux-systemnamespace exists (created byflux install/flux bootstrap). kubectlcontext points at the dev cluster.
Create the Secret (command template — NOT run)
Flux discovers age keys inside the sops-age Secret by the .agekey filename suffix, so the
data key MUST end in .agekey:
# Reads the private key file and stores it under data key "age.agekey".
cat ~/.config/sops/age/keys.txt | kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=/dev/stdin
Idempotent re-apply variant (avoids "already exists" on re-runs):
kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey="$HOME/.config/sops/age/keys.txt" \
--dry-run=client -o yaml | kubectl apply -f -
- Secret name
sops-ageand namespaceflux-systemMUST match thedecryption.secretRef.nameon theplatform/appsFlux Kustomizations (see../flux/patches/). - This Secret is created imperatively and is NOT in git — it holds the private key, so it must never be committed, encrypted or otherwise. (It is the trust anchor, not a managed resource.)
Verify (read-only — NOT run)
kubectl -n flux-system get secret sops-age # exists
kubectl -n flux-system get secret sops-age \
-o jsonpath='{.data.age\.agekey}' | base64 -d | age-keygen -y # prints PUBLIC recipient only
The printed public recipient MUST equal the age: value in .sops.yaml. (This command derives the
public key from the private key; it never exposes the private key.)
Rotation (documented per ADR-0007 "Key custody/rotation")
- Generate a NEW key with
age-keygen.sh(to a fresh path; the script refuses to clobber). - Add the new public recipient to
.sops.yamlalongside the old (multi-recipient), thensops updatekeysevery encrypted file so both keys can decrypt. - Update the
sops-ageSecret to contain both private keys (two*.agekeydata entries) so Flux can still decrypt existing files during the transition. - Once all files are re-encrypted to the new recipient, remove the old recipient from
.sops.yaml,sops updatekeysagain, and drop the old key from the Secret.
Key custody / DR (ADR-0007: "losing it blocks decryption")
- The age private key is DR-critical: without it Flux cannot decrypt any committed Secret.
- Keep an off-cluster backup of
keys.txtin the operator's password manager / offline store (NOT in this repo, NOT on the cluster it protects). This mirrors the off-cluster-mirror posture for Gitea (docs/05 "Circularity & DR").