Browse Source

test: add v2 e2e framework support

Moritz Johner 1 month ago
parent
commit
ccc13f8a88
42 changed files with 4094 additions and 264 deletions
  1. 17 1
      .github/actions/e2e/action.yml
  2. 37 3
      .github/workflows/e2e.yml
  3. 12 0
      Makefile
  4. 78 18
      e2e/Makefile
  5. 96 20
      e2e/framework/addon/chart.go
  6. 127 0
      e2e/framework/addon/chart_test.go
  7. 3 5
      e2e/framework/addon/conjur.go
  8. 32 1
      e2e/framework/addon/eso.go
  9. 3 22
      e2e/framework/addon/eso_argocd_application.go
  10. 80 0
      e2e/framework/addon/eso_chart_defaults_test.go
  11. 4 23
      e2e/framework/addon/eso_flux_helm.go
  12. 44 0
      e2e/framework/addon/eso_test.go
  13. 186 0
      e2e/framework/addon/eso_v2_mutators.go
  14. 353 0
      e2e/framework/addon/eso_v2_mutators_test.go
  15. 2 1
      e2e/framework/addon/helmserver.go
  16. 65 0
      e2e/framework/addon/install_eso_crds.go
  17. 4 2
      e2e/framework/addon/port_forward.go
  18. 45 8
      e2e/framework/addon/uninstall_eso_crds.go
  19. 75 0
      e2e/framework/addon/uninstall_eso_crds_test.go
  20. 5 6
      e2e/framework/addon/vault.go
  21. 75 0
      e2e/framework/addon/webhook.go
  22. 113 0
      e2e/framework/addon/webhook_test.go
  23. 13 6
      e2e/framework/eso.go
  24. 31 11
      e2e/framework/framework.go
  25. 60 0
      e2e/framework/framework_test.go
  26. 39 0
      e2e/framework/provider_mode.go
  27. 122 19
      e2e/framework/testcase.go
  28. 205 0
      e2e/framework/testcase_test.go
  29. 110 10
      e2e/framework/util/util.go
  30. 187 0
      e2e/framework/util/util_test.go
  31. 305 0
      e2e/framework/v2/helpers.go
  32. 146 0
      e2e/framework/v2/helpers_test.go
  33. 386 0
      e2e/framework/v2/metrics.go
  34. 106 0
      e2e/framework/v2/metrics_test.go
  35. 148 0
      e2e/framework/v2/operational.go
  36. 35 35
      e2e/go.mod
  37. 66 66
      e2e/go.sum
  38. 187 0
      e2e/makefile_test.go
  39. 24 7
      e2e/run.sh
  40. 34 0
      e2e/run_test.go
  41. 367 0
      hack/install-eso-v2-e2e.sh
  42. 67 0
      hack/uninstall-eso-v2-e2e.sh

+ 17 - 1
.github/actions/e2e/action.yml

@@ -1,6 +1,12 @@
 name: "e2e"
 description: "runs our e2e test suite"
 
+inputs:
+  make-target:
+    description: "Make target to execute (for example: test.e2e or test.e2e.v2)"
+    required: false
+    default: "test.e2e"
+
 runs:
   using: composite
   steps:
@@ -56,4 +62,14 @@ runs:
       shell: bash
       env:
         DOCKER_BUILD_ARGS: --load
-      run: make test.e2e
+        MAKE_TARGET: ${{ inputs.make-target }}
+      run: |
+        case "$MAKE_TARGET" in
+          test.e2e|test.e2e.v2|test.e2e.v2.operational)
+            make "$MAKE_TARGET"
+            ;;
+          *)
+            echo "unsupported make target: $MAKE_TARGET" >&2
+            exit 1
+            ;;
+        esac

+ 37 - 3
.github/workflows/e2e.yml

@@ -18,7 +18,22 @@ env:
 jobs:
 
   integration-trusted:
+    name: integration-trusted (${{ matrix.suite.name }})
     runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        suite:
+        - name: classic
+          make_target: test.e2e
+          allow_failure: false
+        - name: v2
+          make_target: test.e2e.v2
+          allow_failure: true
+        - name: v2-operational
+          make_target: test.e2e.v2.operational
+          allow_failure: true
+    continue-on-error: ${{ matrix.suite.allow_failure }}
     permissions:
       id-token: write #for oidc auth with aws/gcp/azure
       contents: read  #for checkout
@@ -66,10 +81,27 @@ jobs:
       run: git fetch --prune --unshallow
 
     - uses: ./.github/actions/e2e
+      with:
+        make-target: ${{ matrix.suite.make_target }}
 
   # Repo owner has commented /ok-to-test on a (fork-based) pull request
   integration-fork:
+    name: integration-fork (${{ matrix.suite.name }})
     runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        suite:
+        - name: classic
+          make_target: test.e2e
+          allow_failure: false
+        - name: v2
+          make_target: test.e2e.v2
+          allow_failure: true
+        - name: v2-operational
+          make_target: test.e2e.v2.operational
+          allow_failure: true
+    continue-on-error: ${{ matrix.suite.allow_failure }}
     permissions:
       id-token: write      #for oidc auth with aws/gcp/azure
       contents: read       #for checkout
@@ -121,8 +153,10 @@ jobs:
 
     - id: e2e
       uses: ./.github/actions/e2e
+      with:
+        make-target: ${{ matrix.suite.make_target }}
     - id: create_token
-      if: always()
+      if: always() && matrix.suite.name == 'classic'
       uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
       env:
         APP_ID: ${{ secrets.APP_ID }}
@@ -132,7 +166,7 @@ jobs:
         owner: ${{ github.repository_owner }}
 
     - name: Update on Succeess
-      if: always() && steps.e2e.conclusion == 'success'
+      if: always() && matrix.suite.name == 'classic' && steps.e2e.conclusion == 'success'
       uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
       with:
         token: ${{ steps.create_token.outputs.token }}
@@ -140,7 +174,7 @@ jobs:
         body: |
             [Bot] - :white_check_mark: [e2e for ${{ env.TARGET_SHA }} passed](https://github.com/external-secrets/external-secrets/actions/runs/${{ github.run_id }})
     - name: Update on Failure
-      if: always() &&  steps.e2e.conclusion != 'success'
+      if: always() && matrix.suite.name == 'classic' && steps.e2e.conclusion != 'success'
       uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
       with:
         token: ${{ steps.create_token.outputs.token }}

+ 12 - 0
Makefile

@@ -160,6 +160,18 @@ test.e2e.managed: generate ## Run e2e tests managed
 	$(MAKE) -C ./e2e test.managed
 	@$(OK) go test e2e-tests-managed
 
+.PHONY: test.e2e.v2
+test.e2e.v2: generate ## Run V2 E2E tests
+	@$(INFO) go test v2 e2e-tests
+	$(MAKE) -C ./e2e test.v2
+	@$(OK) go test v2 e2e-tests
+
+.PHONY: test.e2e.v2.operational
+test.e2e.v2.operational: generate ## Run focused V2 operational E2E tests
+	@$(INFO) go test v2 operational e2e-tests
+	$(MAKE) -C ./e2e test.v2.operational
+	@$(OK) go test v2 operational e2e-tests
+
 .PHONY: test.crds
 test.crds: cty crds.generate.tests ## Test CRDs for modification and backwards compatibility
 	@$(INFO) $(CTY) test tests

+ 78 - 18
e2e/Makefile

@@ -2,41 +2,100 @@ MAKEFLAGS   += --warn-undefined-variables
 SHELL       := /usr/bin/env bash
 .SHELLFLAGS := -euo pipefail -c
 
-KIND_IMG       = "kindest/node:v1.33.4@sha256:25a6018e48dfcaee478f4a59af81157a437f15e6e140bf103f85a2e7cd0cbbf2"
+KIND_IMG ?= kindest/node:v1.33.4@sha256:25a6018e48dfcaee478f4a59af81157a437f15e6e140bf103f85a2e7cd0cbbf2
+KIND_CLUSTER_NAME ?= external-secrets
+KIND_CONTEXT ?= kind-$(KIND_CLUSTER_NAME)
 DOCKER_BUILD_ARGS     ?=
+SKIP_PROVIDER_KUBERNETES_BUILD ?= false
 
 export E2E_IMAGE_NAME ?= ghcr.io/external-secrets/external-secrets-e2e
-export GINKGO_LABELS ?= !managed
+export GINKGO_LABELS ?= !managed && !v2
+export V2_GINKGO_LABELS ?= !managed && v2
 export TEST_SUITES ?= provider generator flux argocd
+export V2_TEST_SUITES ?= provider
+export GOCACHE ?= $(CURDIR)/.cache/go-build
+export GOMODCACHE ?= $(CURDIR)/.cache/go-mod
 
 export OCI_IMAGE_NAME = ghcr.io/external-secrets/external-secrets
+export IMAGE_NAME ?= $(OCI_IMAGE_NAME)
+TEST_V2_PROVIDER_KUBERNETES_BUILD_CMD = $(MAKE) -C ../ docker.build.provider.kubernetes VERSION=$(VERSION) ARCH=amd64 DOCKER_BUILD_ARGS="${DOCKER_BUILD_ARGS} --build-arg TARGETARCH=amd64 --build-arg TARGETOS=linux"
+TEST_V2_PROVIDER_AWS_BUILD_CMD = $(MAKE) -C ../ docker.build.provider.aws VERSION=$(VERSION) ARCH=amd64 DOCKER_BUILD_ARGS="${DOCKER_BUILD_ARGS} --build-arg TARGETARCH=amd64 --build-arg TARGETOS=linux"
+TEST_V2_PROVIDER_FAKE_BUILD_CMD = $(MAKE) -C ../ docker.build.provider.fake VERSION=$(VERSION) ARCH=amd64 DOCKER_BUILD_ARGS="${DOCKER_BUILD_ARGS} --build-arg TARGETARCH=amd64 --build-arg TARGETOS=linux"
+
+ifeq ($(shell git tag),)
+export VERSION ?= $(shell echo "v0.0.0-$$(git rev-list HEAD --count)-g$$(git describe --dirty --always)" | sed 's/-/./2' | sed 's/-/./2')
+else
+export VERSION ?= $(shell git describe --dirty --always --tags --exclude 'helm*' | sed 's/-/./2' | sed 's/-/./2')
+endif
 
 start-kind: ## Start kind cluster
-	kind create cluster \
-	  --name external-secrets \
-	  --config kind.yaml \
-	  --retain \
-	  --image "$(KIND_IMG)"
+	@if kind get clusters | grep -qx "$(KIND_CLUSTER_NAME)"; then \
+		kind export kubeconfig --name "$(KIND_CLUSTER_NAME)"; \
+		if kubectl --context "$(KIND_CONTEXT)" --request-timeout=5s get --raw=/readyz >/dev/null 2>&1; then \
+			echo "kind cluster $(KIND_CLUSTER_NAME) is ready"; \
+		else \
+			echo "kind cluster $(KIND_CLUSTER_NAME) exists but is unhealthy, recreating"; \
+			kind delete cluster --name "$(KIND_CLUSTER_NAME)"; \
+			kind create cluster \
+			  --name "$(KIND_CLUSTER_NAME)" \
+			  --config kind.yaml \
+			  --retain \
+			  --image "$(KIND_IMG)"; \
+		fi; \
+	else \
+		kind create cluster \
+		  --name "$(KIND_CLUSTER_NAME)" \
+		  --config kind.yaml \
+		  --retain \
+		  --image "$(KIND_IMG)"; \
+	fi
+	kind export kubeconfig --name "$(KIND_CLUSTER_NAME)"
 
 stop-kind: ## Stop kind cluster
 	kind delete cluster \
-		--name external-secrets \
+		--name "$(KIND_CLUSTER_NAME)" \
 
-test: e2e-image ## Run e2e tests against current kube context
-	$(MAKE) -C ../ docker.build \
+test: start-kind e2e-image ## Run e2e tests against current kube context
+	$(MAKE) -C ../ docker.build.controller.e2e \
+		IMAGE_NAME=$(OCI_IMAGE_NAME) \
+		VERSION=$(VERSION) \
+		ARCH=amd64 \
+		DOCKER_BUILD_ARGS="${DOCKER_BUILD_ARGS} --build-arg TARGETARCH=amd64 --build-arg TARGETOS=linux"
+	kind load docker-image --name="$(KIND_CLUSTER_NAME)" $(OCI_IMAGE_NAME):$(VERSION)
+	kind load docker-image --name="$(KIND_CLUSTER_NAME)" $(E2E_IMAGE_NAME):$(VERSION)
+	KUBECTL_CONTEXT="$(KIND_CONTEXT)" E2E_SKIP_HELM_DEPENDENCY_UPDATE="true" ./run.sh
+
+test.v2: start-kind e2e-image ## Run v2 e2e tests against current kube context
+	$(MAKE) -C ../ docker.build.controller.e2e \
 		IMAGE_NAME=$(IMAGE_NAME) \
 		VERSION=$(VERSION) \
 		ARCH=amd64 \
 		DOCKER_BUILD_ARGS="${DOCKER_BUILD_ARGS} --build-arg TARGETARCH=amd64 --build-arg TARGETOS=linux"
-	$(MAKE) -C ../ docker.build \
+	$(if $(filter true,$(SKIP_PROVIDER_KUBERNETES_BUILD)),,$(TEST_V2_PROVIDER_KUBERNETES_BUILD_CMD))
+	$(TEST_V2_PROVIDER_AWS_BUILD_CMD)
+	$(TEST_V2_PROVIDER_FAKE_BUILD_CMD)
+ifneq ($(IMAGE_NAME),$(OCI_IMAGE_NAME))
+	$(MAKE) -C ../ docker.build.controller.e2e \
 		IMAGE_NAME=$(OCI_IMAGE_NAME) \
 		VERSION=$(VERSION) \
 		ARCH=amd64 \
 		DOCKER_BUILD_ARGS="${DOCKER_BUILD_ARGS} --build-arg TARGETARCH=amd64 --build-arg TARGETOS=linux"
-	kind load docker-image --name="external-secrets" $(IMAGE_NAME):$(VERSION)
-	kind load docker-image --name="external-secrets" $(OCI_IMAGE_NAME):$(VERSION)
-	kind load docker-image --name="external-secrets" $(E2E_IMAGE_NAME):$(VERSION)
-	./run.sh
+endif
+	kind load docker-image --name="$(KIND_CLUSTER_NAME)" $(IMAGE_NAME):$(VERSION)
+ifneq ($(IMAGE_NAME),$(OCI_IMAGE_NAME))
+	kind load docker-image --name="$(KIND_CLUSTER_NAME)" $(OCI_IMAGE_NAME):$(VERSION)
+endif
+	kind load docker-image --name="$(KIND_CLUSTER_NAME)" $(E2E_IMAGE_NAME):$(VERSION)
+	kind load docker-image --name="$(KIND_CLUSTER_NAME)" ghcr.io/external-secrets/provider-kubernetes:$(VERSION)
+	kind load docker-image --name="$(KIND_CLUSTER_NAME)" ghcr.io/external-secrets/provider-aws:$(VERSION)
+	kind load docker-image --name="$(KIND_CLUSTER_NAME)" ghcr.io/external-secrets/provider-fake:$(VERSION)
+ifeq ($(CI),true)
+	docker system prune --all --force --volumes
+endif
+	KUBECTL_CONTEXT="$(KIND_CONTEXT)" GINKGO_LABELS="$(V2_GINKGO_LABELS)" E2E_PROVIDER_MODE="v2" TEST_SUITES="$(V2_TEST_SUITES)" E2E_SKIP_HELM_DEPENDENCY_UPDATE="true" ./run.sh
+
+test.v2.operational: ## Run focused operational v2 e2e tests
+	$(MAKE) test.v2 V2_GINKGO_LABELS='v2 && operational && fake' V2_TEST_SUITES='provider'
 
 test.managed: e2e-image ## Run e2e tests against current kube context
 	$(MAKE) -C ../ docker.build \
@@ -56,23 +115,24 @@ test.managed: e2e-image ## Run e2e tests against current kube context
 	$(MAKE) -C ../ docker.push \
 		IMAGE_NAME=$(E2E_IMAGE_NAME) \
 		VERSION=$(VERSION)
-	./run.sh
+	E2E_SKIP_HELM_DEPENDENCY_UPDATE="true" ./run.sh
 
 
 e2e-bin: install-ginkgo
-	   CGO_ENABLED=0 ginkgo build ./suites/...
+	   GOWORK=off CGO_ENABLED=0 ginkgo build ./suites/...
 
 e2e-image: e2e-bin
 	-rm -rf ./k8s/deploy
 	mkdir -p k8s
 	$(MAKE) -C ../ helm.generate
+	helm dependency build ../deploy/charts/external-secrets
 	cp -r ../deploy ./k8s
 	docker build $(DOCKER_BUILD_ARGS) -t $(E2E_IMAGE_NAME):$(VERSION) -f Dockerfile ..
 
 GINKGO_VERSION := $(shell grep 'github.com/onsi/ginkgo/v2' go.mod | awk '{print $$2}')
 install-ginkgo:
 	   @echo "Installing ginkgo version $(GINKGO_VERSION) from go.mod"
-	   go install github.com/onsi/ginkgo/v2/ginkgo@$(GINKGO_VERSION)
+	   GOWORK=off go install github.com/onsi/ginkgo/v2/ginkgo@$(GINKGO_VERSION)
 
 help: ## displays this help message
 	@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_\/-]+:.*?## / {printf "\033[34m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | \

+ 96 - 20
e2e/framework/addon/chart.go

@@ -19,14 +19,17 @@ package addon
 import (
 	"bytes"
 	"fmt"
+	"os"
 	"os/exec"
 	"path/filepath"
+	"strings"
 
-	. "github.com/onsi/ginkgo/v2"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
 	"github.com/external-secrets/external-secrets-e2e/framework/log"
+
+	. "github.com/onsi/ginkgo/v2"
 )
 
 // HelmChart installs the specified Chart into the cluster.
