diff --git a/pkg/controller/external_secrets/constants.go b/pkg/controller/external_secrets/constants.go index 8850ed83a..08ea0ac71 100644 --- a/pkg/controller/external_secrets/constants.go +++ b/pkg/controller/external_secrets/constants.go @@ -81,6 +81,26 @@ const ( webhookContainerName = "webhook" certControllerContainerName = "cert-controller" bitwardenContainerName = "bitwarden-sdk-server" + + // Operand deployment / container names used by e2e and env-based arg overrides. + OperandCoreControllerDeployment = externalsecretsCommonName + OperandWebhookDeployment = externalsecretsCommonName + "-webhook" + OperandCertControllerDeployment = externalsecretsCommonName + "-cert-controller" + OperandBitwardenSDKServerDeployment = "bitwarden-sdk-server" + + OperandCoreControllerContainer = controllerContainerName + OperandWebhookContainer = webhookContainerName + OperandCertControllerContainer = certControllerContainerName + OperandBitwardenContainer = bitwardenContainerName + + // OperandExternalSecretsArgsEnvVar is the operator env var for core controller container args overrides. + OperandExternalSecretsArgsEnvVar = "OPERAND_EXTERNAL_SECRETS_ARGS" + // OperandWebhookArgsEnvVar is the operator env var for webhook container args overrides. + OperandWebhookArgsEnvVar = "OPERAND_WEBHOOK_ARGS" + // OperandCertControllerArgsEnvVar is the operator env var for cert-controller container args overrides. + OperandCertControllerArgsEnvVar = "OPERAND_CERT_CONTROLLER_ARGS" + // OperandBitwardenSDKServerArgsEnvVar is the operator env var for bitwarden-sdk-server container args overrides. + OperandBitwardenSDKServerArgsEnvVar = "OPERAND_BITWARDEN_SDK_SERVER_ARGS" ) var ( diff --git a/pkg/controller/external_secrets/deployments.go b/pkg/controller/external_secrets/deployments.go index 4d4228fd1..119d31d76 100644 --- a/pkg/controller/external_secrets/deployments.go +++ b/pkg/controller/external_secrets/deployments.go @@ -4,6 +4,8 @@ import ( "fmt" "maps" "os" + "slices" + "strings" "unsafe" appsv1 "k8s.io/api/apps/v1" @@ -119,6 +121,9 @@ func (r *Reconciler) getDeploymentObject(assetName string, esc *operatorv1alpha1 switch assetName { case controllerDeploymentAssetName: updateContainerSpec(deployment, esc, image, logLevel) + if err := applyOperandArgsFromEnv(deployment, OperandCoreControllerContainer, OperandExternalSecretsArgsEnvVar); err != nil { + return nil, fmt.Errorf("failed to apply operand args from env: %w", err) + } case webhookDeploymentAssetName: checkInterval := "5m" if esc.Spec.ApplicationConfig.WebhookConfig != nil && @@ -126,12 +131,21 @@ func (r *Reconciler) getDeploymentObject(assetName string, esc *operatorv1alpha1 checkInterval = esc.Spec.ApplicationConfig.WebhookConfig.CertificateCheckInterval.Duration.String() } updateWebhookContainerSpec(deployment, image, logLevel, checkInterval) + if err := applyOperandArgsFromEnv(deployment, OperandWebhookContainer, OperandWebhookArgsEnvVar); err != nil { + return nil, fmt.Errorf("failed to apply operand args from env: %w", err) + } updateWebhookVolumeConfig(deployment, esc) case certControllerDeploymentAssetName: updateCertControllerContainerSpec(deployment, image, logLevel) + if err := applyOperandArgsFromEnv(deployment, OperandCertControllerContainer, OperandCertControllerArgsEnvVar); err != nil { + return nil, fmt.Errorf("failed to apply operand args from env: %w", err) + } case bitwardenDeploymentAssetName: deployment.Labels["app.kubernetes.io/version"] = os.Getenv(bitwardenImageVersionEnvVarName) updateBitwardenServerContainerSpec(deployment, bitwardenImage) + if err := applyOperandArgsFromEnv(deployment, OperandBitwardenContainer, OperandBitwardenSDKServerArgsEnvVar); err != nil { + return nil, fmt.Errorf("failed to apply operand args from env: %w", err) + } updateBitwardenVolumeConfig(deployment, esc) } @@ -749,3 +763,93 @@ func getComponentNameFromAsset(assetName string) (operatorv1alpha1.ComponentName return "", "", fmt.Errorf("unknown deployment asset name: %s", assetName) } } + +// argFlagKey returns the flag key for a container arg (everything before the first '='), +// or the whole token when there is no '='. Positional args without a leading "--" return "". +func argFlagKey(arg string) string { + if !strings.HasPrefix(arg, "--") { + return "" + } + if i := strings.IndexByte(arg, '='); i >= 0 { + return arg[:i] + } + return arg +} + +// parseOperandArgsEnv parses a comma-separated list of full CLI flags +// (e.g. "--concurrent=5,--loglevel=debug"). Empty segments (from trailing or +// repeated commas) are skipped. Callers should treat a fully empty/whitespace +// env value as a no-op before calling this helper. +func parseOperandArgsEnv(raw string) ([]string, error) { + parts := strings.Split(raw, ",") + args := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if !strings.HasPrefix(part, "--") { + return nil, common.NewIrrecoverableError( + fmt.Errorf("argument %q must start with --", part), + "invalid custom arg override", + ) + } + args = append(args, part) + } + return args, nil +} + +// mergeContainerArgs overrides matching --flag keys in base with overrides and appends +// unknown keys. Positional args (no leading "--") in base are preserved in place; +// non-flag overrides are skipped (parseOperandArgsEnv already rejects them for the env path). +func mergeContainerArgs(base []string, overrides []string) []string { + if len(overrides) == 0 { + return base + } + + keyIndex := make(map[string]int, len(base)) + for i, arg := range base { + if key := argFlagKey(arg); key != "" { + keyIndex[key] = i + } + } + + result := slices.Clone(base) + for _, override := range overrides { + key := argFlagKey(override) + if key == "" { + continue + } + if idx, ok := keyIndex[key]; ok { + result[idx] = override + continue + } + result = append(result, override) + keyIndex[key] = len(result) - 1 + } + return result +} + +// applyOperandArgsFromEnv reads envVarName and merges its comma-separated --key=value +// flags into the named container's Args. Unset or empty env is a no-op. +func applyOperandArgsFromEnv(deployment *appsv1.Deployment, containerName, envVarName string) error { + raw := strings.TrimSpace(os.Getenv(envVarName)) + if raw == "" { + return nil + } + + overrides, err := parseOperandArgsEnv(raw) + if err != nil { + return fmt.Errorf("%s: %w", envVarName, err) + } + + for i := range deployment.Spec.Template.Spec.Containers { + if deployment.Spec.Template.Spec.Containers[i].Name != containerName { + continue + } + deployment.Spec.Template.Spec.Containers[i].Args = mergeContainerArgs( + deployment.Spec.Template.Spec.Containers[i].Args, overrides) + return nil + } + return fmt.Errorf("container %s not found in deployment %s", containerName, deployment.GetName()) +} diff --git a/pkg/controller/external_secrets/deployments_test.go b/pkg/controller/external_secrets/deployments_test.go index 45fbe3ec3..0a349ac8c 100644 --- a/pkg/controller/external_secrets/deployments_test.go +++ b/pkg/controller/external_secrets/deployments_test.go @@ -16,6 +16,7 @@ import ( "github.com/openshift/external-secrets-operator/api/v1alpha1" "github.com/openshift/external-secrets-operator/pkg/controller/client/fakes" + "github.com/openshift/external-secrets-operator/pkg/controller/common" "github.com/openshift/external-secrets-operator/pkg/controller/commontest" ) @@ -1756,3 +1757,291 @@ func TestApplyUserDeploymentConfigsWithOverrideEnv(t *testing.T) { }) } } + +func TestParseOperandArgsEnv(t *testing.T) { + tests := []struct { + name string + raw string + want []string + wantErr bool + }{ + {name: "empty", raw: "", want: []string{}}, + {name: "whitespace only", raw: " \t ", want: []string{}}, + { + name: "single flag", + raw: "--concurrent=5", + want: []string{"--concurrent=5"}, + }, + { + name: "multiple flags with spaces", + raw: "--concurrent=5, --loglevel=debug", + want: []string{"--concurrent=5", "--loglevel=debug"}, + }, + { + name: "boolean style without equals", + raw: "--enable-foo,--enable-bar=true", + want: []string{"--enable-foo", "--enable-bar=true"}, + }, + { + name: "empty segments skipped", + raw: "--concurrent=5,,--loglevel=debug,", + want: []string{"--concurrent=5", "--loglevel=debug"}, + }, + {name: "missing dashes", raw: "concurrent=5", wantErr: true}, + {name: "single dash rejected", raw: "-concurrent=5", wantErr: true}, + {name: "positional token rejected", raw: "webhook,--port=10250", wantErr: true}, + {name: "positional only rejected", raw: "certcontroller", wantErr: true}, + {name: "bare equals rejected", raw: "=value", wantErr: true}, + {name: "valid then positional rejected", raw: "--port=10250,webhook", wantErr: true}, + {name: "positional mid-list rejected", raw: "--concurrent=5,not-a-flag,--loglevel=debug", wantErr: true}, + {name: "single dash mid-list rejected", raw: "--concurrent=5,-v,--loglevel=debug", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseOperandArgsEnv(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("parseOperandArgsEnv(%q) error = nil, want error", tt.raw) + } + if !common.IsIrrecoverableError(err) { + t.Fatalf("parseOperandArgsEnv(%q) error = %v, want IrrecoverableError", tt.raw, err) + } + if !strings.Contains(err.Error(), "invalid custom arg override") { + t.Fatalf("parseOperandArgsEnv(%q) error = %v, want message %q", tt.raw, err, "invalid custom arg override") + } + if !strings.Contains(err.Error(), "must start with --") { + t.Fatalf("parseOperandArgsEnv(%q) error = %v, want cause mentioning must start with --", tt.raw, err) + } + return + } + if err != nil { + t.Fatalf("parseOperandArgsEnv(%q) unexpected error: %v", tt.raw, err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseOperandArgsEnv(%q) = %#v, want %#v", tt.raw, got, tt.want) + } + }) + } +} + +func TestMergeContainerArgs(t *testing.T) { + tests := []struct { + name string + base []string + overrides []string + want []string + }{ + { + name: "nil overrides unchanged", + base: []string{"--concurrent=1"}, + overrides: nil, + want: []string{"--concurrent=1"}, + }, + { + name: "override existing key", + base: []string{"--concurrent=1", "--metrics-addr=:8080"}, + overrides: []string{"--concurrent=5"}, + want: []string{"--concurrent=5", "--metrics-addr=:8080"}, + }, + { + name: "append new key", + base: []string{"--concurrent=1"}, + overrides: []string{"--enable-foo=true"}, + want: []string{"--concurrent=1", "--enable-foo=true"}, + }, + { + name: "boolean token replaces valued flag with same key", + base: []string{"--enable-foo=true", "--loglevel=info"}, + overrides: []string{"--enable-foo"}, + want: []string{"--enable-foo", "--loglevel=info"}, + }, + { + name: "preserve leading positional token", + base: []string{"webhook", "--port=10250", "--loglevel=info"}, + overrides: []string{"--loglevel=debug", "--metrics-addr=:9090"}, + want: []string{"webhook", "--port=10250", "--loglevel=debug", "--metrics-addr=:9090"}, + }, + { + name: "preserve certcontroller positional token", + base: []string{"certcontroller", "--crd-requeue-interval=5m"}, + overrides: []string{"--crd-requeue-interval=10m"}, + want: []string{"certcontroller", "--crd-requeue-interval=10m"}, + }, + { + name: "skip non-flag overrides", + base: []string{"webhook", "--port=10250"}, + overrides: []string{"webhook", "--port=10251"}, + want: []string{"webhook", "--port=10251"}, + }, + { + name: "all non-flag overrides leave base unchanged", + base: []string{"webhook", "--port=10250"}, + overrides: []string{"webhook", "certcontroller", "-v"}, + want: []string{"webhook", "--port=10250"}, + }, + { + name: "empty override tokens skipped", + base: []string{"--concurrent=1"}, + overrides: []string{"", "--concurrent=2", ""}, + want: []string{"--concurrent=2"}, + }, + { + name: "single-dash override skipped", + base: []string{"--loglevel=info"}, + overrides: []string{"-loglevel=debug", "--metrics-addr=:9090"}, + want: []string{"--loglevel=info", "--metrics-addr=:9090"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeContainerArgs(tt.base, tt.overrides) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("mergeContainerArgs() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestApplyOperandArgsFromEnv(t *testing.T) { + deploymentWithContainer := func(name string, args []string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: name, Args: args}}, + }, + }, + }, + } + } + + t.Run("unset env is no-op", func(t *testing.T) { + dep := deploymentWithContainer(OperandCoreControllerContainer, []string{"--concurrent=1"}) + if err := applyOperandArgsFromEnv(dep, OperandCoreControllerContainer, OperandExternalSecretsArgsEnvVar); err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"--concurrent=1"} + if !reflect.DeepEqual(dep.Spec.Template.Spec.Containers[0].Args, want) { + t.Errorf("Args = %#v, want %#v", dep.Spec.Template.Spec.Containers[0].Args, want) + } + }) + + t.Run("empty env is no-op", func(t *testing.T) { + t.Setenv(OperandExternalSecretsArgsEnvVar, " ") + dep := deploymentWithContainer(OperandCoreControllerContainer, []string{"--concurrent=1"}) + if err := applyOperandArgsFromEnv(dep, OperandCoreControllerContainer, OperandExternalSecretsArgsEnvVar); err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"--concurrent=1"} + if !reflect.DeepEqual(dep.Spec.Template.Spec.Containers[0].Args, want) { + t.Errorf("Args = %#v, want %#v", dep.Spec.Template.Spec.Containers[0].Args, want) + } + }) + + t.Run("overrides and appends", func(t *testing.T) { + t.Setenv(OperandExternalSecretsArgsEnvVar, "--concurrent=5,--enable-foo=true") + dep := deploymentWithContainer(OperandCoreControllerContainer, []string{"--concurrent=1", "--metrics-addr=:8080"}) + if err := applyOperandArgsFromEnv(dep, OperandCoreControllerContainer, OperandExternalSecretsArgsEnvVar); err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"--concurrent=5", "--metrics-addr=:8080", "--enable-foo=true"} + if !reflect.DeepEqual(dep.Spec.Template.Spec.Containers[0].Args, want) { + t.Errorf("Args = %#v, want %#v", dep.Spec.Template.Spec.Containers[0].Args, want) + } + }) + + t.Run("invalid entry fails", func(t *testing.T) { + t.Setenv(OperandWebhookArgsEnvVar, "not-a-flag") + dep := deploymentWithContainer(OperandWebhookContainer, []string{"webhook", "--port=10250"}) + err := applyOperandArgsFromEnv(dep, OperandWebhookContainer, OperandWebhookArgsEnvVar) + if err == nil { + t.Fatal("expected error for invalid args env value") + } + if !common.IsIrrecoverableError(err) { + t.Fatalf("error = %v, want IrrecoverableError", err) + } + wantSubstrings := []string{ + OperandWebhookArgsEnvVar, + "invalid custom arg override", + `argument "not-a-flag" must start with --`, + } + for _, sub := range wantSubstrings { + if !strings.Contains(err.Error(), sub) { + t.Fatalf("error = %v, want substring %q", err, sub) + } + } + }) + + t.Run("positional env value fails without mutating args", func(t *testing.T) { + t.Setenv(OperandWebhookArgsEnvVar, "webhook,--port=10251") + original := []string{"webhook", "--port=10250"} + dep := deploymentWithContainer(OperandWebhookContainer, append([]string(nil), original...)) + err := applyOperandArgsFromEnv(dep, OperandWebhookContainer, OperandWebhookArgsEnvVar) + if err == nil { + t.Fatal("expected error for positional env value") + } + if !common.IsIrrecoverableError(err) { + t.Fatalf("error = %v, want IrrecoverableError", err) + } + if !reflect.DeepEqual(dep.Spec.Template.Spec.Containers[0].Args, original) { + t.Errorf("Args mutated on error: %#v, want %#v", dep.Spec.Template.Spec.Containers[0].Args, original) + } + }) + + t.Run("single dash env value fails", func(t *testing.T) { + t.Setenv(OperandExternalSecretsArgsEnvVar, "-concurrent=5") + dep := deploymentWithContainer(OperandCoreControllerContainer, []string{"--concurrent=1"}) + err := applyOperandArgsFromEnv(dep, OperandCoreControllerContainer, OperandExternalSecretsArgsEnvVar) + if err == nil { + t.Fatal("expected error for single-dash env value") + } + if !common.IsIrrecoverableError(err) { + t.Fatalf("error = %v, want IrrecoverableError", err) + } + }) + + t.Run("invalid entry after valid flags fails", func(t *testing.T) { + t.Setenv(OperandExternalSecretsArgsEnvVar, "--concurrent=5,bad") + original := []string{"--concurrent=1"} + dep := deploymentWithContainer(OperandCoreControllerContainer, append([]string(nil), original...)) + err := applyOperandArgsFromEnv(dep, OperandCoreControllerContainer, OperandExternalSecretsArgsEnvVar) + if err == nil { + t.Fatal("expected error for mixed valid/invalid env value") + } + if !common.IsIrrecoverableError(err) { + t.Fatalf("error = %v, want IrrecoverableError", err) + } + if !reflect.DeepEqual(dep.Spec.Template.Spec.Containers[0].Args, original) { + t.Errorf("Args mutated on error: %#v, want %#v", dep.Spec.Template.Spec.Containers[0].Args, original) + } + }) + + t.Run("missing container fails", func(t *testing.T) { + t.Setenv(OperandExternalSecretsArgsEnvVar, "--concurrent=5") + dep := deploymentWithContainer("other", []string{"--concurrent=1"}) + if err := applyOperandArgsFromEnv(dep, OperandCoreControllerContainer, OperandExternalSecretsArgsEnvVar); err == nil { + t.Fatal("expected error for missing container") + } + }) + + t.Run("empty containers list fails", func(t *testing.T) { + t.Setenv(OperandExternalSecretsArgsEnvVar, "--concurrent=5") + dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "test"}} + if err := applyOperandArgsFromEnv(dep, OperandCoreControllerContainer, OperandExternalSecretsArgsEnvVar); err == nil { + t.Fatal("expected error for empty containers list") + } + }) + + t.Run("bitwarden args applied onto empty base", func(t *testing.T) { + t.Setenv(OperandBitwardenSDKServerArgsEnvVar, "--port=9999") + dep := deploymentWithContainer(OperandBitwardenContainer, nil) + if err := applyOperandArgsFromEnv(dep, OperandBitwardenContainer, OperandBitwardenSDKServerArgsEnvVar); err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"--port=9999"} + if !reflect.DeepEqual(dep.Spec.Template.Spec.Containers[0].Args, want) { + t.Errorf("Args = %#v, want %#v", dep.Spec.Template.Spec.Containers[0].Args, want) + } + }) +} diff --git a/test/e2e/README.md b/test/e2e/README.md index fea526eda..63c59942a 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -135,6 +135,18 @@ kubectl create secret generic aws-creds \ --- +### Feature:OverrideOperandArgs + +File: `operand_args_test.go` — operator env vars override operand Deployment container args. + +| Item | Details | +|------|--------| +| **Label filter** | `"Feature:OverrideOperandArgs"` | +| **Pre-requisites** | Operator installed (OLM Subscription preferred). Suite enables Bitwarden temporarily to cover bitwarden-sdk-server args, then restores ESC plugin state. | +| **Make command** | `make test-e2e E2E_GINKGO_LABEL_FILTER="Feature:OverrideOperandArgs"` | + +--- + ## Running multiple suites To run more than one label (e.g. Bitwarden provider and API): diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index b3a09d13a..be59e5941 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -20,12 +20,55 @@ limitations under the License. import ( "context" + "fmt" + "slices" + "strings" + "time" . "github.com/onsi/gomega" appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/util/retry" + + operatorv1alpha1 "github.com/openshift/external-secrets-operator/api/v1alpha1" + "github.com/openshift/external-secrets-operator/pkg/controller/common" +) + +const ( + operatorDeploymentName = common.ExternalSecretsOperatorCommonName + "-controller-manager" + operatorManagerContainerName = "manager" + // operatorCSVNamePrefix matches ClusterServiceVersion names like + // openshift-external-secrets-operator.v1.1.0. + operatorCSVNamePrefix = "openshift-external-secrets-operator." + // operatorPackageName is the OLM package / Subscription.spec.name value. + operatorPackageName = "openshift-external-secrets-operator" + olmOperatorNamespaceAnnotation = "olm.operatorNamespace" +) + +var ( + csvGVR = schema.GroupVersionResource{ + Group: "operators.coreos.com", + Version: "v1alpha1", + Resource: "clusterserviceversions", + } + subscriptionGVR = schema.GroupVersionResource{ + Group: "operators.coreos.com", + Version: "v1alpha1", + Resource: "subscriptions", + } + externalSecretsConfigGVR = schema.GroupVersionResource{ + Group: "operator.openshift.io", + Version: "v1alpha1", + Resource: "externalsecretsconfigs", + } ) // resourceType defines a Kubernetes resource type to verify annotations on @@ -162,3 +205,380 @@ func getResourceTypesToVerify() []resourceType { func asDeployment(obj metav1.Object) *appsv1.Deployment { return obj.(*appsv1.Deployment) } + +// getDeploymentContainerArgs returns container args for the named container in a deployment. +func getDeploymentContainerArgs(deployment *appsv1.Deployment, containerName string) ([]string, bool) { + if deployment == nil { + return nil, false + } + for _, container := range deployment.Spec.Template.Spec.Containers { + if container.Name == containerName { + return container.Args, true + } + } + return nil, false +} + +// deploymentContainerHasArg reports whether the named container has the given arg. +func deploymentContainerHasArg(deployment *appsv1.Deployment, containerName, arg string) (bool, bool) { + args, found := getDeploymentContainerArgs(deployment, containerName) + if !found { + return false, false + } + return slices.Contains(args, arg), true +} + +// setOperatorManagerEnv sets or updates env vars on the operator manager container. +// Prefer updating Subscription.spec.config.env when a matching CSV and Subscription +// exist; otherwise update the Deployment directly. +func setOperatorManagerEnv(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, envVars map[string]string) error { + if len(envVars) == 0 { + return nil + } + updatedViaSub, err := updateSubscriptionEnv(ctx, clientset, dynamicClient, envVars, nil) + if err != nil { + return err + } + if !updatedViaSub { + if err := updateOperatorDeploymentEnv(ctx, clientset, envVars, nil); err != nil { + return err + } + } + waitForOperatorManagerEnv(ctx, clientset, envVars, nil) + return nil +} + +// unsetOperatorManagerEnv removes env vars from the operator manager container. +func unsetOperatorManagerEnv(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, keys []string) error { + if len(keys) == 0 { + return nil + } + updatedViaSub, err := updateSubscriptionEnv(ctx, clientset, dynamicClient, nil, keys) + if err != nil { + return err + } + if !updatedViaSub { + if err := updateOperatorDeploymentEnv(ctx, clientset, nil, keys); err != nil { + return err + } + } + waitForOperatorManagerEnv(ctx, clientset, nil, keys) + return nil +} + +func updateSubscriptionEnv(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface, set map[string]string, unset []string) (bool, error) { + csv, err := findOperatorCSV(ctx, clientset, dynamicClient) + if err != nil { + return false, err + } + if csv == nil { + return false, nil + } + + subNamespace := csv.GetAnnotations()[olmOperatorNamespaceAnnotation] + if subNamespace == "" { + subNamespace = csv.GetNamespace() + } + if subNamespace == "" { + return false, fmt.Errorf("CSV %s has empty namespace and no %s annotation", csv.GetName(), olmOperatorNamespaceAnnotation) + } + + sub, err := findOperatorSubscription(ctx, dynamicClient, subNamespace) + if err != nil { + return false, err + } + if sub == nil { + return false, nil + } + + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + current, err := dynamicClient.Resource(subscriptionGVR).Namespace(subNamespace).Get(ctx, sub.GetName(), metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("get Subscription %s/%s: %w", subNamespace, sub.GetName(), err) + } + + config, _, _ := unstructured.NestedMap(current.Object, "spec", "config") + if config == nil { + config = map[string]interface{}{} + } + rawEnv, _, _ := unstructured.NestedSlice(config, "env") + merged := mergeUnstructuredEnv(rawEnv, set, unset) + if len(merged) == 0 { + delete(config, "env") + } else { + config["env"] = merged + } + if len(config) == 0 { + unstructured.RemoveNestedField(current.Object, "spec", "config") + } else if err := unstructured.SetNestedMap(current.Object, config, "spec", "config"); err != nil { + return err + } + + _, err = dynamicClient.Resource(subscriptionGVR).Namespace(subNamespace).Update(ctx, current, metav1.UpdateOptions{}) + return err + }) + if err != nil { + return false, err + } + return true, nil +} + +func findOperatorCSV(ctx context.Context, clientset kubernetes.Interface, dynamicClient dynamic.Interface) (*unstructured.Unstructured, error) { + list, err := dynamicClient.Resource(csvGVR).Namespace(operatorNamespace).List(ctx, metav1.ListOptions{}) + if err != nil { + if !k8serrors.IsNotFound(err) && !meta.IsNoMatchError(err) { + return nil, fmt.Errorf("list CSVs in %s: %w", operatorNamespace, err) + } + } + if list != nil { + for i := range list.Items { + if strings.HasPrefix(list.Items[i].GetName(), operatorCSVNamePrefix) { + return list.Items[i].DeepCopy(), nil + } + } + } + + dep, err := clientset.AppsV1().Deployments(operatorNamespace).Get(ctx, operatorDeploymentName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get operator deployment: %w", err) + } + for _, ref := range dep.OwnerReferences { + if ref.Kind != "ClusterServiceVersion" || ref.Name == "" { + continue + } + csv, err := dynamicClient.Resource(csvGVR).Namespace(operatorNamespace).Get(ctx, ref.Name, metav1.GetOptions{}) + if err != nil { + if meta.IsNoMatchError(err) { + return nil, nil + } + if k8serrors.IsNotFound(err) { + continue + } + return nil, fmt.Errorf("get CSV %s/%s: %w", operatorNamespace, ref.Name, err) + } + return csv, nil + } + return nil, nil +} + +func findOperatorSubscription(ctx context.Context, dynamicClient dynamic.Interface, ns string) (*unstructured.Unstructured, error) { + list, err := dynamicClient.Resource(subscriptionGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + if meta.IsNoMatchError(err) || k8serrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("list Subscriptions in %s: %w", ns, err) + } + for i := range list.Items { + item := &list.Items[i] + pkg, _, _ := unstructured.NestedString(item.Object, "spec", "name") + if pkg == operatorPackageName || strings.HasPrefix(item.GetName(), operatorPackageName) { + return item.DeepCopy(), nil + } + } + return nil, nil +} + +func updateOperatorDeploymentEnv(ctx context.Context, clientset kubernetes.Interface, set map[string]string, unset []string) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + dep, err := clientset.AppsV1().Deployments(operatorNamespace).Get(ctx, operatorDeploymentName, metav1.GetOptions{}) + if err != nil { + return err + } + idx := managerContainerIndex(dep.Spec.Template.Spec.Containers) + if idx < 0 { + return fmt.Errorf("manager container not found in operator deployment") + } + dep.Spec.Template.Spec.Containers[idx].Env = mergeEnvVars(dep.Spec.Template.Spec.Containers[idx].Env, set, unset) + _, err = clientset.AppsV1().Deployments(operatorNamespace).Update(ctx, dep, metav1.UpdateOptions{}) + return err + }) +} + +func mergeUnstructuredEnv(raw []interface{}, set map[string]string, unset []string) []interface{} { + remove := make(map[string]struct{}, len(unset)) + for _, k := range unset { + remove[k] = struct{}{} + } + seen := make(map[string]bool) + out := make([]interface{}, 0, len(raw)+len(set)) + for _, item := range raw { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + name, _, _ := unstructured.NestedString(m, "name") + if name == "" { + continue + } + if _, drop := remove[name]; drop { + continue + } + if val, ok := set[name]; ok { + m["value"] = val + delete(m, "valueFrom") + } + out = append(out, m) + seen[name] = true + } + toAdd := make([]string, 0, len(set)) + for name := range set { + if seen[name] { + continue + } + toAdd = append(toAdd, name) + } + slices.Sort(toAdd) + for _, name := range toAdd { + out = append(out, map[string]interface{}{"name": name, "value": set[name]}) + } + return out +} + +func managerContainerIndex(containers []corev1.Container) int { + for i, c := range containers { + if c.Name == operatorManagerContainerName { + return i + } + } + return -1 +} + +func mergeEnvVars(existing []corev1.EnvVar, set map[string]string, unset []string) []corev1.EnvVar { + remove := make(map[string]struct{}, len(unset)) + for _, k := range unset { + remove[k] = struct{}{} + } + out := make([]corev1.EnvVar, 0, len(existing)+len(set)) + seen := make(map[string]bool, len(existing)) + for _, env := range existing { + if _, drop := remove[env.Name]; drop { + continue + } + if val, ok := set[env.Name]; ok { + env.Value = val + env.ValueFrom = nil + } + out = append(out, env) + seen[env.Name] = true + } + toAdd := make([]string, 0, len(set)) + for name := range set { + if seen[name] { + continue + } + toAdd = append(toAdd, name) + } + slices.Sort(toAdd) + for _, name := range toAdd { + out = append(out, corev1.EnvVar{Name: name, Value: set[name]}) + } + return out +} + +func waitForOperatorManagerEnv(ctx context.Context, clientset kubernetes.Interface, want map[string]string, unset []string) { + Eventually(func(g Gomega) { + dep, err := clientset.AppsV1().Deployments(operatorNamespace).Get(ctx, operatorDeploymentName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + idx := managerContainerIndex(dep.Spec.Template.Spec.Containers) + g.Expect(idx).To(BeNumerically(">=", 0), "manager container should exist") + assertEnvMap(g, envSliceToMap(dep.Spec.Template.Spec.Containers[idx].Env), want, unset, "operator Deployment") + + pods, err := clientset.CoreV1().Pods(operatorNamespace).List(ctx, metav1.ListOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + var readyPod *corev1.Pod + for i := range pods.Items { + pod := &pods.Items[i] + if pod.DeletionTimestamp != nil || !strings.HasPrefix(pod.Name, operatorPodPrefix) { + continue + } + if pod.Status.Phase == corev1.PodRunning && isOperatorPodReady(pod) { + readyPod = pod + break + } + } + g.Expect(readyPod).NotTo(BeNil(), "expected a Ready non-terminating operator manager pod") + cidx := managerContainerIndex(readyPod.Spec.Containers) + g.Expect(cidx).To(BeNumerically(">=", 0), "manager container should exist on Ready pod %s", readyPod.Name) + assertEnvMap(g, envSliceToMap(readyPod.Spec.Containers[cidx].Env), want, unset, "operator pod "+readyPod.Name) + }, 3*time.Minute, 5*time.Second).Should(Succeed()) +} + +func envSliceToMap(env []corev1.EnvVar) map[string]string { + out := make(map[string]string, len(env)) + for _, e := range env { + out[e.Name] = e.Value + } + return out +} + +func assertEnvMap(g Gomega, envMap map[string]string, want map[string]string, unset []string, where string) { + for name, val := range want { + g.Expect(envMap).To(HaveKeyWithValue(name, val), "%s should have env %s=%s", where, name, val) + } + for _, name := range unset { + g.Expect(envMap).NotTo(HaveKey(name), "%s should not have env %s", where, name) + } +} + +func isOperatorPodReady(pod *corev1.Pod) bool { + ready, containersReady := false, false + for _, cond := range pod.Status.Conditions { + if cond.Status != corev1.ConditionTrue { + continue + } + switch cond.Type { + case corev1.PodReady: + ready = true + case corev1.ContainersReady: + containersReady = true + } + } + return ready && containersReady +} + +func isExternalSecretsConfigDegraded(ctx context.Context) bool { + u, err := suiteDynamicClient.Resource(externalSecretsConfigGVR).Get(ctx, common.ExternalSecretsConfigObjectName, metav1.GetOptions{}) + if err != nil { + return false + } + conds, found, _ := unstructured.NestedSlice(u.Object, "status", "conditions") + if !found { + return false + } + for _, c := range conds { + cond, ok := c.(map[string]interface{}) + if !ok { + continue + } + if cond["type"] == operatorv1alpha1.Degraded && cond["status"] == string(metav1.ConditionTrue) { + return true + } + } + return false +} + +func externalSecretsConfigDegradedMessage(ctx context.Context) string { + u, err := suiteDynamicClient.Resource(externalSecretsConfigGVR).Get(ctx, common.ExternalSecretsConfigObjectName, metav1.GetOptions{}) + if err != nil { + return "" + } + conds, found, _ := unstructured.NestedSlice(u.Object, "status", "conditions") + if !found { + return "" + } + for _, c := range conds { + cond, ok := c.(map[string]interface{}) + if !ok { + continue + } + if cond["type"] == operatorv1alpha1.Degraded && cond["status"] == string(metav1.ConditionTrue) { + msg, _, _ := unstructured.NestedString(cond, "message") + return msg + } + } + return "" +} diff --git a/test/e2e/operand_args_test.go b/test/e2e/operand_args_test.go new file mode 100644 index 000000000..e49c668a4 --- /dev/null +++ b/test/e2e/operand_args_test.go @@ -0,0 +1,259 @@ +//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" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + operatorv1alpha1 "github.com/openshift/external-secrets-operator/api/v1alpha1" + "github.com/openshift/external-secrets-operator/pkg/controller/common" + externalsecrets "github.com/openshift/external-secrets-operator/pkg/controller/external_secrets" + "github.com/openshift/external-secrets-operator/test/utils" +) + +var _ = Describe("Operand Args Env Overrides", Ordered, Label("Platform:Generic", "Feature:OverrideOperandArgs"), func() { + ctx := context.Background() + + const ( + controllerArg = "--concurrent=2" + webhookArg = "--check-interval=10m0s" + certControllerArg = "--crd-requeue-interval=10m" + bitwardenArg = "--key-file=/certs/key.pem" + ) + + var ( + clientset *kubernetes.Clientset + dynamicClient *dynamic.DynamicClient + runtimeClient client.Client + + originalBitwardenProvider *operatorv1alpha1.BitwardenSecretManagerProvider + + operandArgsEnv = map[string]string{ + externalsecrets.OperandExternalSecretsArgsEnvVar: controllerArg, + externalsecrets.OperandWebhookArgsEnvVar: webhookArg, + externalsecrets.OperandCertControllerArgsEnvVar: certControllerArg, + externalsecrets.OperandBitwardenSDKServerArgsEnvVar: bitwardenArg, + } + operandArgsEnvKeys = []string{ + externalsecrets.OperandExternalSecretsArgsEnvVar, + externalsecrets.OperandWebhookArgsEnvVar, + externalsecrets.OperandCertControllerArgsEnvVar, + externalsecrets.OperandBitwardenSDKServerArgsEnvVar, + } + ) + + waitForOperandArg := func(deploymentName, containerName, arg string, present bool) { + Eventually(func(g Gomega) { + deployment, err := clientset.AppsV1().Deployments(operandNamespace).Get(ctx, deploymentName, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred(), "should get %s deployment", deploymentName) + hasArg, found := deploymentContainerHasArg(deployment, containerName, arg) + g.Expect(found).To(BeTrue(), "%s container should exist in %s", containerName, deploymentName) + if present { + g.Expect(hasArg).To(BeTrue(), "%s/%s should include %q", deploymentName, containerName, arg) + } else { + g.Expect(hasArg).To(BeFalse(), "%s/%s should not include %q", deploymentName, containerName, arg) + } + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + } + + BeforeAll(func() { + clientset = suiteClientset + dynamicClient = suiteDynamicClient + runtimeClient = suiteRuntimeClient + Expect(clientset).NotTo(BeNil()) + Expect(dynamicClient).NotTo(BeNil()) + Expect(runtimeClient).NotTo(BeNil()) + + // Snapshot Bitwarden plugin state before ensureBitwardenOperandReady creates/updates ESC. + esc := &operatorv1alpha1.ExternalSecretsConfig{} + if err := runtimeClient.Get(ctx, client.ObjectKey{Name: common.ExternalSecretsConfigObjectName}, esc); err != nil { + Expect(k8serrors.IsNotFound(err)).To(BeTrue(), "unexpected error getting ExternalSecretsConfig: %v", err) + } else if esc.Spec.Plugins.BitwardenSecretManagerProvider != nil { + originalBitwardenProvider = esc.Spec.Plugins.BitwardenSecretManagerProvider.DeepCopy() + } + + By("Ensuring ExternalSecretsConfig exists and bitwarden-sdk-server is ready") + Expect(ensureBitwardenOperandReady(ctx)).To(Succeed()) + + By("Setting OPERAND_*_ARGS on the operator manager") + Expect(setOperatorManagerEnv(ctx, clientset, dynamicClient, operandArgsEnv)).To(Succeed()) + + By("Waiting for operator pod to be ready after env update") + Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operatorNamespace, []string{operatorPodPrefix})).To(Succeed()) + }) + + AfterAll(func() { + By("Clearing OPERAND_*_ARGS from the operator manager") + Expect(unsetOperatorManagerEnv(ctx, clientset, dynamicClient, operandArgsEnvKeys)).To(Succeed()) + + By("Waiting for operator pod to be ready after env cleanup") + Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operatorNamespace, []string{operatorPodPrefix})).To(Succeed()) + + By("Reverting ExternalSecretsConfig Bitwarden plugin to pre-suite state") + Expect(retry.RetryOnConflict(retry.DefaultRetry, func() error { + esc := &operatorv1alpha1.ExternalSecretsConfig{} + if err := runtimeClient.Get(ctx, client.ObjectKey{Name: common.ExternalSecretsConfigObjectName}, esc); err != nil { + return err + } + esc.Spec.Plugins.BitwardenSecretManagerProvider = originalBitwardenProvider + return runtimeClient.Update(ctx, esc) + })).To(Succeed()) + Expect(utils.WaitForExternalSecretsConfigReady(ctx, dynamicClient, common.ExternalSecretsConfigObjectName, 3*time.Minute)).To(Succeed()) + }) + + It("should override args on the core controller Deployment", func() { + By("Verifying controller args override and default concurrent flag is replaced") + waitForOperandArg(externalsecrets.OperandCoreControllerDeployment, externalsecrets.OperandCoreControllerContainer, controllerArg, true) + Eventually(func(g Gomega) { + deployment, err := clientset.AppsV1().Deployments(operandNamespace).Get(ctx, externalsecrets.OperandCoreControllerDeployment, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + hasDefault, found := deploymentContainerHasArg(deployment, externalsecrets.OperandCoreControllerContainer, "--concurrent=1") + g.Expect(found).To(BeTrue()) + g.Expect(hasDefault).To(BeFalse(), "default --concurrent=1 should be replaced") + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + }) + + It("should override args on the webhook Deployment", func() { + waitForOperandArg(externalsecrets.OperandWebhookDeployment, externalsecrets.OperandWebhookContainer, webhookArg, true) + Eventually(func(g Gomega) { + deployment, err := clientset.AppsV1().Deployments(operandNamespace).Get(ctx, externalsecrets.OperandWebhookDeployment, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + args, found := getDeploymentContainerArgs(deployment, externalsecrets.OperandWebhookContainer) + g.Expect(found).To(BeTrue()) + g.Expect(args).NotTo(BeEmpty()) + g.Expect(args[0]).To(Equal("webhook"), "webhook positional token should be preserved") + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + }) + + It("should override args on the cert-controller Deployment", func() { + waitForOperandArg(externalsecrets.OperandCertControllerDeployment, externalsecrets.OperandCertControllerContainer, certControllerArg, true) + Eventually(func(g Gomega) { + deployment, err := clientset.AppsV1().Deployments(operandNamespace).Get(ctx, externalsecrets.OperandCertControllerDeployment, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + args, found := getDeploymentContainerArgs(deployment, externalsecrets.OperandCertControllerContainer) + g.Expect(found).To(BeTrue()) + g.Expect(args).NotTo(BeEmpty()) + g.Expect(args[0]).To(Equal("certcontroller"), "certcontroller positional token should be preserved") + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + }) + + It("should apply args on the bitwarden-sdk-server Deployment", func() { + waitForOperandArg(externalsecrets.OperandBitwardenSDKServerDeployment, externalsecrets.OperandBitwardenContainer, bitwardenArg, true) + }) + + It("should mark ExternalSecretsConfig Degraded for invalid OPERAND_*_ARGS", func() { + By("Setting an invalid controller args override") + Expect(setOperatorManagerEnv(ctx, clientset, dynamicClient, map[string]string{ + externalsecrets.OperandExternalSecretsArgsEnvVar: "not-a-flag", + })).To(Succeed()) + Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operatorNamespace, []string{operatorPodPrefix})).To(Succeed()) + + By("Waiting for ExternalSecretsConfig to become Degraded with a user-configuration message") + Eventually(func(g Gomega) { + g.Expect(isExternalSecretsConfigDegraded(ctx)).To(BeTrue(), + "ExternalSecretsConfig should be Degraded for invalid OPERAND_*_ARGS") + msg := externalSecretsConfigDegradedMessage(ctx) + g.Expect(msg).To(ContainSubstring("invalid custom arg override")) + g.Expect(msg).To(ContainSubstring(`argument "not-a-flag" must start with --`)) + g.Expect(msg).To(ContainSubstring(externalsecrets.OperandExternalSecretsArgsEnvVar)) + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + + By("Verifying the invalid token was not applied to the controller Deployment") + Eventually(func(g Gomega) { + deployment, err := clientset.AppsV1().Deployments(operandNamespace).Get(ctx, externalsecrets.OperandCoreControllerDeployment, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + hasArg, found := deploymentContainerHasArg(deployment, externalsecrets.OperandCoreControllerContainer, "not-a-flag") + g.Expect(found).To(BeTrue()) + g.Expect(hasArg).To(BeFalse(), "invalid arg must not be present on controller container") + }, time.Minute, 5*time.Second).Should(Succeed()) + }) + + It("should mark ExternalSecretsConfig Degraded for positional OPERAND_WEBHOOK_ARGS", func() { + By("Restoring a valid controller override so webhook invalidation is the only failure") + Expect(setOperatorManagerEnv(ctx, clientset, dynamicClient, map[string]string{ + externalsecrets.OperandExternalSecretsArgsEnvVar: controllerArg, + externalsecrets.OperandWebhookArgsEnvVar: "webhook,--port=10251", + })).To(Succeed()) + Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operatorNamespace, []string{operatorPodPrefix})).To(Succeed()) + + By("Waiting for ExternalSecretsConfig to become Degraded") + Eventually(func(g Gomega) { + g.Expect(isExternalSecretsConfigDegraded(ctx)).To(BeTrue(), + "ExternalSecretsConfig should be Degraded for positional webhook args") + msg := externalSecretsConfigDegradedMessage(ctx) + g.Expect(msg).To(ContainSubstring("invalid custom arg override")) + g.Expect(msg).To(ContainSubstring(externalsecrets.OperandWebhookArgsEnvVar)) + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + }) + + It("should recover from Degraded when invalid OPERAND_*_ARGS are corrected", func() { + By("Ensuring ExternalSecretsConfig is currently Degraded from prior invalid args") + Eventually(func(g Gomega) { + g.Expect(isExternalSecretsConfigDegraded(ctx)).To(BeTrue()) + }, time.Minute, 5*time.Second).Should(Succeed()) + + By("Correcting OPERAND_*_ARGS back to valid overrides") + Expect(setOperatorManagerEnv(ctx, clientset, dynamicClient, operandArgsEnv)).To(Succeed()) + Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operatorNamespace, []string{operatorPodPrefix})).To(Succeed()) + + // WaitForExternalSecretsConfigReady fails fast while Degraded=True; poll until + // recovery clears it, then confirm Ready. + By("Waiting for ExternalSecretsConfig to recover from Degraded") + Eventually(func(g Gomega) { + g.Expect(isExternalSecretsConfigDegraded(ctx)).To(BeFalse(), + "ExternalSecretsConfig should leave Degraded after args are corrected") + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + Expect(utils.WaitForExternalSecretsConfigReady(ctx, dynamicClient, common.ExternalSecretsConfigObjectName, 2*time.Minute)).To(Succeed()) + + By("Verifying valid overrides are applied after recovery") + waitForOperandArg(externalsecrets.OperandCoreControllerDeployment, externalsecrets.OperandCoreControllerContainer, controllerArg, true) + waitForOperandArg(externalsecrets.OperandWebhookDeployment, externalsecrets.OperandWebhookContainer, webhookArg, true) + }) + + It("should restore default operand args when OPERAND_*_ARGS are cleared", func() { + By("Clearing OPERAND_*_ARGS to verify restoration") + Expect(unsetOperatorManagerEnv(ctx, clientset, dynamicClient, operandArgsEnvKeys)).To(Succeed()) + Expect(utils.VerifyPodsReadyByPrefix(ctx, clientset, operatorNamespace, []string{operatorPodPrefix})).To(Succeed()) + + waitForOperandArg(externalsecrets.OperandCoreControllerDeployment, externalsecrets.OperandCoreControllerContainer, controllerArg, false) + Eventually(func(g Gomega) { + deployment, err := clientset.AppsV1().Deployments(operandNamespace).Get(ctx, externalsecrets.OperandCoreControllerDeployment, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + hasDefault, found := deploymentContainerHasArg(deployment, externalsecrets.OperandCoreControllerContainer, "--concurrent=1") + g.Expect(found).To(BeTrue()) + g.Expect(hasDefault).To(BeTrue(), "default --concurrent=1 should be restored") + }, 3*time.Minute, 5*time.Second).Should(Succeed()) + + waitForOperandArg(externalsecrets.OperandWebhookDeployment, externalsecrets.OperandWebhookContainer, webhookArg, false) + waitForOperandArg(externalsecrets.OperandCertControllerDeployment, externalsecrets.OperandCertControllerContainer, certControllerArg, false) + waitForOperandArg(externalsecrets.OperandBitwardenSDKServerDeployment, externalsecrets.OperandBitwardenContainer, bitwardenArg, false) + }) +}) diff --git a/test/go.mod b/test/go.mod index 63de809f8..adfe69e73 100644 --- a/test/go.mod +++ b/test/go.mod @@ -22,10 +22,13 @@ require ( require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect @@ -45,6 +48,7 @@ require ( github.com/go-openapi/swag/yamlutils v0.24.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250903194437-c28834ac2320 // indirect @@ -58,6 +62,7 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/ginkgo v1.16.5 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -65,6 +70,8 @@ require ( github.com/prometheus/procfs v0.17.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect @@ -78,13 +85,20 @@ require ( golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.13.0 // indirect golang.org/x/tools v0.47.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiserver v0.34.4 // indirect + k8s.io/component-base v0.34.4 // indirect + k8s.io/component-helpers v0.32.1 // indirect + k8s.io/controller-manager v0.32.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/kubelet v0.32.1 // indirect + k8s.io/kubernetes v1.34.4 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/gateway-api v1.1.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/test/go.sum b/test/go.sum index 83db7e974..dc886d096 100644 --- a/test/go.sum +++ b/test/go.sum @@ -4,6 +4,8 @@ github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cert-manager/cert-manager v1.18.5 h1:Gx4FSpSPYcSC4MQf43QjbxDfyTEbwZgfZQs5Lq9QlBs= github.com/cert-manager/cert-manager v1.18.5/go.mod h1:HbPSO5MW/44wu19t84eY/K4c4/WwyPB4bA3uffOH92s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -12,8 +14,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -76,6 +82,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -84,6 +92,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250903194437-c28834ac2320 h1:c7ayAhbRP9HnEl/hg/WQOM9s0snWztfW6feWXZbGHw0= github.com/google/pprof v0.0.0-20250903194437-c28834ac2320/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -101,12 +111,16 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/krishicks/yaml-patch v0.0.10 h1:H4FcHpnNwVmw8u0MjPRjWyIXtco6zM2F78t+57oNM3E= github.com/krishicks/yaml-patch v0.0.10/go.mod h1:Sm5TchwZS6sm7RJoyg87tzxm2ZcKzdRE4Q7TjNhPrME= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= @@ -134,6 +148,10 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -170,6 +188,10 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -267,12 +289,24 @@ k8s.io/apiextensions-apiserver v0.34.4 h1:TAh2mEduc27sR7lfEthOL2oNeQuux9pQCEJCVC k8s.io/apiextensions-apiserver v0.34.4/go.mod h1:13rZ7iu/F4APVV0I0StgBmhvWBGgjGTDaqi21G0113E= k8s.io/apimachinery v0.34.4 h1:C5SiSzLEMyWIk53sSbnk0WlOOyqv/MFnWvuc/d6M+xc= k8s.io/apimachinery v0.34.4/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.4 h1:QmMakuCjlFBJpsXKIUom8OUE7+PhZk7hyNiLqlyDH58= +k8s.io/apiserver v0.34.4/go.mod h1:4dM2Pfd+VQQA/4pLVPorZJbIadaTLcvgQn2GYYcA6Ic= k8s.io/client-go v0.34.4 h1:IXhvzFdm0e897kXtLbeyMpAGzontcShJ/gi/XCCsOLc= k8s.io/client-go v0.34.4/go.mod h1:tXIVJTQabT5QRGlFdxZQFxrIhcGUPpKL5DAc4gSWTE8= +k8s.io/component-base v0.34.4 h1:jP4XqR48YelfXIlRpOHQgms5GebU23zSE6xcvTwpXDE= +k8s.io/component-base v0.34.4/go.mod h1:uujRfLNOwNiFWz47eBjNZEj/Swn2cdhqI7lW2MeFdrU= +k8s.io/component-helpers v0.32.1 h1:TwdsSM1vW9GjnfX18lkrZbwE5G9psCIS2/rhenTDXd8= +k8s.io/component-helpers v0.32.1/go.mod h1:1JT1Ei3FD29yFQ18F3laj1WyvxYdHIhyxx6adKMFQXI= +k8s.io/controller-manager v0.32.1 h1:z3oQp1O5l0cSzM/MKf8V4olhJ9TmnELoJRPcV/v1s+Y= +k8s.io/controller-manager v0.32.1/go.mod h1:dVA1UZPbqHH4hEhrrnLvQ4d5qVQCklNB8GEzYV59v/4= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubelet v0.32.1 h1:bB91GvMsZb+LfzBxnjPEr1Fal/sdxZtYphlfwAaRJGw= +k8s.io/kubelet v0.32.1/go.mod h1:4sAEZ6PlewD0GroV3zscY7llym6kmNNTVmUI/Qshm6w= +k8s.io/kubernetes v1.34.4 h1:Yy6R4QB8C9kJPp25GFqEvX5XQwY5qzKeqD0Xx6oAcmk= +k8s.io/kubernetes v1.34.4/go.mod h1:m6pZk6a179pRo2wsTiCPORJ86iOEQmfIzUvtyEF8BwA= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.22.5 h1:v3nfSUMowX/2WMp27J9slwGFyAt7IV0YwBxAkrUr0GE=