Kubernetes operator that synchronizes secrets from external providers (AWS Secrets Manager, Vault, GCP Secret Manager, Azure Key Vault, etc.) into Kubernetes Secrets.
Use make targets — refer to the Makefile for available commands. Do not run go test, golangci-lint, or helm directly.
Single binary built from main.go. The controller reconciles ExternalSecrets into K8s Secrets. The webhook (validates and defaults CRDs) and certcontroller (manages webhook TLS) are subcommands registered via rootCmd.AddCommand().
Multi-module repo: apis/, runtime/, e2e/, and each providers/v1/*/ have their own go.mod.
make reviewable is the gate for PRs. Run it, not individual checks.make manifests generates static YAML from it.--8<--). Auth docs are shared across providers via docs/snippets/.make test.crds.update to update snapshots after CRD changes.make update-deps updates dependencies across all modules at once.git notes add HEAD entry on every non-trivial commit. Record key design decisions, trade-offs, and gotchas. Queryable via git notes show <sha>.zz_generated.* files by hand. They are owned by controller-gen. Modify the source types and run make generate (included in make reviewable).make check-diff - this is the first step where PRs fall apart that LLMs forget - there are a lot of generated code outside of the main make reviewable spec like helm chart tests, docs, etc.A provider is its own Go module under providers/v1/<name>/ with no build tags on the package itself.
Build tags live in pkg/register/<name>.go.
apis/externalsecrets/v1/secretstore_<name>_types.go.apis/externalsecrets/v1/secretstore_types.go
(the SecretStoreProvider struct). The JSON tag is the provider name; apis/externalsecrets/v1/provider_schema.go
resolves it from the first JSON key of the marshaled union.*<Name>Auth struct. Multi-method auth uses +kubebuilder:validation:MaxProperties=1. Selector types
are esmeta.SecretKeySelector and esmeta.ServiceAccountSelector.CABundle []byte and CAProvider *CAProvider if the backend speaks TLS.runtime/esutils/resolvers.SecretKeyRef(ctx, kube, storeKind, namespace, ref) for credential resolution. It enforces
ClusterSecretStore vs SecretStore namespace scoping. Pass store.GetKind() and the ES namespace.runtime/esutils.FetchCACertFromSource(ctx, esutils.CreateCertOpts{...}) for CA bundles.runtime/esutils.ValidateSecretSelector / ValidateReferentSecretSelector / ValidateServiceAccountSelector for spec validation.runtime/esutils/metadata for parsing PushSecretMetadata into a typed spec.runtime/constants for metric label values.SecretsClient contractDefined at apis/externalsecrets/v1/provider.go. All eight methods are mandatory; Close may be a no-op.
esv1.NoSecretErr from GetSecret when the secret is missing. The reconciler depends on this for deletionPolicy.Capabilities() honestly: SecretStoreReadOnly, SecretStoreWriteOnly, or SecretStoreReadWrite. Read-only
providers still implement Push/Delete but return a sentinel error! Do NOT return nil!gjson is the conventional path extractor for ref.Property on JSON payloads.runtime/cache.Must[T](size, cleanup). Keyed by cache.Key{Name, Namespace, Kind},
versioned by store.GetObjectMeta().ResourceVersion. Use this for OIDC, vault leases, token exchange, etc. Default to no cache.expirable.LRU[string, []byte] with a user-facing CacheConfig{TTL, MaxSize}
field on the spec.Pipeline: helm value to deployment extraArgs to cmd flag to feature.Register to Initialize().
init() using runtime/feature.Feature{Flags, Initialize}.cmd/controller/root.go collects them and runs Initialize after manager startup.extraArgs in deploy/charts/external-secrets/values.yaml, rendered by templates/deployment.yaml.
Out-of-process SDKs (e.g. bitwarden) ship as a sidecar subchart.Provider package exports three symbols: NewProvider() esv1.Provider, ProviderSpec() *esv1.SecretStoreProvider,
MaintenanceStatus() esv1.MaintenanceStatus. ProviderSpec() must set exactly one field on the union.
Registration lives in pkg/register/<name>.go:
//go:build <name> || all_providers
package register
import (
esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
foo "github.com/external-secrets/external-secrets/providers/v1/foo"
)
func init() {
esv1.Register(foo.NewProvider(), foo.ProviderSpec(), foo.MaintenanceStatus())
}
Maintenance values: MaintenanceStatusMaintained, NotMaintained, Deprecated (apis/externalsecrets/v1/provider_schema_maintenance.go).
providers/v1/<name> => ./providers/v1/<name> to root go.mod (alphabetized).Makefile honors PROVIDER ?= all_providers and passes it as go build -tags.docs/provider/<slug>.md. Conventional sections: intro, Authentication or Store Configuration, External Secret Spec / GetSecret, optional PushSecret.docs/snippets/<name>-secret-store.yaml, <name>-external-secret.yaml, <name>-push-secret.yaml.
Pull them in via {% include '<name>-secret-store.yaml' %}.Provider: block in hack/api-docs/mkdocs.yml. Order is historical; append at the bottom.A generator is its own Go module under generators/v1/<name>/. Generators are v1alpha1 only and are
unconditionally compiled into the binary (no build tags, unlike providers).
The repo ships a scaffold: esoctl bootstrap generator --name <Name> (cmd/esoctl/generator/bootstrap.go).
Run it first; the manual steps below are the audit checklist for what it produced and what it skipped.
esoctl bootstrap generator wires for youapis/generators/v1alpha1/types_<pkg>.go (CRD types).generators/v1/<pkg>/{<pkg>.go,<pkg>_test.go,go.mod,go.sum} from templates in cmd/esoctl/generator/templates/.pkg/register/generators.go with the import and genv1alpha1.Register(<pkg>.Kind(), <pkg>.NewGenerator()).apis/generators/v1alpha1/types_cluster.go: enum value, GeneratorKind<Name> const, and a field on
GeneratorSpec (the discriminator union).replace directive to root go.mod.runtime/esutils/resolvers/generator.go clusterGeneratorToVirtual switch.apis/generators/v1alpha1/register.go (<Name>Kind var + SchemeBuilder.Register).apis/externalsecrets/v1/externalsecret_types.go GeneratorRef.Kind enum. This is the one v1 enum write the
bootstrap performs; it is documentation-class, not behavioral.What it does NOT do: ClusterRole RBAC, mkdocs nav, docs, snippets, helm.
apis/generators/v1alpha1/. No v1beta1, no v1.types_<name>.go. Standard shape: <Name>Spec, <Name> (TypeMeta + ObjectMeta + Spec), <Name>List.
Most generators have no Status field.+kubebuilder:object:root=true, +kubebuilder:storageversion, +kubebuilder:subresource:status,
+kubebuilder:metadata:labels="external-secrets.io/component=controller",
+kubebuilder:resource:scope=Namespaced,categories={external-secrets, external-secrets-generators}.scope=Namespaced. Cluster-scoped use is delivered by the single ClusterGenerator
umbrella type (apis/generators/v1alpha1/types_cluster.go) which embeds a GeneratorSpec discriminator union with
MaxProperties=1 / MinProperties=1. Do NOT write a Cluster<Name> type. Add one field to that union and one
GeneratorKind enum value.Generator interfaceDefined at apis/generators/v1alpha1/generator_interfaces.go. Two methods:
Generate(ctx, obj *apiextensions.JSON, kube client.Client, namespace string) (map[string][]byte, GeneratorProviderState, error)
Cleanup(ctx, obj *apiextensions.JSON, status GeneratorProviderState, kube client.Client, namespace string) error
apiextensions.JSON. YAML-unmarshal it inside Generate.map[string][]byte of generated keys at once. There is no per-key GetSecret.GeneratorProviderState is *apiextensions.JSON, an opaque blob persisted between Generate and Cleanup.Cleanup MUST be idempotent.runtime/esutils/resolvers.SecretKeyRef(ctx, kube, resolvers.EmptyStoreKind, ns, ref) for credential refs. Generators
pass EmptyStoreKind because they have no SecretStore; namespace scoping does not apply.runtime/esutils.FetchServiceAccountToken for SA-token auth, esutils.ExtractJWTExpiration for JWT parsing.awsauth "github.com/external-secrets/external-secrets/providers/v1/aws/auth" then awsauth.NewGeneratorSession(...).
Vault generator imports providers/v1/vault and calls provider.NewGeneratorClient. Cross-module imports of providers
are normal; wire via replace in the generator's own go.mod.Stateless by default. Return nil for GeneratorProviderState from Generate and a no-op Cleanup (uuid, password,
ecr, sts all do this).
Stateful generators return a non-nil state. runtime/statemanager persists it to a GeneratorState CR
(apis/generators/v1alpha1/generator_state_types.go). The generatorstate controller runs a finalizer that calls
Cleanup on deletion. If state persistence fails post-Generate, statemanager invokes Cleanup as rollback; if Cleanup
itself errors, it creates a GeneratorState with an immediate GarbageCollectionDeadline.
No generator currently uses runtime/cache.Must style client caching.
Generator package exports two symbols: NewGenerator() genv1alpha1.Generator and Kind() string. Registration lives in
pkg/register/generators.go:
import (
genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
foo "github.com/external-secrets/external-secrets/generators/v1/foo"
)
func init() {
genv1alpha1.Register(foo.Kind(), foo.NewGenerator())
}
Register panics on duplicate kinds. Scheme registration is separate, in apis/generators/v1alpha1/register.go:
<Name>Kind = reflect.TypeFor[<Name>]().Name() and SchemeBuilder.Register(&<Name>{}, &<Name>List{}).
The runtime resolver (runtime/esutils/resolvers/generator.go) loads the typed object via the scheme then dispatches to
the registered Generator by kind. ClusterGenerator goes through clusterGeneratorToVirtual which materializes a
synthetic namespaced object from the union spec; every generator must have a case there.
No precedent. None of the existing generators register runtime/feature flags. If you need one, follow the provider
pattern, but expect to be the first.
docs/api/generator/<name>.md.docs/snippets/<name>-...yaml, transcluded via --8<--.Reference: -> API: -> Generators: in hack/api-docs/mkdocs.yml. Append at the bottom.deploy/charts/external-secrets/templates/rbac.yaml for any new resources the generator reads.go.work, run go work use to reconcile the go directive version.