@@ -62,29 +65,62 @@ func (c *HelmChart) Setup(cfg *Config) error {
 
 // Install adds the chart repo and installs the helm chart.
 func (c *HelmChart) Install() error {
-	args := []string{
-		"dependency", "update", filepath.Join(AssetDir(), "deploy/charts/external-secrets"),
+	if helmDependencyUpdateEnabled() {
+		args := []string{
+			"dependency", "update", filepath.Join(AssetDir(), "deploy/charts/external-secrets"),
+		}
+		log.Logf("updating chart dependencies with args: %+q", args)
+		cmd := exec.Command("helm", args...)
+		output, err := cmd.CombinedOutput()
+		if err != nil {
+			return fmt.Errorf("unable to run update cmd: %w: %s", err, string(output))
+		}
 	}
-	log.Logf("updating chart dependencies with args: %+q", args)
-	cmd := exec.Command("helm", args...)
-	output, err := cmd.CombinedOutput()
+
+	err := c.addRepo()
 	if err != nil {
-		return fmt.Errorf("unable to run update cmd: %w: %s", err, string(output))
+		return err
 	}
 
-	err = c.addRepo()
+	args := c.installArgs()
+	output, err := c.runInstall(args)
 	if err != nil {
-		return err
+		if !isHelmReleaseNameInUseError(string(output)) {
+			return fmt.Errorf("unable to run cmd: %w: %s", err, string(output))
+		}
+
+		log.Logf("helm install detected stale release state for %q in namespace %q; attempting cleanup", c.ReleaseName, c.Namespace)
+		if cleanupErr := c.cleanupExistingRelease(); cleanupErr != nil {
+			return fmt.Errorf("unable to clean stale helm release %s/%s after install failure: %w", c.Namespace, c.ReleaseName, cleanupErr)
+		}
+
+		output, err = c.runInstall(args)
+		if err != nil {
+			return fmt.Errorf("unable to run cmd after stale release cleanup: %w: %s", err, string(output))
+		}
 	}
 
-	args = []string{"install", c.ReleaseName, c.Chart,
-		"--dependency-update",
+	log.Logf("finished running chart install")
+
+	return nil
+}
+
+func helmDependencyUpdateEnabled() bool {
+	return os.Getenv("E2E_SKIP_HELM_DEPENDENCY_UPDATE") != "true"
+}
+
+func (c *HelmChart) installArgs() []string {
+	args := []string{"install", c.ReleaseName, c.Chart}
+	if helmDependencyUpdateEnabled() {
+		args = append(args, "--dependency-update")
+	}
+	args = append(args,
 		"--debug",
 		"--wait",
 		"--timeout", "600s",
 		"-o", "yaml",
 		"--namespace", c.Namespace,
-	}
+	)
 
 	if c.ChartVersion != "" {
 		args = append(args, "--version", c.ChartVersion)
@@ -99,23 +135,63 @@ func (c *HelmChart) Install() error {
 	}
 
 	args = append(args, c.Args...)
+	return args
+}
+
+func (c *HelmChart) uninstallArgs() []string {
+	return []string{"uninstall", "--namespace", c.Namespace, c.ReleaseName, "--wait", "--ignore-not-found"}
+}
+
+func (c *HelmChart) cleanupUninstallArgs() []string {
+	return []string{"uninstall", "--namespace", c.Namespace, c.ReleaseName, "--ignore-not-found"}
+}
+
+func (c *HelmChart) releaseStatusArgs() []string {
+	return []string{"status", "--namespace", c.Namespace, c.ReleaseName}
+}
 
+func (c *HelmChart) runInstall(args []string) ([]byte, error) {
 	log.Logf("installing chart with args: %+q", args)
-	cmd = exec.Command("helm", args...)
-	output, err = cmd.CombinedOutput()
-	if err != nil {
-		return fmt.Errorf("unable to run cmd: %w: %s", err, string(output))
+	cmd := exec.Command("helm", args...)
+	return cmd.CombinedOutput()
+}
+
+func (c *HelmChart) cleanupExistingRelease() error {
+	cmd := exec.Command("helm", c.cleanupUninstallArgs()...)
+	output, err := cmd.CombinedOutput()
+	if err != nil && !strings.Contains(string(output), "release: not found") {
+		statusOutput, statusErr := c.releaseStatus()
+		if canIgnoreHelmCleanupError(string(statusOutput)) {
+			return nil
+		}
+		if statusErr != nil {
+			return fmt.Errorf("unable to uninstall stale helm release: %w: %s (status check failed: %v: %s)", err, string(output), statusErr, string(statusOutput))
+		}
+		return fmt.Errorf("unable to uninstall stale helm release: %w: %s", err, string(output))
 	}
+	return nil
+}
 
-	log.Logf("finished running chart install")
+func (c *HelmChart) releaseStatus() ([]byte, error) {
+	cmd := exec.Command("helm", c.releaseStatusArgs()...)
+	return cmd.CombinedOutput()
+}
 
-	return nil
+func isHelmReleaseNameInUseError(output string) bool {
+	return strings.Contains(output, "cannot re-use a name that is still in use")
+}
+
+func isHelmReleaseNotFoundError(output string) bool {
+	return strings.Contains(output, "release: not found")
+}
+
+func canIgnoreHelmCleanupError(statusOutput string) bool {
+	return isHelmReleaseNotFoundError(statusOutput)
 }
 
 // Uninstall removes the chart aswell as the repo.
 func (c *HelmChart) Uninstall() error {
-	args := []string{"uninstall", "--namespace", c.Namespace, c.ReleaseName, "--wait"}
-	cmd := exec.Command("helm", args...)
+	cmd := exec.Command("helm", c.uninstallArgs()...)
 	output, err := cmd.CombinedOutput()
 	if err != nil {
 		return fmt.Errorf("unable to uninstall helm release: %w: %s", err, string(output))

+ 127 - 0
e2e/framework/addon/chart_test.go

@@ -0,0 +1,127 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import "testing"
+
+func TestHelmDependencyUpdateEnabledByDefault(t *testing.T) {
+	t.Setenv("E2E_SKIP_HELM_DEPENDENCY_UPDATE", "")
+
+	if !helmDependencyUpdateEnabled() {
+		t.Fatalf("expected helm dependency update to be enabled by default")
+	}
+}
+
+func TestHelmDependencyUpdateCanBeSkipped(t *testing.T) {
+	t.Setenv("E2E_SKIP_HELM_DEPENDENCY_UPDATE", "true")
+
+	if helmDependencyUpdateEnabled() {
+		t.Fatalf("expected helm dependency update to be disabled when E2E_SKIP_HELM_DEPENDENCY_UPDATE=true")
+	}
+}
+
+func TestInstallArgsIncludeDependencyUpdateByDefault(t *testing.T) {
+	t.Setenv("E2E_SKIP_HELM_DEPENDENCY_UPDATE", "")
+
+	args := (&HelmChart{
+		ReleaseName: "eso",
+		Chart:       "/tmp/chart",
+		Namespace:   "default",
+	}).installArgs()
+
+	if !contains(args, "--dependency-update") {
+		t.Fatalf("expected install args to include --dependency-update, got %v", args)
+	}
+}
+
+func TestInstallArgsOmitDependencyUpdateWhenSkipped(t *testing.T) {
+	t.Setenv("E2E_SKIP_HELM_DEPENDENCY_UPDATE", "true")
+
+	args := (&HelmChart{
+		ReleaseName: "eso",
+		Chart:       "/tmp/chart",
+		Namespace:   "default",
+	}).installArgs()
+
+	if contains(args, "--dependency-update") {
+		t.Fatalf("expected install args to omit --dependency-update when E2E_SKIP_HELM_DEPENDENCY_UPDATE=true, got %v", args)
+	}
+}
+
+func TestUninstallArgsIncludeIgnoreNotFound(t *testing.T) {
+	args := (&HelmChart{
+		ReleaseName: "external-secrets",
+		Namespace:   "external-secrets-system",
+	}).uninstallArgs()
+
+	if !contains(args, "--ignore-not-found") {
+		t.Fatalf("expected uninstall args to include --ignore-not-found, got %v", args)
+	}
+}
+
+func TestCleanupUninstallArgsOmitWait(t *testing.T) {
+	args := (&HelmChart{
+		ReleaseName: "external-secrets",
+		Namespace:   "external-secrets-system",
+	}).cleanupUninstallArgs()
+
+	if contains(args, "--wait") {
+		t.Fatalf("expected stale release cleanup uninstall args to omit --wait, got %v", args)
+	}
+	if !contains(args, "--ignore-not-found") {
+		t.Fatalf("expected stale release cleanup uninstall args to include --ignore-not-found, got %v", args)
+	}
+}
+
+func TestIsHelmReleaseNameInUseError(t *testing.T) {
+	if !isHelmReleaseNameInUseError("Error: INSTALLATION FAILED: cannot re-use a name that is still in use") {
+		t.Fatal("expected stale release message to be detected")
+	}
+	if isHelmReleaseNameInUseError("release: not found") {
+		t.Fatal("did not expect unrelated helm output to be detected as stale release state")
+	}
+}
+
+func TestIsHelmReleaseNotFoundError(t *testing.T) {
+	if !isHelmReleaseNotFoundError("Error: release: not found") {
+		t.Fatal("expected missing release message to be detected")
+	}
+	if isHelmReleaseNotFoundError("STATUS: deployed") {
+		t.Fatal("did not expect deployed release output to be treated as missing")
+	}
+}
+
+func TestCanIgnoreHelmCleanupErrorWhenReleaseMissingAfterUninstallFailure(t *testing.T) {
+	if !canIgnoreHelmCleanupError("Error: release: not found") {
+		t.Fatal("expected cleanup error to be ignored when release status reports not found")
+	}
+}
+
+func TestCanIgnoreHelmCleanupErrorWhenReleaseStillExists(t *testing.T) {
+	if canIgnoreHelmCleanupError("NAME: external-secrets\nSTATUS: uninstalling") {
+		t.Fatal("did not expect cleanup error to be ignored when release still exists")
+	}
+}
+
+func contains(values []string, want string) bool {
+	for _, value := range values {
+		if value == want {
+			return true
+		}
+	}
+	return false
+}

+ 3 - 5
e2e/framework/addon/conjur.go

@@ -27,15 +27,13 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/cyberark/conjur-api-go/conjurapi"
+	"github.com/cyberark/conjur-api-go/conjurapi/authn"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
-	// nolint
+	"github.com/external-secrets/external-secrets-e2e/framework/util"
 
 	. "github.com/onsi/ginkgo/v2"
-
-	"github.com/cyberark/conjur-api-go/conjurapi"
-	"github.com/cyberark/conjur-api-go/conjurapi/authn"
-	"github.com/external-secrets/external-secrets-e2e/framework/util"
 )
 
 type Conjur struct {

+ 32 - 1
e2e/framework/addon/eso.go

@@ -208,13 +208,32 @@ func WithAllowGenericTargets() MutationFunc {
 }
 
 func (l *ESO) Install() error {
+	restoreInstallCRDs := false
+	if needsCRDPreinstall(l.HelmChart) {
+		By("Pre-installing eso CRDs")
+		if err := installCRDs(l.config); err != nil {
+			return err
+		}
+		setOrAppendVar(l.HelmChart, StringTuple{
+			Key:   installCRDsVar,
+			Value: "false",
+		})
+		restoreInstallCRDs = true
+	}
+	if restoreInstallCRDs {
+		defer setOrAppendVar(l.HelmChart, StringTuple{
+			Key:   installCRDsVar,
+			Value: "true",
+		})
+	}
+
 	By("Installing eso\n")
 	err := l.HelmChart.Install()
 	if err != nil {
 		return err
 	}
 
-	return nil
+	return waitForExternalSecretWebhookReady(webhookServiceName(l.ReleaseName), l.Namespace)
 }
 
 func (l *ESO) Uninstall() error {
@@ -238,3 +257,15 @@ func (l *ESO) Uninstall() error {
 	}
 	return nil
 }
+
+func needsCRDPreinstall(chart *HelmChart) bool {
+	if !chart.HasVar(installCRDsVar, "true") {
+		return false
+	}
+	for _, variable := range chart.Vars {
+		if variable.Key == "crds.createClusterProviderClass" {
+			return variable.Value == "true"
+		}
+	}
+	return true
+}

+ 3 - 22
e2e/framework/addon/eso_argocd_application.go

@@ -17,21 +17,19 @@ limitations under the License.
 package addon
 
 import (
-	"bytes"
 	"context"
-	"crypto/tls"
 	"fmt"
-	"net/http"
 	"strings"
 	"time"
 
-	. "github.com/onsi/ginkgo/v2"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/dynamic"
 	"sigs.k8s.io/yaml"
+
+	. "github.com/onsi/ginkgo/v2"
 )
 
 // HelmChart installs the specified Chart into the cluster.
@@ -140,24 +138,7 @@ func (c *ArgoCDApplication) Install() error {
 		return fmt.Errorf("failed waiting for argo app to become ready: %w", err)
 	}
 
-	// we have to wait for the webhook to become ready
-	tr := &http.Transport{
-		// nolint:gosec
-		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
-	}
-	client := &http.Client{Transport: tr}
-	return wait.PollUntilContextTimeout(GinkgoT().Context(), time.Second, time.Minute*5, true, func(ctx context.Context) (bool, error) {
-		const payload = `{"apiVersion": "admission.k8s.io/v1","kind": "AdmissionReview","request": {"uid": "test","kind": {"group": "external-secrets.io","version": "v1","kind": "ExternalSecret"}, "resource": {"group": "external-secrets.io","version": "v1","kind": "ExternalSecret"},"dryRun": true, "operation": "CREATE", "userInfo":{"username":"test","uid":"test","groups":[],"extra":{}}}}`
-		res, err := client.Post("https://external-secrets-webhook.external-secrets.svc.cluster.local/validate-external-secrets-io-v1-externalsecret", "application/json", bytes.NewBufferString(payload))
-		if err != nil {
-			return false, nil
-		}
-		defer func() {
-			_ = res.Body.Close()
-		}()
-		GinkgoWriter.Printf("webhook res: %d", res.StatusCode)
-		return res.StatusCode == http.StatusOK, nil
-	})
+	return waitForExternalSecretWebhookReady(webhookServiceName(c.Name), c.DestinationNamespace)
 }
 
 // Uninstall removes the chart aswell as the repo.

+ 80 - 0
e2e/framework/addon/eso_chart_defaults_test.go

@@ -0,0 +1,80 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"sigs.k8s.io/yaml"
+)
+
+func TestExternalSecretsChartDefaultsEnableV2StoreAPIs(t *testing.T) {
+	path := filepath.Join("..", "..", "..", "deploy", "charts", "external-secrets", "values.yaml")
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read values.yaml: %v", err)
+	}
+
+	var values map[string]any
+	if err := yaml.Unmarshal(data, &values); err != nil {
+		t.Fatalf("unmarshal values.yaml: %v", err)
+	}
+
+	crds := requireMap(t, values, "crds")
+	v2 := requireMap(t, values, "v2")
+
+	requireBool(t, crds, "createClusterProviderClass", true)
+	requireBool(t, crds, "createProviderStore", true)
+	requireBool(t, crds, "createClusterProviderStore", true)
+	requireBool(t, v2, "enabled", true)
+}
+
+func requireMap(t *testing.T, values map[string]any, key string) map[string]any {
+	t.Helper()
+
+	raw, ok := values[key]
+	if !ok {
+		t.Fatalf("missing %q", key)
+	}
+
+	out, ok := raw.(map[string]any)
+	if !ok {
+		t.Fatalf("%q is %T, want map[string]any", key, raw)
+	}
+
+	return out
+}
+
+func requireBool(t *testing.T, values map[string]any, key string, want bool) {
+	t.Helper()
+
+	raw, ok := values[key]
+	if !ok {
+		t.Fatalf("missing %q", key)
+	}
+
+	got, ok := raw.(bool)
+	if !ok {
+		t.Fatalf("%q is %T, want bool", key, raw)
+	}
+
+	if got != want {
+		t.Fatalf("%q = %t, want %t", key, got, want)
+	}
+}

+ 4 - 23
e2e/framework/addon/eso_flux_helm.go

@@ -17,22 +17,20 @@ limitations under the License.
 package addon
 
 import (
-	"bytes"
 	"context"
-	"crypto/tls"
-	"net/http"
 	"time"
 
 	fluxhelm "github.com/fluxcd/helm-controller/api/v2"
 	"github.com/fluxcd/pkg/apis/meta"
 	fluxsrc "github.com/fluxcd/source-controller/api/v1"
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
 	v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/wait"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
 )
 
 const fluxNamespace = "flux-system"
@@ -130,24 +128,7 @@ func (c *FluxHelmRelease) Install() error {
 		return err
 	}
 
-	// we have to wait for the webhook to become ready
-	tr := &http.Transport{
-		// nolint:gosec
-		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
-	}
-	client := &http.Client{Transport: tr}
-	return wait.PollUntilContextTimeout(GinkgoT().Context(), time.Second, time.Minute*5, true, func(ctx context.Context) (bool, error) {
-		const payload = `{"apiVersion": "admission.k8s.io/v1","kind": "AdmissionReview","request": {"uid": "test","kind": {"group": "external-secrets.io","version": "v1","kind": "ExternalSecret"}, "resource": "external-secrets.io/v1.externalsecrets","dryRun": true, "operation": "CREATE", "userInfo":{"username":"test","uid":"test","groups":[],"extra":{}}}}`
-		res, err := client.Post("https://external-secrets-webhook.external-secrets.svc.cluster.local/validate-external-secrets-io-v1-externalsecret", "application/json", bytes.NewBufferString(payload))
-		if err != nil {
-			return false, nil
-		}
-		defer func() {
-			_ = res.Body.Close()
-		}()
-		GinkgoWriter.Printf("webhook res: %d", res.StatusCode)
-		return res.StatusCode == http.StatusOK, nil
-	})
+	return waitForExternalSecretWebhookReady(webhookServiceName(c.Name), c.TargetNamespace)
 }
 
 // Uninstall removes the chart aswell as the repo.

+ 44 - 0
e2e/framework/addon/eso_test.go

@@ -0,0 +1,44 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import "testing"
+
+func TestNeedsCRDPreinstallForV2ProvidersWhenHelmWouldCreateCRDs(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO(WithCRDs(), WithV2FakeProvider())
+	if !needsCRDPreinstall(eso.HelmChart) {
+		t.Fatal("expected v2 provider install with installCRDs=true to require CRD preinstall")
+	}
+}
+
+func TestNeedsCRDPreinstallDisabledWhenHelmCRDsDisabled(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO(WithV2FakeProvider())
+	if needsCRDPreinstall(eso.HelmChart) {
+		t.Fatal("did not expect CRD preinstall when installCRDs=false")
+	}
+}
+
+func TestNeedsCRDPreinstallForDefaultV2RuntimeCRDs(t *testing.T) {
+	eso := NewESO(WithCRDs())
+	if !needsCRDPreinstall(eso.HelmChart) {
+		t.Fatal("expected default chart v2 runtime CRDs to require CRD preinstall")
+	}
+}

+ 186 - 0
e2e/framework/addon/eso_v2_mutators.go

@@ -0,0 +1,186 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import (
+	"os"
+	"strconv"
+	"strings"
+)
+
+const (
+	v2HelmNamespace   = "external-secrets-system"
+	v2HelmReleaseName = "external-secrets"
+)
+
+func WithV2Namespace() MutationFunc {
+	return func(eso *ESO) {
+		eso.HelmChart.Namespace = v2HelmNamespace
+		eso.HelmChart.ReleaseName = v2HelmReleaseName
+		if !containsArg(eso.HelmChart.Args, "--create-namespace") {
+			eso.HelmChart.Args = append(eso.HelmChart.Args, "--create-namespace")
+		}
+	}
+}
+
+func WithV2KubernetesProvider() MutationFunc {
+	return func(eso *ESO) {
+		ensureV2ProviderConfig(eso.HelmChart)
+		setProvider(eso.HelmChart, "kubernetes", "kubernetes", "ghcr.io/external-secrets/provider-kubernetes", os.Getenv("VERSION"))
+	}
+}
+
+func WithV2FakeProvider() MutationFunc {
+	return func(eso *ESO) {
+		ensureV2ProviderConfig(eso.HelmChart)
+		setProvider(eso.HelmChart, "fake", "fake", "ghcr.io/external-secrets/provider-fake", os.Getenv("VERSION"))
+	}
+}
+
+func WithV2AWSProvider() MutationFunc {
+	return func(eso *ESO) {
+		ensureV2ProviderConfig(eso.HelmChart)
+		setProvider(eso.HelmChart, "aws", "aws", "ghcr.io/external-secrets/provider-aws", os.Getenv("VERSION"))
+	}
+}
+
+func WithV2ProviderServiceAccount(providerName, serviceAccountName string) MutationFunc {
+	return func(eso *ESO) {
+		index := findProviderIndex(eso.HelmChart, providerName)
+		if index < 0 {
+			panic("provider entry must exist before overriding service account")
+		}
+
+		prefix := "providers.list[" + strconv.Itoa(index) + "].serviceAccount"
+		setOrAppendVar(eso.HelmChart, StringTuple{Key: prefix + ".create", Value: "false"})
+		setOrAppendVar(eso.HelmChart, StringTuple{Key: prefix + ".name", Value: serviceAccountName})
+	}
+}
+
+func setOrAppendVar(chart *HelmChart, variable StringTuple) {
+	for i := range chart.Vars {
+		if chart.Vars[i].Key == variable.Key {
+			chart.Vars[i].Value = variable.Value
+			return
+		}
+	}
+	chart.Vars = append(chart.Vars, variable)
+}
+
+func ensureV2ProviderConfig(chart *HelmChart) {
+	requiredVars := []StringTuple{
+		{Key: "v2.enabled", Value: "true"},
+		{Key: "crds.createClusterProviderClass", Value: "true"},
+		{Key: "crds.createProviderStore", Value: "true"},
+		{Key: "crds.createClusterProviderStore", Value: "true"},
+		{Key: "providers.enabled", Value: "true"},
+	}
+	for _, variable := range requiredVars {
+		setOrAppendVar(chart, variable)
+	}
+
+	defaultVars := []StringTuple{
+		{Key: "replicaCount", Value: "1"},
+		{Key: "providerDefaults.replicaCount", Value: "1"},
+	}
+	for _, variable := range defaultVars {
+		setVarIfMissing(chart, variable)
+	}
+}
+
+func setVarIfMissing(chart *HelmChart, variable StringTuple) {
+	for i := range chart.Vars {
+		if chart.Vars[i].Key == variable.Key {
+			return
+		}
+	}
+	chart.Vars = append(chart.Vars, variable)
+}
+
+func setProvider(chart *HelmChart, name, providerType, imageRepository, imageTag string) {
+	index := findProviderIndex(chart, name)
+	if index < 0 {
+		index = nextProviderIndex(chart)
+	}
+
+	prefix := "providers.list[" + strconv.Itoa(index) + "]"
+	vars := []StringTuple{
+		{Key: prefix + ".name", Value: name},
+		{Key: prefix + ".type", Value: providerType},
+		{Key: prefix + ".enabled", Value: "true"},
+		{Key: prefix + ".replicaCount", Value: "1"},
+		{Key: prefix + ".image.repository", Value: imageRepository},
+		{Key: prefix + ".image.tag", Value: imageTag},
+		{Key: prefix + ".image.pullPolicy", Value: "IfNotPresent"},
+	}
+	for _, variable := range vars {
+		setOrAppendVar(chart, variable)
+	}
+}
+
+func findProviderIndex(chart *HelmChart, name string) int {
+	const prefix = "providers.list["
+	const suffix = "].name"
+	for _, variable := range chart.Vars {
+		if !strings.HasPrefix(variable.Key, prefix) || !strings.HasSuffix(variable.Key, suffix) {
+			continue
+		}
+		if variable.Value != name {
+			continue
+		}
+		indexStr := strings.TrimSuffix(strings.TrimPrefix(variable.Key, prefix), suffix)
+		index, err := strconv.Atoi(indexStr)
+		if err == nil {
+			return index
+		}
+	}
+	return -1
+}
+
+func nextProviderIndex(chart *HelmChart) int {
+	const prefix = "providers.list["
+	maxIndex := -1
+	for _, variable := range chart.Vars {
+		if !strings.HasPrefix(variable.Key, prefix) {
+			continue
+		}
+
+		remainder := strings.TrimPrefix(variable.Key, prefix)
+		closingBracket := strings.Index(remainder, "]")
+		if closingBracket < 0 {
+			continue
+		}
+
+		index, err := strconv.Atoi(remainder[:closingBracket])
+		if err != nil {
+			continue
+		}
+		if index > maxIndex {
+			maxIndex = index
+		}
+	}
+	return maxIndex + 1
+}
+
+func containsArg(args []string, target string) bool {
+	for _, arg := range args {
+		if arg == target {
+			return true
+		}
+	}
+	return false
+}

+ 353 - 0
e2e/framework/addon/eso_v2_mutators_test.go

@@ -0,0 +1,353 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import (
+	"regexp"
+	"strconv"
+	"testing"
+)
+
+func TestWithV2FakeProvider(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO(WithV2FakeProvider())
+
+	assertV2ProviderBaseVars(t, eso.HelmChart)
+	assertVarValue(t, eso.HelmChart, "providers.enabled", "true")
+	assertProvider(
+		t,
+		eso.HelmChart,
+		"fake",
+		"fake",
+		"ghcr.io/external-secrets/provider-fake",
+		"test-version",
+	)
+	assertSequentialProviderIndexes(t, eso.HelmChart)
+
+	providers := providerEntries(t, eso.HelmChart)
+	if len(providers) != 1 {
+		t.Fatalf("expected exactly one provider entry, got %d", len(providers))
+	}
+	if providers[0].Name != "fake" {
+		t.Fatalf("expected fake to be at index 0 when standalone, got index 0 name %q", providers[0].Name)
+	}
+}
+
+func TestWithV2AWSProvider(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO(WithV2AWSProvider())
+
+	assertV2ProviderBaseVars(t, eso.HelmChart)
+	assertVarValue(t, eso.HelmChart, "providers.enabled", "true")
+	assertProvider(
+		t,
+		eso.HelmChart,
+		"aws",
+		"aws",
+		"ghcr.io/external-secrets/provider-aws",
+		"test-version",
+	)
+	assertSequentialProviderIndexes(t, eso.HelmChart)
+
+	providers := providerEntries(t, eso.HelmChart)
+	if len(providers) != 1 {
+		t.Fatalf("expected exactly one provider entry, got %d", len(providers))
+	}
+	if providers[0].Name != "aws" {
+		t.Fatalf("expected aws to be at index 0 when standalone, got index 0 name %q", providers[0].Name)
+	}
+}
+
+func TestWithV2ProvidersComposeIncludesAWS(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO(
+		WithV2Namespace(),
+		WithV2KubernetesProvider(),
+		WithV2FakeProvider(),
+		WithV2AWSProvider(),
+	)
+
+	if eso.HelmChart.Namespace != v2HelmNamespace {
+		t.Fatalf("expected namespace %q, got %q", v2HelmNamespace, eso.HelmChart.Namespace)
+	}
+	if eso.HelmChart.ReleaseName != v2HelmReleaseName {
+		t.Fatalf("expected release name %q, got %q", v2HelmReleaseName, eso.HelmChart.ReleaseName)
+	}
+	if !containsArg(eso.HelmChart.Args, "--create-namespace") {
+		t.Fatalf("expected --create-namespace arg, got %v", eso.HelmChart.Args)
+	}
+
+	assertV2ProviderBaseVars(t, eso.HelmChart)
+	assertVarValue(t, eso.HelmChart, "providers.enabled", "true")
+	assertProvider(
+		t,
+		eso.HelmChart,
+		"kubernetes",
+		"kubernetes",
+		"ghcr.io/external-secrets/provider-kubernetes",
+		"test-version",
+	)
+	assertProvider(
+		t,
+		eso.HelmChart,
+		"fake",
+		"fake",
+		"ghcr.io/external-secrets/provider-fake",
+		"test-version",
+	)
+	assertProvider(
+		t,
+		eso.HelmChart,
+		"aws",
+		"aws",
+		"ghcr.io/external-secrets/provider-aws",
+		"test-version",
+	)
+	assertSequentialProviderIndexes(t, eso.HelmChart)
+
+	providers := providerEntries(t, eso.HelmChart)
+	if providers[0].Name != "kubernetes" {
+		t.Fatalf("expected kubernetes to remain first provider entry, got %q at index 0", providers[0].Name)
+	}
+	if providers[1].Name != "fake" {
+		t.Fatalf("expected fake to be second provider entry, got %q at index 1", providers[1].Name)
+	}
+	if providers[2].Name != "aws" {
+		t.Fatalf("expected aws to be third provider entry, got %q at index 2", providers[2].Name)
+	}
+}
+
+func TestWithV2FakeProviderDoesNotDuplicateOnRepeat(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO(WithV2FakeProvider(), WithV2FakeProvider())
+
+	providers := providerEntries(t, eso.HelmChart)
+	if len(providers) != 1 {
+		t.Fatalf("expected one provider entry after applying fake mutator twice, got %d", len(providers))
+	}
+	if providers[0].Name != "fake" {
+		t.Fatalf("expected fake provider at index 0 after repeat application, got %q", providers[0].Name)
+	}
+	assertProvider(t, eso.HelmChart, "fake", "fake", "ghcr.io/external-secrets/provider-fake", "test-version")
+}
+
+func TestWithV2FakeProviderUpdatesExistingEntryInPlace(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO()
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providers.list[3].name", Value: "fake"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providers.list[3].type", Value: "fake"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providers.list[3].enabled", Value: "false"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providers.list[3].replicaCount", Value: "9"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providers.list[3].image.repository", Value: "example.invalid/old-fake"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providers.list[3].image.tag", Value: "old-tag"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providers.list[3].image.pullPolicy", Value: "Always"})
+
+	WithV2FakeProvider()(eso)
+
+	providers := providerEntries(t, eso.HelmChart)
+	if len(providers) != 1 {
+		t.Fatalf("expected one fake provider entry after in-place update, got %d", len(providers))
+	}
+	if providers[3].Name != "fake" {
+		t.Fatalf("expected fake provider to stay at index 3, got %q", providers[3].Name)
+	}
+	assertProvider(t, eso.HelmChart, "fake", "fake", "ghcr.io/external-secrets/provider-fake", "test-version")
+}
+
+func TestWithV2FakeProviderEnforcesRequiredFlags(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO()
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "replicaCount", Value: "7"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "v2.enabled", Value: "false"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "crds.createClusterProviderClass", Value: "false"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "crds.createProviderStore", Value: "false"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "crds.createClusterProviderStore", Value: "false"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providers.enabled", Value: "false"})
+	setOrAppendVar(eso.HelmChart, StringTuple{Key: "providerDefaults.replicaCount", Value: "8"})
+
+	WithV2FakeProvider()(eso)
+
+	assertVarValue(t, eso.HelmChart, "replicaCount", "7")
+	assertVarValue(t, eso.HelmChart, "v2.enabled", "true")
+	assertVarValue(t, eso.HelmChart, "crds.createClusterProviderClass", "true")
+	assertVarValue(t, eso.HelmChart, "crds.createProviderStore", "true")
+	assertVarValue(t, eso.HelmChart, "crds.createClusterProviderStore", "true")
+	assertVarValue(t, eso.HelmChart, "providers.enabled", "true")
+	assertVarValue(t, eso.HelmChart, "providerDefaults.replicaCount", "8")
+}
+
+func TestWithV2ProviderServiceAccountOverridesAWSInPlace(t *testing.T) {
+	t.Setenv("VERSION", "test-version")
+
+	eso := NewESO(WithV2AWSProvider())
+	WithV2ProviderServiceAccount("aws", "irsa-sa")(eso)
+
+	assertVarValue(t, eso.HelmChart, "providers.list[0].serviceAccount.create", "false")
+	assertVarValue(t, eso.HelmChart, "providers.list[0].serviceAccount.name", "irsa-sa")
+}
+
+func assertVarValue(t *testing.T, chart *HelmChart, key, wantValue string) {
+	t.Helper()
+
+	for _, variable := range chart.Vars {
+		if variable.Key == key {
+			if variable.Value != wantValue {
+				t.Fatalf("expected %s=%s, got %s", key, wantValue, variable.Value)
+			}
+			return
+		}
+	}
+
+	t.Fatalf("expected %s=%s to be set", key, wantValue)
+}
+
+func assertV2ProviderBaseVars(t *testing.T, chart *HelmChart) {
+	t.Helper()
+
+	assertVarValue(t, chart, "replicaCount", "1")
+	assertVarValue(t, chart, "v2.enabled", "true")
+	assertVarValue(t, chart, "crds.createClusterProviderClass", "true")
+	assertVarValue(t, chart, "crds.createProviderStore", "true")
+	assertVarValue(t, chart, "crds.createClusterProviderStore", "true")
+	assertVarValue(t, chart, "providerDefaults.replicaCount", "1")
+}
+
+func assertProvider(t *testing.T, chart *HelmChart, name, providerType, imageRepository, imageTag string) {
+	t.Helper()
+
+	for _, provider := range providerEntries(t, chart) {
+		if provider.Name != name {
+			continue
+		}
+		if provider.Type != providerType {
+			t.Fatalf("expected provider %q to have type %q, got %q", name, providerType, provider.Type)
+		}
+		if provider.Enabled != "true" {
+			t.Fatalf("expected provider %q to be enabled, got %q", name, provider.Enabled)
+		}
+		if provider.ReplicaCount != "1" {
+			t.Fatalf("expected provider %q replicaCount 1, got %q", name, provider.ReplicaCount)
+		}
+		if provider.ImageRepository != imageRepository {
+			t.Fatalf("expected provider %q image repository %q, got %q", name, imageRepository, provider.ImageRepository)
+		}
+		if provider.ImageTag != imageTag {
+			t.Fatalf("expected provider %q image tag %q, got %q", name, imageTag, provider.ImageTag)
+		}
+		if provider.ImagePullPolicy != "IfNotPresent" {
+			t.Fatalf("expected provider %q image pull policy IfNotPresent, got %q", name, provider.ImagePullPolicy)
+		}
+		return
+	}
+
+	t.Fatalf("expected provider %q to exist", name)
+}
+
+func assertSequentialProviderIndexes(t *testing.T, chart *HelmChart) {
+	t.Helper()
+
+	providers := providerEntries(t, chart)
+	for i := 0; i < len(providers); i++ {
+		if _, ok := providers[i]; !ok {
+			t.Fatalf("expected provider index %d to exist, got indexes %v", i, sortedProviderIndexes(providers))
+		}
+	}
+}
+
+type providerEntry struct {
+	Name            string
+	Type            string
+	Enabled         string
+	ReplicaCount    string
+	ImageRepository string
+	ImageTag        string
+	ImagePullPolicy string
+}
+
+var providerVarPattern = regexp.MustCompile(`^providers\.list\[(\d+)\]\.(.+)$`)
+var allowedProviderFields = map[string]struct{}{
+	"name":             {},
+	"type":             {},
+	"enabled":          {},
+	"replicaCount":     {},
+	"image.repository": {},
+	"image.tag":        {},
+	"image.pullPolicy": {},
+}
+
+func providerEntries(t *testing.T, chart *HelmChart) map[int]providerEntry {
+	t.Helper()
+
+	providers := make(map[int]providerEntry)
+	for _, variable := range chart.Vars {
+		matches := providerVarPattern.FindStringSubmatch(variable.Key)
+		if matches == nil {
+			continue
+		}
+		index, err := strconv.Atoi(matches[1])
+		if err != nil {
+			t.Fatalf("unable to parse provider index from key %q: %v", variable.Key, err)
+		}
+		field := matches[2]
+		if _, ok := allowedProviderFields[field]; !ok {
+			t.Fatalf("unexpected provider field %q in key %q", field, variable.Key)
+		}
+
+		entry := providers[index]
+		switch field {
+		case "name":
+			entry.Name = variable.Value
+		case "type":
+			entry.Type = variable.Value
+		case "enabled":
+			entry.Enabled = variable.Value
+		case "replicaCount":
+			entry.ReplicaCount = variable.Value
+		case "image.repository":
+			entry.ImageRepository = variable.Value
+		case "image.tag":
+			entry.ImageTag = variable.Value
+		case "image.pullPolicy":
+			entry.ImagePullPolicy = variable.Value
+		}
+		providers[index] = entry
+	}
+	return providers
+}
+
+func sortedProviderIndexes(providers map[int]providerEntry) []int {
+	indexes := make([]int, 0, len(providers))
+	for index := range providers {
+		indexes = append(indexes, index)
+	}
+
+	for i := 0; i < len(indexes); i++ {
+		for j := i + 1; j < len(indexes); j++ {
+			if indexes[j] < indexes[i] {
+				indexes[i], indexes[j] = indexes[j], indexes[i]
+			}
+		}
+	}
+
+	return indexes
+}

+ 2 - 1
e2e/framework/addon/helmserver.go

@@ -22,11 +22,12 @@ import (
 	"os"
 	"os/exec"
 
-	. "github.com/onsi/ginkgo/v2"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/util/intstr"
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+	. "github.com/onsi/ginkgo/v2"
 )
 
 type HelmServer struct {

+ 65 - 0
e2e/framework/addon/install_eso_crds.go

@@ -0,0 +1,65 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"path/filepath"
+	"time"
+
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+
+	. "github.com/onsi/ginkgo/v2"
+)
+
+const clusterProviderClassCRDName = "clusterproviderclasses.external-secrets.io"
+
+var (
+	externalSecretsCRDInstallPollInterval = time.Second
+	externalSecretsCRDInstallTimeout      = 5 * time.Minute
+)
+
+func installCRDs(cfg *Config) error {
+	bundlePath := filepath.Join(AssetDir(), "deploy/crds/bundle.yaml")
+	cmd := exec.Command("kubectl", "apply", "--server-side", "--force-conflicts", "-f", bundlePath)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("unable to install eso CRDs from %s: %w: %s", bundlePath, err, string(output))
+	}
+
+	return wait.PollUntilContextTimeout(GinkgoT().Context(), externalSecretsCRDInstallPollInterval, externalSecretsCRDInstallTimeout, true, func(ctx context.Context) (bool, error) {
+		var crd apiextensionsv1.CustomResourceDefinition
+		err := cfg.CRClient.Get(ctx, types.NamespacedName{Name: clusterProviderClassCRDName}, &crd)
+		if apierrors.IsNotFound(err) {
+			return false, nil
+		}
+		if err != nil {
+			return false, err
+		}
+		for _, condition := range crd.Status.Conditions {
+			if condition.Type == apiextensionsv1.Established && condition.Status == apiextensionsv1.ConditionTrue {
+				return true, nil
+			}
+		}
+		return false, nil
+	})
+}

+ 4 - 2
e2e/framework/addon/port_forward.go

@@ -22,13 +22,15 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/external-secrets/external-secrets-e2e/framework/log"
-	. "github.com/onsi/ginkgo/v2"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/portforward"
 	"k8s.io/client-go/transport/spdy"
+
+	"github.com/external-secrets/external-secrets-e2e/framework/log"
+
+	. "github.com/onsi/ginkgo/v2"
 )
 
 type PortForward struct {

+ 45 - 8
e2e/framework/addon/uninstall_eso_crds.go

@@ -17,29 +17,66 @@ limitations under the License.
 package addon
 
 import (
+	"context"
 	"strings"
+	"time"
 
-	. "github.com/onsi/ginkgo/v2"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/util/wait"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	. "github.com/onsi/ginkgo/v2"
+)
+
+var (
+	externalSecretsCRDDeletePollInterval = time.Second
+	externalSecretsCRDDeleteTimeout      = 5 * time.Minute
 )
 
 func uninstallCRDs(cfg *Config) error {
 	By("Uninstalling eso CRDs")
-	var crdList apiextensionsv1.CustomResourceDefinitionList
-	if err := cfg.CRClient.List(GinkgoT().Context(), &crdList); err != nil {
+	crdList, err := listExternalSecretsCRDs(GinkgoT().Context(), cfg)
+	if err != nil {
 		return err
 	}
 
-	for _, crd := range crdList.Items {
-		if !strings.Contains(crd.Spec.Group, "external-secrets.io") {
-			continue
-		}
+	for _, crd := range crdList {
 		err := cfg.CRClient.Delete(GinkgoT().Context(), &crd, &client.DeleteOptions{})
 		if err != nil && !apierrors.IsNotFound(err) {
 			return err
 		}
 	}
-	return nil
+
+	if len(crdList) == 0 {
+		return nil
+	}
+
+	return wait.PollUntilContextTimeout(GinkgoT().Context(), externalSecretsCRDDeletePollInterval, externalSecretsCRDDeleteTimeout, true, func(ctx context.Context) (bool, error) {
+		crds, err := listExternalSecretsCRDs(ctx, cfg)
+		if err != nil {
+			return false, err
+		}
+		return len(crds) == 0, nil
+	})
+}
+
+func listExternalSecretsCRDs(ctx context.Context, cfg *Config) ([]apiextensionsv1.CustomResourceDefinition, error) {
+	var crdList apiextensionsv1.CustomResourceDefinitionList
+	if err := cfg.CRClient.List(ctx, &crdList); err != nil {
+		return nil, err
+	}
+
+	crds := make([]apiextensionsv1.CustomResourceDefinition, 0, len(crdList.Items))
+	for _, crd := range crdList.Items {
+		if !isExternalSecretsCRDGroup(crd.Spec.Group) {
+			continue
+		}
+		crds = append(crds, crd)
+	}
+	return crds, nil
+}
+
+func isExternalSecretsCRDGroup(group string) bool {
+	return strings.Contains(group, "external-secrets.io")
 }

+ 75 - 0
e2e/framework/addon/uninstall_eso_crds_test.go

@@ -0,0 +1,75 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import (
+	"context"
+	"testing"
+
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+func TestIsExternalSecretsCRDGroup(t *testing.T) {
+	t.Helper()
+
+	if !isExternalSecretsCRDGroup("external-secrets.io") {
+		t.Fatal("expected external-secrets.io group to match")
+	}
+	if !isExternalSecretsCRDGroup("provider.external-secrets.io") {
+		t.Fatal("expected provider.external-secrets.io group to match")
+	}
+	if isExternalSecretsCRDGroup("example.com") {
+		t.Fatal("did not expect unrelated group to match")
+	}
+}
+
+func TestListExternalSecretsCRDsFiltersByGroup(t *testing.T) {
+	t.Helper()
+
+	scheme := runtime.NewScheme()
+	if err := apiextensionsv1.AddToScheme(scheme); err != nil {
+		t.Fatalf("add apiextensions scheme: %v", err)
+	}
+
+	cfg := &Config{
+		CRClient: fake.NewClientBuilder().WithScheme(scheme).WithObjects(
+			&apiextensionsv1.CustomResourceDefinition{
+				ObjectMeta: metav1.ObjectMeta{Name: "externalsecrets.external-secrets.io"},
+				Spec:       apiextensionsv1.CustomResourceDefinitionSpec{Group: "external-secrets.io"},
+			},
+			&apiextensionsv1.CustomResourceDefinition{
+				ObjectMeta: metav1.ObjectMeta{Name: "providers.provider.external-secrets.io"},
+				Spec:       apiextensionsv1.CustomResourceDefinitionSpec{Group: "provider.external-secrets.io"},
+			},
+			&apiextensionsv1.CustomResourceDefinition{
+				ObjectMeta: metav1.ObjectMeta{Name: "widgets.example.com"},
+				Spec:       apiextensionsv1.CustomResourceDefinitionSpec{Group: "example.com"},
+			},
+		).Build(),
+	}
+
+	crds, err := listExternalSecretsCRDs(context.Background(), cfg)
+	if err != nil {
+		t.Fatalf("listExternalSecretsCRDs returned error: %v", err)
+	}
+	if len(crds) != 2 {
+		t.Fatalf("expected 2 external-secrets CRDs, got %d", len(crds))
+	}
+}

+ 5 - 6
e2e/framework/addon/vault.go

@@ -32,18 +32,17 @@ import (
 	"path/filepath"
 	"time"
 
-	rbacv1 "k8s.io/api/rbac/v1"
-
-	"k8s.io/apimachinery/pkg/types"
-	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
-
 	"github.com/golang-jwt/jwt/v4"
 	vault "github.com/hashicorp/vault/api"
-	. "github.com/onsi/ginkgo/v2"
 	v1 "k8s.io/api/core/v1"
+	rbacv1 "k8s.io/api/rbac/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
 
 	"github.com/external-secrets/external-secrets-e2e/framework/util"
+
+	. "github.com/onsi/ginkgo/v2"
 )
 
 type Vault struct {

+ 75 - 0
e2e/framework/addon/webhook.go

@@ -0,0 +1,75 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"k8s.io/apimachinery/pkg/util/wait"
+
+	. "github.com/onsi/ginkgo/v2"
+)
+
+const externalSecretValidationReview = `{"apiVersion":"admission.k8s.io/v1","kind":"AdmissionReview","request":{"uid":"test","kind":{"group":"external-secrets.io","version":"v1","kind":"ExternalSecret"},"resource":{"group":"external-secrets.io","version":"v1","resource":"externalsecrets"},"dryRun":true,"operation":"CREATE","userInfo":{"username":"test","uid":"test","groups":[],"extra":{}}}}`
+const externalSecretsChartName = "external-secrets"
+
+var (
+	externalSecretWebhookURL = func(serviceName, namespace string) string {
+		return fmt.Sprintf("https://%s.%s.svc.cluster.local/validate-external-secrets-io-v1-externalsecret", serviceName, namespace)
+	}
+	webhookReadyPollInterval = time.Second
+	webhookReadyTimeout      = 5 * time.Minute
+	webhookReadyContext      = func() context.Context { return GinkgoT().Context() }
+)
+
+func webhookServiceName(releaseName string) string {
+	return fmt.Sprintf("%s-webhook", chartFullName(releaseName))
+}
+
+func chartFullName(releaseName string) string {
+	if strings.Contains(releaseName, externalSecretsChartName) {
+		return releaseName
+	}
+	return fmt.Sprintf("%s-%s", releaseName, externalSecretsChartName)
+}
+
+func waitForExternalSecretWebhookReady(serviceName, namespace string) error {
+	tr := &http.Transport{
+		// nolint:gosec
+		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+	}
+	client := &http.Client{Transport: tr}
+	url := externalSecretWebhookURL(serviceName, namespace)
+
+	return wait.PollUntilContextTimeout(webhookReadyContext(), webhookReadyPollInterval, webhookReadyTimeout, true, func(ctx context.Context) (bool, error) {
+		res, err := client.Post(url, "application/json", bytes.NewBufferString(externalSecretValidationReview))
+		if err != nil {
+			return false, nil
+		}
+		defer func() {
+			_ = res.Body.Close()
+		}()
+		GinkgoWriter.Printf("webhook res: %d", res.StatusCode)
+		return res.StatusCode == http.StatusOK, nil
+	})
+}

+ 113 - 0
e2e/framework/addon/webhook_test.go

@@ -0,0 +1,113 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 addon
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestWebhookServiceNameUsesReleaseName(t *testing.T) {
+	eso := NewESO()
+	serviceName := webhookServiceName(eso.ReleaseName)
+	if serviceName != "eso-external-secrets-webhook" {
+		t.Fatalf("expected classic ESO release to use eso-external-secrets-webhook, got %q", serviceName)
+	}
+
+	url := externalSecretWebhookURL(serviceName, eso.Namespace)
+	if !strings.Contains(url, "https://eso-external-secrets-webhook.default.") {
+		t.Fatalf("expected classic webhook readiness URL to target eso-external-secrets-webhook.default, got %q", url)
+	}
+}
+
+func TestWebhookServiceNamePreservesChartFullnameRelease(t *testing.T) {
+	serviceName := webhookServiceName("external-secrets")
+	if serviceName != "external-secrets-webhook" {
+		t.Fatalf("expected external-secrets release to use external-secrets-webhook, got %q", serviceName)
+	}
+}
+
+func TestWaitForExternalSecretWebhookReadyRetriesUntilOK(t *testing.T) {
+	t.Helper()
+
+	originalURL := externalSecretWebhookURL
+	originalPollInterval := webhookReadyPollInterval
+	originalTimeout := webhookReadyTimeout
+	originalContext := webhookReadyContext
+	t.Cleanup(func() {
+		externalSecretWebhookURL = originalURL
+		webhookReadyPollInterval = originalPollInterval
+		webhookReadyTimeout = originalTimeout
+		webhookReadyContext = originalContext
+	})
+
+	attempts := 0
+	server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		attempts++
+		if attempts < 3 {
+			http.Error(w, "not ready", http.StatusServiceUnavailable)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	externalSecretWebhookURL = func(string, string) string { return server.URL }
+	webhookReadyPollInterval = 10 * time.Millisecond
+	webhookReadyTimeout = time.Second
+	webhookReadyContext = context.Background
+
+	if err := waitForExternalSecretWebhookReady("external-secrets-webhook", "external-secrets-system"); err != nil {
+		t.Fatalf("waitForExternalSecretWebhookReady returned error: %v", err)
+	}
+	if attempts != 3 {
+		t.Fatalf("expected 3 webhook attempts, got %d", attempts)
+	}
+}
+
+func TestWaitForExternalSecretWebhookReadyTimesOut(t *testing.T) {
+	t.Helper()
+
+	originalURL := externalSecretWebhookURL
+	originalPollInterval := webhookReadyPollInterval
+	originalTimeout := webhookReadyTimeout
+	originalContext := webhookReadyContext
+	t.Cleanup(func() {
+		externalSecretWebhookURL = originalURL
+		webhookReadyPollInterval = originalPollInterval
+		webhookReadyTimeout = originalTimeout
+		webhookReadyContext = originalContext
+	})
+
+	server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		http.Error(w, "not ready", http.StatusServiceUnavailable)
+	}))
+	defer server.Close()
+
+	externalSecretWebhookURL = func(string, string) string { return server.URL }
+	webhookReadyPollInterval = 10 * time.Millisecond
+	webhookReadyTimeout = 50 * time.Millisecond
+	webhookReadyContext = context.Background
+
+	if err := waitForExternalSecretWebhookReady("external-secrets-webhook", "external-secrets-system"); err == nil {
+		t.Fatal("expected waitForExternalSecretWebhookReady to time out")
+	}
+}

+ 13 - 6
e2e/framework/eso.go

@@ -22,10 +22,6 @@ import (
 	"encoding/json"
 	"time"
 
-	//nolint
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
-
 	v1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -34,6 +30,10 @@ import (
 
 	"github.com/external-secrets/external-secrets-e2e/framework/log"
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+
+	//nolint
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
 )
 
 // WaitForSecretValue waits until a secret comes into existence and compares the secret.Data
@@ -75,9 +75,16 @@ func (f *Framework) printESDebugLogs(esName, esNamespace string) {
 	}
 
 	// print most recent logs of default eso installation
-	podList, err := f.KubeClientSet.CoreV1().Pods("default").List(
+	esoNamespace := "default"
+	labelSelector := "app.kubernetes.io/instance=eso,app.kubernetes.io/name=external-secrets"
+	if IsV2ProviderMode() {
+		esoNamespace = "external-secrets-system"
+		labelSelector = "app.kubernetes.io/instance=external-secrets,app.kubernetes.io/name=external-secrets"
+	}
+
+	podList, err := f.KubeClientSet.CoreV1().Pods(esoNamespace).List(
 		GinkgoT().Context(),
-		metav1.ListOptions{LabelSelector: "app.kubernetes.io/instance=eso,app.kubernetes.io/name=external-secrets"})
+		metav1.ListOptions{LabelSelector: labelSelector})
 	Expect(err).ToNot(HaveOccurred())
 	numLines := int64(60)
 	for i := range podList.Items {

+ 31 - 11
e2e/framework/framework.go

@@ -17,13 +17,6 @@ limitations under the License.
 package framework
 
 import (
-
-	// nolint
-
-	. "github.com/onsi/ginkgo/v2"
-
-	// nolint
-	. "github.com/onsi/gomega"
 	api "k8s.io/api/core/v1"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
@@ -34,6 +27,11 @@ import (
 	"github.com/external-secrets/external-secrets-e2e/framework/addon"
 	"github.com/external-secrets/external-secrets-e2e/framework/log"
 	"github.com/external-secrets/external-secrets-e2e/framework/util"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+
+	. "github.com/onsi/ginkgo/v2"
+	// nolint
+	. "github.com/onsi/gomega"
 )
 
 type Framework struct {
@@ -54,15 +52,28 @@ type Framework struct {
 	Addons []addon.Addon
 
 	MakeRemoteRefKey func(base string) string
+
+	ProviderMode                        string
+	DefaultSecretStoreRefKind           string
+	DefaultPushSecretStoreRefKind       string
+	DefaultPushSecretStoreRefAPIVersion string
 }
 
+var newFrameworkConfig = util.NewConfig
+
 // New returns a new framework instance with defaults.
 func New(baseName string) *Framework {
 	f := &Framework{
-		BaseName:         baseName,
-		MakeRemoteRefKey: func(base string) string { return base },
+		BaseName:                            baseName,
+		MakeRemoteRefKey:                    func(base string) string { return base },
+		ProviderMode:                        GetProviderMode(),
+		DefaultPushSecretStoreRefAPIVersion: esv1.SchemeGroupVersion.String(),
+	}
+	if f.ProviderMode == ProviderModeV2 {
+		f.DefaultSecretStoreRefKind = esv1.ProviderStoreKindStr
+		f.DefaultPushSecretStoreRefKind = esv1.ProviderStoreKindStr
 	}
-	f.KubeConfig, f.KubeClientSet, f.CRClient = util.NewConfig()
+	f.refreshClients()
 
 	BeforeEach(f.BeforeEach)
 	AfterEach(f.AfterEach)
@@ -70,9 +81,16 @@ func New(baseName string) *Framework {
 	return f
 }
 
+func (f *Framework) refreshClients() {
+	f.KubeConfig, f.KubeClientSet, f.CRClient = newFrameworkConfig()
+}
+
 // BeforeEach creates a namespace.
 func (f *Framework) BeforeEach() {
 	var err error
+	f.refreshClients()
+	err = util.CleanupTerminatingE2ENamespaces(GinkgoT().Context(), f.CRClient)
+	Expect(err).ToNot(HaveOccurred())
 	f.Namespace, err = util.CreateKubeNamespace(f.BaseName, f.KubeClientSet)
 	log.Logf("created test namespace %s", f.Namespace.Name)
 	Expect(err).ToNot(HaveOccurred())
@@ -92,7 +110,9 @@ func (f *Framework) AfterEach() {
 	// reset addons to default once the run is done
 	f.Addons = []addon.Addon{}
 	log.Logf("deleting test namespace %s", f.Namespace.Name)
-	err := util.DeleteKubeNamespace(f.Namespace.Name, f.KubeClientSet)
+	err := util.ClearKnownNamespaceFinalizers(GinkgoT().Context(), f.CRClient, f.Namespace.Name)
+	Expect(err).NotTo(HaveOccurred())
+	err = util.DeleteKubeNamespace(f.Namespace.Name, f.KubeClientSet)
 	Expect(err).NotTo(HaveOccurred())
 }
 

+ 60 - 0
e2e/framework/framework_test.go

@@ -0,0 +1,60 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 framework
+
+import (
+	"testing"
+
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
+	crclient "sigs.k8s.io/controller-runtime/pkg/client"
+	crfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+func TestRefreshClientsReloadsFrameworkClients(t *testing.T) {
+	t.Helper()
+
+	originalNewConfig := newFrameworkConfig
+	t.Cleanup(func() {
+		newFrameworkConfig = originalNewConfig
+	})
+
+	wantConfig := &rest.Config{Host: "https://refreshed.example"}
+	wantClientset := &kubernetes.Clientset{}
+	wantCRClient := crfake.NewClientBuilder().Build()
+	newFrameworkConfig = func() (*rest.Config, *kubernetes.Clientset, crclient.Client) {
+		return wantConfig, wantClientset, wantCRClient
+	}
+
+	f := &Framework{
+		KubeConfig:    &rest.Config{Host: "https://stale.example"},
+		KubeClientSet: &kubernetes.Clientset{},
+		CRClient:      crfake.NewClientBuilder().Build(),
+	}
+
+	f.refreshClients()
+
+	if f.KubeConfig != wantConfig {
+		t.Fatalf("expected refreshed kube config")
+	}
+	if f.KubeClientSet != wantClientset {
+		t.Fatalf("expected refreshed kube clientset")
+	}
+	if f.CRClient != wantCRClient {
+		t.Fatalf("expected refreshed controller-runtime client")
+	}
+}

+ 39 - 0
e2e/framework/provider_mode.go

@@ -0,0 +1,39 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 framework
+
+import (
+	"os"
+	"strings"
+)
+
+const (
+	ProviderModeEnvVar = "E2E_PROVIDER_MODE"
+	ProviderModeLegacy = "legacy"
+	ProviderModeV2     = "v2"
+)
+
+func GetProviderMode() string {
+	if strings.EqualFold(os.Getenv(ProviderModeEnvVar), ProviderModeV2) {
+		return ProviderModeV2
+	}
+	return ProviderModeLegacy
+}
+
+func IsV2ProviderMode() bool {
+	return GetProviderMode() == ProviderModeV2
+}

+ 122 - 19
e2e/framework/testcase.go

@@ -17,21 +17,33 @@ limitations under the License.
 package framework
 
 import (
+	"context"
+	"strings"
 	"time"
 
+	v1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
 	//nolint
 	"github.com/external-secrets/external-secrets-e2e/framework/log"
+	"github.com/external-secrets/external-secrets-e2e/framework/util"
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	v1 "k8s.io/api/core/v1"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
 var TargetSecretName = "target-secret"
 
+const (
+	createObjectRetryPollInterval = 250 * time.Millisecond
+	createObjectRetryTimeout      = 30 * time.Second
+)
+
 // TestCase contains the test infra to run a table driven test.
 type TestCase struct {
 	Framework               *Framework
@@ -41,6 +53,9 @@ type TestCase struct {
 	AdditionalObjects       []client.Object
 	Secrets                 map[string]SecretEntry
 	ExpectedSecret          *v1.Secret
+	Prepare                 func(*TestCase, SecretStoreProvider)
+	Cleanup                 func()
+	ProviderOverride        SecretStoreProvider
 	AfterSync               func(SecretStoreProvider, *v1.Secret)
 	VerifyPushSecretOutcome func(ps *esv1alpha1.PushSecret, pushClient esv1.SecretsClient)
 }
@@ -67,6 +82,14 @@ func TableFuncWithExternalSecret(f *Framework, prov SecretStoreProvider) func(..
 			tweak(tc)
 		}
 
+		defer func() {
+			if tc.Cleanup != nil {
+				tc.Cleanup()
+			}
+		}()
+
+		prov = prepareTestCase(tc, prov)
+
 		// create secrets & defer delete
 		var deferRemoveKeys []string
 		for k, v := range tc.Secrets {
@@ -81,16 +104,11 @@ func TableFuncWithExternalSecret(f *Framework, prov SecretStoreProvider) func(..
 			}
 		}()
 
-		// create v1alpha1 external secret, if provided
-		createProvidedExternalSecret(tc)
-
 		// create additional objects
 		generateAdditionalObjects(tc)
 
-		// in case target name is empty
-		if tc.ExternalSecret != nil && tc.ExternalSecret.Spec.Target.Name == "" {
-			TargetSecretName = tc.ExternalSecret.ObjectMeta.Name
-		}
+		// create v1alpha1 external secret, if provided
+		createProvidedExternalSecret(tc)
 
 		// wait for Kind=Secret to have the expected data
 		executeAfterSync(tc, f, prov)
@@ -99,7 +117,7 @@ func TableFuncWithExternalSecret(f *Framework, prov SecretStoreProvider) func(..
 
 func executeAfterSync(tc *TestCase, f *Framework, prov SecretStoreProvider) {
 	if tc.ExpectedSecret != nil {
-		secret, err := tc.Framework.WaitForSecretValue(tc.Framework.Namespace.Name, TargetSecretName, tc.ExpectedSecret)
+		secret, err := tc.Framework.WaitForSecretValue(tc.Framework.Namespace.Name, externalSecretTargetName(tc), tc.ExpectedSecret)
 		if err != nil {
 			f.printESDebugLogs(tc.ExternalSecret.Name, tc.ExternalSecret.Namespace)
 			log.Logf("Did not match. Expected: %+v, Got: %+v", tc.ExpectedSecret, secret)
@@ -112,10 +130,23 @@ func executeAfterSync(tc *TestCase, f *Framework, prov SecretStoreProvider) {
 	}
 }
 
+func externalSecretTargetName(tc *TestCase) string {
+	if tc == nil || tc.ExternalSecret == nil {
+		return TargetSecretName
+	}
+	if tc.ExternalSecret.Spec.Target.Name != "" {
+		return tc.ExternalSecret.Spec.Target.Name
+	}
+	if tc.ExternalSecret.Name != "" {
+		return tc.ExternalSecret.Name
+	}
+	return TargetSecretName
+}
+
 func generateAdditionalObjects(tc *TestCase) {
 	if tc.AdditionalObjects != nil {
 		for _, obj := range tc.AdditionalObjects {
-			err := tc.Framework.CRClient.Create(GinkgoT().Context(), obj)
+			err := tc.Framework.CreateObjectWithRetry(obj)
 			Expect(err).ToNot(HaveOccurred())
 		}
 	}
@@ -125,7 +156,7 @@ func createProvidedExternalSecret(tc *TestCase) {
 	if tc.ExternalSecret == nil {
 		return
 	}
-	err := tc.Framework.CRClient.Create(GinkgoT().Context(), tc.ExternalSecret)
+	err := tc.Framework.CreateObjectWithRetry(tc.ExternalSecret)
 	Expect(err).ToNot(HaveOccurred())
 }
 
@@ -141,26 +172,43 @@ func TableFuncWithPushSecret(f *Framework, prov SecretStoreProvider, pushClient
 			tweak(tc)
 		}
 
+		prov = prepareTestCase(tc, prov)
+
+		// additional objects
+		generateAdditionalObjects(tc)
+
 		if tc.PushSecretSource != nil {
-			err := tc.Framework.CRClient.Create(GinkgoT().Context(), tc.PushSecretSource)
+			err := tc.Framework.CreateObjectWithRetry(tc.PushSecretSource)
 			Expect(err).ToNot(HaveOccurred())
 		}
 
 		// create v1alpha1 push secret, if provided
 		if tc.PushSecret != nil {
 			// create v1beta1 external secret otherwise
-			err = tc.Framework.CRClient.Create(GinkgoT().Context(), tc.PushSecret)
+			err = tc.Framework.CreateObjectWithRetry(tc.PushSecret)
 			Expect(err).ToNot(HaveOccurred())
 		}
 
-		// additional objects
-		generateAdditionalObjects(tc)
-
 		// Run verification on the secret that push secret created or not.
 		tc.VerifyPushSecretOutcome(tc.PushSecret, pushClient)
 	}
 }
 
+func prepareTestCase(tc *TestCase, prov SecretStoreProvider) SecretStoreProvider {
+	prov = effectiveTestCaseProvider(tc, prov)
+	if tc.Prepare != nil {
+		tc.Prepare(tc, prov)
+	}
+	return effectiveTestCaseProvider(tc, prov)
+}
+
+func effectiveTestCaseProvider(tc *TestCase, prov SecretStoreProvider) SecretStoreProvider {
+	if tc.ProviderOverride != nil {
+		return tc.ProviderOverride
+	}
+	return prov
+}
+
 func makeDefaultExternalSecretTestCase(f *Framework) *TestCase {
 	return &TestCase{
 		AfterSync: func(ssp SecretStoreProvider, s *v1.Secret) {},
@@ -174,6 +222,7 @@ func makeDefaultExternalSecretTestCase(f *Framework) *TestCase {
 				RefreshInterval: &metav1.Duration{Duration: time.Second * 5},
 				SecretStoreRef: esv1.SecretStoreRef{
 					Name: f.Namespace.Name,
+					Kind: f.DefaultSecretStoreRefKind,
 				},
 				Target: esv1.ExternalSecretTarget{
 					Name: TargetSecretName,
@@ -195,10 +244,64 @@ func makeDefaultPushSecretTestCase(f *Framework) *TestCase {
 				RefreshInterval: &metav1.Duration{Duration: time.Second * 5},
 				SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{
 					{
-						Name: f.Namespace.Name,
+						Name:       f.Namespace.Name,
+						Kind:       f.DefaultPushSecretStoreRefKind,
+						APIVersion: f.DefaultPushSecretStoreRefAPIVersion,
 					},
 				},
 			},
 		},
 	}
 }
+
+func (f *Framework) CreateObjectWithRetry(obj client.Object) error {
+	return f.CreateObjectWithRetryContext(GinkgoT().Context(), obj)
+}
+
+func (f *Framework) CreateObjectWithRetryContext(ctx context.Context, obj client.Object) error {
+	return wait.PollUntilContextTimeout(ctx, createObjectRetryPollInterval, createObjectRetryTimeout, true, func(ctx context.Context) (bool, error) {
+		err := f.CRClient.Create(ctx, obj)
+		switch {
+		case err == nil, apierrors.IsAlreadyExists(err):
+			return true, nil
+		case isRetryableCreateObjectError(err):
+			if f.KubeConfig != nil {
+				f.refreshClients()
+			}
+			return false, nil
+		default:
+			return false, err
+		}
+	})
+}
+
+func createObjectWithRetryPolling(ctx context.Context, c client.Client, obj client.Object, pollInterval, timeout time.Duration) error {
+	return wait.PollUntilContextTimeout(ctx, pollInterval, timeout, true, func(ctx context.Context) (bool, error) {
+		err := c.Create(ctx, obj)
+		switch {
+		case err == nil, apierrors.IsAlreadyExists(err):
+			return true, nil
+		case isRetryableCreateObjectError(err):
+			return false, nil
+		default:
+			return false, err
+		}
+	})
+}
+
+func isRetryableCreateObjectError(err error) bool {
+	if util.IsMissingAPIResourceError(err) {
+		return true
+	}
+	if apierrors.IsNotFound(err) && strings.Contains(err.Error(), "could not find the requested resource") {
+		return true
+	}
+
+	if !(apierrors.IsInternalError(err) || apierrors.IsServiceUnavailable(err)) {
+		return false
+	}
+
+	msg := strings.ToLower(err.Error())
+	return strings.Contains(msg, "failed calling webhook") &&
+		(strings.Contains(msg, "connection refused") || strings.Contains(msg, "no endpoints available"))
+}

+ 205 - 0
e2e/framework/testcase_test.go

@@ -0,0 +1,205 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 framework
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1api "k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+
+	. "github.com/onsi/gomega"
+)
+
+type testcaseProviderStub struct {
+	name string
+}
+
+func (s *testcaseProviderStub) CreateSecret(string, SecretEntry) {}
+
+func (s *testcaseProviderStub) DeleteSecret(string) {}
+
+func TestPrepareTestCaseUsesDefaultProviderWithoutOverride(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	defaultProvider := &testcaseProviderStub{name: "default"}
+	tc := &TestCase{}
+
+	provider := prepareTestCase(tc, defaultProvider)
+
+	Expect(provider).To(BeIdenticalTo(defaultProvider))
+}
+
+func TestPrepareTestCaseLetsPrepareInstallProviderOverride(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	defaultProvider := &testcaseProviderStub{name: "default"}
+	overrideProvider := &testcaseProviderStub{name: "override"}
+	tc := &TestCase{
+		Prepare: func(tc *TestCase, provider SecretStoreProvider) {
+			Expect(provider).To(BeIdenticalTo(defaultProvider))
+			tc.ProviderOverride = overrideProvider
+		},
+	}
+
+	provider := prepareTestCase(tc, defaultProvider)
+
+	Expect(provider).To(BeIdenticalTo(overrideProvider))
+}
+
+func TestPrepareTestCaseUsesExistingOverrideDuringPrepare(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	defaultProvider := &testcaseProviderStub{name: "default"}
+	overrideProvider := &testcaseProviderStub{name: "override"}
+	tc := &TestCase{
+		ProviderOverride: overrideProvider,
+		Prepare: func(tc *TestCase, provider SecretStoreProvider) {
+			Expect(provider).To(BeIdenticalTo(overrideProvider))
+		},
+	}
+
+	provider := prepareTestCase(tc, defaultProvider)
+
+	Expect(provider).To(BeIdenticalTo(overrideProvider))
+}
+
+func TestExternalSecretTargetNameUsesExplicitTargetName(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	tc := &TestCase{
+		ExternalSecret: &esv1.ExternalSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "external-secret-name",
+			},
+			Spec: esv1.ExternalSecretSpec{
+				Target: esv1.ExternalSecretTarget{
+					Name: "explicit-target-name",
+				},
+			},
+		},
+	}
+
+	Expect(externalSecretTargetName(tc)).To(Equal("explicit-target-name"))
+}
+
+func TestExternalSecretTargetNameFallsBackToExternalSecretName(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	tc := &TestCase{
+		ExternalSecret: &esv1.ExternalSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "external-secret-name",
+			},
+		},
+	}
+
+	Expect(externalSecretTargetName(tc)).To(Equal("external-secret-name"))
+}
+
+type flakyCreateClient struct {
+	client.Client
+	createErrs  []error
+	createCalls int
+}
+
+func (c *flakyCreateClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
+	callIndex := c.createCalls
+	c.createCalls++
+	if callIndex < len(c.createErrs) && c.createErrs[callIndex] != nil {
+		return c.createErrs[callIndex]
+	}
+	return c.Client.Create(ctx, obj, opts...)
+}
+
+func TestCreateObjectWithRetryPollingRetriesMissingAPIResourceErrors(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	scheme := runtime.NewScheme()
+	Expect(esv1.AddToScheme(scheme)).To(Succeed())
+
+	baseClient := fake.NewClientBuilder().WithScheme(scheme).Build()
+	cl := &flakyCreateClient{
+		Client: baseClient,
+		createErrs: []error{
+			&metav1api.NoResourceMatchError{},
+		},
+	}
+
+	es := &esv1.ExternalSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "retry-external-secret",
+			Namespace: "default",
+		},
+	}
+
+	err := createObjectWithRetryPolling(context.Background(), cl, es, 5*time.Millisecond, 100*time.Millisecond)
+	Expect(err).NotTo(HaveOccurred())
+	Expect(cl.createCalls).To(Equal(2))
+
+	var created esv1.ExternalSecret
+	Expect(baseClient.Get(context.Background(), client.ObjectKeyFromObject(es), &created)).To(Succeed())
+}
+
+func TestCreateObjectWithRetryPollingRetriesMissingResourceEndpointErrors(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	scheme := runtime.NewScheme()
+	Expect(esv1.AddToScheme(scheme)).To(Succeed())
+
+	baseClient := fake.NewClientBuilder().WithScheme(scheme).Build()
+	cl := &flakyCreateClient{
+		Client: baseClient,
+		createErrs: []error{
+			&apierrors.StatusError{ErrStatus: metav1.Status{
+				Status:  metav1.StatusFailure,
+				Message: "the server could not find the requested resource (post externalsecrets.external-secrets.io)",
+				Reason:  metav1.StatusReasonNotFound,
+				Code:    404,
+			}},
+		},
+	}
+
+	es := &esv1.ExternalSecret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "retry-endpoint-external-secret",
+			Namespace: "default",
+		},
+	}
+
+	err := createObjectWithRetryPolling(context.Background(), cl, es, 5*time.Millisecond, 100*time.Millisecond)
+	Expect(err).NotTo(HaveOccurred())
+	Expect(cl.createCalls).To(Equal(2))
+
+	var created esv1.ExternalSecret
+	Expect(baseClient.Get(context.Background(), client.ObjectKeyFromObject(es), &created)).To(Succeed())
+}

+ 110 - 10
e2e/framework/util/util.go

@@ -23,6 +23,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
 	fluxhelm "github.com/fluxcd/helm-controller/api/v2"
@@ -30,6 +31,7 @@ import (
 	v1 "k8s.io/api/core/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
@@ -43,12 +45,16 @@ import (
 	"k8s.io/client-go/util/homedir"
 	crclient "sigs.k8s.io/controller-runtime/pkg/client"
 
-	// nolint
-	. "github.com/onsi/ginkgo/v2"
-
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
 	genv1alpha1 "github.com/external-secrets/external-secrets/apis/generators/v1alpha1"
+	awsv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/aws/v2alpha1"
+	fakev2alpha1 "github.com/external-secrets/external-secrets/apis/provider/fake/v2alpha1"
+	k8sv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/kubernetes/v2alpha1"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
 )
 
 var scheme = runtime.NewScheme()
@@ -61,16 +67,24 @@ func init() {
 	// external-secrets schemes
 	utilruntime.Must(esv1.AddToScheme(scheme))
 	utilruntime.Must(esv1alpha1.AddToScheme(scheme))
+	utilruntime.Must(esv2alpha1.AddToScheme(scheme))
 	utilruntime.Must(genv1alpha1.AddToScheme(scheme))
 
 	// other schemes
 	utilruntime.Must(fluxhelm.AddToScheme(scheme))
 	utilruntime.Must(fluxsrc.AddToScheme(scheme))
+
+	// v2alpha1 provider schemes
+	utilruntime.Must(awsv2alpha1.AddToScheme(scheme))
+	utilruntime.Must(fakev2alpha1.AddToScheme(scheme))
+	utilruntime.Must(k8sv2alpha1.AddToScheme(scheme))
 }
 
 const (
 	// How often to poll for conditions.
 	Poll = 2 * time.Second
+
+	e2eNamespacePrefix = "e2e-tests-"
 )
 
 // CreateKubeNamespace creates a new Kubernetes Namespace for a test.
@@ -89,6 +103,67 @@ func DeleteKubeNamespace(namespace string, kubeClientSet kubernetes.Interface) e
 	return kubeClientSet.CoreV1().Namespaces().Delete(GinkgoT().Context(), namespace, metav1.DeleteOptions{})
 }
 
+func IsE2ETestNamespace(namespace string) bool {
+	return strings.HasPrefix(namespace, e2eNamespacePrefix)
+}
+
+func IsMissingAPIResourceError(err error) bool {
+	return err != nil && meta.IsNoMatchError(err)
+}
+
+func ClearKnownNamespaceFinalizers(ctx context.Context, c crclient.Client, namespace string) error {
+	var secretList v1.SecretList
+	if err := c.List(ctx, &secretList, crclient.InNamespace(namespace)); err != nil {
+		return err
+	}
+	for i := range secretList.Items {
+		if len(secretList.Items[i].Finalizers) == 0 {
+			continue
+		}
+		secret := secretList.Items[i].DeepCopy()
+		secret.Finalizers = nil
+		if err := c.Update(ctx, secret); err != nil {
+			return err
+		}
+	}
+
+	var pushSecretList esv1alpha1.PushSecretList
+	if err := c.List(ctx, &pushSecretList, crclient.InNamespace(namespace)); err != nil {
+		return err
+	}
+	for i := range pushSecretList.Items {
+		if len(pushSecretList.Items[i].Finalizers) == 0 {
+			continue
+		}
+		pushSecret := pushSecretList.Items[i].DeepCopy()
+		pushSecret.Finalizers = nil
+		if err := c.Update(ctx, pushSecret); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func CleanupTerminatingE2ENamespaces(ctx context.Context, c crclient.Client) error {
+	var namespaceList v1.NamespaceList
+	if err := c.List(ctx, &namespaceList); err != nil {
+		return err
+	}
+
+	for i := range namespaceList.Items {
+		namespace := namespaceList.Items[i]
+		if !IsE2ETestNamespace(namespace.Name) || namespace.DeletionTimestamp == nil {
+			continue
+		}
+		if err := ClearKnownNamespaceFinalizers(ctx, c, namespace.Name); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 // WaitForKubeNamespaceNotExist will wait for the namespace with the given name
 // to not exist for up to 2 minutes.
 func WaitForKubeNamespaceNotExist(namespace string, kubeClientSet kubernetes.Interface) error {
@@ -281,24 +356,49 @@ func GetKubeSecret(client kubernetes.Interface, namespace, secretName string) (*
 }
 
 // NewConfig loads and returns the kubernetes credentials from the environment.
-// KUBECONFIG env var takes precedence, falls back to in-cluster config, then to default KUBECONFIG location.
+// KUBECONFIG env var takes precedence, then ~/.kube/config, then in-cluster config.
 func NewConfig() (*restclient.Config, *kubernetes.Clientset, crclient.Client) {
-	cfg, err := BuildKubeConfig()
-	if err != nil {
-		Fail(err.Error())
+	var kubeConfig *restclient.Config
+	var err error
+	kcPath := os.Getenv("KUBECONFIG")
+	if kcPath != "" {
+		kubeConfig, err = clientcmd.BuildConfigFromFlags("", kcPath)
+		if err != nil {
+			Fail(err.Error())
+		}
+	} else {
+		// Try ~/.kube/config
+		homeDir, err := os.UserHomeDir()
+		if err == nil {
+			defaultKubeconfig := homeDir + "/.kube/config"
+			if _, err := os.Stat(defaultKubeconfig); err == nil {
+				kubeConfig, err = clientcmd.BuildConfigFromFlags("", defaultKubeconfig)
+				if err != nil {
+					Fail(err.Error())
+				}
+			}
+		}
+
+		// Fall back to in-cluster config if ~/.kube/config doesn't exist
+		if kubeConfig == nil {
+			kubeConfig, err = restclient.InClusterConfig()
+			if err != nil {
+				Fail(err.Error())
+			}
+		}
 	}
 
-	kubeClientSet, err := kubernetes.NewForConfig(cfg)
+	kubeClientSet, err := kubernetes.NewForConfig(kubeConfig)
 	if err != nil {
 		Fail(err.Error())
 	}
 
-	CRClient, err := crclient.New(cfg, crclient.Options{Scheme: scheme})
+	CRClient, err := crclient.New(kubeConfig, crclient.Options{Scheme: scheme})
 	if err != nil {
 		Fail(err.Error())
 	}
 
-	return cfg, kubeClientSet, CRClient
+	return kubeConfig, kubeClientSet, CRClient
 }
 
 func BuildKubeConfig() (*rest.Config, error) {

+ 187 - 0
e2e/framework/util/util_test.go

@@ -0,0 +1,187 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 util
+
+import (
+	"context"
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1api "k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+)
+
+func TestClearKnownNamespaceFinalizers(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "target-secret",
+				Namespace:  "e2e-tests-demo-12345",
+				Finalizers: []string{"example.com/finalizer"},
+			},
+		},
+		&esv1alpha1.PushSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "push-secret",
+				Namespace:  "e2e-tests-demo-12345",
+				Finalizers: []string{"pushsecret.externalsecrets.io/finalizer"},
+			},
+		},
+	).Build()
+
+	if err := ClearKnownNamespaceFinalizers(ctx, cl, "e2e-tests-demo-12345"); err != nil {
+		t.Fatalf("ClearKnownNamespaceFinalizers() error = %v", err)
+	}
+
+	var secret corev1.Secret
+	if err := cl.Get(ctx, client.ObjectKey{Name: "target-secret", Namespace: "e2e-tests-demo-12345"}, &secret); err != nil {
+		t.Fatalf("Get(secret) error = %v", err)
+	}
+	if len(secret.Finalizers) != 0 {
+		t.Fatalf("expected secret finalizers to be cleared, got %v", secret.Finalizers)
+	}
+
+	var pushSecret esv1alpha1.PushSecret
+	if err := cl.Get(ctx, client.ObjectKey{Name: "push-secret", Namespace: "e2e-tests-demo-12345"}, &pushSecret); err != nil {
+		t.Fatalf("Get(pushsecret) error = %v", err)
+	}
+	if len(pushSecret.Finalizers) != 0 {
+		t.Fatalf("expected pushsecret finalizers to be cleared, got %v", pushSecret.Finalizers)
+	}
+}
+
+func TestCleanupTerminatingE2ENamespaces(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	now := metav1.Now()
+	cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
+		&corev1.Namespace{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:              "e2e-tests-demo-12345",
+				Finalizers:        []string{"kubernetes"},
+				DeletionTimestamp: &now,
+			},
+		},
+		&corev1.Namespace{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "plain-namespace",
+			},
+		},
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "target-secret",
+				Namespace:  "e2e-tests-demo-12345",
+				Finalizers: []string{"example.com/finalizer"},
+			},
+		},
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:       "untouched-secret",
+				Namespace:  "plain-namespace",
+				Finalizers: []string{"example.com/finalizer"},
+			},
+		},
+	).Build()
+
+	if err := CleanupTerminatingE2ENamespaces(ctx, cl); err != nil {
+		t.Fatalf("CleanupTerminatingE2ENamespaces() error = %v", err)
+	}
+
+	var cleaned corev1.Secret
+	if err := cl.Get(ctx, client.ObjectKey{Name: "target-secret", Namespace: "e2e-tests-demo-12345"}, &cleaned); err != nil {
+		t.Fatalf("Get(cleaned) error = %v", err)
+	}
+	if len(cleaned.Finalizers) != 0 {
+		t.Fatalf("expected terminating e2e namespace secret finalizers to be cleared, got %v", cleaned.Finalizers)
+	}
+
+	var untouched corev1.Secret
+	if err := cl.Get(ctx, client.ObjectKey{Name: "untouched-secret", Namespace: "plain-namespace"}, &untouched); err != nil {
+		t.Fatalf("Get(untouched) error = %v", err)
+	}
+	if len(untouched.Finalizers) != 1 {
+		t.Fatalf("expected non-e2e namespace secret finalizers to remain, got %v", untouched.Finalizers)
+	}
+}
+
+func TestIsMissingAPIResourceError(t *testing.T) {
+	t.Parallel()
+
+	gv := schema.GroupVersion{Group: "generators.external-secrets.io", Version: "v1alpha1"}
+	discoveryErr := apiutil.ErrResourceDiscoveryFailed{
+		gv: &metav1api.NoResourceMatchError{
+			PartialResource: gv.WithResource(""),
+		},
+	}
+
+	if !IsMissingAPIResourceError(&discoveryErr) {
+		t.Fatal("expected missing resource discovery failure to be treated as ignorable")
+	}
+	if !IsMissingAPIResourceError(&metav1api.NoResourceMatchError{}) {
+		t.Fatal("expected no-match error to be treated as ignorable")
+	}
+	if IsMissingAPIResourceError(context.Canceled) {
+		t.Fatal("did not expect unrelated errors to be treated as ignorable")
+	}
+}
+
+func TestSchemeIncludesV2StoreTypes(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name   string
+		object client.Object
+		wantGV schema.GroupVersionKind
+	}{
+		{
+			name:   "ProviderStore",
+			object: &esv2alpha1.ProviderStore{},
+			wantGV: esv2alpha1.SchemeGroupVersion.WithKind("ProviderStore"),
+		},
+		{
+			name:   "ClusterProviderStore",
+			object: &esv2alpha1.ClusterProviderStore{},
+			wantGV: esv2alpha1.SchemeGroupVersion.WithKind("ClusterProviderStore"),
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			gvk, err := apiutil.GVKForObject(tt.object, scheme)
+			if err != nil {
+				t.Fatalf("GVKForObject(%s) error = %v", tt.name, err)
+			}
+			if gvk != tt.wantGV {
+				t.Fatalf("GVKForObject(%s) = %s, want %s", tt.name, gvk.String(), tt.wantGV.String())
+			}
+		})
+	}
+}

