Follow-up to #825 (static-seal auto-unseal). The rebuild path and the operator docs still assumed shamir with a manual unseal per replica. bootstrap.sh - Required secrets: `seal-key` (pass show homelab/openbao/seal-key) replaces `unseal-key`. The snapshots are encrypted under the seal key, so it is the DR-critical secret now; the former unseal key is the recovery key and is not needed for a restore (upstream: recovery keys "are purely an authorization mechanism" and cannot unseal a server whose auto-seal is broken). - flux phase creates the `openbao-seal-key` Secret (namespace + Secret, before `flux bootstrap`) from the collected key. --from-literal via a substitution, because `pass show` appends a newline and OpenBao rejects a key that is not exactly 32 bytes of base64 ("unknown encoding for AES-256 key" — verified). An existing Secret with the same value is left alone; a different value is refused, since the data is written under whichever key OpenBao started with. - restore_openbao: `init -recovery-shares=1 -recovery-threshold=1`, root token from the output, wait for the self-unseal (init returns with the node sealed; the server's own retry loop unseals it within ~5 s), then `snapshot restore` WITHOUT -force: OpenBao verifies the snapshot was written under this server's seal key and refuses a mismatch by name, which is the one guard against restoring under a wrong Secret. -force skipped that check and would leave a server that can never unseal. The throwaway `unseal` step and its argv/tty discussion are gone — under an auto-seal there is no unseal key. - unseal_openbao -> bring_up_openbao: waits for spec.replicas pods to report sealed=false, walking the OrderedReady chain as before, and on timeout prints each still-sealed pod's `bao status` instead of a bare "timed out". Rehearsed locally with two OpenBao servers sharing a static key (file:// form, bao 2.5.5): init on an auto-seal returns an empty unseal_keys_b64, one recovery key and a root token and the server is unsealed within seconds; a restore of A's snapshot into fresh B (both with and without force) re-seals and reloads the root key via the auto seal ~5 s later ("done reloading root key using auto seal"), after which B serves A's data with A's root token. Restarting B under a different key leaves it sealed with "failed to decrypt keys from storage: message authentication failed". Docs and alerts: RUNBOOK-cold-rebuild Step 4 and MIGRATION-sops-openbao describe the auto-seal flow and the recovery-key semantics; the OpenBao alert descriptions and the snapshot CronJob comment no longer tell the reader to unseal by hand. vmtest-job comment updated. Not covered: a full VMTEST_FULL=1 rehearsal through the flux phase. The wiki records none ever reaching past the Flux bootstrap checkpoint, so that gap predates this change. Refs #805 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 KiB
Migrating k8s secrets: SOPS/age in git → OpenBao + External Secrets Operator
Goal: secret values leave git. Git keeps only ExternalSecret manifests
(non-sensitive references); the External Secrets Operator (ESO) syncs real
values from OpenBao into k8s Secrets at reconcile time. Editing a secret
becomes bao kv put / UI edit instead of the sops encrypt-commit-push cycle.
Scope: the 31 *.sops.yaml files under kubernetes/ only. The Ansible side
(ansible-vault via GPG pass) is a separate mechanism and stays untouched;
unifying it onto OpenBao is a possible follow-up, not part of this migration.
The two constraints that dictate everything
- Never commit a CR that cannot become Ready into a
wait: trueKustomization. AClusterSecretStorepointing at an uninitialized OpenBao stays NotReady and freezes the whole Flux dependsOn chain — the exact shape of the 2026-07-13 ImageUpdateAutomation incident. Hence the strict phase split below: the store CR is only committed after OpenBao is initialized and its k8s auth verified by hand. - The age key is retired LAST. While it lives, any migrated app rolls
back with a plain
git revert(sops file returns, decryption still configured). Retiring the key (phase 4) is the point of no return — everything before it is cheaply reversible.
DR chicken-and-egg to accept consciously: after migration, a cluster rebuild needs OpenBao's data (raft snapshot restore + unseal) before any secret-consuming app can start. See "Disaster recovery" below — the runbook must exist before phase 3 begins, not after.
Phase 1 — deploy (this PR)
Adds kubernetes/infrastructure/openbao/ (single-node raft, 5Gi ceph-block,
UI at openbao.renner.uno, not publicly routed) and
kubernetes/infrastructure/external-secrets/ (ESO, CRDs via CreateReplace).
No ClusterSecretStore yet (constraint 1). Merging is safe: nothing consumes
either component; the OpenBao pod sits 0/1 Ready (sealed) by design
(disableWait on the HelmRelease keeps helm-controller green).
Post-merge, one-time init (single admin → 1 share / threshold 1):
kubectl -n openbao exec -it openbao-0 -- bao operator init \
-key-shares=1 -key-threshold=1
# Store BOTH outputs in the GPG pass store, nowhere else:
# (bootstrapped 2026-07-13: key 6B8FED8020C1478F892EA83816866E146A8252A4,
# no passphrase yet — set one via `gpg --edit-key ... passwd`, and BACK THE
# KEY UP off-machine: without it, pass and thus unseal key are gone.)
pass insert homelab/openbao/unseal-key
pass insert homelab/openbao/root-token
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
Unseal is automatic since 2026-09-08 (static seal, PR #825, issue #805):
the seal key lives in the openbao-seal-key Secret (created by bootstrap.sh
from pass show homelab/openbao/seal-key) and every restarted replica unseals
itself. The shamir key above became the recovery key in the migration; it
is kept in pass under its old name, homelab/openbao/unseal-key. The server
runs three raft peers (see infrastructure/openbao/helmrelease.yaml); a sealed
one is neither a voter nor a standby, so a replica that stays sealed after a
restart — wrong or missing seal-key Secret — costs redundancy, two cost
quorum. The trade-off taken with the static seal: the key that unlocks the
store sits in the same etcd as the Secrets ESO already syncs there in
plaintext. Symptom of sealed: ESO stores go NotReady, synced Secrets keep
their last value (they are real k8s Secrets — pods keep running).
Phase 2 — configure + ClusterSecretStore (follow-up PR)
Manual, with the root token (then stop using the root token day-to-day):
export BAO_ADDR=https://openbao.renner.uno
bao login # root token, from pass
bao secrets enable -path=homelab -version=2 kv
# NOTE: `bao audit enable` is disabled in OpenBao 2.5 — audit devices are
# declarative-only, configured in the server HCL (see the audit stanza in
# infrastructure/openbao/helmrelease.yaml; keep log_raw OFF or plaintext
# secrets land in Loki).
bao auth enable kubernetes
bao write auth/kubernetes/config \
kubernetes_host=https://kubernetes.default.svc.cluster.local:443
bao policy write eso-read - <<'EOF'
path "homelab/data/*" { capabilities = ["read"] }
path "homelab/metadata/*" { capabilities = ["read", "list"] }
EOF
bao write auth/kubernetes/role/eso \
bound_service_account_names=external-secrets \
bound_service_account_namespaces=external-secrets \
policies=eso-read ttl=15m
(auth/kubernetes/config from outside the pod needs the reviewer JWT flow;
simpler: run these bao commands via kubectl exec in openbao-0, where the
in-cluster host/CA defaults apply.)
Optional in the same session: OIDC auth method against authentik (issuer
https://auth.renner.uno/...) for human UI login, so the root token goes
back into pass and stays there.
Then the follow-up PR adds to kubernetes/infrastructure/post-crds/:
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: openbao
spec:
provider:
vault: # ESO's provider name; speaks OpenBao fine
server: http://openbao.openbao.svc.cluster.local:8200
path: homelab
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: eso
serviceAccountRef:
name: external-secrets
namespace: external-secrets
Merge only after bao read auth/kubernetes/role/eso looks right; verify the
store goes Ready before starting phase 3.
Phase 3 — migrate, app by app
Per sops file: put the values into OpenBao, replace the manifest, one PR per app (or small batch), verify the app's Secret + workload before the next.
# 1. decrypt and read (from repo root; .sops.yaml lives under kubernetes/)
sops -d kubernetes/apps/evcc/evcc.sops.yaml
# 2. write values (key per Secret data field)
bao kv put homelab/apps/evcc username=... password=...
# 3. replace evcc.sops.yaml with evcc-externalsecret.yaml:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: evcc # target Secret keeps its old name — pods
namespace: evcc # and mounts don't change at all
spec:
refreshInterval: 1h
secretStoreRef: {kind: ClusterSecretStore, name: openbao}
target:
name: evcc # must match the old Secret's metadata.name
data:
- secretKey: username # key in the k8s Secret
remoteRef: {key: apps/evcc, property: username}
- secretKey: password
remoteRef: {key: apps/evcc, property: password}
# 4. update the dir's kustomization.yaml resource entry, PR, merge,
# then verify the real consumer path:
kubectl -n evcc get externalsecret evcc # SecretSynced/Ready
kubectl -n evcc get secret evcc -o yaml # keys present, app still works
When the last sops file in a Flux KS path is migrated, drop the
spec.decryption block from that KS in kubernetes/clusters/homelab/ in the
same PR — a decryption block with nothing to decrypt is dormant drift.
Suggested order — blast radius ascending, checklist:
Warm-up (infrastructure, low blast radius): — done 2026-07-13
infrastructure/external-dns-unifi/secret.sops.yamlinfrastructure/netbird-ingress/secret.sops.yamlinfrastructure/velero/secret.sops.yaml+hetzner-creds.sops.yamlinfrastructure/post-crds/cloudflare-secret.sops.yaml(decryption blocks already dropped frominfrastructure+infrastructure-crds— these two KS paths are sops-free)
Apps, non-critical: — done 2026-07-13
apps/evcc/evcc.sops.yamlapps/otbr/dataset-secret.sops.yamlapps/libation/bootstrap-secret.sops.yaml(binary SQLite payload — stored base64 in KV, ES usesdecodingStrategy: Base64)apps/external-dns/cloudflare-secret-apps.sops.yamlapps/monitoring/(4: alertmanager-smtp, grafana-oauth, loki-basic-auth, pve-exporter-config)apps/woodpecker/secret.sops.yaml(CI must keep building — verify a pipeline run after) (decryption blocks dropped fromapps,evcc,otbr,libation,monitoring+ the dormant one ongrafana-db)
Apps, stateful/central: — done 2026-07-13
apps/home-assistant/(5: backup-password, config-sync-token, mosquitto-evcc-credentials, prometheus-token, shelly-ble-proxy-key)apps/immich/secrets.sops.yamlapps/paperless/secrets.sops.yaml+ftp-scanner-secret.sops.yaml(decryption blocks dropped fromhome-assistant,immich,paperless- the dormant one on
speech-to-phrase)
- the dormant one on
Seed (bootstrap-only; seed KS is suspended — migrate the files anyway so
a future rebuild doesn't need the age key): — done 2026-07-14
seed/authentik/,seed/immich/,seed/paperless/barman-creds + hetzner-creds (6),seed/influxdb/bootstrap-secret.sops.yaml(decryption blocks dropped from all seed-* KS incl. the dormant ones on seed-audiobookshelf/seed-music-assistant and the suspended seed KS)
Last, the identity provider (everything else must be proven first): — done 2026-07-14
apps/authentik/secret.sops.yaml+samba-bridge-secret.sops.yaml(authentik KS decryption dropped — ZERO sops files remain under kubernetes/; phase 4 may start once the soak criteria are met)
Phase 4 — decommission SOPS (point of no return)
Backup is decoupled from the soak gate below and should happen now — it
only adds an offline copy in pass, it doesn't touch the live decryption
path, so there's no reason to wait for the reconcile/unseal soak:
- Back up the live age key to
pass:pass insert -m homelab/sops-age-key < ~/.config/sops/age/keys.txt(verify:pass show homelab/sops-age-key | diff - ~/.config/sops/age/keys.txtexits 0).pass's GPG key already has an external backup (see Disaster recovery below), so this gives the age key the same offline durability as the OpenBao unseal key/root token.
Only when every box above is checked and the cluster has survived at least one full reconcile + one OpenBao pod restart/unseal cycle:
- Remove remaining
spec.decryptionblocks from all Flux KS. - Delete
kubernetes/.sops.yaml; delete thesops-ageSecret in flux-system. - Remove
SOPS_AGE_KEYfrom Woodpecker CI secrets. - Re-label the
passbackup fromhomelab/sops-age-keytohomelab/sops-age-key.retired(pass mv homelab/sops-age-key homelab/sops-age-key.retired) — git history stays decryptable.
Disaster recovery (write into ops docs before phase 3)
- Backups: ✅ nightly raft-snapshot CronJob (03:00, keeps 7 on a
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
openbao-dailySchedule (06:15) ships that PVC offsite, 30 d retention. A raft snapshot restore needs the matching seal key — which lives inpass, outside the cluster (GPG key backed up externally 2026-07-14). A failed snapshot Job is the sealed-after-restart alarm. Still owed: one tested restore before phase 4. - Cluster rebuild order: seal-key Secret from
pass→ Flux brings up openbao (empty) → init → restore raft snapshot → replicas unseal themselves → ClusterSecretStore Ready → ESO materializes Secrets → apps start (bootstrap.sh fluxdoes all of it; RUNBOOK-cold-rebuild.md Step 4 by hand). Until then, secret-consuming apps simply stay Pending on missing Secrets — same as any missing dependency. - Break-glass:
passholds seal key, recovery key + root token (GPG, offline-capable). With those, everything is recoverable from a raft snapshot alone. - Ansible Vault password:
ansible/vault-password.shreadsANSIBLE_VAULT_PASSWORDfrom the environment with no offline backup as of 2026-07-14. It gates decryption of several Ansible-Vault files (PBS credentials onnas-1.renner.uno, plusedge_host,grist,authentik,paperless,rustfsvars) — unrelated to the SOPS/OpenBao migration but in the same recoverability class. Back up withpass insert homelab/ansible-vault-password; verify withANSIBLE_VAULT_PASSWORD=$(pass show homelab/ansible-vault-password) ansible-vault view ansible/inventory/host_vars/nas-1.renner.uno/vault.ymldecrypting cleanly.
Known gotchas
- ESO chart pinned
>=2.7.0 <3.0.0; major bumps have moved CRD APIs before — bump together with all ExternalSecret/ClusterSecretStore manifests. refreshIntervalis the propagation delay after abao kv put; force withkubectl annotate externalsecret <n> force-sync=$(date +%s).- The OpenBao HelmRelease has
disableWait— a genuinely broken chart upgrade will NOT be caught by helm-controller; watch the pod after chart bumps. - Sealed OpenBao ≠ outage: existing k8s Secrets persist; only changes stop propagating until unsealed.