Read-only copy of pvginkel/Ansible
docs/runbooks/openbao.md as of 80f07c4 (2026-10-17).
The source is authoritative: change it there.
OpenBao operator runbook #
Day-to-day administration and disaster recovery for the homelab OpenBao cluster. Read this when you need to reach the cluster as an admin, recover a lost node, recover the whole cluster, or read a secret out of a backup.
Design context:
../../../AnsibleSpecs/phases/completed/openbao.md,
"Secrets — OpenBao" and "OpenBao backup / DR" in
../../../AnsibleSpecs/decisions.md,
and the role README at
../../ansible/roles/openbao/README.md.
The IaC-agent escape hatch is iac-cold-boot.md.
Conventions #
- The operator runs every
terraformandansible-playbookinvocation. Claude prepares changes; it does not apply them. - "Roboform" is the password manager of record: it holds the ansible-vault passphrase, the Shamir recovery keys (3-of-5), and the age private key for backup decryption.
- Ansible runs from
ansible/; Terraform fromterraform/prd/. - Once
openbao_ufw_enableistrue,srvvaultNSSH is reachable fromsrviaconly — drive recovery fromsrviac(or flip ufw off out-of-band first; see the role README §Locking down with ufw).
Cluster facts #
| Host | VMID | PVE node | IPv4 | Raft role |
|---|---|---|---|---|
srvvault1 |
913 | pve |
10.1.0.40 |
bootstrap candidate |
srvvault2 |
914 | pve1 |
10.1.0.41 |
— |
srvvault3 |
915 | pve2 |
10.1.0.42 |
— |
- Client endpoint:
https://secrets/— leader-tracking VIPsecrets.home(10.1.0.39), HAProxy 443 → 8200. - Direct node API:
https://srvvaultN.home:8200. - Seal: static auto-unseal. The key is ansible-vault'd at
roles/openbao/files/static.key; its id isopenbao_seal_current_key_idininventories/prd/group_vars/openbao.yml. - Root token: retired. Mint a fresh one from the
Shamir recovery keys with
bao operator generate-rootwhen an admin path outside theopenbao-adminAppRole is needed. - Convergence playbook:
playbooks/site-openbao.yml— runs bootstrap → baseline → managed_filesystems → openbao → ssh_host_cert against theopenbaogroup. - Listener cert renewal:
playbooks/renew-internal-tls.yml, run weekly byIaC/Scheduled Certs, reissues the listener leaf on each peer once it is inside its 14-day window. It reaches the hosts over the plainansible.cfgargs — no Terraformknown_hostshandoff, no Proxmox credentials — so it can run without the setupsite-openbao.ymlneeds. The window allows two attempts before a leaf lapses, so a red Friday is worth chasing before the next one. The dailyIaC/Scheduled Driftbuild reds once a peer's leaf is under 7 days — a Friday that was missed, not one still to come. A leaf that has already lapsed:internal-tls-expiry.md.
1 — Admin access #
Routine reads/writes go through a consumer's AppRole, not an admin session. For interactive administration:
-
Web UI —
https://secrets/ui/in a browser. The listener cert chains to the homelab CA, so it validates on any.homeclient with the CA trust root. Log in with theopenbao-adminAppRole or a recovery-key-minted root token. -
CLI — on a host with
/usr/bin/bao(everysrvvaultN, orsrviac):export BAO_ADDR=https://secrets read -rs secret_id # paste the openbao-admin secret_id, then Enter export BAO_TOKEN=$(printf '%s' "$secret_id" | bao write -field=token auth/approle/login \ role_id=<openbao-admin role_id> secret_id=-) unset secret_idsecret_id=-reads the value from stdin, andprintfis a shell builtin, so the secret_id is in neither the host's shell history nor its process table — the same routescripts/bao-login.shtakes.AppRole login is
bao write auth/approle/login, notbao login -method=— the CLI's-methodflag has no approle handler. Theopenbao-admincreds are ansible-vault'd inline ininventories/prd/group_vars/openbao.yml; read them back with:cd ansible && poetry run ansible srvvault1 -m debug \ -a 'msg="role_id={{ openbao_admin_role_id }} secret_id={{ openbao_admin_secret_id }}"'
2 — Single-node loss #
One srvvaultN is gone (PVE host down, disk failure, corruption).
The other two hold quorum, the VIP rides the surviving leader, and
clients are unaffected. Goal: rebuild the node and return it to a
voter.
-
Recreate the VM. Terraform refuses to replace a prd VM, so destroy it on Proxmox first if it is still there, then apply — Terraform's refresh finds it gone and recreates it. Its
vm_idandpve_nodeare under its key interraform/prd/vms.tf:ssh root@<pve_node> 'qm stop <vm_id> && qm destroy <vm_id>' cd terraform/prd && terraform apply -
Converge the node. A full run is safe — the two healthy nodes reconverge to a no-op:
cd ansible && poetry run ansible-playbook playbooks/site-openbao.ymlelect-bootstrap.ymlprobes all peers, sees the cluster already initialized, and routes the rebuilt node throughjoin.yml— it targets a live initialized peer, so this works even when the rebuilt node issrvvault1. The leader then streams the Raft snapshot and the static seal auto-unseals the node.(To converge only the rebuilt node, add
--limit '<srvvaultN>,localhost'—localhostmust stay in the limit or Play 0's known-hosts seeding is skipped.) -
Verify.
BAO_ADDR=https://srvvaultN.home:8200 bao operator raft list-peersExpect three voters. Confirm the VIP never moved off a surviving node (
ip -br addr showon each), and that a known secret reads back on the rebuilt node.
3 — Whole-cluster loss #
All three nodes are gone simultaneously. The cluster is rebuilt empty and the latest backup's Raft snapshot is restored into it.
-
Fetch and unpack the latest backup.
backup-serveris upload-only — pull the object from the rclone destination it ships to (backupServer.rcloneRemotein HomelabAppsDeploy'sstorage/config/prd/values.yaml). The newestopenbao/<ts>_openbao-backup.tgz.ageis the one you want, not the.metadata.jsonfile beside it:age -d -i <age-key-from-Roboform> \ openbao/<ts>_openbao-backup.tgz.age > openbao-backup.tgz tar xzf openbao-backup.tgz # yields raft.snap + *.json -
Rebuild all three VMs. Destroy each
srvvaultNstill on Proxmox (vm_idandpve_nodeunder its key interraform/prd/vms.tf), then apply; Terraform recreates the three:ssh root@<pve_node> 'qm stop <vm_id> && qm destroy <vm_id>' # each srvvaultN still on Proxmox cd terraform/prd && terraform apply -
Converge a fresh empty cluster. Same seal key, so it auto-unseals;
srvvault1initialises,srvvault2/3join:cd ansible && poetry run ansible-playbook playbooks/site-openbao.ymlCapture the fresh root token from the init output — it authorises the restore in the next step:
ssh srvvault1 sudo cat /dev/shm/openbao-init.jsonThis converge leaves the backup pipeline unconfigured. The empty cluster has no AppRole auth to prove a staged backup secret_id against, so it prints
Backup AppRole secret_id not delivered, orOpenBao backup pipeline not configuredwhen none is staged. Step 7 delivers it. -
Restore the snapshot. Copy
raft.snaptosrvvault1, then:BAO_ADDR=https://srvvault1.home:8200 BAO_TOKEN=<fresh root token> \ bao operator raft snapshot restore raft.snapThe restore replaces cluster state with the snapshot's. The fresh root token is overwritten in the process; from here authenticate as the
openbao-adminAppRole (step 5). The fresh init file at/dev/shm/openbao-init.jsonis now stale — leave it; tmpfs clears on reboot and the keys it holds are inert.Every AppRole is back with the secret_ids it held at the snapshot. A consumer outside OpenBao keeps the secret_id it was last given, so a consumer whose AppRole was rotated after the snapshot is rejected. Steps 5 to 8 give each rejected consumer a fresh one:
openbao-adminfirst, because the playbook logs in with it; theniac-agentby the playbook, because srviac resolves the rotator's own credentials through it; then the rest with the rotator. The rotator's own pair needs nothing: it lives inkv/iac/rotator-approle, restored together with its AppRole. -
The admin AppRole. From the Ansible checkout, define
drop, which steps 5 to 8 use, then log in:drop() { for a in $(bao list -format=json "auth/approle/role/$1/secret-id" | jq -r '.[]'); do bao write "auth/approle/role/$1/secret-id-accessor/destroy" secret_id_accessor="$a"; done; } . scripts/bao-login.shdropdestroys every secret_id an AppRole holds. Run it only for an AppRole whose consumer is rejected: then no consumer holds any of them, and the rotator's mint foropenbao-admin,iac-agent,jenkinsandbackupfails while the role holds more than one.bao-login: BAO_ADDR=…means the inventory'sopenbao-adminsecret_id still logs in: go to step 6.approle login failedmeans the snapshot predates the admin's last rotation. Theopenbaorole then skips every auth task without failing, so the playbook runs below would deliver nothing. Mint a root token from the Shamir recovery keys in Roboform, 3 of 5:export BAO_ADDR=https://secrets bao operator generate-root -init # prints the nonce and the OTP bao operator generate-root -nonce=<nonce> # three times, one recovery key each, at its prompt export BAO_TOKEN=$(bao operator generate-root -decode=<encoded token> -otp=<otp>)Drop the admin's, then mint a fresh one straight into the vault format:
drop openbao-admin bao write -f -field=secret_id auth/approle/role/openbao-admin/secret-id \ | (cd ansible && poetry run ansible-vault encrypt_string --stdin-name openbao_admin_secret_id)Replace the
openbao_admin_secret_id: !vault |block inansible/inventories/prd/group_vars/openbao.ymlwith the printed one, commit and push. Then retire the root token and log in as the admin:bao token revoke -self unset BAO_TOKEN && . scripts/bao-login.shSteps 6 to 8 use
dropunder this admin session. -
The
iac-agentAppRole.ssh ansible@srviac 'sudo iac -c true'Exit 0 means srviac's
OPENBAO_SECRET_IDstill logs in: go to step 7. A failed OpenBao login meansiac-agentwas rotated after the snapshot. Drop its secret_ids and mint it alone:drop iac-agent cd ansible && poetry run ansible-playbook playbooks/site-openbao.yml \ -e openbao_rotate_secret_ids=true -e '{"openbao_rotate_secret_id_roles": ["iac-agent"]}'Put
tmp/openbao-credentials/iac-agent-secret-idinto srviac's/etc/iac/secrets.yamlas theOPENBAO_SECRET_IDliteral, wipe the staging files with theshred -uthe run's closing message prints, and runiac -c trueagain. -
Converge again, to deliver the backup credential. The rebuilt nodes hold no backup secret_id: step 3 had no
backupAppRole to prove one against. This converge authenticates as theopenbao-adminAppRole, re-stages the restoredbackuprole_id, and logs in with the staged secret_id before installing it:cd ansible && poetry run ansible-playbook playbooks/site-openbao.ymlThe outcome depends on the checkout's
tmp/openbao-backup-secret-id:- Staged, and the restored role accepts it: delivered to every
node, with
openbao-backup.timerenabled. - Staged, and the restored role rejects it: the run fails at
Refuse a staged backup secret_id the backup AppRole rejects, naming-e openbao_rotate_secret_ids=true. - None staged (a fresh clone, as every Jenkins run is): the
run completes with
OpenBao backup pipeline not configured, and no timer is installed.
For either of the last two, drop the
backupsecret_ids (no node holds one) and converge once more, minting forbackupalone:drop backup cd ansible && poetry run ansible-playbook playbooks/site-openbao.yml \ -e openbao_rotate_secret_ids=true -e '{"openbao_rotate_secret_id_roles": ["backup"]}'It mints and stages a fresh
backupsecret_id, which every node proves and receives. Once the nodes hold it, delete the staged copy: the rotator's nextbackuprotation makes it stale, and a converge from a checkout holding a stale one fails at its login.shred -u tmp/openbao-backup-secret-id - Staged, and the restored role accepts it: delivered to every
node, with
-
The rotations made since the snapshot. The restore undid in OpenBao every rotation made after the snapshot, while the consumers may hold the newer values.
Once SecretRotator's
ssh-keykind is enabled, the Ansible key comes first, before any plan below and before the night's run. Its plan cannot repair it: the rotator logs in asansiblewith the key the leaf holds, which the restore may have set back to one the hosts no longer take. srviac's own copy of the key holds the one they take, since nothing writes it from the leaf (iac-cold-boot.md). Write it back to the leaf and its catalog copy instead, without showing it:key=$(ssh ansible@srviac 'sudo cat /var/lib/iac/ansible-ssh-key/id_ed25519_ansible') printf '%s\n' "$key" | bao kv patch -mount=kv iac/ansible-ssh-key private=- printf '%s\n' "$key" | bao kv patch -mount=kv eso/prd/kubecoder/prd/catalog ssh-key-ansible=- unset key bao kv get -mount=kv -field=private iac/ansible-ssh-key | ssh-keygen -y -f /dev/stdin git fetch -q git show origin/main:ansible/roles/bootstrap/files/ansible.pubThe two public halves carry the same key, the
AAAA…field. The nextiacstart on srviac takes it, and each KubeCoder environment at its next start, once ESO has synced the catalog.The nightly job's log lists the other rotations. In each
IaC/Scheduled Secret Rotationbuild since the snapshot's date, a plan starts with a line── <kind> plan of <leaf> (<keys>) · …, and a plan that ran ends inrotated. Add the rotations made by hand since, withsecret-rotator run <leaf>, which no log lists: the vendor tokens pasted since are among them, and the store holds their predecessors. Run each again, on srviac:ssh -t ansible@srviac "sudo iac -c 'secret-rotator run <leaf>'"For an AppRole leaf,
rotator/approle/<role>, rundrop <role>first: its consumer holds a secret_id the store does not know. Steps 5 to 7 have doneopenbao-admin,iac-agentandbackup, andiac/rotator-approleneeds nothing, so skip those. -
Verify.
export BAO_ADDR=https://secrets bao operator raft list-peers # three voters bao secrets list # kv/ present bao policy list # the seven role policies present bao kv get kv/<a known path> # a real secret reads backRun one backup on the node
list-peersshows as leader:ssh srvvaultN sudo systemctl start openbao-backup.service ssh srvvaultN sudo journalctl -u openbao-backup -n 5 --no-pagerExpect
openbao-backup: backup uploaded (…). A failed run names the call that broke.Each consumer logs in again:
iac -c trueon srviac exits 0 (iac-agent), ClusterSecretStoreopenbao-prdreadsValid(eso), awithVaultbuild passes (jenkins), and the backup run above uploaded (backup).
4 — Break-glass: read a secret without a cluster #
The backup .tgz carries a plaintext kv.json alongside the
snapshot. To read one secret when no OpenBao is running — e.g. a
credential needed to bring the cluster itself back:
age -d -i <age-key-from-Roboform> \
openbao/<ts>_openbao-backup.tgz.age | tar xzOf - kv.json \
| jq '."kv/<path>"'
This is for reading, not restoring. A full recovery always goes through the snapshot (§3).
5 — Rotation #
SecretRotator rotates the secrets of the kv mount, by the
annotations of
secret-rotation/design.md
§5 on each key (below). Every night at 05:30,
IaC/Scheduled Secret Rotation runs it on srviac: it rotates each
key that is due, rolls the new value out to its consumers and stamps
the key. Its findings and failures go on one standing YouTrack card
tagged Rotator Standing Card, and to Telegram. Its switches (dry_run,
paused, kinds_enabled, max_rotations_per_run) are committed in
SecretRotator's src/secret_rotator/switches.yaml. Disabling the job
stops it at once. Bringing it up is
secret-rotator-go-live.md.
The nightly run skips a due plan whose rollout targets are not Ready
with their Argo Application Healthy, or whose Argo Application to sync
(every terraform plan has one) is not Synced and Healthy. A plan with
a step on the dev cluster — the k8s-sa-token plans of the KubeCoder
catalog's kubeconfig and kubeconfig-dev-write, and the dev plans of
cephx and rgw-admin — starts with vm.start: it starts srvk8sdev,
off by default, through its PVE node if it is stopped, then waits up to
15 min until what the plan acts on in it answers. Once the plan is
done, rolled back or stopped failed, the rotator shuts srvk8sdev down
again if the plan started it; one it found running stays running. If
dev does not answer within the 15 min, the nightly run skips the plan,
and run <leaf> fails it. A skipped plan changes nothing and rolls
nothing back. The standing card lists it with the reason, Telegram
does not, and it is due again the next night;
secret-rotator run <leaf> runs it by hand.
Its state is in Prometheus too. At its end, every nightly run,
run <leaf>, stamp and secret-rotator ui push the run state (each
key's due day and rotation stamp, each leaf's status) to the
Pushgateway in prometheus-prd, through the Kubernetes API with the
rotator's ServiceAccount token. A nightly run also pushes the audit's
findings and its own run health; one that found the lock held or that
paused stopped pushes the run health alone (design §3.4). A push
that fails is one line of the output, metrics: the <group> group is not pushed: <error>, and changes nothing else. PlatformAddOnsDeploy's
prometheus/ alerts on the series, all three at warning:
SecretRotatorStale: no nightly run has pushed for 48 h, in dry run too.SecretRotationFailed: while a leaf's status isfailedorfailed-activation.SecretRotationOverdue: a key is over 7 days past its due day, and its leaf's status is notskipped. Aneverkey falls due at its expiry less 7 days whether the rotator has stamped it or not; a key never stamped that has an interval has no due day and never fires it.
The last two fire only while the nightly run's push says the rotator
is out of dry run. Grafana's Secret rotation dashboard charts the
same series: when each key falls due and last rotated, the keys
never stamped, each leaf's status, the findings and the nightly
run's health. SecretRotator's build publishes it from
dashboards/secret-rotation.json as its last stage, with Jenkins'
Grafana token from kv/jenkins/grafana-api; until the operator has
stored that token, the stage fails and the dashboard is not
published. A change made in Grafana's UI is lost at the next publish.
Its commands run on srviac, because its AppRole is bound to srviac's
address. The VS Code tasks secret-rotator plan (srviac),
secret-rotator run (srviac) and secret-rotator ui (srviac) run the
last three:
ssh -t ansible@srviac "sudo iac -c 'secret-rotator audit'" # the whole mount against the annotation contract
ssh -t ansible@srviac "sudo iac -c 'secret-rotator annotate'" # dry run: the entries the seed adds or changes, the keys it removes
ssh -t ansible@srviac "sudo iac -c 'secret-rotator annotate --apply'" # writes them, and creates the marker leaves
ssh -t ansible@srviac "sudo iac -c 'secret-rotator stamp <leaf> <key> --rotated-at <date>'" # a key's rotation stamp (below)
ssh -t ansible@srviac "sudo iac -c 'secret-rotator plan <leaf>'" # the leaf's plans: when each falls due, every step
ssh -t ansible@srviac "sudo iac -c 'secret-rotator run <leaf>'" # runs one of them, its operator steps as prompts
ssh -t ansible@srviac secret-rotator-ui # every plan with an operator step, worked one at a time; run it again to reattach
A plan with an operator step runs only by hand: a manual or
external key's (below), the iac-agent and openbao-admin
AppRoles', and one whose activation has a manual: text, such as
eso/prd/argocd/prd/webhook's. The nightly run starts no such plan;
it marks the leaf manual-due and says so in Telegram.
secret-rotator ui is where they are worked. It lists every such plan, due or not: the plans in flight
and failed first, then by when they fall due, the earliest first, then
those of keys with no due date, which read no due date configured.
It runs one plan at a time as a wizard: each operator step is a
screen, and the tool steps between them show their progress.
f narrows the list to the selected box's type (a manual key's
credential type, else its kind), and f again shows every box.
run <leaf> runs one plan of a leaf in the terminal, any plan, so it
also rotates a key by hand. A plan that failed stays stopped where it
failed; both offer Retry, Abort (roll back) and Details. Neither heeds
dry_run, paused or kinds_enabled. A value is pasted into a
masked field or typed at a hidden prompt, never on a command line.
A manual key's screen, or its prompt, shows the standard
instructions of its credential type, the type in its entry's args
(SecretRotator's src/secret_rotator/kinds/manual/types/<type>.md),
with the key's notes below them. For a type whose credential
expires it asks the new credential's expiry too: the UI's expires
field comes filled with today plus the key's interval, and
run <leaf> asks expires on (YYYY-MM-DD, blank for none). A blank
expiry means the credential never expires.
An external key is one the tool never rotates: its value changes by
a procedure of its own, which the key's notes give or point at. The
bootstrap tier is external; the catalog lists the rest. It falls due
like any key; once external is among kinds_enabled, the nightly
run announces it as it does a manual key. Its box in
secret-rotator ui shows the notes and Done, which stamps the key
and writes no value: external-key-due.md.
-
Backup upload token —
terraform taint homelab_backup_credential.openbao, then re-apply Terraform andsite-openbao.yml. The role rewrites/etc/openbao/backup-token. -
AppRole secret-ids — the rotator's
approlekind rotates the seven AppRoles, fromiac/rotator-approlefor its own and from the marker leavesrotator/approle/<role>for the rest. It mints a secret_id that expires after four times the role's interval, and never sooner than 90 days, and records that date as the key'sexpires_at. It proves the new secret_id with a login, delivers it, then destroys the one the consumer held. Every AppRole keepssecret_id_ttl0. A role's first secret_id, and recovery (§3), go through the playbook instead:cd ansible && poetry run ansible-playbook playbooks/site-openbao.yml \ -e openbao_rotate_secret_ids=true -e '{"openbao_rotate_secret_id_roles": ["<role>"]}'It mints a secret_id that never expires, for each role listed (all seven without the list), and destroys none. Recapture the staged creds per the role README §First-apply procedure. While
jenkins,backup,iac-agentoropenbao-adminholds more than one secret_id, the rotator's mint for it fails before minting. Once every consumer holds its new secret_id,scripts/rotation/accessor_cleanup.pydestroys each accessor that no consumer holds: run it dry first, then with--apply(its header gives the rest). -
OIDC client secret — the rotator's
keycloak-clientkind rotates it in the leafrotator/oidc-auth-client, which theopenbaorole reads its client secret from: Keycloak regenerates clientopenbao's secret in realmhomelab, which ends the old one, and the rotator writes the new one to the leaf, then intoauth/oidc/config. OIDC login fails between the two. Nothing else follows: the role's next converge writes the same secret (role README §OIDC). -
Ceph client key — the rotator's
cephxkind rotatesshared/prd/ceph-csiandshared/dev/ceph-csi, the Ceph client the CSI drivers and Argo CD's Terraform hook authenticate as. Two clients with the same caps,client.k8sandclient.k8s-b, take turns in the leaf. A rotation gives the client the leaf does not hold a new key and the caps of the one it holds, moves the leaf onto it, syncs every reader, and ends no key. Mounted volumes keep the client they were mounted with until their pods restart, so a rotation first asks the monitors whether any client still uses the one it would re-key. If one does, the rotation is blocked: the plan fails before any change, and Telegram names the k8s nodes those clients run on. The monitors know a client by its entity and its node's address, never by pod. The nightly run tries again each night, and after three failed nights the standing card holds the leaf. To clear it, restart the Ceph-backed pods on the nodes named, or let the next node update round that reboots them move them. Then close the card: the next night rotates, orrun <leaf>does it at once.A caps change made by hand on prd Ceph goes on both clients:
ceph auth caps client.k8s …, then the same forclient.k8s-b. Each rotation gives the client it re-keys the caps of the one the leaf holds, so a change made on the idle client alone is lost. Made on the active client alone, it is missing from the idle one until the next rotation. On dev, both take the caps of one anchor inansible/inventories/prd/group_vars/ceph_dev.yml, which themicrocephrole converges. -
Static seal key — generate a new key, bump
openbao_seal_current_key_id, and follow the seal-rekey path; the old key id must stay declared until every node has migrated.
Custom metadata: the rotation entries; the run state #
OpenBao KV-v2 supports per-path custom_metadata — string→string
pairs that travel alongside the secret data but are invisible to
consumers (ESO, the Jenkins Vault plugin, the iac-impl !bao
resolver). The rotator keeps the annotations there: one entry per
data key, and nothing else.
The entries: rotation_<key>, <key> the data key's name, a
JSON object of the key's kind, interval (14d when absent),
args, activate, expires_at and notes — for example
{"kind":"random","activate":"auto"}. A copy's entry holds its
kind and activate, a none key's its kind, each with any
notes. Their values come from
secret-rotation/catalog.md.
secret-rotator annotate --apply writes them by metadata patch from
SecretRotator's seed, src/secret_rotator/seed.yaml, which is
transcribed from the catalog: a change to a catalog row is made in
the seed too. The apply makes a seed leaf's custom metadata exactly
its entries: it removes every other key, which its dry run lists
first, and sets max_versions 20 on a leaf with a key whose kind is
neither manual nor none. secret-rotator audit, and every
nightly run, check the whole mount against the contract; a key
without its entry is a finding on the standing card. Until the
go-live's apply
(secret-rotator-go-live.md §6), the
live leaves still carry the layout
openbao-hygiene-cutover.md wrote on
2026-10-04 (rotation_mechanism, rotation_interval,
rotation_activate, key_<key>, …), which that apply removes.
A key's expires_at is the date its current credential stops
working; the key falls due 7 days before it. Rotations write it:
approle the expiry of the secret_id it minted, a manual rotation
of a type that expires the date the operator enters, every other
rotation clears it. annotate writes the seed's expires_at only
when it creates the key's entry.
The run state is not on a secret leaf. It is the rotator's own
leaf, kv/rotator/state, one JSON object per secret leaf: each
scheduled key's rotation stamp (the date it was last rotated), the
leaf's status, last run, last error, consumers and backoff. The
rotator writes it, by check-and-set. A key is due at its stamp plus
its interval, or 7 days before its expires_at when that comes
first; a key with no stamp is due at once. A rotation stamps exactly
the keys it rotated. A key that is a copy or none carries no
stamp: a copy is written with its primary. A plan in flight is its
staging leaf, kv/rotator/staging/<kind>/<leaf>, which lasts until
the plan is stamped or rolled back.
A key whose value was written outside a rotation gets its stamp from
secret-rotator stamp, after the bao kv put of the value (an
external key's Done stamps it instead):
ssh -t ansible@srviac "sudo iac -c 'secret-rotator stamp <leaf> <key> --rotated-at $(date -uI)'"
It prints <leaf>#<key>: rotation stamp <date>, was <date|none>.
--rotated-at takes the date the value was written, never after
today (UTC). --expires-at <date> sets the key's expires_at, and
--clear-expires-at clears it, for a credential whose expiry
changed outside a rotation; either goes with --rotated-at or
alone. Then it pushes the run state, so the dashboard shows the
stamp at once.
Never use bao kv metadata put here. It replaces the leaf's whole
custom metadata, so the leaf loses its entries.
Inspect one leaf with secret-rotator plan <leaf>, which says when
each key falls due, or the stamps in bulk:
bao kv get -mount=kv -format=json rotator/state \
| jq '.data.data | map_values(fromjson | .stamps // {})'
At go-live no key had a stamp, the values exposed during the
migration among them (Jenkins-credentials-dump, HelmCharts configs,
the iac-impl secrets.yaml capture during the runtime-secrets-sweep
slice). The rotator's first pass rotates them, at most
max_rotations_per_run a night, kind by kind as each is enabled.
A new leaf #
A leaf written to the store also needs:
- a catalog row;
- its leaf in SecretRotator's
src/secret_rotator/seed.yaml, and its key names insrc/secret_rotator/store-keys.json, which SecretRotator's tests hold the seed to. A push to SecretRotator'smainreaches srviac once its build, green at its lint and tests, has rebuilt theiacimage; secret-rotator annotate --applyon srviac, once the leaf exists.
Until the apply has run, the audit reports each of the leaf's keys as
rotation_<key>: missing. Its keys have no stamp, so they are due
at once: stamp a freshly minted value with today's date, as above,
unless it should rotate at the next run.
A key's notes are freeform context in its entry — what consumer it
serves, the account, scopes or project a re-mint needs. A manual
key's prompt shows them below its type's standard instructions. The
audit requires notes on a key whose interval is never. Change
them in the seed: the apply replaces a live note with the seed's, and
removes one the seed does not carry.
Consumer cold-boot #
Three consumers read from OpenBao at runtime: the iac-agent
container on srviac, the Jenkins controller, and ESO inside each
microk8s cluster. When OpenBao is unreachable (whole-cluster loss
mid-recovery, network partition, listener cert expired), each one
degrades differently. The right intervention depends on which one
is on fire. An expired listener cert is renewed per
internal-tls-expiry.md.
iac-agent #
iac-impl resolves every !bao kv/iac/<leaf>#<key> reference in
/etc/iac/secrets.yaml at container startup, before any iac flow
runs. With OpenBao unreachable the container exits non-zero and
the iac CLI prints the failed path / property.
Escape hatch: edit /etc/iac/secrets.yaml on srviac and replace
the failing !bao ref with a literal value from Roboform. The
file format accepts a literal where a !bao tag was expected, so
the swap is a one-line edit. Restart the iac container, run the
flow, swap back when OpenBao is up.
Full sequence is in iac-cold-boot.md.
Jenkins (HashiCorp Vault plugin) #
In-flight pipelines that hit withVault block at the resolution
step and fail with the path/property the plugin couldn't read.
Pipelines already past withVault keep running on the values they
captured into the closure body. Queued pipelines retry per the
plugin's retry config (default: none — they fail immediately).
Escape hatch: Jenkins → Manage Credentials → System → Global —
re-create the now-deleted Secret-text credential with the same
ID as the withVault block's envVar. The pipeline reads the
Secret text credential at the same env-var name; if it sees one
that matches, it doesn't touch Vault. Restore the value from
Roboform. Delete the credential entry when OpenBao is back.
Tokens already minted by withVault (during a successful
pipeline run) have a 1h TTL (openbao_jenkins_token_ttl); a
mid-pipeline OpenBao outage past that window kills the next
in-pipeline resolution attempt. Long-running pipelines that need
to span an outage should pre-resolve every secret at the top.
ESO (External Secrets Operator) #
Existing k8s Secrets keep working (ESO caches the last successful
sync). New ExternalSecret values stall at
status.conditions.reason=SecretSyncError; consumer pods don't
see updates, but the cached Secret content is still served from
the API server. Pods that restart pull the cached value, not a
fresh one — that's a footgun on rolling deploys during an outage.
Escape hatches:
- Edit the target Secret directly. Replace the ESO-managed
data field with the Roboform value, then add
external-secrets.io/suspend: "true"as an annotation on the ExternalSecret so ESO doesn't overwrite the edit on the next sync attempt. Remove the annotation when OpenBao is back. - Hand-stage Secrets in the namespace with the same name, then delete the ExternalSecret. Re-deploy the chart later to restore the ExternalSecret CR.
Bootstrap-tier reminder: each cluster's ESO AppRole secret_id
(eso on the prd cluster, eso-dev on the dev cluster) reaches
ESO via the hand-staged openbao-eso-approle Secret in that
cluster's ESO namespace (external-secrets-prd on prd) — not via ESO. Losing that
Secret (or rotating the AppRole's secret_id in OpenBao without
re-staging) breaks that cluster's ESO until you re-create it. The
secret_ids live in Roboform under "OpenBao eso AppRole" and
"OpenBao eso-dev AppRole".
Drill log #
Timings from the recovery drills:
- Single-node loss — TBD: record VM rebuild, converge, and Raft-join durations from the single-node-loss drill.
- Whole-cluster loss (drill of 2026-05-23) — converge of a fresh empty cluster took 6m32s; snapshot restore plus end-to-end verification (peers, kv read) finished ~5 min after that. Terraform rebuild and snapshot fetch/decrypt durations weren't captured this round; record them next drill.
What can go wrong #
- A converge fails at
Refuse a staged backup secret_id the backup AppRole rejects— the checkout'stmp/openbao-backup-secret-idis a leftover the livebackupAppRole no longer accepts, and nothing was installed. When the rotator has delivered abackupsecret_id since it was staged, delete the file: the nodes keep the one they hold. Otherwise mint one forbackupalone, as §3 step 7 does. - A nightly backup failed — once two nights in a row have not
landed a backup,
BackupOverduefires for theopenbaoscope;backup-freshness.md§1 is its triage.journalctl -u openbao-backupon the node that was leader names the call that broke, with its HTTP status and OpenBao's error text.POST auth/approle/login failed: HTTP 400: invalid role or secret IDmeans the AppRole rejected the nodes' backup credentials: deliver a fresh one withsecret-rotator run rotator/approle/backupon srviac (§5). An upload that fails withHTTP 401has a bad upload token: rotate it per §5. - Snapshot endpoint returns 403 — the
backupAppRole policy is missingreadonsys/storage/raft/snapshot. Re-apply the role. (readalone is sufficient — confirmed against OpenBao 2.5.4.) - Restore rejected for a seal mismatch — the rebuilt cluster
must use the same static seal key as the snapshot. Confirm
openbao_seal_current_key_idandfiles/static.keymatch what was live when the snapshot was taken. - VIP duplicated during a partition — a minority node may briefly hold the VIP on its segment. Raft denies writes without quorum, so correctness holds; clients on the wrong side just get errors.
Pre-flight checklist #
- The age private key is in Roboform — needed for every backup decrypt.
- The Shamir recovery keys (3-of-5) are in Roboform — needed to mint a root token post-restore.
- The ansible-vault passphrase is in Roboform — needed for the static seal key on every converge.
- You know the rclone destination path where
backup-serverstores theopenbao/scope.