+ 305 - 0
e2e/framework/v2/helpers.go

@@ -0,0 +1,305 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 v2
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	rbacv1 "k8s.io/api/rbac/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/util/retry"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/framework/log"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	k8sv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/kubernetes/v2alpha1"
+
+	. "github.com/onsi/gomega"
+)
+
+const (
+	ProviderNamespace = "external-secrets-system"
+	DefaultSAName     = "default"
+)
+
+func ProviderAddress(providerName string) string {
+	return ProviderAddressInNamespace(providerName, ProviderNamespace)
+}
+
+func ProviderAddressInNamespace(providerName, namespace string) string {
+	return fmt.Sprintf("provider-%s.%s.svc:8080", providerName, namespace)
+}
+
+func GetClusterCABundle(f *framework.Framework, namespace string) []byte {
+	var caBundle []byte
+	krc := &corev1.ConfigMap{}
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	err := wait.PollUntilContextTimeout(ctx, 250*time.Millisecond, 30*time.Second, true, func(ctx context.Context) (bool, error) {
+		if err := f.CRClient.Get(ctx, types.NamespacedName{Name: "kube-root-ca.crt", Namespace: namespace}, krc); err != nil {
+			if apierrors.IsNotFound(err) {
+				return false, nil
+			}
+			return false, err
+		}
+		caBundle = []byte(krc.Data["ca.crt"])
+		return len(caBundle) > 0, nil
+	})
+	Expect(err).NotTo(HaveOccurred())
+	return caBundle
+}
+
+func CreateKubernetesAccessRole(f *framework.Framework, name, serviceAccountName, serviceAccountNamespace, remoteNamespace string) {
+	role := &rbacv1.Role{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: remoteNamespace,
+		},
+		Rules: []rbacv1.PolicyRule{
+			{
+				APIGroups: []string{""},
+				Resources: []string{"secrets"},
+				Verbs:     []string{"get", "list", "watch", "create", "update", "patch", "delete"},
+			},
+			{
+				APIGroups: []string{"authorization.k8s.io"},
+				Resources: []string{"selfsubjectrulesreviews", "selfsubjectaccessreviews"},
+				Verbs:     []string{"create"},
+			},
+		},
+	}
+	Expect(createOrIgnoreAlreadyExists(f, role)).To(Succeed())
+
+	roleBinding := &rbacv1.RoleBinding{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: remoteNamespace,
+		},
+		Subjects: []rbacv1.Subject{
+			{
+				Kind:      "ServiceAccount",
+				Name:      serviceAccountName,
+				Namespace: serviceAccountNamespace,
+			},
+		},
+		RoleRef: rbacv1.RoleRef{
+			APIGroup: "rbac.authorization.k8s.io",
+			Kind:     "Role",
+			Name:     name,
+		},
+	}
+	Expect(createOrIgnoreAlreadyExists(f, roleBinding)).To(Succeed())
+}
+
+func CreateKubernetesProvider(f *framework.Framework, namespace, name, remoteNamespace, serviceAccountName string, serviceAccountNamespace *string, caBundle []byte) *k8sv2alpha1.Kubernetes {
+	k8ss := &k8sv2alpha1.Kubernetes{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       "Kubernetes",
+			APIVersion: "provider.external-secrets.io/v2alpha1",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: esv1.KubernetesProvider{
+			Server: esv1.KubernetesServer{
+				URL:      "https://kubernetes.default.svc",
+				CABundle: caBundle,
+			},
+			RemoteNamespace: remoteNamespace,
+			Auth: &esv1.KubernetesAuth{
+				ServiceAccount: &esmeta.ServiceAccountSelector{
+					Name:      serviceAccountName,
+					Namespace: serviceAccountNamespace,
+				},
+			},
+		},
+	}
+	Expect(createOrIgnoreAlreadyExists(f, k8ss)).To(Succeed())
+	log.Logf("created Kubernetes provider: %s/%s", namespace, name)
+	return k8ss
+}
+
+func runtimeClassName(name string) string {
+	return fmt.Sprintf("%s-runtime", name)
+}
+
+func ensureClusterProviderClass(f *framework.Framework, name, address string) *esv1alpha1.ClusterProviderClass {
+	runtimeClass := &esv1alpha1.ClusterProviderClass{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+		Spec: esv1alpha1.ClusterProviderClassSpec{
+			Address: address,
+		},
+	}
+	Expect(createOrIgnoreAlreadyExists(f, runtimeClass)).To(Succeed())
+	log.Logf("created ClusterProviderClass: %s", name)
+	return runtimeClass
+}
+
+func mapStoreConditions(conditions []esv1.ClusterSecretStoreCondition) []esv2alpha1.StoreNamespaceCondition {
+	if len(conditions) == 0 {
+		return nil
+	}
+	out := make([]esv2alpha1.StoreNamespaceCondition, 0, len(conditions))
+	for _, condition := range conditions {
+		out = append(out, esv2alpha1.StoreNamespaceCondition{
+			NamespaceSelector: condition.NamespaceSelector,
+			Namespaces:        condition.Namespaces,
+			NamespaceRegexes:  condition.NamespaceRegexes,
+		})
+	}
+	return out
+}
+
+func CreateProviderConnection(f *framework.Framework, namespace, name, address, providerAPIVersion, providerKind, providerName, providerNamespace string) *esv2alpha1.ProviderStore {
+	runtimeClass := ensureClusterProviderClass(f, runtimeClassName(name), address)
+
+	providerStore := &esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: esv2alpha1.ProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{
+				Name: runtimeClass.Name,
+			},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: providerAPIVersion,
+				Kind:       providerKind,
+				Name:       providerName,
+				Namespace:  providerNamespace,
+			},
+		},
+	}
+	Expect(createOrIgnoreAlreadyExists(f, providerStore)).To(Succeed())
+	log.Logf("created ProviderStore: %s/%s", namespace, name)
+	return providerStore
+}
+
+func CreateClusterProviderConnection(f *framework.Framework, name, address, providerAPIVersion, providerKind, providerName, providerNamespace string, _ esv1.AuthenticationScope, conditions []esv1.ClusterSecretStoreCondition) *esv2alpha1.ClusterProviderStore {
+	runtimeClass := ensureClusterProviderClass(f, runtimeClassName(name), address)
+
+	clusterProviderStore := &esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+		Spec: esv2alpha1.ClusterProviderStoreSpec{
+			RuntimeRef: esv2alpha1.StoreRuntimeRef{
+				Name: runtimeClass.Name,
+			},
+			BackendRef: esv2alpha1.BackendObjectReference{
+				APIVersion: providerAPIVersion,
+				Kind:       providerKind,
+				Name:       providerName,
+				Namespace:  providerNamespace,
+			},
+			Conditions: mapStoreConditions(conditions),
+		},
+	}
+	Expect(createOrIgnoreAlreadyExists(f, clusterProviderStore)).To(Succeed())
+	log.Logf("created ClusterProviderStore: %s", name)
+	return clusterProviderStore
+}
+
+func WaitForProviderConnectionReady(f *framework.Framework, namespace, name string, timeout time.Duration) *esv2alpha1.ProviderStore {
+	return WaitForProviderConnectionCondition(f, namespace, name, metav1.ConditionTrue, timeout)
+}
+
+func WaitForProviderConnectionNotReady(f *framework.Framework, namespace, name string, timeout time.Duration) *esv2alpha1.ProviderStore {
+	return WaitForProviderConnectionCondition(f, namespace, name, metav1.ConditionFalse, timeout)
+}
+
+func WaitForProviderConnectionCondition(f *framework.Framework, namespace, name string, status metav1.ConditionStatus, timeout time.Duration) *esv2alpha1.ProviderStore {
+	var providerStore esv2alpha1.ProviderStore
+	Eventually(func() bool {
+		err := f.CRClient.Get(context.Background(),
+			types.NamespacedName{Name: name, Namespace: namespace},
+			&providerStore)
+		if err != nil {
+			log.Logf("failed to get ProviderStore: %v", err)
+			return false
+		}
+
+		for _, condition := range providerStore.Status.Conditions {
+			if condition.Type == esv2alpha1.ProviderStoreReady && condition.Status == corev1.ConditionStatus(status) {
+				return true
+			}
+		}
+		return false
+	}, timeout, time.Second).Should(BeTrue(), fmt.Sprintf("ProviderStore should become %s", status))
+
+	return &providerStore
+}
+
+func WaitForClusterProviderReady(f *framework.Framework, name string, timeout time.Duration) *esv2alpha1.ClusterProviderStore {
+	return WaitForClusterProviderCondition(f, name, metav1.ConditionTrue, timeout)
+}
+
+func WaitForClusterProviderCondition(f *framework.Framework, name string, status metav1.ConditionStatus, timeout time.Duration) *esv2alpha1.ClusterProviderStore {
+	var clusterProviderStore esv2alpha1.ClusterProviderStore
+	Eventually(func() bool {
+		err := f.CRClient.Get(context.Background(),
+			types.NamespacedName{Name: name},
+			&clusterProviderStore)
+		if err != nil {
+			log.Logf("failed to get ClusterProviderStore: %v", err)
+			return false
+		}
+
+		for _, condition := range clusterProviderStore.Status.Conditions {
+			if condition.Type == esv2alpha1.ProviderStoreReady && condition.Status == corev1.ConditionStatus(status) {
+				return true
+			}
+		}
+		return false
+	}, timeout, time.Second).Should(BeTrue(), fmt.Sprintf("ClusterProviderStore should become %s", status))
+
+	return &clusterProviderStore
+}
+func createOrIgnoreAlreadyExists(f *framework.Framework, obj client.Object) error {
+	err := f.CRClient.Create(context.Background(), obj)
+	if err == nil {
+		return nil
+	}
+	if !apierrors.IsAlreadyExists(err) {
+		return err
+	}
+
+	return retry.RetryOnConflict(retry.DefaultRetry, func() error {
+		existing := obj.DeepCopyObject().(client.Object)
+		if err := f.CRClient.Get(context.Background(), client.ObjectKeyFromObject(obj), existing); err != nil {
+			return err
+		}
+
+		obj.SetResourceVersion(existing.GetResourceVersion())
+		obj.SetUID(existing.GetUID())
+		return f.CRClient.Update(context.Background(), obj)
+	})
+}

