From 6ef3225b70d1fdfc5bc5869bd254469eab812a6d Mon Sep 17 00:00:00 2001 From: Swarup Ghosh Date: Mon, 6 Apr 2026 13:48:31 +0530 Subject: [PATCH 1/2] feat: add componentConfig and annotations API types to ExternalSecretsConfig Extend the ExternalSecretsConfig API with componentConfig and annotations fields as specified in enhancement proposal #1898 (ESO-266). New types added: - ComponentConfig: per-component deployment overrides and env vars - DeploymentConfig: deployment-level settings (revisionHistoryLimit) - Annotation: custom annotation key-value pairs New ComponentName enum values: Webhook, CertController Includes CEL validation for reserved annotation prefixes and env var names, integration test suite coverage, regenerated deepcopy and CRD manifests. Co-Authored-By: Claude Opus 4.6 --- api/v1alpha1/external_secrets_config_types.go | 98 +++- .../externalsecretsconfig.testsuite.yaml | 495 +++++++++++++++++- api/v1alpha1/zz_generated.deepcopy.go | 70 +++ ...r.openshift.io_externalsecretsconfigs.yaml | 215 ++++++++ 4 files changed, 874 insertions(+), 4 deletions(-) diff --git a/api/v1alpha1/external_secrets_config_types.go b/api/v1alpha1/external_secrets_config_types.go index f06a7df79..39e7ba8b9 100644 --- a/api/v1alpha1/external_secrets_config_types.go +++ b/api/v1alpha1/external_secrets_config_types.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -113,6 +114,36 @@ type ControllerConfig struct { // +kubebuilder:validation:Optional Labels map[string]string `json:"labels,omitempty"` + // annotations allows adding custom annotations to all external-secrets component + // Deployments and Pod templates. These annotations are applied globally to all + // operand components (Controller, Webhook, CertController, BitwardenSDKServer). + // These annotations are merged with any default annotations set by the operator. + // User-specified annotations take precedence over defaults in case of conflicts. + // Annotations with keys starting with kubernetes.io/, app.kubernetes.io/, openshift.io/, or k8s.io/ + // are reserved and cannot be overridden. + // + // +kubebuilder:validation:XValidation:rule="self.all(a, !['kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/'].exists(p, a.key.startsWith(p)))",message="annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + // +listType=map + // +listMapKey=key + // +kubebuilder:validation:Optional + // +optional + Annotations []Annotation `json:"annotations,omitempty"` + + // componentConfigs allows specifying component-specific configuration overrides + // for each external-secrets operand component (ExternalSecretsCoreController, Webhook, + // CertController, BitwardenSDKServer). + // Each entry must target a unique componentName. A maximum of 4 entries is allowed, + // one for each supported component. + // + // +kubebuilder:validation:XValidation:rule="self.all(x, self.exists_one(y, x.componentName == y.componentName))",message="componentName must be unique across all componentConfig entries" + // +kubebuilder:validation:MinItems:=0 + // +kubebuilder:validation:MaxItems:=4 + // +listType=map + // +listMapKey=componentName + // +kubebuilder:validation:Optional + // +optional + ComponentConfigs []ComponentConfig `json:"componentConfigs,omitempty"` + // networkPolicies specifies the list of network policy configurations // to be applied to external-secrets pods. // @@ -212,17 +243,78 @@ type CertProvidersConfig struct { CertManager *CertManagerConfig `json:"certManager,omitempty"` } -// ComponentName represents the different external-secrets components that can have network policies applied. +// ComponentName represents the different external-secrets operand components +// that can be individually configured or targeted by policies. type ComponentName string const ( - // CoreController represents the external-secrets component + // CoreController represents the external-secrets core controller component. CoreController ComponentName = "ExternalSecretsCoreController" - // BitwardenSDKServer represents the bitwarden-sdk-server component + // Webhook represents the external-secrets webhook component. + Webhook ComponentName = "Webhook" + + // CertController represents the external-secrets cert-controller component. + CertController ComponentName = "CertController" + + // BitwardenSDKServer represents the bitwarden-sdk-server component. BitwardenSDKServer ComponentName = "BitwardenSDKServer" ) +// ComponentConfig specifies configuration overrides for a specific external-secrets +// operand component. Each entry targets a single component by name and allows +// deployment-level configuration and custom environment variables. +type ComponentConfig struct { + // componentName specifies which deployment component this configuration applies to. + // +kubebuilder:validation:Enum:=ExternalSecretsCoreController;Webhook;CertController;BitwardenSDKServer + // +kubebuilder:validation:Required + ComponentName ComponentName `json:"componentName"` + + // deploymentConfigs allows specifying deployment-level configuration overrides + // for the targeted component. + // +kubebuilder:validation:Optional + // +optional + DeploymentConfigs DeploymentConfig `json:"deploymentConfigs,omitempty"` + + // overrideEnv allows setting custom environment variables for the component's container. + // These environment variables are merged with the default environment variables set by + // the operator. User-specified variables take precedence in case of conflicts. + // Environment variables starting with HOSTNAME, KUBERNETES_, or EXTERNAL_SECRETS_ are reserved + // and cannot be overridden. + // + // +kubebuilder:validation:XValidation:rule="self.all(e, !['HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_'].exists(p, e.name.startsWith(p)))",message="Environment variable names with reserved prefixes 'HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_' are not allowed" + // +kubebuilder:validation:Optional + // +optional + OverrideEnv []corev1.EnvVar `json:"overrideEnv,omitempty"` +} + +// DeploymentConfig specifies deployment-level configuration overrides for +// an external-secrets operand component. +type DeploymentConfig struct { + // revisionHistoryLimit specifies the number of old ReplicaSets to retain for rollback. + // Minimum value of 1 is enforced to ensure rollback capability. + // + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Optional + // +optional + RevisionHistoryLimit *int32 `json:"revisionHistoryLimit,omitempty"` +} + +// Annotation represents a custom annotation key-value pair. +type Annotation struct { + // key is the annotation key. It must conform to Kubernetes annotation key constraints. + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:MaxLength:=317 + // +kubebuilder:validation:Required + Key string `json:"key"` + + // value is the annotation value. It can be an empty string. + // +kubebuilder:validation:MaxLength:=262144 + // +kubebuilder:validation:Optional + // +optional + Value string `json:"value,omitempty"` +} + // NetworkPolicy represents a custom network policy configuration for operator-managed components. // It includes a name for identification and the network policy rules to be enforced. type NetworkPolicy struct { diff --git a/api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml b/api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml index 720d7fc23..b97d3569b 100644 --- a/api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml +++ b/api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml @@ -474,6 +474,393 @@ tests: plugins: bitwardenSecretManagerProvider: mode: Disabled + - name: Should be able to create ExternalSecretsConfig with annotations + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/custom-annotation" + value: "my-value" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/custom-annotation" + value: "my-value" + - name: Should be able to create ExternalSecretsConfig with multiple annotations + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation-one" + value: "value-one" + - key: "example.com/annotation-two" + value: "value-two" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation-one" + value: "value-one" + - key: "example.com/annotation-two" + value: "value-two" + - name: Should fail with reserved annotation prefix kubernetes.io/ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "kubernetes.io/reserved-key" + value: "test" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should fail with reserved annotation prefix app.kubernetes.io/ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "app.kubernetes.io/name" + value: "test" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should fail with reserved annotation prefix openshift.io/ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "openshift.io/some-key" + value: "test" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should fail with reserved annotation prefix k8s.io/ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "k8s.io/some-key" + value: "test" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should be able to create ExternalSecretsConfig with componentConfigs for Controller + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 5 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 5 + - name: Should be able to create ExternalSecretsConfig with componentConfigs for Webhook + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: Webhook + deploymentConfigs: + revisionHistoryLimit: 3 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: Webhook + deploymentConfigs: + revisionHistoryLimit: 3 + - name: Should be able to create ExternalSecretsConfig with componentConfigs for CertController + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: CertController + deploymentConfigs: + revisionHistoryLimit: 3 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: CertController + deploymentConfigs: + revisionHistoryLimit: 3 + - name: Should be able to create ExternalSecretsConfig with componentConfigs for BitwardenSDKServer + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: BitwardenSDKServer + deploymentConfigs: + revisionHistoryLimit: 2 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: BitwardenSDKServer + deploymentConfigs: + revisionHistoryLimit: 2 + - name: Should be able to create ExternalSecretsConfig with multiple componentConfigs + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 10 + - componentName: Webhook + deploymentConfigs: + revisionHistoryLimit: 3 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 10 + - componentName: Webhook + deploymentConfigs: + revisionHistoryLimit: 3 + - name: Should be able to create ExternalSecretsConfig with all four componentConfigs + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 10 + - componentName: Webhook + deploymentConfigs: + revisionHistoryLimit: 5 + - componentName: CertController + deploymentConfigs: + revisionHistoryLimit: 3 + - componentName: BitwardenSDKServer + deploymentConfigs: + revisionHistoryLimit: 2 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 10 + - componentName: Webhook + deploymentConfigs: + revisionHistoryLimit: 5 + - componentName: CertController + deploymentConfigs: + revisionHistoryLimit: 3 + - componentName: BitwardenSDKServer + deploymentConfigs: + revisionHistoryLimit: 2 + - name: Should fail with invalid componentName in componentConfigs + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: InvalidComponent + deploymentConfigs: + revisionHistoryLimit: 5 + expectedError: "spec.controllerConfig.componentConfigs[0].componentName: Unsupported value: \"InvalidComponent\": supported values: \"ExternalSecretsCoreController\", \"Webhook\", \"CertController\", \"BitwardenSDKServer\"" + - name: Should fail with more than 4 componentConfigs entries + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + - componentName: Webhook + - componentName: CertController + - componentName: BitwardenSDKServer + - componentName: ExternalSecretsCoreController + expectedError: "spec.controllerConfig.componentConfigs: Too many: 5: must have at most 4 items" + - name: Should fail with revisionHistoryLimit less than 1 + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 0 + expectedError: "spec.controllerConfig.componentConfigs[0].deploymentConfigs.revisionHistoryLimit: Invalid value: 0: spec.controllerConfig.componentConfigs[0].deploymentConfigs.revisionHistoryLimit in body should be greater than or equal to 1" + - name: Should be able to create ExternalSecretsConfig with overrideEnv + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: GOMAXPROCS + value: "4" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: GOMAXPROCS + value: "4" + - name: Should fail with reserved env var prefix HOSTNAME + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: HOSTNAME + value: "test" + expectedError: "Environment variable names with reserved prefixes 'HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_' are not allowed" + - name: Should fail with reserved env var prefix KUBERNETES_ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: KUBERNETES_SERVICE_HOST + value: "10.0.0.1" + expectedError: "Environment variable names with reserved prefixes 'HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_' are not allowed" + - name: Should fail with reserved env var prefix EXTERNAL_SECRETS_ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: EXTERNAL_SECRETS_CONFIG + value: "test" + expectedError: "Environment variable names with reserved prefixes 'HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_' are not allowed" + - name: Should be able to create ExternalSecretsConfig with combined annotations and componentConfigs + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/custom-annotation" + value: "my-value" + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 10 + overrideEnv: + - name: GOMAXPROCS + value: "4" + - componentName: Webhook + deploymentConfigs: + revisionHistoryLimit: 3 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/custom-annotation" + value: "my-value" + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 10 + overrideEnv: + - name: GOMAXPROCS + value: "4" + - componentName: Webhook + deploymentConfigs: + revisionHistoryLimit: 3 + - name: Should be able to create ExternalSecretsConfig with componentConfigs having no deploymentConfigs + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: GOMAXPROCS + value: "4" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: GOMAXPROCS + value: "4" - name: Should allow webhookConfig with custom certificateCheckInterval resourceName: cluster initial: | @@ -596,4 +983,110 @@ tests: bitwardenSecretManagerProvider: mode: Enabled secretRef: - name: "bitwarden-certs" \ No newline at end of file + name: "bitwarden-certs" + - name: Should be able to add annotations to controllerConfig on update + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/custom-annotation" + value: "my-value" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/custom-annotation" + value: "my-value" + - name: Should be able to add componentConfigs on update + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 5 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 5 + - name: Should be able to update revisionHistoryLimit + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 5 + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 10 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfigs: + revisionHistoryLimit: 10 + - name: Should be able to add overrideEnv on update + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: GOMAXPROCS + value: "4" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: GOMAXPROCS + value: "4" \ No newline at end of file diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 99f6ec14b..ddbd70204 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -27,6 +27,21 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Annotation) DeepCopyInto(out *Annotation) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Annotation. +func (in *Annotation) DeepCopy() *Annotation { + if in == nil { + return nil + } + out := new(Annotation) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApplicationConfig) DeepCopyInto(out *ApplicationConfig) { *out = *in @@ -162,6 +177,29 @@ func (in *CommonConfigs) DeepCopy() *CommonConfigs { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ComponentConfig) DeepCopyInto(out *ComponentConfig) { + *out = *in + in.DeploymentConfigs.DeepCopyInto(&out.DeploymentConfigs) + if in.OverrideEnv != nil { + in, out := &in.OverrideEnv, &out.OverrideEnv + *out = make([]corev1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComponentConfig. +func (in *ComponentConfig) DeepCopy() *ComponentConfig { + if in == nil { + return nil + } + out := new(ComponentConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Condition) DeepCopyInto(out *Condition) { *out = *in @@ -214,6 +252,18 @@ func (in *ControllerConfig) DeepCopyInto(out *ControllerConfig) { (*out)[key] = val } } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make([]Annotation, len(*in)) + copy(*out, *in) + } + if in.ComponentConfigs != nil { + in, out := &in.ComponentConfigs, &out.ComponentConfigs + *out = make([]ComponentConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.NetworkPolicies != nil { in, out := &in.NetworkPolicies, &out.NetworkPolicies *out = make([]NetworkPolicy, len(*in)) @@ -253,6 +303,26 @@ func (in *ControllerStatus) DeepCopy() *ControllerStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentConfig) DeepCopyInto(out *DeploymentConfig) { + *out = *in + if in.RevisionHistoryLimit != nil { + in, out := &in.RevisionHistoryLimit, &out.RevisionHistoryLimit + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentConfig. +func (in *DeploymentConfig) DeepCopy() *DeploymentConfig { + if in == nil { + return nil + } + out := new(DeploymentConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExternalSecretsConfig) DeepCopyInto(out *ExternalSecretsConfig) { *out = *in diff --git a/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml b/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml index ae6890cdc..ec493581e 100644 --- a/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml +++ b/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml @@ -1173,6 +1173,42 @@ spec: for the controller to use while installing the `external-secrets` operand and the plugins. properties: + annotations: + description: |- + annotations allows adding custom annotations to all external-secrets component + Deployments and Pod templates. These annotations are applied globally to all + operand components (Controller, Webhook, CertController, BitwardenSDKServer). + These annotations are merged with any default annotations set by the operator. + User-specified annotations take precedence over defaults in case of conflicts. + Annotations with keys starting with kubernetes.io/, app.kubernetes.io/, openshift.io/, or k8s.io/ + are reserved and cannot be overridden. + items: + description: Annotation represents a custom annotation key-value + pair. + properties: + key: + description: key is the annotation key. It must conform + to Kubernetes annotation key constraints. + maxLength: 317 + minLength: 1 + type: string + value: + description: value is the annotation value. It can be an + empty string. + maxLength: 262144 + type: string + required: + - key + type: object + type: array + x-kubernetes-list-map-keys: + - key + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: annotations with reserved prefixes 'kubernetes.io/', + 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed + rule: self.all(a, !['kubernetes.io/', 'app.kubernetes.io/', + 'openshift.io/', 'k8s.io/'].exists(p, a.key.startsWith(p))) certProvider: description: certProvider is for defining the configuration for certificate providers used to manage TLS certificates for webhook @@ -1263,6 +1299,185 @@ spec: rule: 'has(self.injectAnnotations) && self.injectAnnotations != ''false'' ? self.mode != ''Disabled'' : true' type: object + componentConfigs: + description: |- + componentConfigs allows specifying component-specific configuration overrides + for each external-secrets operand component (ExternalSecretsCoreController, Webhook, + CertController, BitwardenSDKServer). + Each entry must target a unique componentName. A maximum of 4 entries is allowed, + one for each supported component. + items: + description: |- + ComponentConfig specifies configuration overrides for a specific external-secrets + operand component. Each entry targets a single component by name and allows + deployment-level configuration and custom environment variables. + properties: + componentName: + description: componentName specifies which deployment component + this configuration applies to. + enum: + - ExternalSecretsCoreController + - Webhook + - CertController + - BitwardenSDKServer + type: string + deploymentConfigs: + description: |- + deploymentConfigs allows specifying deployment-level configuration overrides + for the targeted component. + properties: + revisionHistoryLimit: + description: |- + revisionHistoryLimit specifies the number of old ReplicaSets to retain for rollback. + Minimum value of 1 is enforced to ensure rollback capability. + format: int32 + minimum: 1 + type: integer + type: object + overrideEnv: + description: |- + overrideEnv allows setting custom environment variables for the component's container. + These environment variables are merged with the default environment variables set by + the operator. User-specified variables take precedence in case of conflicts. + Environment variables starting with HOSTNAME, KUBERNETES_, or EXTERNAL_SECRETS_ are reserved + and cannot be overridden. + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-validations: + - message: Environment variable names with reserved prefixes + 'HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_' are not + allowed + rule: self.all(e, !['HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_'].exists(p, + e.name.startsWith(p))) + required: + - componentName + type: object + maxItems: 4 + minItems: 0 + type: array + x-kubernetes-list-map-keys: + - componentName + x-kubernetes-list-type: map + x-kubernetes-validations: + - message: componentName must be unique across all componentConfig + entries + rule: self.all(x, self.exists_one(y, x.componentName == y.componentName)) labels: additionalProperties: type: string From 8fd97a74f4f9d9bf2bbc3ceb3fb28e28cb5ee60c Mon Sep 17 00:00:00 2001 From: Swarup Ghosh Date: Mon, 6 Apr 2026 13:53:24 +0530 Subject: [PATCH 2/2] feat: implement componentConfig and annotations controller logic Add controller reconciliation logic for the componentConfig and annotations API fields introduced in EP #1898: - applyAnnotationsToDeployment: applies custom annotations (with reserved prefix filtering) to deployment and pod template metadata - applyComponentConfigToDeployment: applies per-component overrides including revisionHistoryLimit and overrideEnv to target deployments - Maps ComponentName enum values to deployment assets and container names Integrates with existing getDeploymentObject pipeline so all deployment reconciliation flows (create and update) apply the new configuration. Includes comprehensive unit tests for all new functions. Co-Authored-By: Claude Opus 4.6 --- .../external_secrets/component_config.go | 190 ++++++++++ .../external_secrets/component_config_test.go | 351 ++++++++++++++++++ .../external_secrets/deployments.go | 8 + 3 files changed, 549 insertions(+) create mode 100644 pkg/controller/external_secrets/component_config.go create mode 100644 pkg/controller/external_secrets/component_config_test.go diff --git a/pkg/controller/external_secrets/component_config.go b/pkg/controller/external_secrets/component_config.go new file mode 100644 index 000000000..4c1c54d06 --- /dev/null +++ b/pkg/controller/external_secrets/component_config.go @@ -0,0 +1,190 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external_secrets + +import ( + "fmt" + "regexp" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + + operatorv1alpha1 "github.com/openshift/external-secrets-operator/api/v1alpha1" +) + +var ( + // disallowedAnnotationMatcher restricts annotations with reserved prefixes from being + // applied to operator-managed resources. Reserved prefixes match platform-managed annotations + // that should not be overridden by users. + disallowedAnnotationMatcher = regexp.MustCompile(`^kubernetes\.io/|^app\.kubernetes\.io/|^openshift\.io/|^k8s\.io/`) + + // componentNameToDeploymentAsset maps ComponentName enum values to their corresponding + // deployment asset file paths used by the controller. + componentNameToDeploymentAsset = map[operatorv1alpha1.ComponentName]string{ + operatorv1alpha1.CoreController: controllerDeploymentAssetName, + operatorv1alpha1.Webhook: webhookDeploymentAssetName, + operatorv1alpha1.CertController: certControllerDeploymentAssetName, + operatorv1alpha1.BitwardenSDKServer: bitwardenDeploymentAssetName, + } + + // componentNameToContainerName maps ComponentName enum values to their primary + // container names within each deployment. + componentNameToContainerName = map[operatorv1alpha1.ComponentName]string{ + operatorv1alpha1.CoreController: "external-secrets", + operatorv1alpha1.Webhook: "webhook", + operatorv1alpha1.CertController: "cert-controller", + operatorv1alpha1.BitwardenSDKServer: "bitwarden-sdk-server", + } +) + +// applyAnnotationsToDeployment applies custom annotations from controllerConfig.annotations +// to the deployment's metadata and pod template metadata. +// Annotations with reserved prefixes are skipped with a log warning. +func (r *Reconciler) applyAnnotationsToDeployment(deployment *appsv1.Deployment, esc *operatorv1alpha1.ExternalSecretsConfig) { + if len(esc.Spec.ControllerConfig.Annotations) == 0 { + return + } + + // Build the annotation map, skipping reserved prefixes + customAnnotations := make(map[string]string, len(esc.Spec.ControllerConfig.Annotations)) + for _, annotation := range esc.Spec.ControllerConfig.Annotations { + if disallowedAnnotationMatcher.MatchString(annotation.Key) { + r.log.V(1).Info("skip adding annotation with reserved prefix", "key", annotation.Key) + continue + } + customAnnotations[annotation.Key] = annotation.Value + } + + if len(customAnnotations) == 0 { + return + } + + // Apply to deployment metadata + deploymentAnnotations := deployment.GetAnnotations() + if deploymentAnnotations == nil { + deploymentAnnotations = make(map[string]string, len(customAnnotations)) + } + for k, v := range customAnnotations { + deploymentAnnotations[k] = v + } + deployment.SetAnnotations(deploymentAnnotations) + + // Apply to pod template metadata + podAnnotations := deployment.Spec.Template.GetAnnotations() + if podAnnotations == nil { + podAnnotations = make(map[string]string, len(customAnnotations)) + } + for k, v := range customAnnotations { + podAnnotations[k] = v + } + deployment.Spec.Template.SetAnnotations(podAnnotations) +} + +// applyComponentConfigToDeployment applies component-specific configuration overrides +// (revisionHistoryLimit and overrideEnv) to a deployment based on its asset name. +// It finds the matching ComponentConfig entry by mapping the asset name to a ComponentName. +func (r *Reconciler) applyComponentConfigToDeployment(deployment *appsv1.Deployment, esc *operatorv1alpha1.ExternalSecretsConfig, assetName string) error { + if len(esc.Spec.ControllerConfig.ComponentConfigs) == 0 { + return nil + } + + // Determine which ComponentName this asset corresponds to + componentName, found := getComponentNameForAsset(assetName) + if !found { + return nil + } + + // Find the matching ComponentConfig entry + var componentConfig *operatorv1alpha1.ComponentConfig + for i := range esc.Spec.ControllerConfig.ComponentConfigs { + if esc.Spec.ControllerConfig.ComponentConfigs[i].ComponentName == componentName { + componentConfig = &esc.Spec.ControllerConfig.ComponentConfigs[i] + break + } + } + if componentConfig == nil { + return nil + } + + // Apply revisionHistoryLimit + if componentConfig.DeploymentConfigs.RevisionHistoryLimit != nil { + deployment.Spec.RevisionHistoryLimit = componentConfig.DeploymentConfigs.RevisionHistoryLimit + r.log.V(1).Info("applied revisionHistoryLimit", + "component", componentName, + "revisionHistoryLimit", *componentConfig.DeploymentConfigs.RevisionHistoryLimit) + } + + // Apply overrideEnv to the primary container + if len(componentConfig.OverrideEnv) > 0 { + containerName, ok := componentNameToContainerName[componentName] + if !ok { + return fmt.Errorf("no container name mapping for component %q", componentName) + } + + if err := applyOverrideEnvToContainer(deployment, containerName, componentConfig.OverrideEnv); err != nil { + return fmt.Errorf("failed to apply overrideEnv for component %q: %w", componentName, err) + } + r.log.V(1).Info("applied overrideEnv", + "component", componentName, + "envCount", len(componentConfig.OverrideEnv)) + } + + return nil +} + +// getComponentNameForAsset returns the ComponentName that corresponds to the given +// deployment asset name. Returns false if the asset is not recognized. +func getComponentNameForAsset(assetName string) (operatorv1alpha1.ComponentName, bool) { + for componentName, asset := range componentNameToDeploymentAsset { + if asset == assetName { + return componentName, true + } + } + return "", false +} + +// applyOverrideEnvToContainer merges user-specified environment variables into the +// primary container of a deployment. User-specified variables take precedence over +// existing variables with the same name. +func applyOverrideEnvToContainer(deployment *appsv1.Deployment, containerName string, overrideEnv []corev1.EnvVar) error { + for i := range deployment.Spec.Template.Spec.Containers { + if deployment.Spec.Template.Spec.Containers[i].Name != containerName { + continue + } + container := &deployment.Spec.Template.Spec.Containers[i] + + // Build a map of existing env vars for fast lookup + existingEnvMap := make(map[string]int, len(container.Env)) + for idx, env := range container.Env { + existingEnvMap[env.Name] = idx + } + + // Merge overrideEnv — update existing or append new + for _, override := range overrideEnv { + if idx, exists := existingEnvMap[override.Name]; exists { + container.Env[idx] = override + } else { + container.Env = append(container.Env, override) + existingEnvMap[override.Name] = len(container.Env) - 1 + } + } + + return nil + } + + return fmt.Errorf("container %q not found in deployment %s/%s", containerName, deployment.GetNamespace(), deployment.GetName()) +} diff --git a/pkg/controller/external_secrets/component_config_test.go b/pkg/controller/external_secrets/component_config_test.go new file mode 100644 index 000000000..eb1d0deee --- /dev/null +++ b/pkg/controller/external_secrets/component_config_test.go @@ -0,0 +1,351 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external_secrets + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + "github.com/go-logr/logr" + + operatorv1alpha1 "github.com/openshift/external-secrets-operator/api/v1alpha1" +) + +func newTestReconciler() *Reconciler { + return &Reconciler{ + log: logr.Discard(), + } +} + +func newTestDeployment(containerName string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "external-secrets", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: containerName, + Image: "test-image:latest", + Env: []corev1.EnvVar{ + {Name: "EXISTING_VAR", Value: "existing-value"}, + }, + }, + }, + }, + }, + }, + } +} + +func newTestESC() *operatorv1alpha1.ExternalSecretsConfig { + return &operatorv1alpha1.ExternalSecretsConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + }, + Spec: operatorv1alpha1.ExternalSecretsConfigSpec{}, + } +} + +func TestApplyAnnotationsToDeployment(t *testing.T) { + tests := []struct { + name string + annotations []operatorv1alpha1.Annotation + expectedDeployAnnot map[string]string + expectedPodAnnot map[string]string + }{ + { + name: "no annotations", + annotations: nil, + expectedDeployAnnot: nil, + expectedPodAnnot: nil, + }, + { + name: "single custom annotation", + annotations: []operatorv1alpha1.Annotation{ + {Key: "example.com/my-key", Value: "my-value"}, + }, + expectedDeployAnnot: map[string]string{"example.com/my-key": "my-value"}, + expectedPodAnnot: map[string]string{"example.com/my-key": "my-value"}, + }, + { + name: "multiple annotations", + annotations: []operatorv1alpha1.Annotation{ + {Key: "example.com/key-1", Value: "value-1"}, + {Key: "example.com/key-2", Value: "value-2"}, + }, + expectedDeployAnnot: map[string]string{ + "example.com/key-1": "value-1", + "example.com/key-2": "value-2", + }, + expectedPodAnnot: map[string]string{ + "example.com/key-1": "value-1", + "example.com/key-2": "value-2", + }, + }, + { + name: "reserved annotations are skipped", + annotations: []operatorv1alpha1.Annotation{ + {Key: "kubernetes.io/reserved", Value: "skip"}, + {Key: "app.kubernetes.io/name", Value: "skip"}, + {Key: "openshift.io/some-key", Value: "skip"}, + {Key: "k8s.io/some-key", Value: "skip"}, + {Key: "example.com/allowed", Value: "keep"}, + }, + expectedDeployAnnot: map[string]string{"example.com/allowed": "keep"}, + expectedPodAnnot: map[string]string{"example.com/allowed": "keep"}, + }, + { + name: "annotation with empty value", + annotations: []operatorv1alpha1.Annotation{ + {Key: "example.com/empty-value", Value: ""}, + }, + expectedDeployAnnot: map[string]string{"example.com/empty-value": ""}, + expectedPodAnnot: map[string]string{"example.com/empty-value": ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := newTestReconciler() + deployment := newTestDeployment("external-secrets") + esc := newTestESC() + esc.Spec.ControllerConfig.Annotations = tt.annotations + + r.applyAnnotationsToDeployment(deployment, esc) + + // Check deployment annotations + deployAnnot := deployment.GetAnnotations() + if tt.expectedDeployAnnot == nil { + if deployAnnot != nil && len(deployAnnot) > 0 { + t.Errorf("expected no deployment annotations, got %v", deployAnnot) + } + } else { + for k, v := range tt.expectedDeployAnnot { + if got, ok := deployAnnot[k]; !ok || got != v { + t.Errorf("expected deployment annotation %q=%q, got %q", k, v, got) + } + } + } + + // Check pod template annotations + podAnnot := deployment.Spec.Template.GetAnnotations() + if tt.expectedPodAnnot == nil { + if podAnnot != nil && len(podAnnot) > 0 { + t.Errorf("expected no pod annotations, got %v", podAnnot) + } + } else { + for k, v := range tt.expectedPodAnnot { + if got, ok := podAnnot[k]; !ok || got != v { + t.Errorf("expected pod annotation %q=%q, got %q", k, v, got) + } + } + } + }) + } +} + +func TestApplyComponentConfigToDeployment_RevisionHistoryLimit(t *testing.T) { + tests := []struct { + name string + componentConfigs []operatorv1alpha1.ComponentConfig + assetName string + expectedRevisionHistory *int32 + }{ + { + name: "no component configs", + componentConfigs: nil, + assetName: controllerDeploymentAssetName, + expectedRevisionHistory: nil, + }, + { + name: "matching component config sets revisionHistoryLimit", + componentConfigs: []operatorv1alpha1.ComponentConfig{ + { + ComponentName: operatorv1alpha1.CoreController, + DeploymentConfigs: operatorv1alpha1.DeploymentConfig{ + RevisionHistoryLimit: ptr.To(int32(5)), + }, + }, + }, + assetName: controllerDeploymentAssetName, + expectedRevisionHistory: ptr.To(int32(5)), + }, + { + name: "non-matching component config", + componentConfigs: []operatorv1alpha1.ComponentConfig{ + { + ComponentName: operatorv1alpha1.Webhook, + DeploymentConfigs: operatorv1alpha1.DeploymentConfig{ + RevisionHistoryLimit: ptr.To(int32(3)), + }, + }, + }, + assetName: controllerDeploymentAssetName, + expectedRevisionHistory: nil, + }, + { + name: "webhook component config", + componentConfigs: []operatorv1alpha1.ComponentConfig{ + { + ComponentName: operatorv1alpha1.Webhook, + DeploymentConfigs: operatorv1alpha1.DeploymentConfig{ + RevisionHistoryLimit: ptr.To(int32(10)), + }, + }, + }, + assetName: webhookDeploymentAssetName, + expectedRevisionHistory: ptr.To(int32(10)), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := newTestReconciler() + deployment := newTestDeployment("external-secrets") + esc := newTestESC() + esc.Spec.ControllerConfig.ComponentConfigs = tt.componentConfigs + + err := r.applyComponentConfigToDeployment(deployment, esc, tt.assetName) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.expectedRevisionHistory == nil { + if deployment.Spec.RevisionHistoryLimit != nil { + t.Errorf("expected nil revisionHistoryLimit, got %d", *deployment.Spec.RevisionHistoryLimit) + } + } else { + if deployment.Spec.RevisionHistoryLimit == nil { + t.Errorf("expected revisionHistoryLimit=%d, got nil", *tt.expectedRevisionHistory) + } else if *deployment.Spec.RevisionHistoryLimit != *tt.expectedRevisionHistory { + t.Errorf("expected revisionHistoryLimit=%d, got %d", *tt.expectedRevisionHistory, *deployment.Spec.RevisionHistoryLimit) + } + } + }) + } +} + +func TestApplyOverrideEnvToContainer(t *testing.T) { + tests := []struct { + name string + containerName string + overrideEnv []corev1.EnvVar + expectedEnv []corev1.EnvVar + expectError bool + }{ + { + name: "new env var is appended", + containerName: "external-secrets", + overrideEnv: []corev1.EnvVar{ + {Name: "NEW_VAR", Value: "new-value"}, + }, + expectedEnv: []corev1.EnvVar{ + {Name: "EXISTING_VAR", Value: "existing-value"}, + {Name: "NEW_VAR", Value: "new-value"}, + }, + }, + { + name: "existing env var is overridden", + containerName: "external-secrets", + overrideEnv: []corev1.EnvVar{ + {Name: "EXISTING_VAR", Value: "overridden-value"}, + }, + expectedEnv: []corev1.EnvVar{ + {Name: "EXISTING_VAR", Value: "overridden-value"}, + }, + }, + { + name: "container not found", + containerName: "nonexistent-container", + overrideEnv: []corev1.EnvVar{ + {Name: "NEW_VAR", Value: "new-value"}, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deployment := newTestDeployment("external-secrets") + + err := applyOverrideEnvToContainer(deployment, tt.containerName, tt.overrideEnv) + + if tt.expectError { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + container := deployment.Spec.Template.Spec.Containers[0] + if len(container.Env) != len(tt.expectedEnv) { + t.Errorf("expected %d env vars, got %d", len(tt.expectedEnv), len(container.Env)) + } + + for _, expected := range tt.expectedEnv { + found := false + for _, actual := range container.Env { + if actual.Name == expected.Name && actual.Value == expected.Value { + found = true + break + } + } + if !found { + t.Errorf("expected env var %s=%s not found", expected.Name, expected.Value) + } + } + }) + } +} + +func TestGetComponentNameForAsset(t *testing.T) { + tests := []struct { + assetName string + expectedComponent operatorv1alpha1.ComponentName + expectedFound bool + }{ + {controllerDeploymentAssetName, operatorv1alpha1.CoreController, true}, + {webhookDeploymentAssetName, operatorv1alpha1.Webhook, true}, + {certControllerDeploymentAssetName, operatorv1alpha1.CertController, true}, + {bitwardenDeploymentAssetName, operatorv1alpha1.BitwardenSDKServer, true}, + {"unknown-asset", "", false}, + } + + for _, tt := range tests { + t.Run(tt.assetName, func(t *testing.T) { + component, found := getComponentNameForAsset(tt.assetName) + if found != tt.expectedFound { + t.Errorf("expected found=%v, got %v", tt.expectedFound, found) + } + if component != tt.expectedComponent { + t.Errorf("expected component=%q, got %q", tt.expectedComponent, component) + } + }) + } +} diff --git a/pkg/controller/external_secrets/deployments.go b/pkg/controller/external_secrets/deployments.go index 47ab49d9c..4f6ca7677 100644 --- a/pkg/controller/external_secrets/deployments.go +++ b/pkg/controller/external_secrets/deployments.go @@ -148,6 +148,14 @@ func (r *Reconciler) getDeploymentObject(assetName string, esc *operatorv1alpha1 return nil, fmt.Errorf("failed to update proxy configuration: %w", err) } + // Apply custom annotations from controllerConfig.annotations + r.applyAnnotationsToDeployment(deployment, esc) + + // Apply component-specific overrides (revisionHistoryLimit, overrideEnv) + if err := r.applyComponentConfigToDeployment(deployment, esc, assetName); err != nil { + return nil, fmt.Errorf("failed to apply component config: %w", err) + } + return deployment, nil }