ADR-072: A generated credential is written exactly once, by a create-only Job that cannot write it twice
Status: Accepted (2026-10-30)
Amends: ADR-064: A cache is a platform-rendered single-node Valkey StatefulSet, without an operator; its password is generated once in the cluster (point 3, the cache password), ADR-065: A broker is NATS JetStream from the official chart, with declared streams and consumers and one user per component (point 4, the broker users), ADR-059: An in-cluster bucket is its own SeaweedFS, run by the platform-installed SeaweedFS operator; its keys are generated in the cluster and Argo CD never deletes it (point 3, the bucket keys) and ADR-018: Secrets come from External Secrets Operator; git holds references, never values (the generate kind) where they say a generated password is “written once” by External Secrets Operator
Builds on: ADR-018: Secrets come from External Secrets Operator; git holds references, never values (secrets from External Secrets Operator), ADR-022: Kyverno guard rails, checked before merge and enforced in the cluster (guard rails), ADR-069: One versions table pins everything, and every release proves the upgrade from the previous one (one versions table, upgrade proof), spec 128, issue #316
Context
Section titled “Context”Issue #316: in the upgrade e2e (previous release v5.4.0), a cache’s Valkey server ran with a different password than its <system>-<name>-app Secret held a few minutes later, so components got WRONGPASS. The Secret and the server pod were created in the same second and the pod never restarted, so the Secret was rewritten after the server read it. The ExternalSecret said refreshPolicy: CreatedOnce. Which environment was hit changed from run to run. The broker (ADR-065) and bucket (ADR-059) keys are written the same way and carry the same risk.
What was examined (External Secrets v2.12.0, the version installed; source at tag v2.12.0, pkg/controllers/externalsecret/externalsecret_controller.go):
shouldRefreshdecidesCreatedOnceonly from the ExternalSecret’s own status: it returns true whileStatus.SyncedResourceVersionis empty orStatus.RefreshTimeis zero, false afterwards. It never asks whether the target Secret already exists.- The reconcile skips a refresh only when
!shouldRefresh(es) && isSecretValid(existingSecret, es).isSecretValidis false when the Secret is missing, lacks themanagedlabel, or its data-hash annotation differs from the hash of its data. So aCreatedOnceExternalSecret still refreshes whenever the Secret looks invalid, and a refresh of a generator-backed ExternalSecret runs thePasswordgenerator again and gets a new value. - The reconcile first reads the Secret as metadata from the partial cache, patches the
managedlabel onto a Secret that lacks it and requeues, then reads the Secret from the full cache (r.SecretClient); the code comments say the two caches can disagree (resolveSecretCacheMismatch). The status thatshouldRefreshrelies on is written at the end of the reconcile, after the Secret.
Finding: the cause is not confirmed by a reproduction. The evidence fits this sequence: ESO creates the Secret, a second reconcile (the requeue after the label patch, or a status conflict) runs before the status records the first sync or while the Secret still looks invalid, shouldRefresh or isSecretValid lets the refresh through, the generator produces a second value and the Secret is updated. That stays a working hypothesis. Upstream: no issue found that describes this sequence. Issue 5499 (closed, “refreshPolicy: CreatedOnce still updates secrets”) was answered as documented behaviour: the refresh policy only controls the operator’s job queue and a Secret with creationPolicy: Owner is still reconciled; and issue 6640 (closed) reports that CreatedOnce gates only on the ExternalSecret’s status, so a recreated ExternalSecret regenerates an existing Secret. Both agree that CreatedOnce does not mean “never write a second time”. No fixed version known: no fixed version was found, and the latest release seen while writing this is chart 2.14.0, which was not examined.
Whatever the exact trigger, the guarantee the platform needs (“the Secret holds the value the server started with, forever”) cannot rest on a controller that decides from its own status and caches.
Decision
Section titled “Decision”-
A credentials Job writes each generated credential, once. Per owner (a cache, broker or bucket instance, or a component with a
generate:secret) the renderer writes<owner>-credentialsas a ServiceAccount, Role, RoleBinding, ConfigMap (create.pyandplan.json) and a Job named<owner[:42]>-credentials-<h8>(h8: the first 8 hex characters ofsha256(plan.json + script + image)). The five objects are Argo CD hooks:PreSync, wave 0 for the four, wave 1 for the Job; hook-delete-policyBeforeHookCreationfor the four andBeforeHookCreation,HookSucceededfor the Job. Outside Argo CD (the e2e harness) the same five files are applied in order and the Job is awaited; likeBeforeHookCreation, the harness deletes a Job of the same name first, so the Job runs on every apply as it does on every sync (a release that renders the same Job name would otherwise leave it “unchanged” and repair nothing). -
Create if absent. The Job
POSTs each planned Secret. On409it keeps the Secret: it patches only labels, ownership metadata and keys the Secret lacks, as one JSON patch whose first operation tests/metadata/resourceVersion; no operation touches an existing/data/<key>, and a Secret with nothing to change is not written at all. Re-running the Job, a forced sync or a changed hash therefore cannot write a value twice. This is the mechanism that “cannot write it twice”: the only write that can replace a value iscreate, andcreatefails when the Secret exists. -
Adoption order. Before creating, the Job deletes the 5.4 ExternalSecret of a planned Secret with
propagationPolicy: Orphan(so the Secret stays) once it has confirmed the ExternalSecret is generator-backed, waits for it to go, then deletes the retiredPasswordgenerators. An ExternalSecret that is not generator-backed (aremote:secret) makes the Job stop before writing: it is not ours to replace. Existing Secrets keep their values; theirownerReferencesof kindExternalSecret, the Argo CD tracking id and the instance label are removed so Argo CD does not prune them. -
One-time repair of a wrong server (cache and broker). After the Secrets exist the Job probes each running server pod with the password its pod reads from the Secret (Valkey
AUTH, NATSCONNECT). A pod that refuses it is deleted once; the Job waits for the replacement and probes again, and fails (exit 2) when it still refuses. An unknown answer never restarts a pod. This repairs a cache or broker already hit by #316; a correct one is never restarted. -
Rights. The Role is namespaced and holds exactly these rules (a rule is left out when its
resourceNameswould be empty):rules:- {apiGroups: [""], resources: [secrets], verbs: [create]}- {apiGroups: [""], resources: [secrets], resourceNames: [<planned Secrets>], verbs: [get, patch]}- {apiGroups: [external-secrets.io], resources: [externalsecrets], resourceNames: [<planned Secrets>], verbs: [get, delete]}- {apiGroups: [generators.external-secrets.io], resources: [passwords], resourceNames: [<retired generators>], verbs: [delete]}- {apiGroups: [""], resources: [pods], resourceNames: [<server pods>], verbs: [get, delete]} # cache and broker onlyThere is no
update,list,watchor wildcard.createcannot be limited by name in Kubernetes RBAC, so the Role can create any Secret in its namespace; the program only creates its planned names, and it cannot read or change any other Secret.patchis limited to the planned names; with noupdate, the only way to replace a whole Secret is to delete it, which the Role does not allow. -
The image is one pinned row of the versions table (
CREDENTIALS_IMAGE, a Python image); the Job runspython3 /credentials/create.py /credentials/plan.jsonwith the standard library only, as non-root with a read-only root filesystem and all capabilities dropped. A new image changesh8and so the Job name, and the Job then runs again and keeps every Secret. -
Exception to ADR-018. ADR-018 says every Secret comes from External Secrets Operator. Generated credentials are now the exception: they come from this Job.
remote:secrets still come from External Secrets (a store, never git) and external resources’ Secrets come from the secret store (ADR-057). Values still never reach git, logs or the Job’s output. The ConfigMap holdscreate.pyas a YAML literal block whose header line carries# pragma: allowlist secret: detect-secrets reports a whole YAML value at its key line and took the script for a secret (#341);plan.jsonand every other value stay scanned. -
Rejected: ESO
CreatedOnce(the cause above, and no fixed version known); aPushSecret(a second controller with the same refresh logic); a Kyvernogeneraterule (it would need rights to every Secret and re-synchronises on policy change); an init container in the server pod (it would write the Secret from a pod’s identity on every start and needsupdateto survive races).
Consequences
Section titled “Consequences”- A cache, broker or bucket password is created exactly once; renders, syncs, restarts and image upgrades never change it. A server that already refuses its Secret’s password is restarted once, on the first sync of this release.
- Deleting a Secret regenerates it on the next sync (the Job sees it absent and creates it). Servers do not follow the change by themselves: restart them.
- Secrets and hook objects outlive their declaration: removing a resource or a
generate:secret leaves the Secret in the namespace (as ADR-059 already did for buckets); remove it by hand. Finished Jobs are cleaned byttlSecondsAfterFinishedand the hook policies. - The Job needs a namespace where
createon Secrets is allowed to the ServiceAccount<owner>-credentials, and Argo CD must run PreSync hooks. No CI run exercises Argo CD hooks; the e2e harness applies the same five files by hand and compares each Secret’sresourceVersionand value hashes after a forced second run. - External Secrets Operator is still needed for
remote:secrets, not for in-cluster buckets, caches or brokers. - If External Secrets ships a fix that makes
CreatedOncehold, this ADR still stands: the Job is the guarantee and does not depend on the controller’s caches.
Not included
Section titled “Not included”- Password rotation, or a server that follows a changed Secret without a restart.
- A change to
remote:secrets, to external resources’ Secrets, or to how a server reads its Secret (secretKeyRefandenvFromare unchanged). - Reproducing the External Secrets behaviour upstream or filing an upstream issue.