diff --git a/api/v1alpha1/external_secrets_config_types.go b/api/v1alpha1/external_secrets_config_types.go index f06a7df79..932717473 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,33 @@ 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" + // +kubebuilder:validation:MinItems:=0 + // +kubebuilder:validation:MaxItems:=50 + // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=key + Annotations []Annotation `json:"annotations,omitempty"` + + // componentConfigs allows specifying component-specific (Controller, Webhook, CertController, BitwardenSDKServer) + // configuration overrides. Each entry targets a specific operand component by its componentName. + // The componentName must be unique across all entries in this list. + // +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 + // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=componentName + ComponentConfigs []ComponentConfig `json:"componentConfigs,omitempty"` + // networkPolicies specifies the list of network policy configurations // to be applied to external-secrets pods. // @@ -212,17 +240,83 @@ 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 have network policies applied. 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 holds the configuration overrides for a specific external-secrets operand component. +// Each entry targets a component by its componentName and allows setting deployment-level overrides +// 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"` + + // deploymentConfig allows specifying deployment-level configuration overrides + // for the targeted component. + // +kubebuilder:validation:Optional + DeploymentConfig DeploymentConfig `json:"deploymentConfig,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 with names 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:MinItems:=0 + // +kubebuilder:validation:MaxItems:=50 + // +kubebuilder:validation:Optional + // +listType=atomic + OverrideEnv []corev1.EnvVar `json:"overrideEnv,omitempty"` +} + +// DeploymentConfig holds deployment-level configuration overrides for an 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 + RevisionHistoryLimit *int32 `json:"revisionHistoryLimit,omitempty"` +} + +// KVPair represents a generic key-value pair for configuration. +type KVPair struct { + // key is the key of the key-value pair. + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:MaxLength:=317 + // +kubebuilder:validation:Required + Key string `json:"key"` + + // value is the value of the key-value pair. + // +kubebuilder:validation:MaxLength:=1024 + // +kubebuilder:validation:Optional + Value string `json:"value,omitempty"` +} + +// Annotation represents a custom annotation key-value pair. +// Embeds KVPair inline for reusability of key and value fields. +type Annotation struct { + // Embedded KVPair provides key and value fields. + KVPair `json:",inline"` +} + // 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..3954b255c 100644 --- a/api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml +++ b/api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml @@ -596,4 +596,461 @@ tests: bitwardenSecretManagerProvider: mode: Enabled secretRef: - name: "bitwarden-certs" \ No newline at end of file + name: "bitwarden-certs" + - 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 annotation using reserved kubernetes.io prefix + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "kubernetes.io/my-annotation" + value: "my-value" + expectedError: "annotations with reserved prefixes" + - name: Should fail with annotation using reserved app.kubernetes.io prefix + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "app.kubernetes.io/managed-by" + value: "my-value" + expectedError: "annotations with reserved prefixes" + - name: Should fail with annotation using reserved openshift.io prefix + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "openshift.io/my-annotation" + value: "my-value" + expectedError: "annotations with reserved prefixes" + - name: Should fail with annotation using reserved k8s.io prefix + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "k8s.io/my-annotation" + value: "my-value" + expectedError: "annotations with reserved prefixes" + - name: Should fail with annotation key empty + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "" + value: "my-value" + expectedError: "spec.controllerConfig.annotations[0].key in body should be at least 1 chars long" + - 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 + deploymentConfig: + revisionHistoryLimit: 5 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + 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 + deploymentConfig: + revisionHistoryLimit: 3 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: Webhook + deploymentConfig: + 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 + deploymentConfig: + revisionHistoryLimit: 10 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: CertController + deploymentConfig: + revisionHistoryLimit: 10 + - 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 + deploymentConfig: + revisionHistoryLimit: 7 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: BitwardenSDKServer + deploymentConfig: + revisionHistoryLimit: 7 + - 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 + deploymentConfig: + revisionHistoryLimit: 10 + - componentName: Webhook + deploymentConfig: + revisionHistoryLimit: 3 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + - componentName: Webhook + deploymentConfig: + revisionHistoryLimit: 3 + - name: Should fail with invalid componentName + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: InvalidComponent + deploymentConfig: + revisionHistoryLimit: 5 + expectedError: "Unsupported value" + - name: Should fail with duplicate componentName in componentConfigs + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 5 + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + expectedError: "componentName must be unique across all componentConfig entries" + - name: Should fail with revisionHistoryLimit less than 1 + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 0 + expectedError: "spec.controllerConfig.componentConfigs[0].deploymentConfig.revisionHistoryLimit in body should be greater than or equal to 1" + - name: Should fail with more than 4 componentConfigs + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 5 + - componentName: Webhook + deploymentConfig: + revisionHistoryLimit: 3 + - componentName: CertController + deploymentConfig: + revisionHistoryLimit: 10 + - componentName: BitwardenSDKServer + deploymentConfig: + revisionHistoryLimit: 7 + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 1 + expectedError: "Too many" + - 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 overrideEnv using reserved HOSTNAME prefix + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: HOSTNAME + value: "custom-host" + expectedError: "environment variable names with reserved prefixes" + - name: Should fail with overrideEnv using reserved KUBERNETES_ prefix + 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" + - name: Should fail with overrideEnv using reserved EXTERNAL_SECRETS_ prefix + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: EXTERNAL_SECRETS_LOGLEVEL + value: "debug" + expectedError: "environment variable names with reserved prefixes" + - 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 + deploymentConfig: + revisionHistoryLimit: 10 + overrideEnv: + - name: GOMAXPROCS + value: "4" + - componentName: Webhook + deploymentConfig: + revisionHistoryLimit: 3 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/custom-annotation" + value: "my-value" + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + overrideEnv: + - name: GOMAXPROCS + value: "4" + - componentName: Webhook + deploymentConfig: + revisionHistoryLimit: 3 + - name: Should be able to update annotations in controller config + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation-one" + value: "value-one" + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation-one" + value: "updated-value" + - 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: "updated-value" + - key: "example.com/annotation-two" + value: "value-two" + - name: Should be able to update componentConfigs revisionHistoryLimit + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 5 + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + 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 + deploymentConfig: + revisionHistoryLimit: 5 + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 5 + overrideEnv: + - name: GOMAXPROCS + value: "8" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 5 + overrideEnv: + - name: GOMAXPROCS + value: "8" \ No newline at end of file diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 99f6ec14b..c62f1acf7 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -27,6 +27,22 @@ 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 + out.KVPair = in.KVPair +} + +// 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 +178,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.DeploymentConfig.DeepCopyInto(&out.DeploymentConfig) + 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 +253,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 +304,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 @@ -471,6 +542,21 @@ func (in *GlobalConfig) DeepCopy() *GlobalConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVPair) DeepCopyInto(out *KVPair) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVPair. +func (in *KVPair) DeepCopy() *KVPair { + if in == nil { + return nil + } + out := new(KVPair) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkPolicy) DeepCopyInto(out *NetworkPolicy) { *out = *in diff --git a/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml b/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml index ae6890cdc..b6613b3c1 100644 --- a/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml +++ b/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml @@ -1173,6 +1173,43 @@ 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. + Embeds KVPair inline for reusability of key and value fields. + properties: + key: + description: key is the key of the key-value pair. + maxLength: 317 + minLength: 1 + type: string + value: + description: value is the value of the key-value pair. + maxLength: 1024 + type: string + required: + - key + type: object + maxItems: 50 + minItems: 0 + 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 +1300,186 @@ spec: rule: 'has(self.injectAnnotations) && self.injectAnnotations != ''false'' ? self.mode != ''Disabled'' : true' type: object + componentConfigs: + description: |- + componentConfigs allows specifying component-specific (Controller, Webhook, CertController, BitwardenSDKServer) + configuration overrides. Each entry targets a specific operand component by its componentName. + The componentName must be unique across all entries in this list. + items: + description: |- + ComponentConfig holds the configuration overrides for a specific external-secrets operand component. + Each entry targets a component by its componentName and allows setting deployment-level overrides + and custom environment variables. + properties: + componentName: + description: componentName specifies which deployment component + this configuration applies to. + enum: + - ExternalSecretsCoreController + - Webhook + - CertController + - BitwardenSDKServer + type: string + deploymentConfig: + description: |- + deploymentConfig 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 with names 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 + maxItems: 50 + minItems: 0 + type: array + x-kubernetes-list-type: atomic + 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/output/e2e_external-secrets-operator/e2e-suggestions.md b/output/e2e_external-secrets-operator/e2e-suggestions.md new file mode 100644 index 000000000..c63aadc4e --- /dev/null +++ b/output/e2e_external-secrets-operator/e2e-suggestions.md @@ -0,0 +1,73 @@ +# E2E Test Suggestions: external-secrets-operator (EP-1898) + +## Detected Operator Structure +- **Framework**: controller-runtime (kubebuilder/operator-sdk) +- **Managed CRDs**: ExternalSecretsConfig, ExternalSecretsManager +- **E2E Pattern**: Ginkgo v2 with `Ordered` Describe blocks +- **Operator Namespace**: `external-secrets-operator` +- **Operand Namespace**: `external-secrets` +- **Install**: OLM-based (CSV in config/manifests) + +## Changes Detected in Diff + +| File | Category | Changes | +|------|----------|---------| +| `api/v1alpha1/external_secrets_config_types.go` | API Types | New types: ComponentConfig, DeploymentConfig, KVPair, Annotation. New fields: ControllerConfig.Annotations, ControllerConfig.ComponentConfigs. New enum values: Webhook, CertController | +| `pkg/controller/external_secrets/component_config.go` | Controller | New reconciliation logic: applyAnnotations, applyComponentConfig, applyOverrideEnv, mergeEnvVars | +| `pkg/controller/external_secrets/deployments.go` | Controller | Integration of component config into getDeploymentObject | +| `api/v1alpha1/zz_generated.deepcopy.go` | Generated | Auto-generated deepcopy functions for new types | +| `config/crd/bases/...externalsecretsconfigs.yaml` | CRD | Schema updates for new fields | +| `api/v1alpha1/tests/.../externalsecretsconfig.testsuite.yaml` | Integration Tests | New test cases for annotations, componentConfigs, overrideEnv | + +## Highly Recommended E2E Scenarios + +### 1. Annotations Applied to All Components (Priority: HIGH) +- **Why**: Core feature from EP-1898, affects all operand deployments +- **Test**: Set annotation, verify it appears on all Deployment and Pod template metadata +- **Risk if not tested**: Annotations may not propagate to all components + +### 2. RevisionHistoryLimit Applied Correctly (Priority: HIGH) +- **Why**: Directly modifies Deployment spec, affects rollback capability +- **Test**: Set revisionHistoryLimit per-component, verify on target Deployment +- **Risk if not tested**: Incorrect deployment spec may prevent rollbacks + +### 3. Override Env Vars Merged (Priority: HIGH) +- **Why**: Modifies container environment, could affect component behavior +- **Test**: Set custom env vars, verify they appear in container spec +- **Risk if not tested**: Env vars may not be injected or may override reserved vars + +### 4. Reserved Prefix Rejection - Annotations (Priority: MEDIUM) +- **Why**: CEL validation rule at API level, but also controller-level filtering +- **Test**: Attempt to set annotation with `kubernetes.io/` prefix +- **Risk if not tested**: Reserved annotations could interfere with platform + +### 5. Reserved Prefix Rejection - Env Vars (Priority: MEDIUM) +- **Why**: CEL validation at API level prevents reserved env var overrides +- **Test**: Attempt to set env var with `KUBERNETES_` prefix +- **Risk if not tested**: Critical env vars could be overridden + +### 6. Multiple Component Configs (Priority: MEDIUM) +- **Why**: Tests list semantics and per-component isolation +- **Test**: Configure Controller and Webhook with different values +- **Risk if not tested**: Config may bleed across components + +## Optional / Nice-to-Have Scenarios + +### 7. Configuration Removal Restores Defaults (Priority: LOW) +- **Why**: Verifies idempotent reconciliation after config removal +- **Test**: Add config, verify, remove, verify restoration + +### 8. Annotation Update Re-reconciliation (Priority: LOW) +- **Why**: Verifies spec-change detection triggers reconciliation +- **Test**: Modify annotation value, verify updated on Deployments + +### 9. Stress Test: All 4 Components Configured (Priority: LOW) +- **Why**: Tests maximum componentConfigs (4) with all component types +- **Test**: Configure all 4 components simultaneously + +## Gaps / Hard to Test Automatically + +1. **Bitwarden component**: Requires Bitwarden plugin enabled with TLS certs, making it hard to test in standard e2e environments +2. **CertController component**: Only present when cert-manager is NOT installed, so testing depends on cluster configuration +3. **Annotation merge precedence**: User annotations should override operator defaults, but testing requires knowing the exact default annotations +4. **Rolling update behavior**: Verifying that annotation/env changes trigger proper rolling updates requires watching ReplicaSets over time diff --git a/output/e2e_external-secrets-operator/e2e_component_config_test.go b/output/e2e_external-secrets-operator/e2e_component_config_test.go new file mode 100644 index 000000000..61fb74755 --- /dev/null +++ b/output/e2e_external-secrets-operator/e2e_component_config_test.go @@ -0,0 +1,367 @@ +//go:build e2e +// +build e2e + +/* +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 e2e + +import ( + "context" + "fmt" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + operatorv1alpha1 "github.com/openshift/external-secrets-operator/api/v1alpha1" + "github.com/openshift/external-secrets-operator/test/utils" +) + +var _ = Describe("Component Configuration Overrides (EP-1898)", Ordered, func() { + ctx := context.TODO() + var ( + clientset *kubernetes.Clientset + k8sClient client.Client + ) + + BeforeAll(func() { + var err error + clientset, err = kubernetes.NewForConfig(cfg) + Expect(err).NotTo(HaveOccurred()) + + k8sClient, err = client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + + // Ensure operator is running + By("Verifying operator pod is ready") + Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operatorNamespace, []string{ + operatorPodPrefix, + })).To(Succeed()) + + // Ensure operand pods are running + By("Verifying external-secrets operand pods are ready") + Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operandNamespace, []string{ + operandCoreControllerPodPrefix, + operandWebhookPodPrefix, + })).To(Succeed()) + }) + + AfterAll(func() { + By("Cleaning up: removing annotations and componentConfigs") + esc := &operatorv1alpha1.ExternalSecretsConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + + esc.Spec.ControllerConfig.Annotations = nil + esc.Spec.ControllerConfig.ComponentConfigs = nil + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + }) + + // Diff-suggested: New annotations field on ControllerConfig (EP-1898) + Context("Global Annotations", func() { + It("should apply custom annotations to all operand Deployments and Pod templates", func() { + By("Patching ExternalSecretsConfig with a custom annotation") + esc := &operatorv1alpha1.ExternalSecretsConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + + esc.Spec.ControllerConfig.Annotations = []operatorv1alpha1.Annotation{ + {KVPair: operatorv1alpha1.KVPair{Key: "example.com/e2e-annotation", Value: "e2e-test-value"}}, + } + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + + By("Waiting for annotation to appear on controller Deployment") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + + g.Expect(deploy.Annotations).To(HaveKeyWithValue("example.com/e2e-annotation", "e2e-test-value"), + "annotation should be present on Deployment metadata") + g.Expect(deploy.Spec.Template.Annotations).To(HaveKeyWithValue("example.com/e2e-annotation", "e2e-test-value"), + "annotation should be present on Pod template metadata") + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + + By("Verifying annotation is also on webhook Deployment") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets-webhook", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + + g.Expect(deploy.Annotations).To(HaveKeyWithValue("example.com/e2e-annotation", "e2e-test-value")) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) + + It("should update annotations when ExternalSecretsConfig is modified", func() { + By("Adding a second annotation") + esc := &operatorv1alpha1.ExternalSecretsConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + + esc.Spec.ControllerConfig.Annotations = []operatorv1alpha1.Annotation{ + {KVPair: operatorv1alpha1.KVPair{Key: "example.com/e2e-annotation", Value: "e2e-test-value"}}, + {KVPair: operatorv1alpha1.KVPair{Key: "example.com/e2e-second", Value: "second-value"}}, + } + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + + By("Verifying both annotations appear on the Deployment") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + + g.Expect(deploy.Annotations).To(HaveKeyWithValue("example.com/e2e-annotation", "e2e-test-value")) + g.Expect(deploy.Annotations).To(HaveKeyWithValue("example.com/e2e-second", "second-value")) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) + }) + + // Diff-suggested: New componentConfigs field with deploymentConfig (EP-1898) + Context("Component Config - RevisionHistoryLimit", func() { + It("should apply revisionHistoryLimit to the targeted component Deployment", func() { + By("Setting revisionHistoryLimit=5 for ExternalSecretsCoreController") + esc := &operatorv1alpha1.ExternalSecretsConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + + esc.Spec.ControllerConfig.ComponentConfigs = []operatorv1alpha1.ComponentConfig{ + { + ComponentName: operatorv1alpha1.CoreController, + DeploymentConfig: operatorv1alpha1.DeploymentConfig{ + RevisionHistoryLimit: ptr.To(int32(5)), + }, + }, + } + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + + By("Waiting for revisionHistoryLimit to be applied to the Deployment") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + + g.Expect(deploy.Spec.RevisionHistoryLimit).NotTo(BeNil(), + "revisionHistoryLimit should be set") + g.Expect(*deploy.Spec.RevisionHistoryLimit).To(Equal(int32(5)), + "revisionHistoryLimit should be 5") + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) + + It("should apply different revisionHistoryLimit values to different components", func() { + By("Setting different limits for Controller and Webhook") + esc := &operatorv1alpha1.ExternalSecretsConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + + esc.Spec.ControllerConfig.ComponentConfigs = []operatorv1alpha1.ComponentConfig{ + { + ComponentName: operatorv1alpha1.CoreController, + DeploymentConfig: operatorv1alpha1.DeploymentConfig{ + RevisionHistoryLimit: ptr.To(int32(10)), + }, + }, + { + ComponentName: operatorv1alpha1.Webhook, + DeploymentConfig: operatorv1alpha1.DeploymentConfig{ + RevisionHistoryLimit: ptr.To(int32(3)), + }, + }, + } + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + + By("Verifying controller Deployment") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + + g.Expect(deploy.Spec.RevisionHistoryLimit).NotTo(BeNil()) + g.Expect(*deploy.Spec.RevisionHistoryLimit).To(Equal(int32(10))) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + + By("Verifying webhook Deployment") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets-webhook", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + + g.Expect(deploy.Spec.RevisionHistoryLimit).NotTo(BeNil()) + g.Expect(*deploy.Spec.RevisionHistoryLimit).To(Equal(int32(3))) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) + }) + + // Diff-suggested: New overrideEnv field on ComponentConfig (EP-1898) + Context("Component Config - Override Environment Variables", func() { + It("should merge custom environment variables into the component container", func() { + By("Setting GOMAXPROCS=4 for ExternalSecretsCoreController") + esc := &operatorv1alpha1.ExternalSecretsConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + + esc.Spec.ControllerConfig.ComponentConfigs = []operatorv1alpha1.ComponentConfig{ + { + ComponentName: operatorv1alpha1.CoreController, + OverrideEnv: []corev1.EnvVar{ + {Name: "GOMAXPROCS", Value: "4"}, + }, + }, + } + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + + By("Verifying GOMAXPROCS is present in the container env vars") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + + found := false + for _, container := range deploy.Spec.Template.Spec.Containers { + if container.Name == "external-secrets" { + for _, env := range container.Env { + if env.Name == "GOMAXPROCS" { + g.Expect(env.Value).To(Equal("4")) + found = true + break + } + } + break + } + } + g.Expect(found).To(BeTrue(), "GOMAXPROCS env var should be present in container spec") + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) + }) + + // Diff-suggested: Combined annotations + componentConfigs workflow (EP-1898) + Context("Combined Configuration", func() { + It("should apply annotations and component configs together", func() { + By("Setting annotations and componentConfigs simultaneously") + esc := &operatorv1alpha1.ExternalSecretsConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + + esc.Spec.ControllerConfig.Annotations = []operatorv1alpha1.Annotation{ + {KVPair: operatorv1alpha1.KVPair{Key: "example.com/combined-test", Value: "combined-value"}}, + } + esc.Spec.ControllerConfig.ComponentConfigs = []operatorv1alpha1.ComponentConfig{ + { + ComponentName: operatorv1alpha1.CoreController, + DeploymentConfig: operatorv1alpha1.DeploymentConfig{ + RevisionHistoryLimit: ptr.To(int32(7)), + }, + OverrideEnv: []corev1.EnvVar{ + {Name: "CUSTOM_VAR", Value: "custom-value"}, + }, + }, + } + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + + By("Verifying all configurations are applied to the controller Deployment") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + + // Check annotation + g.Expect(deploy.Annotations).To(HaveKeyWithValue("example.com/combined-test", "combined-value")) + + // Check revisionHistoryLimit + g.Expect(deploy.Spec.RevisionHistoryLimit).NotTo(BeNil()) + g.Expect(*deploy.Spec.RevisionHistoryLimit).To(Equal(int32(7))) + + // Check env var + found := false + for _, container := range deploy.Spec.Template.Spec.Containers { + for _, env := range container.Env { + if env.Name == "CUSTOM_VAR" && env.Value == "custom-value" { + found = true + break + } + } + } + g.Expect(found).To(BeTrue(), "CUSTOM_VAR env var should be present") + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) + }) + + // Diff-suggested: Verify reconciliation after removing overrides (EP-1898) + Context("Configuration Removal", func() { + It("should restore defaults when componentConfigs are removed", func() { + By("First setting a componentConfig") + esc := &operatorv1alpha1.ExternalSecretsConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + + esc.Spec.ControllerConfig.ComponentConfigs = []operatorv1alpha1.ComponentConfig{ + { + ComponentName: operatorv1alpha1.CoreController, + DeploymentConfig: operatorv1alpha1.DeploymentConfig{ + RevisionHistoryLimit: ptr.To(int32(15)), + }, + }, + } + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + + By("Waiting for revisionHistoryLimit to be applied") + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "external-secrets", + Namespace: operandNamespace, + }, deploy)).To(Succeed()) + g.Expect(deploy.Spec.RevisionHistoryLimit).NotTo(BeNil()) + g.Expect(*deploy.Spec.RevisionHistoryLimit).To(Equal(int32(15))) + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + + By("Removing componentConfigs") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, esc)).To(Succeed()) + esc.Spec.ControllerConfig.ComponentConfigs = nil + esc.Spec.ControllerConfig.Annotations = nil + Expect(k8sClient.Update(ctx, esc)).To(Succeed()) + + By("Verifying ExternalSecretsConfig is reconciled successfully after removal") + Eventually(func(g Gomega) { + updatedEsc := &operatorv1alpha1.ExternalSecretsConfig{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "cluster"}, updatedEsc)).To(Succeed()) + + // Verify Ready condition is True + for _, cond := range updatedEsc.Status.Conditions { + if cond.Type == "Ready" { + g.Expect(string(cond.Status)).To(Equal("True"), + fmt.Sprintf("Ready condition should be True, got: %s, message: %s", cond.Status, cond.Message)) + } + } + }, 2*time.Minute, 5*time.Second).Should(Succeed()) + }) + }) +}) diff --git a/output/e2e_external-secrets-operator/execution-steps.md b/output/e2e_external-secrets-operator/execution-steps.md new file mode 100644 index 000000000..1cabff331 --- /dev/null +++ b/output/e2e_external-secrets-operator/execution-steps.md @@ -0,0 +1,204 @@ +# E2E Execution Steps: external-secrets-operator + +## Prerequisites + +```bash +which oc +oc version +oc whoami +oc get nodes +oc get clusterversion +``` + +## Step 1: Verify Operator Installation + +```bash +# Verify operator pod is running +oc get pods -n external-secrets-operator -l app.kubernetes.io/name=external-secrets-operator +oc wait --for=condition=Ready pod -n external-secrets-operator -l app.kubernetes.io/name=external-secrets-operator --timeout=120s + +# Verify ExternalSecretsConfig CRD exists +oc get crd externalsecretsconfigs.operator.openshift.io +``` + +## Step 2: Verify Operand is Running + +```bash +# Verify operand pods are running +oc get pods -n external-secrets +oc wait --for=condition=Ready pod -n external-secrets -l app=external-secrets --timeout=120s + +# Check ExternalSecretsConfig status +oc get externalsecretsconfigs cluster -o jsonpath='{.status.conditions}' | jq . +``` + +## Step 3: Test Global Annotations + +```bash +# Apply custom annotation +oc patch externalsecretsconfigs cluster --type=merge -p '{ + "spec": { + "controllerConfig": { + "annotations": [ + {"key": "example.com/custom-annotation", "value": "e2e-test-value"} + ] + } + } +}' + +# Wait for reconciliation +sleep 10 + +# Verify annotation on controller deployment metadata +oc get deployment -n external-secrets external-secrets -o jsonpath='{.metadata.annotations.example\.com/custom-annotation}' +# Expected: e2e-test-value + +# Verify annotation on pod template +oc get deployment -n external-secrets external-secrets -o jsonpath='{.spec.template.metadata.annotations.example\.com/custom-annotation}' +# Expected: e2e-test-value + +# Verify annotation on webhook deployment +oc get deployment -n external-secrets external-secrets-webhook -o jsonpath='{.metadata.annotations.example\.com/custom-annotation}' +# Expected: e2e-test-value +``` + +## Step 4: Test Reserved Annotation Prefix Rejection + +```bash +# Attempt to apply reserved annotation (should fail) +oc patch externalsecretsconfigs cluster --type=merge -p '{ + "spec": { + "controllerConfig": { + "annotations": [ + {"key": "kubernetes.io/reserved", "value": "should-fail"} + ] + } + } +}' 2>&1 | grep -q "annotations with reserved prefixes" +echo "Reserved prefix correctly rejected: $?" +``` + +## Step 5: Test Component Config - RevisionHistoryLimit + +```bash +# Apply revisionHistoryLimit for controller +oc patch externalsecretsconfigs cluster --type=merge -p '{ + "spec": { + "controllerConfig": { + "componentConfigs": [ + { + "componentName": "ExternalSecretsCoreController", + "deploymentConfig": { + "revisionHistoryLimit": 5 + } + } + ] + } + } +}' + +# Wait for reconciliation +sleep 10 + +# Verify revisionHistoryLimit +LIMIT=$(oc get deployment -n external-secrets external-secrets -o jsonpath='{.spec.revisionHistoryLimit}') +echo "RevisionHistoryLimit: $LIMIT" +# Expected: 5 +``` + +## Step 6: Test Override Env Vars + +```bash +# Apply custom env var +oc patch externalsecretsconfigs cluster --type=merge -p '{ + "spec": { + "controllerConfig": { + "componentConfigs": [ + { + "componentName": "ExternalSecretsCoreController", + "deploymentConfig": { + "revisionHistoryLimit": 5 + }, + "overrideEnv": [ + {"name": "GOMAXPROCS", "value": "4"} + ] + } + ] + } + } +}' + +# Wait for reconciliation +sleep 10 + +# Verify env var in container spec +oc get deployment -n external-secrets external-secrets -o jsonpath='{.spec.template.spec.containers[0].env}' | jq '.[] | select(.name == "GOMAXPROCS")' +# Expected: {"name": "GOMAXPROCS", "value": "4"} +``` + +## Step 7: Test Reserved Env Var Prefix Rejection + +```bash +# Attempt to apply reserved env var (should fail) +oc patch externalsecretsconfigs cluster --type=merge -p '{ + "spec": { + "controllerConfig": { + "componentConfigs": [ + { + "componentName": "ExternalSecretsCoreController", + "overrideEnv": [ + {"name": "KUBERNETES_SERVICE_HOST", "value": "should-fail"} + ] + } + ] + } + } +}' 2>&1 | grep -q "environment variable names with reserved prefixes" +echo "Reserved env var prefix correctly rejected: $?" +``` + +## Step 8: Test Multiple Component Configs + +```bash +# Apply configs for multiple components +oc patch externalsecretsconfigs cluster --type=merge -p '{ + "spec": { + "controllerConfig": { + "componentConfigs": [ + { + "componentName": "ExternalSecretsCoreController", + "deploymentConfig": {"revisionHistoryLimit": 10} + }, + { + "componentName": "Webhook", + "deploymentConfig": {"revisionHistoryLimit": 3} + } + ] + } + } +}' + +# Wait for reconciliation +sleep 10 + +# Verify both deployments +CTRL_LIMIT=$(oc get deployment -n external-secrets external-secrets -o jsonpath='{.spec.revisionHistoryLimit}') +WH_LIMIT=$(oc get deployment -n external-secrets external-secrets-webhook -o jsonpath='{.spec.revisionHistoryLimit}') +echo "Controller RevisionHistoryLimit: $CTRL_LIMIT (expected: 10)" +echo "Webhook RevisionHistoryLimit: $WH_LIMIT (expected: 3)" +``` + +## Step 9: Cleanup + +```bash +# Remove all component config and annotation overrides +oc patch externalsecretsconfigs cluster --type=json -p '[ + {"op": "remove", "path": "/spec/controllerConfig/annotations"}, + {"op": "remove", "path": "/spec/controllerConfig/componentConfigs"} +]' + +# Verify reconciliation restores defaults +sleep 10 +oc get deployment -n external-secrets external-secrets -o jsonpath='{.spec.revisionHistoryLimit}' +oc get deployment -n external-secrets external-secrets -o jsonpath='{.metadata.annotations}' | jq 'keys' +``` diff --git a/output/e2e_external-secrets-operator/test-cases.md b/output/e2e_external-secrets-operator/test-cases.md new file mode 100644 index 000000000..243abf2d7 --- /dev/null +++ b/output/e2e_external-secrets-operator/test-cases.md @@ -0,0 +1,129 @@ +# E2E Test Cases: external-secrets-operator + +## Operator Information +- **Repository**: github.com/openshift/external-secrets-operator +- **Framework**: controller-runtime +- **API Group**: operator.openshift.io/v1alpha1 +- **Managed CRDs**: ExternalSecretsConfig, ExternalSecretsManager +- **Operator Namespace**: external-secrets-operator +- **Operand Namespace**: external-secrets +- **Changes Analyzed**: git diff origin/ai-staging-release-1.0...HEAD (EP-1898: Component Configuration Overrides) + +## Prerequisites +- OpenShift cluster with admin access +- `oc` CLI installed and authenticated +- External Secrets Operator installed (OLM or manual) +- Operator pod running in `external-secrets-operator` namespace + +## Changes Summary +The diff introduces three new features to ExternalSecretsConfig: +1. **Global annotations** (`controllerConfig.annotations`) — applied to all component Deployments and Pod templates +2. **Component configs** (`controllerConfig.componentConfigs`) — per-component deployment overrides +3. **Override env vars** (`controllerConfig.componentConfigs[].overrideEnv`) — custom environment variables per component + +## Test Cases + +### TC-01: Global Annotations Applied to All Components +- **Test**: Verify that annotations specified in `controllerConfig.annotations` are applied to all operand Deployment and Pod template metadata +- **Steps**: + 1. Create/update ExternalSecretsConfig with `controllerConfig.annotations` + 2. Wait for reconciliation + 3. Check Deployment metadata annotations for each component + 4. Check Pod template metadata annotations for each component +- **Expected**: All operand Deployments and Pod templates contain the custom annotation + +### TC-02: Reserved Annotation Prefixes Rejected +- **Test**: Verify that annotations with reserved prefixes are rejected by API validation +- **Steps**: + 1. Attempt to create ExternalSecretsConfig with annotation key `kubernetes.io/custom` + 2. Attempt with `app.kubernetes.io/custom` + 3. Attempt with `openshift.io/custom` + 4. Attempt with `k8s.io/custom` +- **Expected**: API server rejects each attempt with validation error + +### TC-03: RevisionHistoryLimit Applied to Specific Component +- **Test**: Verify that `deploymentConfig.revisionHistoryLimit` is applied to the correct component deployment +- **Steps**: + 1. Create/update ExternalSecretsConfig with componentConfigs for ExternalSecretsCoreController with revisionHistoryLimit=5 + 2. Wait for reconciliation + 3. Check the controller Deployment spec.revisionHistoryLimit +- **Expected**: Deployment has revisionHistoryLimit set to 5 + +### TC-04: RevisionHistoryLimit Minimum Validation +- **Test**: Verify that revisionHistoryLimit below 1 is rejected +- **Steps**: + 1. Attempt to set revisionHistoryLimit=0 +- **Expected**: API server rejects with validation error + +### TC-05: Override Env Vars Applied to Component Container +- **Test**: Verify that custom environment variables are merged into the component container spec +- **Steps**: + 1. Create/update ExternalSecretsConfig with overrideEnv containing GOMAXPROCS=4 for Controller + 2. Wait for reconciliation + 3. Check the controller Deployment container env vars +- **Expected**: GOMAXPROCS=4 is present in the container env vars + +### TC-06: Reserved Env Var Prefixes Rejected +- **Test**: Verify that environment variables with reserved prefixes are rejected +- **Steps**: + 1. Attempt to set overrideEnv with name HOSTNAME + 2. Attempt with KUBERNETES_SERVICE_HOST + 3. Attempt with EXTERNAL_SECRETS_LOGLEVEL +- **Expected**: API server rejects each with validation error + +### TC-07: Duplicate ComponentName Rejected +- **Test**: Verify that duplicate componentName entries in componentConfigs are rejected +- **Steps**: + 1. Attempt to create componentConfigs with two entries both having componentName=ExternalSecretsCoreController +- **Expected**: API server rejects with uniqueness validation error + +### TC-08: Multiple Component Configs +- **Test**: Verify that multiple components can be configured simultaneously +- **Steps**: + 1. Create ExternalSecretsConfig with componentConfigs for Controller (revisionHistoryLimit=5) and Webhook (revisionHistoryLimit=3) + 2. Wait for reconciliation + 3. Check both Deployment specs +- **Expected**: Each Deployment has its respective revisionHistoryLimit + +### TC-09: Combined Annotations and Component Configs +- **Test**: Verify annotations and componentConfigs work together +- **Steps**: + 1. Create ExternalSecretsConfig with both annotations and componentConfigs + 2. Wait for reconciliation + 3. Verify annotations on all Deployments + 4. Verify revisionHistoryLimit on targeted Deployment + 5. Verify overrideEnv on targeted container +- **Expected**: All configurations applied correctly + +### TC-10: Update Annotations After Creation +- **Test**: Verify that updating annotations triggers re-reconciliation +- **Steps**: + 1. Create ExternalSecretsConfig with one annotation + 2. Wait for reconciliation + 3. Update to add a second annotation + 4. Wait for re-reconciliation + 5. Verify both annotations present +- **Expected**: Both annotations appear on Deployments after update + +### TC-11: Remove ComponentConfigs +- **Test**: Verify that removing componentConfigs restores defaults +- **Steps**: + 1. Create ExternalSecretsConfig with revisionHistoryLimit=10 + 2. Wait for reconciliation, verify it's set + 3. Remove the componentConfigs + 4. Wait for re-reconciliation + 5. Verify revisionHistoryLimit is back to default +- **Expected**: Default revisionHistoryLimit restored + +## Verification +```bash +oc get externalsecretsconfigs cluster -o yaml +oc get deployments -n external-secrets -o yaml +oc get pods -n external-secrets +oc logs -n external-secrets-operator -l app.kubernetes.io/name=external-secrets-operator --tail=50 +``` + +## Cleanup +```bash +oc patch externalsecretsconfigs cluster --type=merge -p '{"spec":{"controllerConfig":{"annotations":null,"componentConfigs":null}}}' +``` diff --git a/pkg/controller/external_secrets/component_config.go b/pkg/controller/external_secrets/component_config.go new file mode 100644 index 000000000..687ea19be --- /dev/null +++ b/pkg/controller/external_secrets/component_config.go @@ -0,0 +1,164 @@ +/* +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 ( + // reservedEnvVarPrefixes defines environment variable name prefixes that cannot be overridden + // by users through the overrideEnv configuration. + reservedEnvVarPrefixes = []string{"HOSTNAME", "KUBERNETES_", "EXTERNAL_SECRETS_"} + + // disallowedAnnotationMatcher is for restricting the annotations defined to apply on all resources + // created for `external-secrets` operand deployment. + disallowedAnnotationMatcher = regexp.MustCompile(`^kubernetes\.io/|^app\.kubernetes\.io/|^openshift\.io/|^k8s\.io/`) +) + +// deploymentAssetToComponentName maps asset names to their corresponding ComponentName values. +var deploymentAssetToComponentName = map[string]operatorv1alpha1.ComponentName{ + controllerDeploymentAssetName: operatorv1alpha1.CoreController, + webhookDeploymentAssetName: operatorv1alpha1.Webhook, + certControllerDeploymentAssetName: operatorv1alpha1.CertController, + bitwardenDeploymentAssetName: operatorv1alpha1.BitwardenSDKServer, +} + +// applyAnnotations applies custom annotations from controllerConfig.annotations to the +// Deployment metadata and Pod template metadata. Annotations with reserved prefixes are +// skipped with a log warning. +func (r *Reconciler) applyAnnotations(deployment *appsv1.Deployment, esc *operatorv1alpha1.ExternalSecretsConfig) { + if len(esc.Spec.ControllerConfig.Annotations) == 0 { + return + } + + deployAnnotations := deployment.GetAnnotations() + if deployAnnotations == nil { + deployAnnotations = make(map[string]string) + } + + podAnnotations := deployment.Spec.Template.GetAnnotations() + if podAnnotations == nil { + podAnnotations = make(map[string]string) + } + + 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, "value", annotation.Value) + continue + } + deployAnnotations[annotation.Key] = annotation.Value + podAnnotations[annotation.Key] = annotation.Value + } + + deployment.SetAnnotations(deployAnnotations) + deployment.Spec.Template.SetAnnotations(podAnnotations) +} + +// applyComponentConfig applies per-component configuration overrides from +// controllerConfig.componentConfigs to the given deployment. It looks up the +// component config matching the deployment's asset name and applies: +// - revisionHistoryLimit from deploymentConfig +// - overrideEnv environment variables merged into the container spec +func (r *Reconciler) applyComponentConfig(deployment *appsv1.Deployment, esc *operatorv1alpha1.ExternalSecretsConfig, assetName string) error { + componentName, ok := deploymentAssetToComponentName[assetName] + if !ok { + return nil + } + + config := getComponentConfigForComponent(esc, componentName) + if config == nil { + return nil + } + + // Apply revisionHistoryLimit + if config.DeploymentConfig.RevisionHistoryLimit != nil { + deployment.Spec.RevisionHistoryLimit = config.DeploymentConfig.RevisionHistoryLimit + } + + // Apply overrideEnv + if len(config.OverrideEnv) > 0 { + if err := r.applyOverrideEnv(deployment, config.OverrideEnv); err != nil { + return fmt.Errorf("failed to apply override env for component %s: %w", componentName, err) + } + } + + return nil +} + +// getComponentConfigForComponent returns the ComponentConfig for the given component name, +// or nil if no configuration is set. +func getComponentConfigForComponent(esc *operatorv1alpha1.ExternalSecretsConfig, componentName operatorv1alpha1.ComponentName) *operatorv1alpha1.ComponentConfig { + for i := range esc.Spec.ControllerConfig.ComponentConfigs { + if esc.Spec.ControllerConfig.ComponentConfigs[i].ComponentName == componentName { + return &esc.Spec.ControllerConfig.ComponentConfigs[i] + } + } + return nil +} + +// applyOverrideEnv merges user-specified environment variables into all containers +// in the deployment. User-specified variables take precedence over existing defaults. +// Environment variables with reserved prefixes are skipped with a log warning. +func (r *Reconciler) applyOverrideEnv(deployment *appsv1.Deployment, overrideEnv []corev1.EnvVar) error { + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + mergeEnvVars(container, overrideEnv, r) + } + return nil +} + +// mergeEnvVars merges override environment variables into a container's existing env vars. +// If an env var with the same name already exists, it is overridden with the user-provided value. +// Env vars with reserved prefixes are skipped. +func mergeEnvVars(container *corev1.Container, overrideEnv []corev1.EnvVar, r *Reconciler) { + for _, envVar := range overrideEnv { + if isReservedEnvVar(envVar.Name) { + r.log.V(1).Info("skip overriding environment variable with reserved prefix", + "name", envVar.Name, "container", container.Name) + continue + } + + found := false + for j := range container.Env { + if container.Env[j].Name == envVar.Name { + container.Env[j] = envVar + found = true + break + } + } + if !found { + container.Env = append(container.Env, envVar) + } + } +} + +// isReservedEnvVar checks if the given environment variable name starts with a reserved prefix. +func isReservedEnvVar(name string) bool { + for _, prefix := range reservedEnvVarPrefixes { + if len(name) >= len(prefix) && name[:len(prefix)] == prefix { + return true + } + } + return false +} diff --git a/pkg/controller/external_secrets/deployments.go b/pkg/controller/external_secrets/deployments.go index 47ab49d9c..d8f6a7dc7 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 global custom annotations to Deployment and Pod template + r.applyAnnotations(deployment, esc) + + // Apply per-component configuration overrides (revisionHistoryLimit, overrideEnv) + if err := r.applyComponentConfig(deployment, esc, assetName); err != nil { + return nil, fmt.Errorf("failed to apply component config: %w", err) + } + return deployment, nil }