+ 146 - 0
e2e/framework/v2/helpers_test.go

@@ -0,0 +1,146 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 v2
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	k8sv2alpha1 "github.com/external-secrets/external-secrets/apis/provider/kubernetes/v2alpha1"
+
+	. "github.com/onsi/gomega"
+)
+
+func TestCreateKubernetesProviderUsesProvidedCABundle(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	scheme := runtime.NewScheme()
+	Expect(corev1.AddToScheme(scheme)).To(Succeed())
+	Expect(k8sv2alpha1.AddToScheme(scheme)).To(Succeed())
+
+	cl := fake.NewClientBuilder().WithScheme(scheme).Build()
+	f := &framework.Framework{
+		CRClient: cl,
+	}
+
+	CreateKubernetesProvider(f, "provider-ns", "example", "remote-ns", "eso-auth", nil, []byte("inline-ca"))
+
+	var provider k8sv2alpha1.Kubernetes
+	err := cl.Get(context.Background(), client.ObjectKey{
+		Namespace: "provider-ns",
+		Name:      "example",
+	}, &provider)
+	Expect(err).NotTo(HaveOccurred())
+
+	Expect(provider.Spec.Server.CABundle).To(Equal([]byte("inline-ca")))
+	Expect(provider.Spec.Server.CAProvider).To(BeNil())
+	Expect(provider.Spec.Auth).NotTo(BeNil())
+	Expect(provider.Spec.Auth.ServiceAccount).To(Equal(&esmeta.ServiceAccountSelector{
+		Name:      "eso-auth",
+		Namespace: nil,
+	}))
+}
+
+func TestGetClusterCABundleWaitsForRootCAConfigMap(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	scheme := runtime.NewScheme()
+	Expect(corev1.AddToScheme(scheme)).To(Succeed())
+
+	cl := fake.NewClientBuilder().WithScheme(scheme).Build()
+	f := &framework.Framework{
+		CRClient: cl,
+	}
+
+	go func() {
+		time.Sleep(25 * time.Millisecond)
+		Expect(cl.Create(context.Background(), &corev1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "kube-root-ca.crt",
+				Namespace: "test",
+			},
+			Data: map[string]string{
+				"ca.crt": "root-ca-data",
+			},
+		})).To(Succeed())
+	}()
+
+	Expect(GetClusterCABundle(f, "test")).To(Equal([]byte("root-ca-data")))
+}
+
+func TestWaitForProviderConnectionConditionMatchesReadyStatus(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	scheme := runtime.NewScheme()
+	Expect(esv2alpha1.AddToScheme(scheme)).To(Succeed())
+
+	cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&esv2alpha1.ProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "example",
+			Namespace: "test",
+		},
+		Status: esv2alpha1.ProviderStoreStatus{
+			Conditions: []esv2alpha1.ProviderStoreCondition{{
+				Type:   esv2alpha1.ProviderStoreReady,
+				Status: corev1.ConditionTrue,
+			}},
+		},
+	}).Build()
+	f := &framework.Framework{CRClient: cl}
+
+	store := WaitForProviderConnectionCondition(f, "test", "example", metav1.ConditionTrue, 100*time.Millisecond)
+	Expect(store).NotTo(BeNil())
+	Expect(store.Name).To(Equal("example"))
+}
+
+func TestWaitForClusterProviderConditionMatchesReadyStatus(t *testing.T) {
+	t.Helper()
+	RegisterTestingT(t)
+
+	scheme := runtime.NewScheme()
+	Expect(esv2alpha1.AddToScheme(scheme)).To(Succeed())
+
+	cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&esv2alpha1.ClusterProviderStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "example",
+		},
+		Status: esv2alpha1.ProviderStoreStatus{
+			Conditions: []esv2alpha1.ProviderStoreCondition{{
+				Type:   esv2alpha1.ProviderStoreReady,
+				Status: corev1.ConditionTrue,
+			}},
+		},
+	}).Build()
+	f := &framework.Framework{CRClient: cl}
+
+	store := WaitForClusterProviderCondition(f, "example", metav1.ConditionTrue, 100*time.Millisecond)
+	Expect(store).NotTo(BeNil())
+	Expect(store.Name).To(Equal("example"))
+}

