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 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 }