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.
You must run make test && make check-diff before the PR is ready. (See also section Non-Obvious patterns for more explanations about the tests)
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 reviewableis the gate for PRs. Run it, not individual checks.- Helm chart is the source of truth for deploy manifests.
make manifestsgenerates static YAML from it. - Provider docs
{% include %}reusable YAML snippets fromdocs/snippets/(macrosplugin). AWS authentication is documented once on the standalonedocs/provider/aws-access.mdpage; the per-service pages (aws-secrets-manager.md,aws-parameter-store.md) link to it rather than transcluding it. - CRD tests use snapshot testing. Run
make test.crds.updateto update snapshots after CRD changes. make update-depsupdates dependencies across all modules at once.- Add a
git notes add HEADentry on every non-trivial commit. Record key design decisions, trade-offs, and gotchas. Queryable viagit notes show <sha>. - If you discover a non-obvious pattern while implementing, add it here before the PR is merged. Keep entries general — applicable across the codebase, not specific to one provider or feature.
- Never edit
zz_generated.*files by hand. They are owned by controller-gen. Modify the source types and runmake generate(included inmake reviewable). - After everything is committed - ALWAYS RUN
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 mainmake reviewablespec 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.
- New spec goes in
apis/externalsecrets/v1/secretstore_<name>_types.go. - Add a one-line slot to the discriminator union in
apis/externalsecrets/v1/secretstore_types.go(theSecretStoreProviderstruct). The JSON tag is the provider name;apis/externalsecrets/v1/provider_schema.goresolves it from the first JSON key of the marshaled union. - Auth: nested
*<Name>Authstruct. Multi-method auth uses+kubebuilder:validation:MaxProperties=1. Selector types areesmeta.SecretKeySelectorandesmeta.ServiceAccountSelector. - CA: include
CABundle []byteandCAProvider *CAProviderif the backend speaks TLS. - v1 API is frozen by default. Net-new provider slots are fine
runtime/esutils/resolvers.SecretKeyRef(ctx, kube, storeKind, namespace, ref)for credential resolution. It enforcesClusterSecretStorevsSecretStorenamespace scoping. Passstore.GetKind()and the ES namespace.runtime/esutils.FetchCACertFromSource(ctx, esutils.CreateCertOpts{...})for CA bundles.runtime/esutils.ValidateSecretSelector/ValidateReferentSecretSelector/ValidateServiceAccountSelectorfor spec validation.runtime/esutils/metadatafor parsingPushSecretMetadatainto a typed spec.runtime/constantsfor metric label values.
Defined at apis/externalsecrets/v1/provider.go. All eight methods are mandatory; Close may be a no-op.
- Return
esv1.NoSecretErrfromGetSecretwhen the secret is missing. The reconciler depends on this fordeletionPolicy. - Set
Capabilities()honestly:SecretStoreReadOnly,SecretStoreWriteOnly, orSecretStoreReadWrite. Read-only providers still implement Push/Delete but return a sentinel error! Do NOT returnnil! gjsonis the conventional path extractor forref.Propertyon JSON payloads.
- Per-Provider client cache:
runtime/cache.Must[T](size, cleanup). Keyed bycache.Key{Name, Namespace, Kind}, versioned bystore.GetObjectMeta().ResourceVersion. Use this for OIDC, vault leases, token exchange, etc. Default to no cache. - Per-secret cache (in the SecretsClient):
expirable.LRU[string, []byte]with a user-facingCacheConfig{TTL, MaxSize}field on the spec.
Pipeline: helm value to deployment extraArgs to cmd flag to feature.Register to Initialize().
- Register flags from the provider's
init()usingruntime/feature.Feature{Flags, Initialize}. cmd/controller/root.gocollects them and runsInitializeafter manager startup.- Helm wiring is
extraArgsindeploy/charts/external-secrets/values.yaml, rendered bytemplates/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).
- Add
providers/v1/<name> => ./providers/v1/<name>to rootgo.mod(alphabetized). MakefilehonorsPROVIDER ?= all_providersand passes it asgo build -tags.
- Write
docs/provider/<slug>.md. Conventional sections: intro, Authentication or Store Configuration, External Secret Spec / GetSecret, optional PushSecret. - YAML examples live in
docs/snippets/<name>-secret-store.yaml,<name>-external-secret.yaml,<name>-push-secret.yaml. Pull them in via{% include '<name>-secret-store.yaml' %}. - Add nav entry to the
Provider:block inhack/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.
- Creates
apis/generators/v1alpha1/types_<pkg>.go(CRD types). - Creates
generators/v1/<pkg>/{<pkg>.go,<pkg>_test.go,go.mod,go.sum}from templates incmd/esoctl/generator/templates/. - Patches
pkg/register/generators.gowith the import andgenv1alpha1.Register(<pkg>.Kind(), <pkg>.NewGenerator()). - Patches
apis/generators/v1alpha1/types_cluster.go: enum value,GeneratorKind<Name>const, and a field onGeneratorSpec(the discriminator union). - Adds the
replacedirective to rootgo.mod. - Patches
runtime/esutils/resolvers/generator.goclusterGeneratorToVirtualswitch. - Patches
apis/generators/v1alpha1/register.go(<Name>Kindvar +SchemeBuilder.Register). - Patches
apis/externalsecrets/v1/externalsecret_types.goGeneratorRef.Kindenum. 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.
- All generators live in
apis/generators/v1alpha1/. No v1beta1, no v1. - Per-generator file is
types_<name>.go. Standard shape:<Name>Spec,<Name>(TypeMeta + ObjectMeta + Spec),<Name>List. Most generators have no Status field. - Standard markers:
+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}. - All concrete generators are
scope=Namespaced. Cluster-scoped use is delivered by the singleClusterGeneratorumbrella type (apis/generators/v1alpha1/types_cluster.go) which embeds aGeneratorSpecdiscriminator union withMaxProperties=1/MinProperties=1. Do NOT write aCluster<Name>type. Add one field to that union and oneGeneratorKindenum value.
Defined 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- Spec arrives as raw
apiextensions.JSON. YAML-unmarshal it insideGenerate. - Returns the full
map[string][]byteof generated keys at once. There is no per-keyGetSecret. GeneratorProviderStateis*apiextensions.JSON, an opaque blob persisted betweenGenerateandCleanup.CleanupMUST be idempotent.
runtime/esutils/resolvers.SecretKeyRef(ctx, kube, resolvers.EmptyStoreKind, ns, ref)for credential refs. Generators passEmptyStoreKindbecause they have no SecretStore; namespace scoping does not apply.runtime/esutils.FetchServiceAccountTokenfor SA-token auth,esutils.ExtractJWTExpirationfor JWT parsing.- AWS-family generators reuse the provider's auth path:
awsauth "github.com/external-secrets/external-secrets/providers/v1/aws/auth"thenawsauth.NewGeneratorSession(...). Vault generator importsproviders/v1/vaultand callsprovider.NewGeneratorClient. Cross-module imports of providers are normal; wire viareplacein the generator's owngo.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.
- Generator docs live at
docs/api/generator/<name>.md. - YAML snippets in
docs/snippets/<name>-...yaml, transcluded via{% include %}(macros plugin). - Nav entry goes under
Reference: -> API: -> Generators:inhack/api-docs/mkdocs.yml. Append at the bottom.
- ClusterRole rules in
deploy/charts/external-secrets/templates/rbac.yamlfor any new resources the generator reads. - Docs page + snippets.
- mkdocs nav entry.
- After adding the module to
go.work, rungo work useto reconcile thegodirective version.
Agents may:
- inspect the repository,
- explain code,
- propose changes,
- edit local files,
- write tests,
- update documentation,
- run checks,
- prepare a local diff for human review,
- ...
in order to assist humans.
Agents must not:
- create pull requests,
- push branches,
- publish releases,
- upload packages,
- change repository settings,
- change permissions,
- rotate credentials,
- modify secrets,
- perform external write actions.
If asked to perform a blocked action, do not perform it. Instead, create a local file named AGENT_BLOCKED_ACTION.md containing:
- the requested action,
- why the action is blocked,
- the local work that was completed, if any,
- the recommended manual steps a human contributor should take next.
Before presenting work as complete, verify:
- the intent is documented,
- the diff is minimal and surgical (must not touch adjacent comments or code unrelated to the work),
- the relevant tests were run (see build and test section),
- the documentation was updated.
If validation could not be completed, state it explicitly and explain why.