diff --git a/api/v1alpha1/external_secrets_config_types.go b/api/v1alpha1/external_secrets_config_types.go index ccac0512..5f45b534 100644 --- a/api/v1alpha1/external_secrets_config_types.go +++ b/api/v1alpha1/external_secrets_config_types.go @@ -191,6 +191,13 @@ type ComponentConfig struct { // +listMapKey=name // +optional OverrideEnv []corev1.EnvVar `json:"overrideEnv,omitempty"` + + // extraArgs specifies additional command-line arguments for this component's container. + // These are appended (de-duped) to the operator's default args for the component. + // +kubebuilder:validation:MaxItems:=50 + // +listType=atomic + // +optional + ExtraArgs []string `json:"extraArgs,omitempty"` } // DeploymentConfig defines configuration overrides for a Kubernetes Deployment resource. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3743fc25..36b71e60 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -177,6 +177,11 @@ func (in *ComponentConfig) DeepCopyInto(out *ComponentConfig) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ExtraArgs != nil { + in, out := &in.ExtraArgs, &out.ExtraArgs + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComponentConfig. diff --git a/bundle/manifests/openshift-external-secrets-operator.clusterserviceversion.yaml b/bundle/manifests/openshift-external-secrets-operator.clusterserviceversion.yaml index fd449d41..0a55e72f 100644 --- a/bundle/manifests/openshift-external-secrets-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openshift-external-secrets-operator.clusterserviceversion.yaml @@ -220,7 +220,7 @@ metadata: categories: Security console.openshift.io/disable-operand-delete: "true" containerImage: openshift.io/external-secrets-operator:latest - createdAt: "2026-06-19T12:17:03Z" + createdAt: "2026-06-20T09:38:01Z" features.operators.openshift.io/cnf: "false" features.operators.openshift.io/cni: "false" features.operators.openshift.io/csi: "false" diff --git a/bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml b/bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml index 699f85bf..ccaca704 100644 --- a/bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml +++ b/bundle/manifests/operator.openshift.io_externalsecretsconfigs.yaml @@ -1330,6 +1330,15 @@ spec: minimum: 1 type: integer type: object + extraArgs: + description: |- + extraArgs specifies additional command-line arguments for this component's container. + These are appended (de-duped) to the operator's default args for the component. + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic overrideEnv: description: |- overrideEnv specifies custom environment variables for this component's container. These are merged with operator-managed environment variables, with user-defined values taking precedence. diff --git a/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml b/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml index 4dcfb213..3d81298e 100644 --- a/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml +++ b/config/crd/bases/operator.openshift.io_externalsecretsconfigs.yaml @@ -1330,6 +1330,15 @@ spec: minimum: 1 type: integer type: object + extraArgs: + description: |- + extraArgs specifies additional command-line arguments for this component's container. + These are appended (de-duped) to the operator's default args for the component. + items: + type: string + maxItems: 50 + type: array + x-kubernetes-list-type: atomic overrideEnv: description: |- overrideEnv specifies custom environment variables for this component's container. These are merged with operator-managed environment variables, with user-defined values taking precedence. diff --git a/docs/api_reference.md b/docs/api_reference.md index a9851a1b..cefb553a 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -130,6 +130,7 @@ _Appears in:_ | `componentName` _[ComponentName](#componentname)_ | componentName identifies which external-secrets component this configuration applies to.
Valid component names: ExternalSecretsCoreController, Webhook, CertController, BitwardenSDKServer. | | Enum: [ExternalSecretsCoreController Webhook CertController BitwardenSDKServer]
| | `deploymentConfigs` _[DeploymentConfig](#deploymentconfig)_ | deploymentConfigs specifies overrides for the Kubernetes Deployment resource of this component. | | | | `overrideEnv` _[EnvVar](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#envvar-v1-core) array_ | overrideEnv specifies custom environment variables for this component's container. These are merged with operator-managed environment variables, with user-defined values taking precedence.
Names starting with 'KUBERNETES_' or 'EXTERNAL_SECRETS_' are reserved prefixes and will be rejected.
The exact names 'HOSTNAME', 'SSL_CERT_DIR', and 'SSL_CERT_FILE' are also reserved. | | MaxItems: 50
| +| `extraArgs` _string array_ | extraArgs specifies additional command-line arguments for this component's container.
These are appended (de-duped) to the operator's default args for the component. | | MaxItems: 50
| #### ComponentName diff --git a/pkg/controller/external_secrets/deployments.go b/pkg/controller/external_secrets/deployments.go index 3f2ed58e..f30751e1 100644 --- a/pkg/controller/external_secrets/deployments.go +++ b/pkg/controller/external_secrets/deployments.go @@ -5,6 +5,7 @@ import ( "maps" "os" "slices" + "strings" "time" "unsafe" @@ -815,6 +816,16 @@ func (r *Reconciler) applyUserDeploymentConfigs(deployment *appsv1.Deployment, e } } } + + // Apply ExtraArgs only to the target component container. + if len(i.ExtraArgs) > 0 { + for j := range deployment.Spec.Template.Spec.Containers { + if deployment.Spec.Template.Spec.Containers[j].Name == containerName { + mergeArgs(&deployment.Spec.Template.Spec.Containers[j], i.ExtraArgs) + break + } + } + } break } } @@ -844,6 +855,29 @@ func mergeUserEnvVars(container *corev1.Container, overrideEnv []corev1.EnvVar) } } +// mergeArgs merges user-defined extra arguments into a container, user-defined values take precedence over existing values. +func mergeArgs(container *corev1.Container, extraArgs []string) { + if container.Args == nil { + container.Args = []string{} + } + + for _, extra := range extraArgs { + extraKey, _, _ := strings.Cut(extra, "=") + found := false + for i, existing := range container.Args { + existingKey, _, _ := strings.Cut(existing, "=") + if existingKey == extraKey { + container.Args[i] = extra // User-defined value takes precedence + found = true + break + } + } + if !found { + container.Args = append(container.Args, extra) + } + } +} + // getComponentNameFromAsset maps asset file names to ComponentName enum values and container names. func getComponentNameFromAsset(assetName string) (operatorv1alpha1.ComponentName, string, error) { switch assetName { diff --git a/pkg/controller/external_secrets/deployments_test.go b/pkg/controller/external_secrets/deployments_test.go index 8c21569b..e52511e0 100644 --- a/pkg/controller/external_secrets/deployments_test.go +++ b/pkg/controller/external_secrets/deployments_test.go @@ -718,6 +718,66 @@ func TestCreateOrApplyDeployments(t *testing.T) { }, validateDeployment: validateRevisionHistory(10), }, + { + name: "deployment with extraArgs appended to controller default args", + preReq: func(r *Reconciler, m *fakes.FakeCtrlClient, d **appsv1.Deployment) { + setupDeploymentCreate(m, d, "external-secrets") + }, + updateExternalSecretsConfig: escWithComponentConfigs(v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.CoreController, + ExtraArgs: []string{"--client-qps=100"}, + }), + validateDeployment: func(t *testing.T, d *appsv1.Deployment) { + if d == nil { + t.Error("deployment should not be nil") + return + } + for _, c := range d.Spec.Template.Spec.Containers { + if c.Name == "external-secrets" { + for _, arg := range c.Args { + if arg == "--client-qps=100" { + return + } + } + t.Errorf("expected --client-qps=100 in args, got %v", c.Args) + return + } + } + t.Error("external-secrets container not found") + }, + }, + { + name: "deployment with extraArgs overriding controller default arg", + preReq: func(r *Reconciler, m *fakes.FakeCtrlClient, d **appsv1.Deployment) { + setupDeploymentCreate(m, d, "external-secrets") + }, + updateExternalSecretsConfig: escWithComponentConfigs(v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.CoreController, + ExtraArgs: []string{"--concurrent=5"}, + }), + validateDeployment: func(t *testing.T, d *appsv1.Deployment) { + if d == nil { + t.Error("deployment should not be nil") + return + } + for _, c := range d.Spec.Template.Spec.Containers { + if c.Name == "external-secrets" { + for _, arg := range c.Args { + if arg == "--concurrent=1" { + t.Errorf("default --concurrent=1 should have been overridden, got args: %v", c.Args) + return + } + if arg == "--concurrent=5" { + return + } + } + t.Errorf("expected --concurrent=5 in args, got %v", c.Args) + return + } + } + t.Error("external-secrets container not found") + }, + }, { name: "multiple components with mixed revisionHistoryLimit configurations", preReq: func(r *Reconciler, m *fakes.FakeCtrlClient, d **appsv1.Deployment) { @@ -1873,6 +1933,67 @@ func TestMergeContainerEnvVars(t *testing.T) { }) } +func TestMergeArgs(t *testing.T) { + tests := []struct { + name string + existingArgs []string + extraArgs []string + expectedArgs []string + }{ + { + name: "nil container args, add new args", + existingArgs: nil, + extraArgs: []string{"--loglevel=debug", "--timeout=30s"}, + expectedArgs: []string{"--loglevel=debug", "--timeout=30s"}, + }, + { + name: "override existing arg by flag key", + existingArgs: []string{"--loglevel=info", "--metrics-addr=:8080"}, + extraArgs: []string{"--loglevel=debug"}, + expectedArgs: []string{"--loglevel=debug", "--metrics-addr=:8080"}, + }, + { + name: "add new arg to existing ones", + existingArgs: []string{"--loglevel=info"}, + extraArgs: []string{"--timeout=30s"}, + expectedArgs: []string{"--loglevel=info", "--timeout=30s"}, + }, + { + name: "mix of override and new args", + existingArgs: []string{"--loglevel=info", "--metrics-addr=:8080"}, + extraArgs: []string{"--loglevel=debug", "--timeout=30s"}, + expectedArgs: []string{"--loglevel=debug", "--metrics-addr=:8080", "--timeout=30s"}, + }, + { + name: "empty extra args does nothing", + existingArgs: []string{"--loglevel=info"}, + extraArgs: []string{}, + expectedArgs: []string{"--loglevel=info"}, + }, + { + name: "positional arg without equals sign is matched exactly", + existingArgs: []string{"webhook", "--loglevel=info"}, + extraArgs: []string{"webhook"}, + expectedArgs: []string{"webhook", "--loglevel=info"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Args: tt.existingArgs, + } + + mergeArgs(container, tt.extraArgs) + + if !reflect.DeepEqual(container.Args, tt.expectedArgs) { + t.Errorf("mergeArgs() got %v, want %v", container.Args, tt.expectedArgs) + } + }) + } +} + func TestApplyUserDeploymentConfigsWithOverrideEnv(t *testing.T) { tests := []struct { name string @@ -2396,6 +2517,148 @@ func TestApplyUserCABundleConfig(t *testing.T) { } } +func TestApplyUserDeploymentConfigsWithExtraArgs(t *testing.T) { + tests := []struct { + name string + assetName string + containerName string + componentConfig v1alpha1.ComponentConfig + existingArgs []string + expectedArgs []string + }{ + { + name: "append extra arg to core controller", + assetName: controllerDeploymentAssetName, + containerName: "external-secrets", + componentConfig: v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.CoreController, + ExtraArgs: []string{"--enable-feature=true"}, + }, + existingArgs: []string{"--loglevel=info", "--metrics-addr=:8080"}, + expectedArgs: []string{"--loglevel=info", "--metrics-addr=:8080", "--enable-feature=true"}, + }, + { + name: "extra arg overrides existing arg by flag key", + assetName: controllerDeploymentAssetName, + containerName: "external-secrets", + componentConfig: v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.CoreController, + ExtraArgs: []string{"--loglevel=debug"}, + }, + existingArgs: []string{"--loglevel=info", "--metrics-addr=:8080"}, + expectedArgs: []string{"--loglevel=debug", "--metrics-addr=:8080"}, + }, + { + name: "apply extra args to webhook container", + assetName: webhookDeploymentAssetName, + containerName: "webhook", + componentConfig: v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.Webhook, + ExtraArgs: []string{"--timeout=60s"}, + }, + existingArgs: []string{"webhook", "--port=10250"}, + expectedArgs: []string{"webhook", "--port=10250", "--timeout=60s"}, + }, + { + name: "apply extra args to cert-controller container", + assetName: certControllerDeploymentAssetName, + containerName: "cert-controller", + componentConfig: v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.CertController, + ExtraArgs: []string{"--requeue-interval=10m"}, + }, + existingArgs: []string{"certcontroller", "--metrics-addr=:8080"}, + expectedArgs: []string{"certcontroller", "--metrics-addr=:8080", "--requeue-interval=10m"}, + }, + { + name: "apply extra args to bitwarden container", + assetName: bitwardenDeploymentAssetName, + containerName: "bitwarden-sdk-server", + componentConfig: v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.BitwardenSDKServer, + ExtraArgs: []string{"--log-level=debug"}, + }, + existingArgs: []string{"--port=9090"}, + expectedArgs: []string{"--port=9090", "--log-level=debug"}, + }, + { + name: "empty extra args does not modify container", + assetName: controllerDeploymentAssetName, + containerName: "external-secrets", + componentConfig: v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.CoreController, + ExtraArgs: nil, + }, + existingArgs: []string{"--loglevel=info"}, + expectedArgs: []string{"--loglevel=info"}, + }, + { + name: "both extra args and override env applied together", + assetName: controllerDeploymentAssetName, + containerName: "external-secrets", + componentConfig: v1alpha1.ComponentConfig{ + ComponentName: v1alpha1.CoreController, + ExtraArgs: []string{"--enable-feature=true"}, + OverrideEnv: []corev1.EnvVar{{Name: "LOG_LEVEL", Value: "debug"}}, + }, + existingArgs: []string{"--loglevel=info"}, + expectedArgs: []string{"--loglevel=info", "--enable-feature=true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Reconciler{} + initArgs := []string{"--init-only-flag"} + deployment := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-setup", Args: initArgs}, + }, + Containers: []corev1.Container{ + { + Name: tt.containerName, + Args: tt.existingArgs, + }, + }, + }, + }, + }, + } + + esc := &v1alpha1.ExternalSecretsConfig{ + Spec: v1alpha1.ExternalSecretsConfigSpec{ + ControllerConfig: v1alpha1.ControllerConfig{ + ComponentConfigs: []v1alpha1.ComponentConfig{tt.componentConfig}, + }, + }, + } + + err := r.applyUserDeploymentConfigs(deployment, esc, tt.assetName) + if err != nil { + t.Errorf("applyUserDeploymentConfigs() unexpected error: %v", err) + return + } + + container := &deployment.Spec.Template.Spec.Containers[0] + if !reflect.DeepEqual(container.Args, tt.expectedArgs) { + t.Errorf("applyUserDeploymentConfigs() args = %v, want %v", container.Args, tt.expectedArgs) + } + + // Verify init containers are NOT modified by ExtraArgs + if len(deployment.Spec.Template.Spec.InitContainers) > 0 { + initContainer := &deployment.Spec.Template.Spec.InitContainers[0] + if !reflect.DeepEqual(initContainer.Args, initArgs) { + t.Errorf("applyUserDeploymentConfigs() should not modify init container args.\nExpected: %+v\nActual: %+v", + initArgs, initContainer.Args) + } + } + }) + } +} + func TestCreateOrApplyDeploymentFromAssetReturnsTrustedCAError(t *testing.T) { esc := commontest.TestExternalSecretsConfig() esc.Spec.ControllerConfig.TrustedCABundle = &v1alpha1.ConfigMapKeyReference{