OpenBao#
Overview#
NVIDIA Mission Control uses OpenBao as the cluster’s secrets backend and internal certificate authority. It holds the KV data that platform components read at runtime and signs the certificate chain that cert-manager issues leaves from. OpenBao is delivered as an Argo CD Application. Refer to Installing GitOps-Managed Components for the deploy flow.
How it works#
OpenBao runs the upstream OpenBao Helm chart on integrated Raft storage, single-node at bootstrap and scalable to a high-availability quorum. It comes up in two passes: a one-time self-initialization the first time the server starts on empty storage, and a configure Job that runs after each Argo CD sync to load the data the components consume.
Auto-unseal. OpenBao unseals itself on every pod restart from a static 32-byte
key you supply in the openbao-seal Secret. You own that key. Keep a backup
outside the cluster, as Break-glass and backup describes.
Warning
The seal key is permanent. Losing it while the Raft volumes still exist leaves the store unrecoverable. Never rotate it while Raft storage persists.
Internal certificate authority. OpenBao builds a two-tier certificate authority, a root and an intermediate, entirely inside the server. No private key ever leaves it. cert-manager signs leaf certificates from the intermediate (refer to Certificates).
Self-initialization. On first start with empty storage, OpenBao enables its
secret and auth mounts and builds the CA. It creates a least-privilege policy and
Kubernetes auth role for each component, so each reads only its own secrets. It
also creates nmc-admin, the Day-2 operator login. nmc-admin manages KV
data, the component policies and roles, and the intermediate CA. It cannot touch
the seal or the root CA, which are break-glass operations.
Self-init runs only on empty storage. Day-2 changes go through nmc-admin, not
a re-init.
Configure Job. After each Argo CD sync of the OpenBao Application, NVIDIA Mission Control’s OpenBao configurator runs a PostSync Job. It loads the KV data the components read, sourced from the bootstrap Secrets the seed step created. This KV load is create-only, so a re-sync never overwrites a value already in use. The Job also applies the cert-manager issuing role. OpenBao syncs first. Every other component waits for this Job to succeed before it authenticates.
For the exact mounts, policies, auth roles, and PKI paths, refer to the OpenBao
self-initialization configuration in the component template tree
(apps/openbao/values.yaml.j2).
Configuration#
Add these settings under the openbao key in values.yaml. The key already
carries the pinned version, configuratorVersion, and syncWave from
Installing GitOps-Managed Components. The following fields are the ones you set. A field shown
with a value uses that value as its default when you omit it.
openbao:
enabled: true # optional, deploy the OpenBao Application
bootstrapInit: true # optional, gate self-init. True on first bring-up, false after
replicas: 1 # optional, HA Raft replicas. Bootstrap at 1, then scale to an odd count
storageSize: 100Gi # optional, size of each server's Raft data volume
injectorEnabled: true # optional, enable the agent injector Keycloak and Launchpad use
csiEnabled: false # optional, enable the OpenBao CSI provider
enabled– deploy the OpenBao Application. Defaulttrue.bootstrapInit– gates self-initialization. Keep ittruefor first bring-up. Set itfalseonce the cluster is bootstrapped so re-syncs onlyretry_join. Defaulttrue.replicas– HA Raft replica count. Bootstrap with1so only node-0 self-inits, then scale out. For a multi-node quorum use an odd count so Raft can elect a leader. Default1.storageSize– size of each server’s Raft data volume. Default100Gi.injectorEnabled– enables the agent injector that Keycloak and Launchpad use to receive their secrets at runtime. Defaulttrue.csiEnabled– enables the OpenBao CSI provider. Defaultfalse.
Note
replicas and bootstrapInit work together. Bring the cluster up at
replicas: 1 with bootstrapInit: true so a single node self-inits,
then scale to an odd replicas count with bootstrapInit: false so the
joining nodes only retry_join instead of re-initializing.
Secrets#
OpenBao owns two bootstrap secrets, seeded into the openbao namespace before
it deploys. Leave either empty to auto-generate it. Add them to
secrets.yaml:
k8s_bootstrap_secrets_seal_key_b64: "" # static auto-unseal key, base64 of 32 bytes. Leave empty to auto-generate
k8s_bootstrap_secrets_admin_password: "" # nmc-admin login. Leave empty to auto-generate
k8s_bootstrap_secrets_seal_key_b64becomes theopenbao-sealSecret, the static 32-byte auto-unseal key read on every pod start. Its lifecycle ispermanent. Refer to the break-glass section for backup.k8s_bootstrap_secrets_admin_passwordbecomes thenmc-adminuserpass login, read once by self-init. A generated value is 24 characters. Its lifecycle isoperator-managed: back it up outside the cluster, then remove theopenbao-admin-credentialsSecret once bootstrap is complete (refer to Clean Up the Bootstrap Secrets).
Operations#
Log in as the nmc-admin operator#
Day-2 changes and credential lookups run through the bao command-line client as
the nmc-admin operator.
Get the password. If you set k8s_bootstrap_secrets_admin_password in
secrets.yaml, that value is the password. If you let NVIDIA Mission Control generate it, read it
from the openbao-admin-credentials Secret while it still exists. Cleanup removes
the Secret (refer to Clean Up the Bootstrap Secrets), so back up this value first and
keep it as your break-glass login.
kubectl -n openbao get secret openbao-admin-credentials \
-o jsonpath='{.data.password}' | base64 -d
Log in. On the active BCM head node, create a temporary client pod in the
openbao namespace. Reuse the deployed OpenBao image so the pod carries a
matching bao client:
IMAGE=$(kubectl -n openbao get statefulset openbao \
-o jsonpath='{.spec.template.spec.containers[0].image}')
kubectl -n openbao run bao-admin --image="$IMAGE" --restart=Never \
--env=BAO_ADDR=http://openbao.openbao.svc.cluster.local:8200 \
--command -- sleep 3600
kubectl -n openbao wait --for=condition=Ready pod/bao-admin --timeout=60s
If a command needs a file, copy it into the pod before you open the shell.
bao reads @file arguments from inside the pod. Skip this step when
you only need to log in:
kubectl -n openbao cp <file> bao-admin:/tmp/<file>
Open a shell in the client pod:
kubectl -n openbao exec -it bao-admin -- sh
In the pod shell, log in:
bao login -method=userpass username=nmc-admin # prompts for the nmc-admin password
Read and write KV. As nmc-admin you read and write the nmc KV store.
In the pod shell, read a seeded value with bao kv get, for example the
generated Keycloak admin password:
bao kv get nmc/keycloak/realm/master/users/admin
A bao kv put that writes file material reads the file from the pod, so stage it
first and reference its staged path with @, for example <key>=@/tmp/<file>.
When you finish, exit the pod shell:
exit
Back on the BCM head node, delete the pod to remove its local credential files and any staged files:
kubectl -n openbao delete pod bao-admin
Intermediate-CA rotation#
Rotate the intermediate entirely inside OpenBao under nmc-admin, with no
offline ceremony: pki_int/intermediate/generate/internal, then
pki/root/sign-intermediate, then set-signed. Leaf certificates need no
action. cert-manager auto-renews them from pki_int. Rotating the root CA is
a break-glass procedure.
Adding a consumer (Day-2)#
A new consumer needs a policy, an auth role, and a KV seed if it reads stored data. Self-init
does not re-run on a non-empty store, so on an existing cluster you, as
nmc-admin, create the policy and auth role with bao write and seed the
KV path directly. The configure Job’s create-only KV load then skips the
already-populated path. To remove an NVIDIA Mission Control-shipped consumer, delete it from the
chart values, then run bao policy delete <name> and
bao delete auth/kubernetes/role/<name>.
Secret rotation#
Rotate KV data with a direct bao kv put under nmc-admin. The configure
Job’s KV load is create-only and will not overwrite. A full rotation also requires
updating the consumer’s persisted state, which is component-specific. Rotate
the nmc-admin password itself with bao write against
auth/userpass/users/nmc-admin while you still hold the current password. If
the password is lost, follow Reset a Lost Administrator Password.
Failure modes#
Use the failure type to choose the recovery action:
A failed PostSync configure Job is usually transient. Re-run it with
argocd app sync <app>. Argo CD deletes the failed Job and runs a fresh one. If the old Job is stuck, delete it first.If self-init failed partway through or storage is in a bad first-run state, wipe and restart (refer to the following procedure). A failed configure Job is re-run, not wiped.
If the seal key is lost but the volumes are intact, restore the
openbao-sealbackup. With no backup, OpenBao cannot be unsealed.If the
nmc-adminpassword is lost, follow Reset a Lost Administrator Password. You need recovery shares committed before you lost access; creating them requires an authenticated admin token.
Wipe and restart#
Use this only when self-init failed partway through or storage is in a bad
first-run state. In a partial-bootstrap state, no operator-managed material has
been backed up yet, so deleting the whole openbao namespace is safe. Argo CD
recreates chart-managed resources and the seed step recreates operator-managed
ones. Suspend the Application’s sync policy, delete the namespace, re-run the
seed step so the seal and admin Secrets are recreated, then resume the sync
policy and sync. OpenBao regenerates the CA on the next self-init. Do not
attempt to repair a partial bootstrap by editing OpenBao state by hand.
Break-glass and backup#
OpenBao auto-recovers from pod restarts, but not from a lost seal key or lost
data. Back up the following items outside the cluster. The openbao-seal key matters most:
OpenBao cannot unseal without it, and it is not part of a Raft snapshot, so its
loss is unrecoverable. A Raft snapshot is the full data backup. Recovery shares
are separate, optional material that authorize generate-root. That procedure
mints a temporary root token to reset a lost nmc-admin password or run a
root-only operation. The server has no recovery shares by default.
Back up the following:
The
openbao-sealkey, the static auto-unseal key. Keep an authoritative copy in your secrets manager, KMS, or HSM, or in sealed offline storage, with access limited to break-glass operators. The in-cluster Secret is only the runtime copy.A Raft snapshot, which captures the root key and the rest of the store.
The
nmc-adminpassword, your Day-2 login.The recovery shares, once you generate them (refer to Generate Recovery Shares).
Generate a Root Token#
For a root-only operation while you can still log in as nmc-admin, use
the upstream generate-root procedure with the
recovery shares. Revoke the temporary root token when you finish. Keep the
legacy listener endpoints disabled.
Reset a Lost Administrator Password#
Use the committed shares from Generate Recovery Shares to reset a lost
nmc-admin password. The bao operator generate-root command requires
authentication, including when it decodes a token. This procedure uses the
legacy recovery API and a small offline script to decode the token.
You need Kubernetes administrator access, Bash, curl, jq, and Python 3.
Run the procedure in two Bash sessions on the active BCM head node, with
kubectl configured for k8s-admin in both. If a command fails, stop and
follow the cleanup instructions at the end of this procedure.
Record the auto-sync settings for the
rootandk8s-admin-openbaoApplications. Disable auto-sync onrootand wait for its current sync to finish. Then disable auto-sync onk8s-admin-openbaoand wait for its current sync to finish. Confirm that auto-sync is disabled on both before continuing; a running parent sync can restore the child’s auto-sync setting. Do not sync either Application until you finish recovery.Edit
openbao-configin theopenbaonamespace. In its TCP listener stanza, temporarily setdisable_unauthed_generate_root_endpoints = false.kubectl -n openbao edit configmap openbao-config
Replace the OpenBao server pods one at a time to load the listener change. The chart uses
OnDelete, so a StatefulSet restart alone does not replace them. For HA, replace standbys first and the active pod last. Before replacing the next pod, wait for the replacement to be Ready, initialized, and unsealed. Its Raft applied index must catch up to its committed index. Confirm that the cluster has an active server. Stop if a pod does not recover. A single-replica deployment is unavailable during its restart.kubectl -n openbao get pods -l component=server -L openbao-active kubectl -n openbao delete pod <pod-name>
After the replacement pod appears, check its readiness and local status:
kubectl -n openbao wait --for=condition=Ready pod/<pod-name> --timeout=180s kubectl -n openbao exec <pod-name> -- bao status
After all servers recover, forward a local port to the active service in a separate terminal. Keep this command running. If the active server changes, restart the port-forward before continuing.
kubectl -n openbao port-forward --address 127.0.0.1 \ service/openbao-active 18200:8200
In your recovery terminal, create a private working directory. Confirm that
sys/healthsucceeds withinitialized: true,sealed: false, andstandby: false. Check generation status and continue only whenstartedisfalse. If another attempt exists, coordinate with its owner before canceling it.set +x recovery_umask=$(umask) umask 077 recovery_dir=$(mktemp -d) cd "$recovery_dir" BAO_ADDR=http://127.0.0.1:18200 curl --fail --silent --show-error "$BAO_ADDR/v1/sys/health" curl --fail --silent --show-error "$BAO_ADDR/v1/sys/generate-root/attempt"
Start an attempt and save its nonce and one-time password (OTP). The server returns the OTP only in this response. Keep
attempt.jsonprivate.curl --fail --silent --show-error \ --header 'Content-Type: application/json' --data '{}' \ "$BAO_ADDR/v1/sys/generate-root/attempt" > attempt.json
Submit one distinct recovery share at a time, using the same attempt nonce. Repeat this step until
completeistrue. The final response contains the encoded token. The prompt hides the share from the terminal and shell history.nonce=$(jq -er '.nonce' attempt.json) IFS= read -r -s -p 'Recovery share: ' recovery_share printf '\n' printf '%s' "$recovery_share" | jq -Rs --arg nonce "$nonce" '{nonce: $nonce, key: .}' > share.json unset recovery_share curl --fail --silent --show-error \ --header 'Content-Type: application/json' --data-binary @share.json \ "$BAO_ADDR/v1/sys/generate-root/update" > update.json rm share.json jq '{progress, required, complete}' update.json
Copy and run the following block from the private working directory containing
attempt.jsonandupdate.json. It creates and runs the decoder script, which uses only the Python standard library and makes no network requests. The script writesroot-headerwithout displaying the root token. Keep these files until you revoke the token.Create and Run the Token Decoder
cat > decode-openbao-root-token.py <<'PY' #!/usr/bin/env python3 """Decode an OpenBao root token from attempt.json and update.json offline.""" import base64 import json import os from pathlib import Path os.umask(0o077) try: attempt = json.loads(Path("attempt.json").read_text()) result = json.loads(Path("update.json").read_text()) if result["complete"] is not True: raise ValueError("Submit the required recovery shares first.") if result["nonce"] != attempt["nonce"]: raise ValueError("The responses belong to different recovery attempts.") encoded = result["encoded_token"] raw = base64.b64decode(encoded + "=" * (-len(encoded) % 4), validate=True) otp = attempt["otp"].encode("ascii") if not raw or len(raw) != len(otp): raise ValueError("The encoded token and OTP lengths differ or are empty.") token = bytes(a ^ b for a, b in zip(raw, otp)).decode("ascii") # Exclusive creation protects a header saved by an earlier recovery attempt. with Path("root-header").open("x") as header: header.write("X-Vault-Token: " + token + "\n") except (OSError, ValueError, KeyError, TypeError) as error: raise SystemExit(f"Cannot decode the root token: {error}") print("Created root-header. Keep it until the temporary root token is revoked.") PY python3 decode-openbao-root-token.py
Set the new
nmc-adminpassword. Back it up outside the cluster before continuing.IFS= read -r -s -p 'New nmc-admin password: ' new_password printf '\n' IFS= read -r -s -p 'Confirm new password: ' confirm_password printf '\n' if [ -n "$new_password" ] && [ "$new_password" = "$confirm_password" ]; then printf '%s' "$new_password" | jq -Rs '{password: .}' > password.json curl --fail --silent --show-error --header @root-header \ --header 'Content-Type: application/json' --data-binary @password.json \ "$BAO_ADDR/v1/auth/userpass/users/nmc-admin/password" else printf 'The password is empty or the entries do not match. Repeat this step.\n' >&2 fi unset new_password confirm_password rm -f password.json
Log in with the new password using the temporary client pod from Log in as the nmc-admin operator. Exit the pod shell and delete the client pod as described there. Back in the recovery terminal, revoke the temporary root token:
curl --fail --silent --show-error --header @root-header \ --request POST "$BAO_ADDR/v1/auth/token/revoke-self"
Restore the listener to
disable_unauthed_generate_root_endpoints = trueor remove the temporary setting. Repeat the pod replacement procedure from step 3. Restart the port-forward and confirm that the legacy endpoint now returns HTTP405.curl --silent --show-error --output /dev/null --write-out '%{http_code}\n' \ "$BAO_ADDR/v1/sys/generate-root/attempt"
After any issued root token is revoked and the legacy endpoint returns HTTP
405, stop the port-forward, remove the private recovery files, and restore your original umask. Restore the original auto-sync settings onk8s-admin-openbao, thenroot.cd - rm -r "$recovery_dir" umask "$recovery_umask" unset BAO_ADDR recovery_dir recovery_umask nonce
If you stop before token generation completes, cancel the attempt with
DELETE /v1/sys/generate-root/attempt, then finish cleanup with steps 11 and
12. If a root token was issued and has not been revoked, complete step 8 if
needed. Run the revocation command in step 10, then finish with steps 11 and 12.
Verify#
Confirm OpenBao is up:
Confirm the server is unsealed and ready: query
/v1/sys/health?standbyok=trueon the OpenBao service, which is the path the readiness and liveness probes use.Confirm self-init completed: the
pki,pki_int, andnmcmounts exist and the CA chain is present atpki_int/cert/ca_chain.Confirm the configure Job succeeded at PostSync. Downstream Applications gate on it.
Confirm
nmc-admincan log in via userpass.