+ 386 - 0
e2e/framework/v2/metrics.go

@@ -0,0 +1,386 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 v2
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/portforward"
+	"k8s.io/client-go/transport/spdy"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+type MetricSample struct {
+	Name   string
+	Labels map[string]string
+	Value  float64
+}
+
+type MetricsMap map[string][]MetricSample
+
+func ScrapeControllerMetrics(ctx context.Context, config *rest.Config, clientset kubernetes.Interface, namespace string) (MetricsMap, error) {
+	podName, err := findPod(ctx, clientset, namespace, "app.kubernetes.io/name=external-secrets")
+	if err != nil {
+		return nil, err
+	}
+
+	return scrapePodMetrics(ctx, config, clientset, namespace, podName, 8080)
+}
+
+func ScrapeProviderMetrics(ctx context.Context, config *rest.Config, clientset kubernetes.Interface, namespace, providerName string) (MetricsMap, error) {
+	labelSelector := fmt.Sprintf("app.kubernetes.io/name=external-secrets-provider-%s", providerName)
+	podName, err := findPod(ctx, clientset, namespace, labelSelector)
+	if err != nil {
+		return nil, err
+	}
+
+	return scrapePodMetrics(ctx, config, clientset, namespace, podName, 8081)
+}
+
+func GetMetricValue(metrics MetricsMap, metricName string, matchLabels map[string]string) (float64, bool) {
+	samples, exists := metrics[metricName]
+	if !exists {
+		return 0, false
+	}
+
+	for _, sample := range samples {
+		if labelsMatch(sample.Labels, matchLabels) {
+			return sample.Value, true
+		}
+	}
+
+	return 0, false
+}
+
+func SumMetricValues(metrics MetricsMap, metricName string, matchLabels map[string]string) float64 {
+	samples, exists := metrics[metricName]
+	if !exists {
+		return 0
+	}
+
+	var total float64
+	for _, sample := range samples {
+		if labelsMatch(sample.Labels, matchLabels) {
+			total += sample.Value
+		}
+	}
+
+	return total
+}
+
+func CountMetricSamples(metrics MetricsMap, metricName string, matchLabels map[string]string) int {
+	samples, exists := metrics[metricName]
+	if !exists {
+		return 0
+	}
+
+	count := 0
+	for _, sample := range samples {
+		if labelsMatch(sample.Labels, matchLabels) {
+			count++
+		}
+	}
+
+	return count
+}
+
+func ExpectMetricExists(metrics MetricsMap, metricName string) {
+	_, exists := metrics[metricName]
+	if !exists {
+		availableMetrics := []string{}
+		for name := range metrics {
+			availableMetrics = append(availableMetrics, name)
+		}
+		sort.Strings(availableMetrics)
+		GinkgoWriter.Printf("Available metrics: %v\n", availableMetrics)
+	}
+	Expect(exists).To(BeTrue(), "metric %s should exist", metricName)
+}
+
+func ExpectMetricValue(metrics MetricsMap, metricName string, matchLabels map[string]string, expectedValue float64) {
+	value, found := GetMetricValue(metrics, metricName, matchLabels)
+	Expect(found).To(BeTrue(), "metric %s with labels %v should exist", metricName, matchLabels)
+	Expect(value).To(Equal(expectedValue), "metric %s value mismatch", metricName)
+}
+
+func ExpectMetricGreaterThan(metrics MetricsMap, metricName string, matchLabels map[string]string, threshold float64) {
+	value, found := GetMetricValue(metrics, metricName, matchLabels)
+	Expect(found).To(BeTrue(), "metric %s with labels %v should exist", metricName, matchLabels)
+	Expect(value).To(BeNumerically(">", threshold), "metric %s should be greater than %f", metricName, threshold)
+}
+
+func WaitForMetric(ctx context.Context, scraper func() (MetricsMap, error), metricName string, matchLabels map[string]string, minValue float64, timeout time.Duration) error {
+	ticker := time.NewTicker(time.Second)
+	defer ticker.Stop()
+
+	timer := time.NewTimer(timeout)
+	defer timer.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return fmt.Errorf("waiting for metric %s canceled: %w", metricName, ctx.Err())
+		case <-timer.C:
+			return fmt.Errorf("timeout waiting for metric %s with labels %v to reach %f", metricName, matchLabels, minValue)
+		default:
+		}
+
+		metrics, err := scraper()
+		if err == nil {
+			value, found := GetMetricValue(metrics, metricName, matchLabels)
+			if found && value >= minValue {
+				return nil
+			}
+		}
+
+		select {
+		case <-ctx.Done():
+			return fmt.Errorf("waiting for metric %s canceled: %w", metricName, ctx.Err())
+		case <-timer.C:
+			return fmt.Errorf("timeout waiting for metric %s with labels %v to reach %f", metricName, matchLabels, minValue)
+		case <-ticker.C:
+		}
+	}
+
+}
+
+func scrapePodMetrics(ctx context.Context, config *rest.Config, clientset kubernetes.Interface, namespace, podName string, podPort int) (MetricsMap, error) {
+	address, cleanup, err := setupPortForward(ctx, config, clientset, namespace, podName, podPort)
+	if err != nil {
+		return nil, fmt.Errorf("failed to setup port forward: %w", err)
+	}
+	defer cleanup()
+
+	body, err := waitForMetricsEndpoint(ctx, address, 10*time.Second)
+	if err != nil {
+		return nil, err
+	}
+
+	return parsePrometheusMetrics(body)
+}
+
+func findPod(ctx context.Context, clientset kubernetes.Interface, namespace, labelSelector string) (string, error) {
+	pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
+		LabelSelector: labelSelector,
+	})
+	if err != nil {
+		return "", fmt.Errorf("failed to list pods: %w", err)
+	}
+
+	for _, pod := range pods.Items {
+		if pod.Status.Phase == corev1.PodRunning {
+			return pod.Name, nil
+		}
+	}
+
+	return "", fmt.Errorf("no running pod found for selector %s", labelSelector)
+}
+
+func setupPortForward(ctx context.Context, config *rest.Config, clientset kubernetes.Interface, namespace, podName string, podPort int) (string, func(), error) {
+	pod, err := clientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{})
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to get pod: %w", err)
+	}
+	if pod.Status.Phase != corev1.PodRunning {
+		return "", nil, fmt.Errorf("pod %s is not running: %s", podName, pod.Status.Phase)
+	}
+
+	transport, upgrader, err := spdy.RoundTripperFor(config)
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create round tripper: %w", err)
+	}
+
+	url := clientset.CoreV1().RESTClient().Post().
+		Resource("pods").
+		Namespace(namespace).
+		Name(podName).
+		SubResource("portforward").
+		URL()
+
+	dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, url)
+
+	stopChan := make(chan struct{}, 1)
+	readyChan := make(chan struct{}, 1)
+	ports := []string{fmt.Sprintf("0:%d", podPort)}
+
+	pf, err := portforward.New(dialer, ports, stopChan, readyChan, GinkgoWriter, GinkgoWriter)
+	if err != nil {
+		return "", nil, fmt.Errorf("failed to create port forwarder: %w", err)
+	}
+
+	errChan := make(chan error, 1)
+	go func() {
+		if forwardErr := pf.ForwardPorts(); forwardErr != nil {
+			errChan <- forwardErr
+		}
+	}()
+
+	select {
+	case <-readyChan:
+		forwardedPorts, portErr := pf.GetPorts()
+		if portErr != nil {
+			close(stopChan)
+			return "", nil, fmt.Errorf("failed to get forwarded ports: %w", portErr)
+		}
+		if len(forwardedPorts) == 0 {
+			close(stopChan)
+			return "", nil, fmt.Errorf("no ports were forwarded")
+		}
+		cleanup := func() {
+			close(stopChan)
+		}
+		return fmt.Sprintf("localhost:%d", forwardedPorts[0].Local), cleanup, nil
+	case err = <-errChan:
+		close(stopChan)
+		return "", nil, fmt.Errorf("port forward failed: %w", err)
+	case <-ctx.Done():
+		close(stopChan)
+		return "", nil, fmt.Errorf("port forward canceled: %w", ctx.Err())
+	case <-time.After(30 * time.Second):
+		close(stopChan)
+		return "", nil, fmt.Errorf("timeout waiting for port forward to be ready")
+	}
+}
+
+func scrapeMetrics(ctx context.Context, address string) (string, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://%s/metrics", address), nil)
+	if err != nil {
+		return "", fmt.Errorf("failed to create request: %w", err)
+	}
+
+	resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
+	if err != nil {
+		return "", fmt.Errorf("failed to scrape metrics: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("failed to read response body: %w", err)
+	}
+	return string(body), nil
+}
+
+func waitForMetricsEndpoint(ctx context.Context, address string, timeout time.Duration) (string, error) {
+	ticker := time.NewTicker(250 * time.Millisecond)
+	defer ticker.Stop()
+
+	timer := time.NewTimer(timeout)
+	defer timer.Stop()
+
+	var lastErr error
+	for {
+		body, err := scrapeMetrics(ctx, address)
+		if err == nil {
+			return body, nil
+		}
+		lastErr = err
+
+		select {
+		case <-ctx.Done():
+			return "", fmt.Errorf("waiting for metrics endpoint canceled: %w", ctx.Err())
+		case <-timer.C:
+			return "", fmt.Errorf("timed out waiting for metrics endpoint: %w", lastErr)
+		case <-ticker.C:
+		}
+	}
+}
+
+func parsePrometheusMetrics(body string) (MetricsMap, error) {
+	metrics := make(MetricsMap)
+	metricRegex := regexp.MustCompile(`^([a-zA-Z_:][a-zA-Z0-9_:]*?)(?:\{([^}]*)\})?\s+([^\s]+)`)
+
+	scanner := bufio.NewScanner(strings.NewReader(body))
+	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
+			continue
+		}
+
+		matches := metricRegex.FindStringSubmatch(line)
+		if len(matches) != 4 {
+			continue
+		}
+
+		value, err := strconv.ParseFloat(matches[3], 64)
+		if err != nil {
+			continue
+		}
+
+		sample := MetricSample{
+			Name:   matches[1],
+			Labels: parseLabels(matches[2]),
+			Value:  value,
+		}
+		metrics[sample.Name] = append(metrics[sample.Name], sample)
+	}
+
+	if err := scanner.Err(); err != nil {
+		return nil, fmt.Errorf("failed to scan metrics response: %w", err)
+	}
+
+	return metrics, nil
+}
+
+func parseLabels(labelsStr string) map[string]string {
+	labels := make(map[string]string)
+	if labelsStr == "" {
+		return labels
+	}
+
+	labelRegex := regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"`)
+	matches := labelRegex.FindAllStringSubmatch(labelsStr, -1)
+	for _, match := range matches {
+		if len(match) == 3 {
+			value, err := strconv.Unquote(`"` + match[2] + `"`)
+			if err != nil {
+				value = match[2]
+			}
+			labels[match[1]] = value
+		}
+	}
+	return labels
+}
+
+func labelsMatch(sampleLabels, matchLabels map[string]string) bool {
+	for key, value := range matchLabels {
+		if sampleLabels[key] != value {
+			return false
+		}
+	}
+	return true
+}

