# External Secrets Operator Kubernetes operator that synchronizes secrets from external providers (AWS Secrets Manager, Vault, GCP Secret Manager, Azure Key Vault, etc.) into Kubernetes Secrets. ## Build and Test Use `make` targets — refer to the Makefile for available commands. Do not run `go test`, `golangci-lint`, or `helm` directly. ## Project Layout 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`. ## Non-Obvious Patterns - `make reviewable` is the gate for PRs. Run it, not individual checks. - Helm chart is the source of truth for deploy manifests. `make manifests` generates static YAML from it. - Provider docs use MkDocs snippet transclusion (`--8<--`). Auth docs are shared across providers via `docs/snippets/`. - CRD tests use snapshot testing. Run `make test.crds.update` to update snapshots after CRD changes. - `make update-deps` updates dependencies across all modules at once. - Add a `git notes add HEAD` entry on every non-trivial commit. Record key design decisions, trade-offs, and gotchas. Queryable via `git notes show `. - 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 run `make generate` (included in `make 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 main `make reviewable` spec like helm chart tests, docs, etc. ## Adding a Provider A provider is its own Go module under `providers/v1//` with no build tags on the package itself. Build tags live in `pkg/register/.go`. ### API types - New spec goes in `apis/externalsecrets/v1/secretstore__types.go`. - Add a one-line slot to the discriminator union in `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. - Auth: nested `*Auth` struct. Multi-method auth uses `+kubebuilder:validation:MaxProperties=1`. Selector types are `esmeta.SecretKeySelector` and `esmeta.ServiceAccountSelector`. - CA: include `CABundle []byte` and `CAProvider *CAProvider` if the backend speaks TLS. - v1 API is frozen by default. Net-new provider slots are fine ### Runtime helpers (use these, do not roll your own) - `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` contract Defined at `apis/externalsecrets/v1/provider.go`. All eight methods are mandatory; `Close` may be a no-op. - Return `esv1.NoSecretErr` from `GetSecret` when the secret is missing. The reconciler depends on this for `deletionPolicy`. - Set `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. ### Caching (skip unless construction is expensive) - Per-Provider client cache: `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. - Per-secret cache (in the SecretsClient): `expirable.LRU[string, []byte]` with a user-facing `CacheConfig{TTL, MaxSize}` field on the spec. ### Feature flags Pipeline: helm value to deployment `extraArgs` to cmd flag to `feature.Register` to `Initialize()`. - Register flags from the provider's `init()` using `runtime/feature.Feature{Flags, Initialize}`. - `cmd/controller/root.go` collects them and runs `Initialize` after manager startup. - Helm wiring is `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. ### Registration 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/.go`: ```go //go:build || 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`). ### Wiring - Add `providers/v1/ => ./providers/v1/` to root `go.mod` (alphabetized). - `Makefile` honors `PROVIDER ?= all_providers` and passes it as `go build -tags`. ### Documentation - Write `docs/provider/.md`. Conventional sections: intro, Authentication or Store Configuration, External Secret Spec / GetSecret, optional PushSecret. - YAML examples live in `docs/snippets/-secret-store.yaml`, `-external-secret.yaml`, `-push-secret.yaml`. Pull them in via `{% include '-secret-store.yaml' %}`. - Add nav entry to the `Provider:` block in `hack/api-docs/mkdocs.yml`. Order is historical; append at the bottom. ## Adding a Generator A generator is its own Go module under `generators/v1//`. 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 ` (`cmd/esoctl/generator/bootstrap.go`). Run it first; the manual steps below are the audit checklist for what it produced and what it skipped. ### What `esoctl bootstrap generator` wires for you - Creates `apis/generators/v1alpha1/types_.go` (CRD types). - Creates `generators/v1//{.go,_test.go,go.mod,go.sum}` from templates in `cmd/esoctl/generator/templates/`. - Patches `pkg/register/generators.go` with the import and `genv1alpha1.Register(.Kind(), .NewGenerator())`. - Patches `apis/generators/v1alpha1/types_cluster.go`: enum value, `GeneratorKind` const, and a field on `GeneratorSpec` (the discriminator union). - Adds the `replace` directive to root `go.mod`. - Patches `runtime/esutils/resolvers/generator.go` `clusterGeneratorToVirtual` switch. - Patches `apis/generators/v1alpha1/register.go` (`Kind` var + `SchemeBuilder.Register`). - Patches `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. ### API types - All generators live in `apis/generators/v1alpha1/`. No v1beta1, no v1. - Per-generator file is `types_.go`. Standard shape: `Spec`, `` (TypeMeta + ObjectMeta + Spec), `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 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` type. Add one field to that union and one `GeneratorKind` enum value. ### `Generator` interface Defined at `apis/generators/v1alpha1/generator_interfaces.go`. Two methods: ```go 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 inside `Generate`. - Returns the full `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 helpers - `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. - AWS-family generators reuse the provider's auth path: `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`. ### State and lifecycle 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. ### Registration Generator package exports two symbols: `NewGenerator() genv1alpha1.Generator` and `Kind() string`. Registration lives in `pkg/register/generators.go`: ```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`: `Kind = reflect.TypeFor[]().Name()` and `SchemeBuilder.Register(&{}, &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. ### Feature flags 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. ### Documentation - Generator docs live at `docs/api/generator/.md`. - YAML snippets in `docs/snippets/-...yaml`, transcluded via `--8<--`. - Nav entry goes under `Reference: -> API: -> Generators:` in `hack/api-docs/mkdocs.yml`. Append at the bottom. ### Manual checklist after bootstrap - ClusterRole rules in `deploy/charts/external-secrets/templates/rbac.yaml` for any new resources the generator reads. - Docs page + snippets. - mkdocs nav entry. - After adding the module to `go.work`, run `go work use` to reconcile the `go` directive version.