diff --git a/api/v1alpha1/external_secrets_config_types.go b/api/v1alpha1/external_secrets_config_types.go index f06a7df79..2ffcd7203 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,35 @@ 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:MaxItems:=50 + // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=key + // +optional + Annotations []Annotation `json:"annotations,omitempty"` + + // componentConfigs allows specifying component-specific configuration overrides for + // individual external-secrets components (Controller, Webhook, CertController, BitwardenSDKServer). + // Each entry configures deployment-level overrides and custom environment variables for a single component. + // The componentName must be unique across all entries. + // + // +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:MaxItems:=4 + // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=componentName + // +optional + ComponentConfigs []ComponentConfig `json:"componentConfigs,omitempty"` + // networkPolicies specifies the list of network policy configurations // to be applied to external-secrets pods. // @@ -212,14 +242,61 @@ type CertProvidersConfig struct { CertManager *CertManagerConfig `json:"certManager,omitempty"` } -// ComponentName represents the different external-secrets components that can have network policies applied. +// ComponentConfig allows specifying configuration overrides for a single external-secrets component. +// This includes deployment-level configuration such as revisionHistoryLimit and custom environment variables. +type ComponentConfig struct { + // componentName specifies which deployment component this configuration applies to. + // Each component can only appear once in the componentConfigs list. + // +kubebuilder:validation:Enum:=ExternalSecretsCoreController;Webhook;CertController;BitwardenSDKServer + // +kubebuilder:validation:Required + ComponentName ComponentName `json:"componentName"` + + // deploymentConfig allows specifying deployment-level configuration overrides + // for the specified component. + // +kubebuilder:validation:Optional + // +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:MaxItems:=50 + // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=name + // +optional + OverrideEnv []corev1.EnvVar `json:"overrideEnv,omitempty"` +} + +// DeploymentConfig allows specifying deployment-level configuration overrides. +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"` +} + +// ComponentName represents the different external-secrets components that can be configured. 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" ) @@ -234,7 +311,7 @@ type NetworkPolicy struct { Name string `json:"name"` // componentName specifies which external-secrets component this network policy applies to. - // +kubebuilder:validation:Enum:=ExternalSecretsCoreController;BitwardenSDKServer + // +kubebuilder:validation:Enum:=ExternalSecretsCoreController;Webhook;CertController;BitwardenSDKServer // +kubebuilder:validation:Required ComponentName ComponentName `json:"componentName"` diff --git a/api/v1alpha1/meta.go b/api/v1alpha1/meta.go index 68a273d97..c66f6987e 100644 --- a/api/v1alpha1/meta.go +++ b/api/v1alpha1/meta.go @@ -112,6 +112,29 @@ type ProxyConfig struct { NoProxy string `json:"noProxy,omitempty"` } +// KVPair represents a generic key-value pair for configuration. +type KVPair struct { + // key is the name of the key-value pair. + // This field must be non-empty and can have a maximum of 317 characters. + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:MaxLength:=317 + // +kubebuilder:validation:Required + Key string `json:"key"` + + // value is the value of the key-value pair. + // This field can have a maximum of 4096 characters. + // +kubebuilder:validation:MaxLength:=4096 + // +kubebuilder:validation:Optional + Value string `json:"value,omitempty"` +} + +// Annotation represents a custom annotation key-value pair. +// It embeds KVPair inline for reusability. +type Annotation struct { + // Embedded KVPair provides key and value fields. + KVPair `json:",inline"` +} + // Mode indicates the operational state of the optional features. type Mode string 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..7583f3112 100644 --- a/api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml +++ b/api/v1alpha1/tests/externalsecretsconfig.operator.openshift.io/externalsecretsconfig.testsuite.yaml @@ -493,6 +493,500 @@ tests: webhookConfig: certificateCheckInterval: "15m" operatingNamespace: "test-ns" + - name: Should be able to create ExternalSecretsConfig with custom 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 to create with annotation using reserved prefix kubernetes.io/ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "kubernetes.io/some-annotation" + value: "forbidden" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should fail to create with annotation using reserved prefix app.kubernetes.io/ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "app.kubernetes.io/name" + value: "forbidden" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should fail to create with annotation using reserved prefix openshift.io/ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "openshift.io/some-annotation" + value: "forbidden" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should fail to create with annotation using reserved prefix k8s.io/ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "k8s.io/some-annotation" + value: "forbidden" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should fail to create with annotation missing required key + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - value: "value-without-key" + expectedError: "spec.controllerConfig.annotations[0].key: Required value" + - 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: 3 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: CertController + deploymentConfig: + 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 + deploymentConfig: + revisionHistoryLimit: 2 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: BitwardenSDKServer + deploymentConfig: + 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 + 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 be able to create ExternalSecretsConfig with all four componentConfigs + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + - componentName: Webhook + deploymentConfig: + revisionHistoryLimit: 3 + - componentName: CertController + deploymentConfig: + revisionHistoryLimit: 5 + - componentName: BitwardenSDKServer + deploymentConfig: + revisionHistoryLimit: 2 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + - componentName: Webhook + deploymentConfig: + revisionHistoryLimit: 3 + - componentName: CertController + deploymentConfig: + revisionHistoryLimit: 5 + - componentName: BitwardenSDKServer + deploymentConfig: + revisionHistoryLimit: 2 + - name: Should fail to create with invalid componentName + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: InvalidComponent + deploymentConfig: + revisionHistoryLimit: 5 + expectedError: "spec.controllerConfig.componentConfigs[0].componentName: Unsupported value: \"InvalidComponent\"" + - name: Should fail to create 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: "spec.controllerConfig.componentConfigs[1].componentName: Duplicate value: \"ExternalSecretsCoreController\"" + - name: Should fail to create 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: Invalid value: 0: spec.controllerConfig.componentConfigs[0].deploymentConfig.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 be able to create ExternalSecretsConfig with multiple overrideEnv entries + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: Webhook + overrideEnv: + - name: GOMAXPROCS + value: "2" + - name: LOG_FORMAT + value: "json" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: Webhook + overrideEnv: + - name: GOMAXPROCS + value: "2" + - name: LOG_FORMAT + value: "json" + - name: Should fail to create with overrideEnv using reserved prefix HOSTNAME + 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 'HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_' are not allowed" + - name: Should fail to create with overrideEnv using reserved 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 to create with overrideEnv using reserved prefix EXTERNAL_SECRETS_ + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: EXTERNAL_SECRETS_CUSTOM_VAR + value: "not-allowed" + expectedError: "environment variable names with reserved prefixes 'HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_' are not allowed" + - name: Should be able to create ExternalSecretsConfig with componentConfigs and overrideEnv together + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + overrideEnv: + - name: GOMAXPROCS + value: "4" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + overrideEnv: + - name: GOMAXPROCS + value: "4" + - name: Should be able to create ExternalSecretsConfig with annotations and componentConfigs combined + 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 create componentConfig without deploymentConfig (only overrideEnv) + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: CertController + overrideEnv: + - name: CUSTOM_VAR + value: "custom-value" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: CertController + overrideEnv: + - name: CUSTOM_VAR + value: "custom-value" + - name: Should be able to create componentConfig without overrideEnv (only deploymentConfig) + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 7 + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 7 + - name: Should fail to create 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: 2 + - componentName: BitwardenSDKServer + deploymentConfig: + revisionHistoryLimit: 1 + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 10 + expectedError: "spec.controllerConfig.componentConfigs: Too many: 5: must have at most 4 items" onUpdate: - name: Should be able to update labels in controller config resourceName: cluster @@ -596,4 +1090,186 @@ tests: bitwardenSecretManagerProvider: mode: Enabled secretRef: - name: "bitwarden-certs" \ No newline at end of file + name: "bitwarden-certs" + - name: Should be able to add annotations after creation + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: {} + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/new-annotation" + value: "new-value" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/new-annotation" + value: "new-value" + - name: Should be able to update annotations + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation" + value: "old-value" + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation" + value: "new-value" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation" + value: "new-value" + - name: Should be able to add componentConfigs after creation + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: {} + updated: | + 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 update 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 after creation + 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: "4" + expected: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + deploymentConfig: + revisionHistoryLimit: 5 + overrideEnv: + - name: GOMAXPROCS + value: "4" + - name: Should fail to update with annotation using reserved prefix + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation" + value: "valid" + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + annotations: + - key: "example.com/annotation" + value: "valid" + - key: "kubernetes.io/forbidden" + value: "not-allowed" + expectedError: "annotations with reserved prefixes 'kubernetes.io/', 'app.kubernetes.io/', 'openshift.io/', 'k8s.io/' are not allowed" + - name: Should fail to update with overrideEnv using reserved prefix + resourceName: cluster + initial: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: GOMAXPROCS + value: "4" + updated: | + apiVersion: operator.openshift.io/v1alpha1 + kind: ExternalSecretsConfig + spec: + controllerConfig: + componentConfigs: + - componentName: ExternalSecretsCoreController + overrideEnv: + - name: GOMAXPROCS + value: "4" + - name: HOSTNAME + value: "custom" + expectedError: "environment variable names with reserved prefixes 'HOSTNAME', 'KUBERNETES_', 'EXTERNAL_SECRETS_' are not allowed" \ 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..88581a81e 100644 --- a/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml +++ b/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml @@ -1173,6 +1173,46 @@ 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. + It embeds KVPair inline for reusability. + properties: + key: + description: |- + key is the name of the key-value pair. + This field must be non-empty and can have a maximum of 317 characters. + maxLength: 317 + minLength: 1 + type: string + value: + description: |- + value is the value of the key-value pair. + This field can have a maximum of 4096 characters. + maxLength: 4096 + type: string + required: + - key + type: object + maxItems: 50 + 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 +1303,187 @@ spec: rule: 'has(self.injectAnnotations) && self.injectAnnotations != ''false'' ? self.mode != ''Disabled'' : true' type: object + componentConfigs: + description: |- + componentConfigs allows specifying component-specific configuration overrides for + individual external-secrets components (Controller, Webhook, CertController, BitwardenSDKServer). + Each entry configures deployment-level overrides and custom environment variables for a single component. + The componentName must be unique across all entries. + items: + description: |- + ComponentConfig allows specifying configuration overrides for a single external-secrets component. + This includes deployment-level configuration such as revisionHistoryLimit and custom environment variables. + properties: + componentName: + description: |- + componentName specifies which deployment component this configuration applies to. + Each component can only appear once in the componentConfigs list. + enum: + - ExternalSecretsCoreController + - Webhook + - CertController + - BitwardenSDKServer + type: string + deploymentConfig: + description: |- + deploymentConfig allows specifying deployment-level configuration overrides + for the specified 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 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + 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 + 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 @@ -1293,6 +1514,8 @@ spec: component this network policy applies to. enum: - ExternalSecretsCoreController + - Webhook + - CertController - BitwardenSDKServer type: string egress: diff --git a/pkg/controller/external_secrets/deployments.go b/pkg/controller/external_secrets/deployments.go index 47ab49d9c..a9996aaf9 100644 --- a/pkg/controller/external_secrets/deployments.go +++ b/pkg/controller/external_secrets/deployments.go @@ -148,6 +148,15 @@ func (r *Reconciler) getDeploymentObject(assetName string, esc *operatorv1alpha1 return nil, fmt.Errorf("failed to update proxy configuration: %w", err) } + // Apply global annotations from ControllerConfig to Deployment and Pod template. + applyAnnotationsToDeployment(deployment, esc) + + // Apply per-component configuration overrides (revisionHistoryLimit, overrideEnv). + componentName := getComponentNameForDeploymentAsset(assetName) + if componentName != "" { + applyComponentConfig(deployment, esc, componentName) + } + return deployment, nil } @@ -660,3 +669,79 @@ func (r *Reconciler) removeTrustedCAVolumeMount(container *corev1.Container) { } container.VolumeMounts = filteredVolumeMounts } + +// applyAnnotationsToDeployment merges annotations from ControllerConfig.Annotations into +// the Deployment's metadata and Pod template's metadata. User-specified annotations take +// precedence over defaults. Annotations with reserved prefixes are already blocked by +// CRD-level CEL validation, so they are not checked here. +func applyAnnotationsToDeployment(deployment *appsv1.Deployment, esc *operatorv1alpha1.ExternalSecretsConfig) { + if len(esc.Spec.ControllerConfig.Annotations) == 0 { + return + } + + // Apply to Deployment metadata annotations + deploymentAnnotations := deployment.GetAnnotations() + if deploymentAnnotations == nil { + deploymentAnnotations = make(map[string]string, len(esc.Spec.ControllerConfig.Annotations)) + } + for _, annotation := range esc.Spec.ControllerConfig.Annotations { + deploymentAnnotations[annotation.Key] = annotation.Value + } + deployment.SetAnnotations(deploymentAnnotations) + + // Apply to Pod template annotations + podAnnotations := deployment.Spec.Template.GetAnnotations() + if podAnnotations == nil { + podAnnotations = make(map[string]string, len(esc.Spec.ControllerConfig.Annotations)) + } + for _, annotation := range esc.Spec.ControllerConfig.Annotations { + podAnnotations[annotation.Key] = annotation.Value + } + deployment.Spec.Template.SetAnnotations(podAnnotations) +} + +// applyComponentConfig applies per-component configuration overrides from +// ComponentConfig to the corresponding Deployment. This includes: +// - revisionHistoryLimit: sets spec.revisionHistoryLimit on the Deployment +// - overrideEnv: merges custom environment variables into all containers +func applyComponentConfig(deployment *appsv1.Deployment, esc *operatorv1alpha1.ExternalSecretsConfig, componentName operatorv1alpha1.ComponentName) { + cc := getComponentConfig(esc, componentName) + if cc == nil { + return + } + + // Apply revisionHistoryLimit if specified + if cc.DeploymentConfig.RevisionHistoryLimit != nil { + deployment.Spec.RevisionHistoryLimit = cc.DeploymentConfig.RevisionHistoryLimit + } + + // Merge overrideEnv into all containers in the Pod template + if len(cc.OverrideEnv) > 0 { + for i := range deployment.Spec.Template.Spec.Containers { + mergeEnvVars(&deployment.Spec.Template.Spec.Containers[i], cc.OverrideEnv) + } + } +} + +// mergeEnvVars merges override environment variables into a container's existing env list. +// User-specified variables take precedence over defaults in case of conflicts. +// Reserved prefixes are already blocked by CRD-level CEL validation. +func mergeEnvVars(container *corev1.Container, overrideEnv []corev1.EnvVar) { + if container.Env == nil { + container.Env = make([]corev1.EnvVar, 0, len(overrideEnv)) + } + + for _, override := range overrideEnv { + found := false + for i, existing := range container.Env { + if existing.Name == override.Name { + container.Env[i] = override + found = true + break + } + } + if !found { + container.Env = append(container.Env, override) + } + } +} diff --git a/pkg/controller/external_secrets/utils.go b/pkg/controller/external_secrets/utils.go index cb96719f0..cb1e58571 100644 --- a/pkg/controller/external_secrets/utils.go +++ b/pkg/controller/external_secrets/utils.go @@ -124,6 +124,50 @@ func (r *Reconciler) IsCertManagerInstalled() bool { return ok } +// deploymentAssetNameForComponent maps a ComponentName enum to its corresponding deployment asset name. +// Returns empty string if no deployment asset is associated with the given component name. +func deploymentAssetNameForComponent(name operatorv1alpha1.ComponentName) string { + switch name { + case operatorv1alpha1.CoreController: + return controllerDeploymentAssetName + case operatorv1alpha1.Webhook: + return webhookDeploymentAssetName + case operatorv1alpha1.CertController: + return certControllerDeploymentAssetName + case operatorv1alpha1.BitwardenSDKServer: + return bitwardenDeploymentAssetName + default: + return "" + } +} + +// getComponentConfig returns the ComponentConfig for the given component name from the ControllerConfig. +// Returns nil if no ComponentConfig is found for the given component. +func getComponentConfig(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 +} + +// getComponentNameForDeploymentAsset maps a deployment asset name back to a ComponentName. +func getComponentNameForDeploymentAsset(assetName string) operatorv1alpha1.ComponentName { + switch assetName { + case controllerDeploymentAssetName: + return operatorv1alpha1.CoreController + case webhookDeploymentAssetName: + return operatorv1alpha1.Webhook + case certControllerDeploymentAssetName: + return operatorv1alpha1.CertController + case bitwardenDeploymentAssetName: + return operatorv1alpha1.BitwardenSDKServer + default: + return "" + } +} + // getProxyConfiguration returns the proxy configuration based on precedence. // The precedence order is: ExternalSecretsConfig > ExternalSecretsManager > OLM environment variables. func (r *Reconciler) getProxyConfiguration(esc *operatorv1alpha1.ExternalSecretsConfig) *operatorv1alpha1.ProxyConfig {