+ 106 - 0
e2e/framework/v2/metrics_test.go

@@ -0,0 +1,106 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 v2
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestSumMetricValues(t *testing.T) {
+	metrics := MetricsMap{
+		"grpc_pool_connections_total": {
+			{Name: "grpc_pool_connections_total", Labels: map[string]string{"address": "provider-a"}, Value: 1},
+			{Name: "grpc_pool_connections_total", Labels: map[string]string{"address": "provider-a"}, Value: 2},
+			{Name: "grpc_pool_connections_total", Labels: map[string]string{"address": "provider-b"}, Value: 4},
+		},
+	}
+
+	got := SumMetricValues(metrics, "grpc_pool_connections_total", map[string]string{"address": "provider-a"})
+	if got != 3 {
+		t.Fatalf("expected sum 3, got %v", got)
+	}
+}
+
+func TestCountMetricSamples(t *testing.T) {
+	metrics := MetricsMap{
+		"grpc_pool_connections_total": {
+			{Name: "grpc_pool_connections_total", Labels: map[string]string{"address": "provider-a"}, Value: 1},
+			{Name: "grpc_pool_connections_total", Labels: map[string]string{"address": "provider-a"}, Value: 2},
+			{Name: "grpc_pool_connections_total", Labels: map[string]string{"address": "provider-b"}, Value: 4},
+		},
+	}
+
+	got := CountMetricSamples(metrics, "grpc_pool_connections_total", map[string]string{"address": "provider-a"})
+	if got != 2 {
+		t.Fatalf("expected count 2, got %d", got)
+	}
+}
+
+func TestGetMetricValueMatchesSubsetOfLabels(t *testing.T) {
+	metrics := MetricsMap{
+		"grpc_pool_connections_total": {
+			{Name: "grpc_pool_connections_total", Labels: map[string]string{"address": "provider-a", "state": "idle"}, Value: 2},
+			{Name: "grpc_pool_connections_total", Labels: map[string]string{"address": "provider-b", "state": "active"}, Value: 4},
+		},
+	}
+
+	got, found := GetMetricValue(metrics, "grpc_pool_connections_total", map[string]string{"address": "provider-a"})
+	if !found {
+		t.Fatalf("expected matching metric sample")
+	}
+	if got != 2 {
+		t.Fatalf("expected value 2, got %v", got)
+	}
+}
+
+func TestWaitForMetricHonorsContextCancellation(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+
+	err := WaitForMetric(ctx, func() (MetricsMap, error) {
+		return MetricsMap{}, nil
+	}, "grpc_pool_connections_total", map[string]string{"address": "provider-a"}, 1, 10*time.Second)
+	if err == nil {
+		t.Fatalf("expected cancellation error")
+	}
+	if !strings.Contains(err.Error(), "context canceled") {
+		t.Fatalf("expected context cancellation error, got %v", err)
+	}
+}
+
+func TestParsePrometheusMetricsParsesEscapedLabels(t *testing.T) {
+	body := "grpc_pool_connections_total{address=\"provider-\\\"a\\\"\",state=\"idle\\nstate\"} 2\n"
+
+	metrics, err := parsePrometheusMetrics(body)
+	if err != nil {
+		t.Fatalf("unexpected parse error: %v", err)
+	}
+
+	got, found := GetMetricValue(metrics, "grpc_pool_connections_total", map[string]string{
+		"address": "provider-\"a\"",
+		"state":   "idle\nstate",
+	})
+	if !found {
+		t.Fatalf("expected escaped metric labels to match parsed sample, got metrics %#v", metrics)
+	}
+	if got != 2 {
+		t.Fatalf("expected value 2, got %v", got)
+	}
+}

+ 148 - 0
e2e/framework/v2/operational.go

@@ -0,0 +1,148 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 v2
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	appsv1 "k8s.io/api/apps/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/util/retry"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv2alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v2alpha1"
+
+	. "github.com/onsi/gomega"
+)
+
+type BackendTarget struct {
+	Namespace        string
+	DeploymentName   string
+	PodLabelSelector string
+}
+
+func ScaleDeploymentBySelector(f *framework.Framework, target BackendTarget, replicas int32) {
+	deploymentName := target.DeploymentName
+	if deploymentName == "" {
+		deploymentName = findDeploymentNameBySelector(f, target)
+	}
+	scaleDeployment(f, target.Namespace, deploymentName, replicas)
+}
+
+func ScaleDeploymentBySelectorAndWait(f *framework.Framework, target BackendTarget, replicas int32, timeout time.Duration) {
+	ScaleDeploymentBySelector(f, target, replicas)
+	WaitForBackendTargetRunningReplicas(f, target, int(replicas), timeout)
+}
+
+func DeleteOneProviderPodBySelector(f *framework.Framework, target BackendTarget) {
+	Expect(target.Namespace).NotTo(BeEmpty(), "backend target namespace must be set")
+	Expect(target.PodLabelSelector).NotTo(BeEmpty(), "backend target pod label selector must be set")
+
+	selector, err := labels.Parse(target.PodLabelSelector)
+	Expect(err).NotTo(HaveOccurred())
+
+	var podList corev1.PodList
+	Expect(f.CRClient.List(context.Background(), &podList, &client.ListOptions{
+		Namespace:     target.Namespace,
+		LabelSelector: selector,
+	})).To(Succeed())
+
+	foundRunningPod := false
+	for i := range podList.Items {
+		pod := &podList.Items[i]
+		if pod.Status.Phase == corev1.PodRunning {
+			foundRunningPod = true
+			Expect(f.CRClient.Delete(context.Background(), pod)).To(Succeed())
+			return
+		}
+	}
+
+	Expect(foundRunningPod).To(BeTrue(), fmt.Sprintf("no running pod found for selector %s in namespace %s", target.PodLabelSelector, target.Namespace))
+}
+
+func findDeploymentNameBySelector(f *framework.Framework, target BackendTarget) string {
+	Expect(target.Namespace).NotTo(BeEmpty(), "backend target namespace must be set")
+	Expect(target.PodLabelSelector).NotTo(BeEmpty(), "backend target pod label selector must be set")
+
+	selector, err := labels.Parse(target.PodLabelSelector)
+	Expect(err).NotTo(HaveOccurred())
+
+	var deploymentList appsv1.DeploymentList
+	Expect(f.CRClient.List(context.Background(), &deploymentList, &client.ListOptions{
+		Namespace: target.Namespace,
+	})).To(Succeed())
+
+	matches := make([]appsv1.Deployment, 0, len(deploymentList.Items))
+	for _, deployment := range deploymentList.Items {
+		if selector.Matches(labels.Set(deployment.Spec.Template.GetLabels())) {
+			matches = append(matches, deployment)
+		}
+	}
+
+	Expect(matches).NotTo(BeEmpty(), "no deployment found for selector %s", target.PodLabelSelector)
+	Expect(matches).To(HaveLen(1), "expected one deployment for selector %s", target.PodLabelSelector)
+
+	return matches[0].Name
+}
+
+func WaitForBackendTargetRunningReplicas(f *framework.Framework, target BackendTarget, expectedReplicas int, timeout time.Duration) {
+	Expect(target.Namespace).NotTo(BeEmpty(), "backend target namespace must be set")
+	Expect(target.PodLabelSelector).NotTo(BeEmpty(), "backend target pod label selector must be set")
+
+	selector, err := labels.Parse(target.PodLabelSelector)
+	Expect(err).NotTo(HaveOccurred())
+
+	Eventually(func(g Gomega) {
+		var podList corev1.PodList
+		g.Expect(f.CRClient.List(context.Background(), &podList, &client.ListOptions{
+			Namespace:     target.Namespace,
+			LabelSelector: selector,
+		})).To(Succeed())
+
+		running := 0
+		for i := range podList.Items {
+			if podList.Items[i].Status.Phase == corev1.PodRunning {
+				running++
+			}
+		}
+		g.Expect(running).To(Equal(expectedReplicas))
+	}, timeout, time.Second).Should(Succeed())
+}
+
+func scaleDeployment(f *framework.Framework, namespace, name string, replicas int32) {
+	Expect(namespace).NotTo(BeEmpty(), "deployment namespace must be set")
+	Expect(name).NotTo(BeEmpty(), "deployment name must be set")
+
+	Expect(retry.RetryOnConflict(retry.DefaultRetry, func() error {
+		var deployment appsv1.Deployment
+		if err := f.CRClient.Get(context.Background(), types.NamespacedName{Namespace: namespace, Name: name}, &deployment); err != nil {
+			return err
+		}
+		deployment.Spec.Replicas = &replicas
+		return f.CRClient.Update(context.Background(), &deployment)
+	})).To(Succeed())
+}
+
+func WaitForClusterProviderNotReady(f *framework.Framework, name string, timeout time.Duration) *esv2alpha1.ClusterProviderStore {
+	return WaitForClusterProviderCondition(f, name, metav1.ConditionFalse, timeout)
+}

+ 35 - 35
e2e/go.mod

@@ -45,11 +45,12 @@ require (
 	github.com/DelineaXPM/tss-sdk-go/v3 v3.0.2
 	github.com/akeylesslabs/akeyless-go-cloud-id v0.3.5
 	github.com/akeylesslabs/akeyless-go/v4 v4.3.0
-	github.com/aws/aws-sdk-go-v2 v1.39.5
+	github.com/aws/aws-sdk-go-v2 v1.41.5
 	github.com/aws/aws-sdk-go-v2/config v1.31.16
 	github.com/aws/aws-sdk-go-v2/credentials v1.18.20
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.9
 	github.com/aws/aws-sdk-go-v2/service/ssm v1.66.3
+	github.com/aws/aws-sdk-go-v2/service/sts v1.39.0
 	github.com/cyberark/conjur-api-go v0.13.8
 	github.com/external-secrets/external-secrets/apis v0.0.0
 	github.com/external-secrets/external-secrets/providers/v1/azure v0.0.0-00010101000000-000000000000
@@ -59,23 +60,24 @@ require (
 	github.com/fluxcd/pkg/apis/meta v1.22.0
 	github.com/fluxcd/source-controller/api v1.7.3
 	github.com/golang-jwt/jwt/v4 v4.5.2
+	github.com/google/go-cmp v0.7.0
 	github.com/grafana/grafana-openapi-client-go v0.0.0-20250925215610-d92957c70d5c
 	github.com/hashicorp/vault/api v1.22.0
-	github.com/onsi/ginkgo/v2 v2.27.2
-	github.com/onsi/gomega v1.38.2
+	github.com/onsi/ginkgo/v2 v2.28.0
+	github.com/onsi/gomega v1.39.1
 	github.com/oracle/oci-go-sdk/v65 v65.103.0
 	github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35
 	gitlab.com/gitlab-org/api/client-go v0.157.1
-	golang.org/x/oauth2 v0.34.0
+	golang.org/x/oauth2 v0.36.0
 	google.golang.org/api v0.254.0
-	k8s.io/api v0.35.0
-	k8s.io/apiextensions-apiserver v0.35.0
-	k8s.io/apimachinery v0.35.0
+	k8s.io/api v0.35.2
+	k8s.io/apiextensions-apiserver v0.35.2
+	k8s.io/apimachinery v0.35.2
 	k8s.io/client-go v1.5.2
-	k8s.io/utils v0.0.0-20260108192941-914a6e750570
-	sigs.k8s.io/controller-runtime v0.23.1
+	k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2
+	sigs.k8s.io/controller-runtime v0.23.3
 	sigs.k8s.io/yaml v1.6.0
-	software.sslmate.com/src/go-pkcs12 v0.6.0
+	software.sslmate.com/src/go-pkcs12 v0.7.0
 )
 
 require (
@@ -112,15 +114,14 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.12 // indirect
 	github.com/aws/aws-sdk-go-v2/service/sso v1.30.0 // indirect
 	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sts v1.39.0 // indirect
-	github.com/aws/smithy-go v1.23.1 // indirect
+	github.com/aws/smithy-go v1.24.2 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/danieljoos/wincred v1.2.3 // indirect
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
-	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
+	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
 	github.com/dimchansky/utfbom v1.1.1 // indirect
 	github.com/emicklei/go-restful/v3 v3.13.0 // indirect
 	github.com/evanphx/json-patch v5.6.0+incompatible // indirect
@@ -136,24 +137,24 @@ require (
 	github.com/go-logr/zapr v1.3.0 // indirect
 	github.com/go-openapi/analysis v0.24.0 // indirect
 	github.com/go-openapi/errors v0.22.3 // indirect
-	github.com/go-openapi/jsonpointer v0.22.4 // indirect
-	github.com/go-openapi/jsonreference v0.21.4 // indirect
+	github.com/go-openapi/jsonpointer v0.22.5 // indirect
+	github.com/go-openapi/jsonreference v0.21.5 // indirect
 	github.com/go-openapi/loads v0.23.1 // indirect
 	github.com/go-openapi/runtime v0.29.0 // indirect
 	github.com/go-openapi/spec v0.22.0 // indirect
 	github.com/go-openapi/strfmt v0.24.0 // indirect
-	github.com/go-openapi/swag v0.25.4 // indirect
-	github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
-	github.com/go-openapi/swag/conv v0.25.4 // indirect
-	github.com/go-openapi/swag/fileutils v0.25.4 // indirect
-	github.com/go-openapi/swag/jsonname v0.25.4 // indirect
-	github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
-	github.com/go-openapi/swag/loading v0.25.4 // indirect
-	github.com/go-openapi/swag/mangling v0.25.4 // indirect
-	github.com/go-openapi/swag/netutils v0.25.4 // indirect
-	github.com/go-openapi/swag/stringutils v0.25.4 // indirect
-	github.com/go-openapi/swag/typeutils v0.25.4 // indirect
-	github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
+	github.com/go-openapi/swag v0.25.5 // indirect
+	github.com/go-openapi/swag/cmdutils v0.25.5 // indirect
+	github.com/go-openapi/swag/conv v0.25.5 // indirect
+	github.com/go-openapi/swag/fileutils v0.25.5 // indirect
+	github.com/go-openapi/swag/jsonname v0.25.5 // indirect
+	github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
+	github.com/go-openapi/swag/loading v0.25.5 // indirect
+	github.com/go-openapi/swag/mangling v0.25.5 // indirect
+	github.com/go-openapi/swag/netutils v0.25.5 // indirect
+	github.com/go-openapi/swag/stringutils v0.25.5 // indirect
+	github.com/go-openapi/swag/typeutils v0.25.5 // indirect
+	github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
 	github.com/go-openapi/validate v0.25.0 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
 	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
@@ -163,9 +164,8 @@ require (
 	github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
 	github.com/google/btree v1.1.3 // indirect
 	github.com/google/gnostic-models v0.7.1 // indirect
-	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
-	github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect
+	github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
 	github.com/google/s2a-go v0.1.9 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
@@ -205,7 +205,7 @@ require (
 	github.com/prometheus/client_golang v1.23.2 // indirect
 	github.com/prometheus/client_model v0.6.2 // indirect
 	github.com/prometheus/common v0.67.5 // indirect
-	github.com/prometheus/procfs v0.19.2 // indirect
+	github.com/prometheus/procfs v0.20.1 // indirect
 	github.com/ryanuber/go-glob v1.0.0 // indirect
 	github.com/segmentio/asm v1.2.1 // indirect
 	github.com/shopspring/decimal v1.4.0 // indirect
@@ -229,7 +229,7 @@ require (
 	go.opentelemetry.io/otel/trace v1.39.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
+	go.yaml.in/yaml/v2 v2.4.4 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
 	golang.org/x/crypto v0.49.0 // indirect
 	golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
@@ -239,7 +239,7 @@ require (
 	golang.org/x/sys v0.42.0 // indirect
 	golang.org/x/term v0.41.0 // indirect
 	golang.org/x/text v0.35.0 // indirect
-	golang.org/x/time v0.14.0 // indirect
+	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.42.0 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
 	google.golang.org/genproto v0.0.0-20251029180050-ab9386a59fda // indirect
@@ -251,11 +251,11 @@ require (
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	grpc.go4.org v0.0.0-20170609214715-11d0a25b4919 // indirect
-	k8s.io/klog/v2 v2.130.1 // indirect
-	k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect
+	k8s.io/klog/v2 v2.140.0 // indirect
+	k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf // indirect
 	sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
 	sigs.k8s.io/randfill v1.0.0 // indirect
-	sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect
+	sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
 )
 
 replace github.com/external-secrets/external-secrets/apis => ../apis

+ 66 - 66
e2e/go.sum

@@ -132,8 +132,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:W
 github.com/aws/aws-sdk-go v1.41.13/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 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/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w=
-github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
+github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
+github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
 github.com/aws/aws-sdk-go-v2/config v1.31.16 h1:E4Tz+tJiPc7kGnXwIfCyUj6xHJNpENlY11oKpRTgsjc=
 github.com/aws/aws-sdk-go-v2/config v1.31.16/go.mod h1:2S9hBElpCyGMifv14WxQ7EfPumgoeCPZUpuPX8VtW34=
 github.com/aws/aws-sdk-go-v2/credentials v1.18.20 h1:KFndAnHd9NUuzikHjQ8D5CfFVO+bgELkmcGY8yAw98Q=
@@ -160,8 +160,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4 h1:tBw2Qhf0kj4ZwtsVpDiVRU3z
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4/go.mod h1:Deq4B7sRM6Awq/xyOBlxBdgW8/Z926KYNNaGMW2lrkA=
 github.com/aws/aws-sdk-go-v2/service/sts v1.39.0 h1:C+BRMnasSYFcgDw8o9H5hzehKzXyAb9GY5v/8bP9DUY=
 github.com/aws/aws-sdk-go-v2/service/sts v1.39.0/go.mod h1:4EjU+4mIx6+JqKQkruye+CaigV7alL3thVPfDd9VlMs=
-github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
-github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
+github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
+github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
 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/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
@@ -188,8 +188,8 @@ 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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
 github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
 github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
 github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
@@ -254,10 +254,10 @@ github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7Bd
 github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw=
 github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs=
 github.com/go-openapi/errors v0.22.3/go.mod h1:+WvbaBBULWCOna//9B9TbLNGSFOfF8lY9dw4hGiEiKQ=
-github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
-github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
-github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
-github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
+github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
+github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
+github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
+github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
 github.com/go-openapi/loads v0.23.1 h1:H8A0dX2KDHxDzc797h0+uiCZ5kwE2+VojaQVaTlXvS0=
 github.com/go-openapi/loads v0.23.1/go.mod h1:hZSXkyACCWzWPQqizAv/Ye0yhi2zzHwMmoXQ6YQml44=
 github.com/go-openapi/runtime v0.29.0 h1:Y7iDTFarS9XaFQ+fA+lBLngMwH6nYfqig1G+pHxMRO0=
@@ -266,36 +266,36 @@ github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0T
 github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
 github.com/go-openapi/strfmt v0.24.0 h1:dDsopqbI3wrrlIzeXRbqMihRNnjzGC+ez4NQaAAJLuc=
 github.com/go-openapi/strfmt v0.24.0/go.mod h1:Lnn1Bk9rZjXxU9VMADbEEOo7D7CDyKGLsSKekhFr7s4=
-github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
-github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
-github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
-github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
-github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
-github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
-github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
-github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
-github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
-github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
-github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
-github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
-github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
-github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
-github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
-github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
-github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
-github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
-github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
-github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
-github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
-github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
-github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
-github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
-github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
-github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
-github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
-github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
-github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
-github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
+github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU=
+github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA=
+github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c=
+github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
+github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
+github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
+github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk=
+github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc=
+github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
+github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
+github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
+github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
+github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
+github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
+github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw=
+github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY=
+github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU=
+github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14=
+github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
+github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
+github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
+github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
+github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
+github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
+github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
+github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
 github.com/go-openapi/validate v0.25.0 h1:JD9eGX81hDTjoY3WOzh6WqxVBVl7xjsLnvDo1GL5WPU=
 github.com/go-openapi/validate v0.25.0/go.mod h1:SUY7vKrN5FiwK6LyvSwKjDfLNirSfWwHNgxd2l29Mmw=
 github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -390,8 +390,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
-github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
+github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
 github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
@@ -509,10 +509,10 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
 github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
 github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
-github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
-github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
-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/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc=
+github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
+github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
+github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
 github.com/oracle/oci-go-sdk/v65 v65.103.0 h1:HfyZx+JefCPK3At0Xt45q+wr914jDXuoyzOFX3XCbno=
 github.com/oracle/oci-go-sdk/v65 v65.103.0/go.mod h1:oB8jFGVc/7/zJ+DbleE8MzGHjhs2ioCz5stRTdZdIcY=
 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
@@ -532,8 +532,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
 github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
 github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
-github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
-github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
+github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
+github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
@@ -624,8 +624,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
-go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -737,8 +737,8 @@ golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
-golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
-golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -831,8 +831,8 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
-golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1038,24 +1038,24 @@ k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
 k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
 k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
 k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
-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-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY=
-k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
-k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
-k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf h1:btPscg4cMql0XdYK2jLsJcNEKmACJz8l+U7geC06FiM=
+k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
-sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE=
-sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0=
+sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80=
+sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0=
 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
 sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
 sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
-sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs=
-sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
 sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
 sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
-software.sslmate.com/src/go-pkcs12 v0.6.0 h1:f3sQittAeF+pao32Vb+mkli+ZyT+VwKaD014qFGq6oU=
-software.sslmate.com/src/go-pkcs12 v0.6.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
+software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

+ 187 - 0
e2e/makefile_test.go

@@ -0,0 +1,187 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 (
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+)
+
+const (
+	testVersionArg           = "VERSION=test-version"
+	kubernetesBuildTarget    = "docker.build.provider.kubernetes"
+	kubernetesProviderImage  = "ghcr.io/external-secrets/provider-kubernetes:test-version"
+	helmDependencyBuildCmd   = "helm dependency build ../deploy/charts/external-secrets"
+	controllerImageLoadCount = `kind load docker-image --name="external-secrets" ghcr.io/external-secrets/external-secrets:test-version`
+	controllerImageBuildCmd  = "docker.build.controller.e2e"
+	dockerCleanupCmd         = "docker system prune --all --force --volumes"
+)
+
+func TestClassicMakeTargetBuildsOnlyControllerImageOnce(t *testing.T) {
+	t.Parallel()
+
+	dryRun := runMakeDryRun(t, "test", testVersionArg, `TEST_SUITES=provider`, `GINKGO_LABELS=kubernetes && !v2`)
+
+	if !strings.Contains(dryRun, controllerImageBuildCmd) {
+		t.Fatalf("expected classic test dry-run to build the controller image via docker.build.controller.e2e, output:\n%s", dryRun)
+	}
+	if strings.Contains(dryRun, kubernetesBuildTarget) {
+		t.Fatalf("expected classic test dry-run to omit kubernetes provider image builds, output:\n%s", dryRun)
+	}
+	if strings.Contains(dryRun, "docker.build.provider.aws") {
+		t.Fatalf("expected classic test dry-run to omit aws provider image builds, output:\n%s", dryRun)
+	}
+	if strings.Contains(dryRun, "docker.build.provider.fake") {
+		t.Fatalf("expected classic test dry-run to omit fake provider image builds, output:\n%s", dryRun)
+	}
+	if count := strings.Count(dryRun, controllerImageLoadCount); count != 1 {
+		t.Fatalf("expected classic test dry-run to load the controller image once, got %d occurrences, output:\n%s", count, dryRun)
+	}
+	if strings.Contains(dryRun, kubernetesProviderImage) {
+		t.Fatalf("expected classic test dry-run to avoid loading the kubernetes provider image, output:\n%s", dryRun)
+	}
+	if !strings.Contains(dryRun, helmDependencyBuildCmd) {
+		t.Fatalf("expected classic test dry-run to ensure helm dependencies before copying the chart, output:\n%s", dryRun)
+	}
+}
+
+func TestV2MakeTargetCanSkipKubernetesProviderBuild(t *testing.T) {
+	t.Parallel()
+
+	defaultDryRun := runMakeDryRun(t, "test.v2", testVersionArg)
+	if !strings.Contains(defaultDryRun, kubernetesBuildTarget) {
+		t.Fatalf("expected default test.v2 dry-run to build the kubernetes provider image, output:\n%s", defaultDryRun)
+	}
+	if count := strings.Count(defaultDryRun, controllerImageBuildCmd); count != 1 {
+		t.Fatalf("expected default test.v2 dry-run to build the controller image once, got %d occurrences, output:\n%s", count, defaultDryRun)
+	}
+	if !strings.Contains(defaultDryRun, "docker.build.provider.aws") {
+		t.Fatalf("expected default test.v2 dry-run to build the aws provider image, output:\n%s", defaultDryRun)
+	}
+	if !strings.Contains(defaultDryRun, "docker.build.provider.fake") {
+		t.Fatalf("expected default test.v2 dry-run to build the fake provider image, output:\n%s", defaultDryRun)
+	}
+	if strings.Contains(defaultDryRun, "docker.build.provider.gcp") {
+		t.Fatalf("expected default test.v2 dry-run to omit nonexistent gcp provider builds, output:\n%s", defaultDryRun)
+	}
+	if count := strings.Count(defaultDryRun, controllerImageLoadCount); count != 1 {
+		t.Fatalf("expected default test.v2 dry-run to load the controller image once, got %d occurrences, output:\n%s", count, defaultDryRun)
+	}
+	if !strings.Contains(defaultDryRun, kubernetesProviderImage) {
+		t.Fatalf("expected default test.v2 dry-run to still load the kubernetes provider image, output:\n%s", defaultDryRun)
+	}
+	if !strings.Contains(defaultDryRun, "ghcr.io/external-secrets/provider-aws:test-version") {
+		t.Fatalf("expected default test.v2 dry-run to load the aws provider image, output:\n%s", defaultDryRun)
+	}
+	if !strings.Contains(defaultDryRun, "ghcr.io/external-secrets/provider-fake:test-version") {
+		t.Fatalf("expected default test.v2 dry-run to load the fake provider image, output:\n%s", defaultDryRun)
+	}
+	if strings.Contains(defaultDryRun, "ghcr.io/external-secrets/provider-gcp:test-version") {
+		t.Fatalf("expected default test.v2 dry-run to omit nonexistent gcp provider image loads, output:\n%s", defaultDryRun)
+	}
+	if !strings.Contains(defaultDryRun, helmDependencyBuildCmd) {
+		t.Fatalf("expected default test.v2 dry-run to ensure helm dependencies before copying the chart, output:\n%s", defaultDryRun)
+	}
+	if !strings.Contains(defaultDryRun, `TEST_SUITES="provider"`) {
+		t.Fatalf("expected default test.v2 dry-run to run the provider suite, output:\n%s", defaultDryRun)
+	}
+	if strings.Contains(defaultDryRun, dockerCleanupCmd) {
+		t.Fatalf("expected default test.v2 dry-run to avoid CI-only docker cleanup, output:\n%s", defaultDryRun)
+	}
+
+	skippedDryRun := runMakeDryRun(t, "test.v2", testVersionArg, "SKIP_PROVIDER_KUBERNETES_BUILD=true")
+	if strings.Contains(skippedDryRun, kubernetesBuildTarget) {
+		t.Fatalf("expected skipped test.v2 dry-run to omit the kubernetes provider build, output:\n%s", skippedDryRun)
+	}
+	if count := strings.Count(skippedDryRun, controllerImageBuildCmd); count != 1 {
+		t.Fatalf("expected skipped test.v2 dry-run to build the controller image once, got %d occurrences, output:\n%s", count, skippedDryRun)
+	}
+	if !strings.Contains(skippedDryRun, "docker.build.provider.fake") {
+		t.Fatalf("expected skipped test.v2 dry-run to still build the fake provider image, output:\n%s", skippedDryRun)
+	}
+	if strings.Contains(skippedDryRun, "docker.build.provider.gcp") {
+		t.Fatalf("expected skipped test.v2 dry-run to omit nonexistent gcp provider builds, output:\n%s", skippedDryRun)
+	}
+	if count := strings.Count(skippedDryRun, controllerImageLoadCount); count != 1 {
+		t.Fatalf("expected skipped test.v2 dry-run to load the controller image once, got %d occurrences, output:\n%s", count, skippedDryRun)
+	}
+	if !strings.Contains(skippedDryRun, kubernetesProviderImage) {
+		t.Fatalf("expected skipped test.v2 dry-run to still load the kubernetes provider image, output:\n%s", skippedDryRun)
+	}
+	if !strings.Contains(skippedDryRun, "ghcr.io/external-secrets/provider-aws:test-version") {
+		t.Fatalf("expected skipped test.v2 dry-run to still load the aws provider image, output:\n%s", skippedDryRun)
+	}
+	if !strings.Contains(skippedDryRun, "ghcr.io/external-secrets/provider-fake:test-version") {
+		t.Fatalf("expected skipped test.v2 dry-run to still load the fake provider image, output:\n%s", skippedDryRun)
+	}
+	if strings.Contains(skippedDryRun, "ghcr.io/external-secrets/provider-gcp:test-version") {
+		t.Fatalf("expected skipped test.v2 dry-run to omit nonexistent gcp provider image loads, output:\n%s", skippedDryRun)
+	}
+	if !strings.Contains(skippedDryRun, helmDependencyBuildCmd) {
+		t.Fatalf("expected skipped test.v2 dry-run to ensure helm dependencies before copying the chart, output:\n%s", skippedDryRun)
+	}
+	if !strings.Contains(skippedDryRun, `TEST_SUITES="provider"`) {
+		t.Fatalf("expected skipped test.v2 dry-run to still run the provider suite, output:\n%s", skippedDryRun)
+	}
+}
+
+func TestV2MakeTargetPrunesDockerImagesInCI(t *testing.T) {
+	t.Parallel()
+
+	dryRun := runMakeDryRunWithEnv(t, []string{"CI=true"}, "test.v2", testVersionArg)
+	if count := strings.Count(dryRun, dockerCleanupCmd); count != 1 {
+		t.Fatalf("expected CI test.v2 dry-run to prune docker state once, got %d occurrences, output:\n%s", count, dryRun)
+	}
+}
+
+func TestV2OperationalMakeTarget(t *testing.T) {
+	t.Parallel()
+
+	dryRun := runMakeDryRun(t, "test.v2.operational", testVersionArg)
+	if !strings.Contains(dryRun, `V2_GINKGO_LABELS='v2 && operational && fake'`) {
+		t.Fatalf("expected operational labels in target, got:\n%s", dryRun)
+	}
+	if !strings.Contains(dryRun, `V2_TEST_SUITES='provider'`) {
+		t.Fatalf("expected provider suite in target, got:\n%s", dryRun)
+	}
+	if !strings.Contains(dryRun, `TEST_SUITES="provider"`) {
+		t.Fatalf("expected operational target to render provider-only v2 suites, got:\n%s", dryRun)
+	}
+}
+
+func runMakeDryRun(t *testing.T, target string, extraArgs ...string) string {
+	t.Helper()
+	return runMakeDryRunWithEnv(t, nil, target, extraArgs...)
+}
+
+func runMakeDryRunWithEnv(t *testing.T, extraEnv []string, target string, extraArgs ...string) string {
+	t.Helper()
+
+	args := append([]string{"-n", target}, extraArgs...)
+	cmd := exec.Command("make", args...)
+	cmd.Dir = "."
+	cmd.Env = append(os.Environ(), extraEnv...)
+
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("make dry-run failed: %v\n%s", err, string(output))
+	}
+
+	return string(output)
+}

+ 24 - 7
e2e/run.sh

@@ -23,29 +23,44 @@ fi
 DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
 cd $DIR
 
+KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-kind-external-secrets}"
+if kubectl config get-contexts "${KUBECTL_CONTEXT}" >/dev/null 2>&1; then
+  KUBECTL=(kubectl --context "${KUBECTL_CONTEXT}")
+else
+  echo "warning: kubectl context ${KUBECTL_CONTEXT} not found, using current context"
+  KUBECTL=(kubectl)
+fi
+
+go_clean_best_effort() {
+  local target="$1"
+  if ! go clean "${target}"; then
+    echo "warning: unable to clean ${target}; continuing"
+  fi
+}
+
 echo "Kubernetes cluster:"
-kubectl get nodes -o wide
+"${KUBECTL[@]}" get nodes -o wide
 
 echo -e "Granting permissions to e2e service account..."
-kubectl create serviceaccount external-secrets-e2e || true
-kubectl create clusterrolebinding permissive-binding \
+"${KUBECTL[@]}" create serviceaccount external-secrets-e2e || true
+"${KUBECTL[@]}" create clusterrolebinding permissive-binding \
   --clusterrole=cluster-admin \
   --user=admin \
   --user=kubelet \
   --serviceaccount=default:external-secrets-e2e || true
 
 echo -e "Granting anonymous access to service account issuer discovery"
-kubectl create clusterrolebinding service-account-issuer-discovery-binding \
+"${KUBECTL[@]}" create clusterrolebinding service-account-issuer-discovery-binding \
   --clusterrole=system:service-account-issuer-discovery \
   --group=system:unauthenticated || true
 
 echo -e "Cleaning cache before running tests"
 docker system prune --force
-go clean -cache
-go clean -modcache
+go_clean_best_effort -cache
+go_clean_best_effort -modcache
 
 echo -e "Starting the e2e test pod ${E2E_IMAGE_NAME}:${VERSION}"
-kubectl run --rm \
+"${KUBECTL[@]}" run --rm \
   --attach \
   --restart=Never \
   --pod-running-timeout=5m \
@@ -93,6 +108,8 @@ kubectl run --rm \
   --env="SECRETSERVER_URL=${SECRETSERVER_URL:-}" \
   --env="GRAFANA_URL=${GRAFANA_URL:-}" \
   --env="GRAFANA_TOKEN=${GRAFANA_TOKEN:-}" \
+  --env="E2E_SKIP_HELM_DEPENDENCY_UPDATE=${E2E_SKIP_HELM_DEPENDENCY_UPDATE:-}" \
+  --env="E2E_PROVIDER_MODE=${E2E_PROVIDER_MODE:-}" \
   --env="VERSION=${VERSION}" \
   --env="TEST_SUITES=${TEST_SUITES}" \
   --overrides='{ "apiVersion": "v1", "spec":{"serviceAccountName": "external-secrets-e2e"}}' \

+ 34 - 0
e2e/run_test.go

@@ -0,0 +1,34 @@
+/*
+Copyright © The ESO Authors
+
+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
+
+    https://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 (
+	"os"
+	"strings"
+	"testing"
+)
+
+func TestRunScriptPassesHelmDependencyUpdateSkipEnv(t *testing.T) {
+	content, err := os.ReadFile("run.sh")
+	if err != nil {
+		t.Fatalf("read run.sh: %v", err)
+	}
+
+	if !strings.Contains(string(content), `--env="E2E_SKIP_HELM_DEPENDENCY_UPDATE=${E2E_SKIP_HELM_DEPENDENCY_UPDATE:-}"`) {
+		t.Fatalf("expected run.sh to pass E2E_SKIP_HELM_DEPENDENCY_UPDATE into the e2e pod")
+	}
+}

+ 367 - 0
hack/install-eso-v2-e2e.sh

@@ -0,0 +1,367 @@
+#!/bin/bash
+#
+# Install External Secrets Operator V2 for E2E testing
+# This script deploys the controller and Kubernetes provider using the monolithic Helm chart
+#
+# Prerequisites:
+#   - kubectl and helm installed
+#   - Access to a Kubernetes cluster (kind recommended for local testing)
+#   - Docker images built and available:
+#     * ghcr.io/external-secrets/external-secrets:latest
+#     * ghcr.io/external-secrets/provider-kubernetes:latest
+#
+# For kind clusters, images will be automatically loaded if available locally.
+#
+# Build images before running (if not already built):
+#   make docker.build VERSION=latest
+#   # This builds:
+#   #   - Controller: ghcr.io/external-secrets/external-secrets:latest
+#   #   - Kubernetes Provider: ghcr.io/external-secrets/provider-kubernetes:latest
+#   #   - AWS Provider: ghcr.io/external-secrets/provider-aws:latest
+#
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+CHARTS_DIR="$ROOT_DIR/deploy/charts"
+NAMESPACE="external-secrets-system"
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+log_info() {
+    local message="$1"
+    echo -e "${GREEN}[INFO]${NC} $message"
+    return 0
+}
+
+log_error() {
+    local message="$1"
+    echo -e "${RED}[ERROR]${NC} $message"
+    return 0
+}
+
+log_warning() {
+    local message="$1"
+    echo -e "${YELLOW}[WARN]${NC} $message"
+    return 0
+}
+
+# Check prerequisites
+check_prerequisites() {
+    log_info "Checking prerequisites"
+    
+    if ! command -v kubectl &> /dev/null; then
+        log_error "kubectl not found"
+        exit 1
+    fi
+    
+    if ! command -v helm &> /dev/null; then
+        log_error "helm not found"
+        exit 1
+    fi
+    
+    if ! kubectl cluster-info &> /dev/null; then
+        log_error "Cannot connect to Kubernetes cluster"
+        exit 1
+    fi
+    
+    log_info "Prerequisites check passed"
+}
+
+# Detect if running in kind cluster
+is_kind_cluster() {
+    kubectl config current-context | grep -q "kind-"
+}
+
+# Get kind cluster name from context
+get_kind_cluster_name() {
+    kubectl config current-context | sed 's/kind-//'
+}
+
+# Load Docker images into kind cluster
+load_images_to_kind() {
+    if ! is_kind_cluster; then
+        log_info "Not a kind cluster, skipping image loading"
+        return 0
+    fi
+    
+    if ! command -v kind &> /dev/null; then
+        log_warning "kind CLI not found, cannot load images"
+        log_warning "Please ensure images are available in the cluster"
+        return 0
+    fi
+    
+    local cluster_name
+    cluster_name=$(get_kind_cluster_name)
+    
+    log_info "Detected kind cluster: $cluster_name"
+    log_info "Loading Docker images into kind cluster"
+    
+    # Controller image
+    local controller_image="ghcr.io/external-secrets/external-secrets:latest"
+    if docker image inspect "$controller_image" &> /dev/null; then
+        log_info "Loading controller image: $controller_image"
+        kind load docker-image "$controller_image" --name "$cluster_name"
+    else
+        log_warning "Controller image not found locally: $controller_image"
+        log_warning "Attempting to pull from registry (may fail if not published)"
+    fi
+    
+    # Provider images
+    local kubernetes_provider_image="ghcr.io/external-secrets/provider-kubernetes:latest"
+    if docker image inspect "$kubernetes_provider_image" &> /dev/null; then
+        log_info "Loading provider image: $kubernetes_provider_image"
+        kind load docker-image "$kubernetes_provider_image" --name "$cluster_name"
+    else
+        log_warning "Provider image not found locally: $kubernetes_provider_image"
+        log_warning "Attempting to pull from registry (may fail if not published)"
+    fi
+    
+    local fake_provider_image="ghcr.io/external-secrets/provider-fake:latest"
+    if docker image inspect "$fake_provider_image" &> /dev/null; then
+        log_info "Loading provider image: $fake_provider_image"
+        kind load docker-image "$fake_provider_image" --name "$cluster_name"
+    else
+        log_warning "Fake provider image not found locally: $fake_provider_image"
+        log_warning "Attempting to pull from registry (may fail if not published)"
+    fi
+
+    local aws_provider_image="ghcr.io/external-secrets/provider-aws:latest"
+    if docker image inspect "$aws_provider_image" &> /dev/null; then
+        log_info "Loading provider image: $aws_provider_image"
+        kind load docker-image "$aws_provider_image" --name "$cluster_name"
+    else
+        log_warning "aws provider image not found locally: $aws_provider_image"
+        log_warning "Attempting to pull from registry (may fail if not published)"
+    fi
+    
+    
+    log_info "Image loading complete"
+}
+
+# Install External Secrets with Kubernetes provider using monolithic chart
+install_external_secrets() {
+    log_info "Installing External Secrets V2 with Kubernetes provider"
+    
+    # Create a temporary values file for the installation
+    local values_file
+    values_file=$(mktemp)
+    
+    cat > "$values_file" <<EOF
+# Controller configuration
+installCRDs: true
+replicaCount: 1
+v2:
+  enabled: true
+
+crds:
+  createProvider: true
+  createClusterProvider: true
+
+image:
+  repository: ghcr.io/external-secrets/external-secrets
+  tag: latest
+  pullPolicy: IfNotPresent
+
+certController:
+  image:
+    repository: ghcr.io/external-secrets/external-secrets
+    tag: latest
+    pullPolicy: IfNotPresent
+
+webhook:
+  create: true
+  image:
+    repository: ghcr.io/external-secrets/external-secrets
+    tag: latest
+    pullPolicy: IfNotPresent
+
+# Provider defaults configuration
+providerDefaults:
+  replicaCount: 1
+  serviceAccount:
+    create: true
+    automount: true
+  podSecurityContext:
+    enabled: true
+    runAsNonRoot: true
+    runAsUser: 65532
+    fsGroup: 65532
+    seccompProfile:
+      type: RuntimeDefault
+  securityContext:
+    enabled: true
+    allowPrivilegeEscalation: false
+    readOnlyRootFilesystem: true
+    runAsNonRoot: true
+    runAsUser: 65532
+    capabilities:
+      drop:
+      - ALL
+  service:
+    type: ClusterIP
+    port: 8080
+  resources:
+    limits:
+      cpu: 200m
+      memory: 256Mi
+    requests:
+      cpu: 50m
+      memory: 64Mi
+  tls:
+    enabled: true
+
+# Enable provider deployments
+providers:
+  enabled: true
+  list:
+    - name: kubernetes
+      type: kubernetes
+      enabled: true
+      image:
+        repository: ghcr.io/external-secrets/provider-kubernetes
+        tag: latest
+        pullPolicy: IfNotPresent
+    
+    - name: fake
+      type: fake
+      enabled: true
+      image:
+        repository: ghcr.io/external-secrets/provider-fake
+        tag: latest
+        pullPolicy: IfNotPresent
+
+    - name: aws
+      type: aws
+      enabled: true
+      image:
+        repository: ghcr.io/external-secrets/provider-aws
+        tag: latest
+        pullPolicy: IfNotPresent
+      extraEnv:
+      - name: AWS_SECRET_ACCESS_KEY
+        value: "${AWS_SECRET_ACCESS_KEY}"
+      - name: AWS_ACCESS_KEY_ID
+        value: "${AWS_ACCESS_KEY_ID}"
+      - name: AWS_SESSION_TOKEN
+        value: "${AWS_SESSION_TOKEN}"
+      - name: AWS_REGION
+        value: "eu-central-1"
+
+# Controller resources
+resources:
+  limits:
+    cpu: 200m
+    memory: 256Mi
+  requests:
+    cpu: 50m
+    memory: 64Mi
+EOF
+    
+
+    log_info "Installing with monolithic Helm chart"
+    helm upgrade --install external-secrets "$CHARTS_DIR/external-secrets" \
+        --create-namespace \
+        --namespace "$NAMESPACE" \
+        --values "$values_file" \
+        --wait \
+        --timeout 5m
+    
+    # Cleanup temporary file
+    rm -f "$values_file"
+    
+    log_info "External Secrets with Kubernetes provider installed"
+
+    kubectl -n "$NAMESPACE" delete po -l app.kubernetes.io/instance=external-secrets
+}
+
+# Verify installation
+verify_installation() {
+    log_info "Verifying installation"
+    
+    # Check controller pod
+    log_info "Waiting for controller pod to be ready"
+    if ! kubectl wait --for=condition=ready pod \
+        -l app.kubernetes.io/name=external-secrets \
+        -n "$NAMESPACE" \
+        --timeout=300s; then
+        log_error "Controller pod not ready"
+        kubectl get pods -n "$NAMESPACE"
+        kubectl describe pods -n "$NAMESPACE" -l app.kubernetes.io/name=external-secrets
+        kubectl logs -n "$NAMESPACE" -l app.kubernetes.io/name=external-secrets --tail=50
+        exit 1
+    fi
+    
+    # Check Kubernetes provider pod
+    log_info "Waiting for Kubernetes provider pod to be ready"
+    if ! kubectl wait --for=condition=ready pod \
+        -l "app.kubernetes.io/name=external-secrets-provider-kubernetes" \
+        -n "$NAMESPACE" \
+        --timeout=300s; then
+        log_error "Kubernetes provider pod not ready"
+        kubectl get pods -n "$NAMESPACE"
+        kubectl describe pods -n "$NAMESPACE" -l app.kubernetes.io/name=external-secrets-provider-kubernetes
+        kubectl logs -n "$NAMESPACE" -l app.kubernetes.io/name=external-secrets-provider-kubernetes --tail=50
+        exit 1
+    fi
+    
+    # Check Fake provider pod
+    log_info "Waiting for Fake provider pod to be ready"
+    if ! kubectl wait --for=condition=ready pod \
+        -l "app.kubernetes.io/name=external-secrets-provider-fake" \
+        -n "$NAMESPACE" \
+        --timeout=300s; then
+        log_error "Fake provider pod not ready"
+        kubectl get pods -n "$NAMESPACE"
+        kubectl describe pods -n "$NAMESPACE" -l app.kubernetes.io/name=external-secrets-provider-fake
+        kubectl logs -n "$NAMESPACE" -l app.kubernetes.io/name=external-secrets-provider-fake --tail=50
+        exit 1
+    fi
+    
+    # Check cert controller pod
+    log_info "Waiting for cert controller pod to be ready"
+    if ! kubectl wait --for=condition=ready pod \
+        -l app.kubernetes.io/name=external-secrets-cert-controller \
+        -n "$NAMESPACE" \
+        --timeout=300s; then
+        log_warning "Cert controller pod not ready (may not be critical for testing)"
+    fi
+    
+    log_info "All pods are ready"
+    kubectl get pods -n "$NAMESPACE"
+    
+    # Show services
+    log_info "Services:"
+    kubectl get svc -n "$NAMESPACE"
+}
+
+# Main installation flow
+main() {
+    log_info "Installing External Secrets Operator V2 for E2E testing"
+    log_info "Using monolithic Helm chart with Kubernetes provider"
+    
+    check_prerequisites
+    load_images_to_kind
+    install_external_secrets
+    verify_installation
+    
+    log_info "Installation complete!"
+    log_info ""
+    log_info "Deployment summary:"
+    log_info "  - Controller: external-secrets"
+    log_info "  - Provider: kubernetes (integrated)"
+    log_info "  - Namespace: $NAMESPACE"
+    log_info ""
+    log_info "Next steps:"
+    log_info "  1. Run E2E tests: make test.e2e.v2"
+    log_info "  2. View controller logs: kubectl logs -n $NAMESPACE -l app.kubernetes.io/name=external-secrets -f"
+    log_info "  3. View provider logs: kubectl logs -n $NAMESPACE -l app.kubernetes.io/component=provider -f"
+    log_info "  4. Cleanup: ./hack/uninstall-eso-v2-e2e.sh"
+}
+
+main "$@"

+ 67 - 0
hack/uninstall-eso-v2-e2e.sh

@@ -0,0 +1,67 @@
+#!/bin/bash
+#
+# Uninstall External Secrets Operator V2 E2E installation
+# This script removes the monolithic Helm chart installation
+#
+
+set -e
+
+NAMESPACE="external-secrets-system"
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+log_info() {
+    local message="$1"
+    echo -e "${GREEN}[INFO]${NC} $message"
+    return 0
+}
+
+log_warning() {
+    local message="$1"
+    echo -e "${YELLOW}[WARN]${NC} $message"
+    return 0
+}
+
+log_info "Uninstalling External Secrets Operator V2"
+
+# Delete all ExternalSecret resources first (they have finalizers that need to be removed by the controller)
+log_info "Deleting all ExternalSecret resources (waiting for finalizers to be processed)"
+kubectl delete externalsecrets --all --all-namespaces --timeout=120s 2>/dev/null || log_warning "No ExternalSecrets found or already deleted"
+
+# Delete other resources that may have finalizers
+log_info "Deleting all PushSecret resources"
+kubectl delete pushsecrets --all --all-namespaces --timeout=120s 2>/dev/null || log_warning "No PushSecrets found or already deleted"
+
+log_info "Deleting all ClusterExternalSecret resources"
+kubectl delete clusterexternalsecrets --all --timeout=120s 2>/dev/null || log_warning "No ClusterExternalSecrets found or already deleted"
+
+log_info "Deleting all ClusterPushSecret resources"
+kubectl delete clusterpushsecrets --all --timeout=120s 2>/dev/null || log_warning "No ClusterPushSecrets found or already deleted"
+
+# Uninstall the monolithic Helm release
+log_info "Removing Helm release: external-secrets"
+helm uninstall external-secrets -n "$NAMESPACE" 2>/dev/null || log_warning "Helm release 'external-secrets' not found"
+
+# Delete any leftover resources
+log_info "Cleaning up any leftover resources"
+
+# Delete CRDs (only if you want to clean them completely)
+log_info "Deleting CRDs"
+kubectl delete crd secretstores.external-secrets.io 2>/dev/null || true
+kubectl delete crd clustersecretstores.external-secrets.io 2>/dev/null || true
+kubectl delete crd externalsecrets.external-secrets.io 2>/dev/null || true
+kubectl delete crd clusterexternalsecrets.external-secrets.io 2>/dev/null || true
+kubectl delete crd pushsecrets.external-secrets.io 2>/dev/null || true
+kubectl delete crd clusterpushsecrets.external-secrets.io 2>/dev/null || true
+kubectl delete crd generators.external-secrets.io 2>/dev/null || true
+kubectl delete crd clustergenerators.external-secrets.io 2>/dev/null || true
+
+# Delete namespace
+log_info "Deleting namespace: $NAMESPACE"
+kubectl delete namespace "$NAMESPACE" --ignore-not-found=true --timeout=60s
+
+log_info "Uninstallation complete"