Browse Source

Merge pull request #120 from external-secrets/feat/e2e-tests

feat: e2e tests
paul-the-alien[bot] 5 years ago
parent
commit
4de378f939

+ 63 - 0
.github/workflows/ci.yml

@@ -171,6 +171,69 @@ jobs:
           flags: unittests
           file: ./cover.out
 
+  e2e-tests:
+    runs-on: ubuntu-18.04
+    needs: detect-noop
+    if: needs.detect-noop.outputs.noop != 'true'
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+
+      - name: Fetch History
+        run: git fetch --prune --unshallow
+
+      - name: Setup Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: ${{ env.GO_VERSION }}
+
+      - name: Find the Go Cache
+        id: go
+        run: |
+          echo "::set-output name=build-cache::$(go env GOCACHE)"
+          echo "::set-output name=mod-cache::$(go env GOMODCACHE)"
+
+      - name: Cache the Go Build Cache
+        uses: actions/cache@v2.1.5
+        with:
+          path: ${{ steps.go.outputs.build-cache }}
+          key: ${{ runner.os }}-build-unit-tests-${{ hashFiles('**/go.sum') }}
+          restore-keys: ${{ runner.os }}-build-unit-tests-
+
+      - name: Cache Go Dependencies
+        uses: actions/cache@v2.1.5
+        with:
+          path: ${{ steps.go.outputs.mod-cache }}
+          key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }}
+          restore-keys: ${{ runner.os }}-pkg-
+
+      - name: Add kubebuilder
+        run:  |
+          curl -L https://github.com/kubernetes-sigs/kubebuilder/releases/download/v${{env.KUBEBUILDER_VERSION}}/kubebuilder_${{env.KUBEBUILDER_VERSION}}_linux_amd64.tar.gz > kubebuilder_${{env.KUBEBUILDER_VERSION}}_linux_amd64.tar.gz
+          tar -xvf kubebuilder_${{env.KUBEBUILDER_VERSION}}_linux_amd64.tar.gz
+          sudo mv kubebuilder_${{env.KUBEBUILDER_VERSION}}_linux_amd64 /usr/local/kubebuilder
+
+      - name: Cache kubebuilder
+        uses: actions/cache@v2.1.5
+        with:
+          path: /usr/local/kubebuilder
+          key: ${{ runner.os }}-kubebuilder-${{env.KUBEBUILDER_VERSION}}
+          restore-keys: ${{ runner.os }}-kubebuilder-
+
+      - name: Setup kind
+        uses: engineerd/setup-kind@v0.5.0
+        with:
+          version: "v0.10.0"
+          node_image: kindest/node:v1.20.2
+          name: external-secrets
+
+      - name: Run e2e Tests
+        run: |
+          export PATH=$PATH:$(go env GOPATH)/bin
+          go get github.com/onsi/ginkgo/ginkgo
+          make test.e2e
+
   publish-artifacts:
     runs-on: ubuntu-18.04
     needs: detect-noop

+ 2 - 0
.gitignore

@@ -13,3 +13,5 @@ cover.out
 deploy/charts/external-secrets/templates/crds/*.yaml
 
 site/
+e2e/k8s/deploy
+e2e/e2e.test

+ 7 - 1
Makefile

@@ -78,7 +78,13 @@ check-diff: reviewable
 .PHONY: test
 test: generate ## Run tests
 	@$(INFO) go test unit-tests
-	go test -v ./... -coverprofile cover.out
+	go test -v $(shell go list ./... | grep -v e2e) -coverprofile cover.out
+	@$(OK) go test unit-tests
+
+.PHONY: test.e2e
+test.e2e: generate ## Run e2e tests
+	@$(INFO) go test e2e-tests
+	$(MAKE) -C ./e2e test
 	@$(OK) go test unit-tests
 
 .PHONY: build

+ 30 - 0
e2e/Dockerfile

@@ -0,0 +1,30 @@
+ARG GO_VERSION=1.15.3
+FROM golang:$GO_VERSION-buster as builder
+
+ENV KUBECTL_VERSION="v1.19.2"
+ENV HELM_VERSION="v3.3.4"
+
+RUN go get -u github.com/onsi/ginkgo/ginkgo
+RUN wget -q https://storage.googleapis.com/kubernetes-release/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl -O /usr/local/bin/kubectl && \
+    chmod +x /usr/local/bin/kubectl && \
+    wget -q https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz -O - | tar -xzO linux-amd64/helm > /usr/local/bin/helm && \
+    chmod +x /usr/local/bin/helm
+
+FROM alpine:3.12
+RUN apk add -U --no-cache \
+    ca-certificates \
+    bash \
+    curl \
+    tzdata \
+    libc6-compat \
+    openssl
+
+COPY --from=builder /go/bin/ginkgo /usr/local/bin/
+COPY --from=builder /usr/local/bin/kubectl /usr/local/bin/
+COPY --from=builder /usr/local/bin/helm /usr/local/bin/
+
+COPY entrypoint.sh                  /entrypoint.sh
+COPY e2e.test                       /e2e.test
+COPY k8s                            /k8s
+
+CMD [ "/entrypoint.sh" ]

+ 42 - 0
e2e/Makefile

@@ -0,0 +1,42 @@
+MAKEFLAGS   += --warn-undefined-variables
+SHELL       := /bin/bash
+.SHELLFLAGS := -euo pipefail -c
+
+IMG_TAG     = test
+IMG         = local/external-secrets-e2e:$(IMG_TAG)
+K8S_VERSION = "1.19.1"
+
+start-kind: ## Start kind cluster
+	kind create cluster \
+	  --name external-secrets \
+	  --config kind.yaml \
+	  --retain \
+	  --image "kindest/node:v$(K8S_VERSION)"
+
+test: e2e-image ## Run e2e tests against current kube context
+	$(MAKE) -C ../ docker.build \
+		IMAGE_REGISTRY=local/external-secrets \
+		VERSION=$(IMG_TAG) \
+		BUILD_ARGS="--build-arg ARCHS=amd64"
+	kind load docker-image --name="external-secrets" local/external-secrets:$(IMG_TAG)
+	kind load docker-image --name="external-secrets" $(IMG)
+	./run.sh
+
+e2e-bin:
+	CGO_ENABLED=0 ginkgo build .
+
+e2e-image: e2e-bin
+	-rm -rf ./k8s/deploy
+	mkdir -p k8s
+	$(MAKE) -C ../ helm.generate
+	cp -r ../deploy ./k8s
+	docker build -t $(IMG) .
+
+stop-kind: ## Stop kind cluster
+	kind delete cluster \
+		--name external-secrets \
+
+help: ## displays this help message
+	@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_\/-]+:.*?## / {printf "\033[34m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | \
+		sort | \
+		grep -v '#'

+ 61 - 0
e2e/e2e_test.go

@@ -0,0 +1,61 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package e2e
+
+import (
+	"testing"
+
+	// nolint
+	. "github.com/onsi/ginkgo"
+	// nolint
+	. "github.com/onsi/gomega"
+
+	"github.com/external-secrets/external-secrets/e2e/framework"
+	"github.com/external-secrets/external-secrets/e2e/framework/addon"
+	"github.com/external-secrets/external-secrets/e2e/framework/util"
+	_ "github.com/external-secrets/external-secrets/e2e/suite"
+)
+
+var _ = SynchronizedBeforeSuite(func() []byte {
+	cfg := &addon.Config{}
+	cfg.KubeConfig, cfg.KubeClientSet, cfg.CRClient = framework.NewConfig()
+
+	By("installing localstack")
+	addon.InstallGlobalAddon(addon.NewLocalstack(), cfg)
+
+	By("waiting for localstack")
+	err := util.WaitForURL("http://localstack.default/health")
+	Expect(err).ToNot(HaveOccurred())
+
+	By("installing vault")
+	addon.InstallGlobalAddon(addon.NewVault(), cfg)
+
+	By("installing eso")
+	addon.InstallGlobalAddon(addon.NewESO(), cfg)
+	return nil
+}, func([]byte) {})
+
+var _ = SynchronizedAfterSuite(func() {}, func() {
+	By("Cleaning up global addons")
+	addon.UninstallGlobalAddons()
+	if CurrentGinkgoTestDescription().Failed {
+		addon.PrintLogs()
+	}
+})
+
+func TestE2E(t *testing.T) {
+	NewWithT(t)
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "external-secrets e2e suite")
+}

+ 53 - 0
e2e/entrypoint.sh

@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Copyright 2019 The Kubernetes 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
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -e
+
+NC='\e[0m'
+BGREEN='\e[32m'
+
+SLOW_E2E_THRESHOLD=${SLOW_E2E_THRESHOLD:-50}
+FOCUS=${FOCUS:-.*}
+E2E_NODES=${E2E_NODES:-5}
+
+if [ ! -f "${HOME}/.kube/config" ]; then
+  kubectl config set-cluster dev --certificate-authority=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt --embed-certs=true --server="https://kubernetes.default/"
+  kubectl config set-credentials user --token="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
+  kubectl config set-context default --cluster=dev --user=user
+  kubectl config use-context default
+fi
+
+ginkgo_args=(
+  "-randomizeSuites"
+  "-randomizeAllSpecs"
+  "-flakeAttempts=2"
+  "-p"
+  "-progress"
+  "-trace"
+  "-slowSpecThreshold=${SLOW_E2E_THRESHOLD}"
+  "-r"
+  "-v"
+  "-timeout=45m"
+)
+
+kubectl apply -f /k8s/deploy/crds
+
+echo -e "${BGREEN}Running e2e test suite (FOCUS=${FOCUS})...${NC}"
+ginkgo "${ginkgo_args[@]}"               \
+  -focus="${FOCUS}"                      \
+  -skip="\[Serial\]|\[MemoryLeak\]"      \
+  -nodes="${E2E_NODES}"                  \
+  /e2e.test

+ 76 - 0
e2e/framework/addon/addon.go

@@ -0,0 +1,76 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package addon
+
+import (
+	"github.com/onsi/ginkgo"
+	"github.com/onsi/gomega"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
+	crclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/external-secrets/external-secrets/e2e/framework/log"
+)
+
+var globalAddons []Addon
+
+func init() {
+	globalAddons = make([]Addon, 0)
+}
+
+type Config struct {
+	// KubeConfig which was used to create the connection.
+	KubeConfig *rest.Config
+
+	// Kubernetes API clientsets
+	KubeClientSet kubernetes.Interface
+
+	// controller-runtime client for newer controllers
+	CRClient crclient.Client
+}
+
+type Addon interface {
+	Setup(*Config) error
+	Install() error
+	Logs() error
+	Uninstall() error
+}
+
+func InstallGlobalAddon(addon Addon, cfg *Config) {
+	globalAddons = append(globalAddons, addon)
+
+	ginkgo.By("installing addon")
+	err := addon.Setup(cfg)
+	gomega.Expect(err).NotTo(gomega.HaveOccurred())
+
+	err = addon.Install()
+	gomega.Expect(err).NotTo(gomega.HaveOccurred())
+}
+
+func UninstallGlobalAddons() {
+	for _, addon := range globalAddons {
+		ginkgo.By("uninstalling addon")
+		err := addon.Uninstall()
+		gomega.Expect(err).NotTo(gomega.HaveOccurred())
+	}
+}
+
+func PrintLogs() {
+	for _, addon := range globalAddons {
+		err := addon.Logs()
+		if err != nil {
+			log.Logf("error fetching logs: %s", err.Error())
+		}
+	}
+}

+ 159 - 0
e2e/framework/addon/chart.go

@@ -0,0 +1,159 @@
+/*
+Copyright 2020 The cert-manager 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
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package addon
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/exec"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/external-secrets/external-secrets/e2e/framework/log"
+)
+
+// HelmChart installs the specified Chart into the cluster.
+type HelmChart struct {
+	Namespace    string
+	ReleaseName  string
+	Chart        string
+	ChartVersion string
+	Repo         ChartRepo
+	Vars         []StringTuple
+	Values       []string
+
+	config *Config
+}
+
+type ChartRepo struct {
+	Name string
+	URL  string
+}
+
+type StringTuple struct {
+	Key   string
+	Value string
+}
+
+// Setup stores the config in an internal field
+// to get access to the k8s api in orderto fetch logs.
+func (c *HelmChart) Setup(cfg *Config) error {
+	c.config = cfg
+	return nil
+}
+
+// Install adds the chart repo and installs the helm chart.
+func (c *HelmChart) Install() error {
+	err := c.addRepo()
+	if err != nil {
+		return err
+	}
+
+	args := []string{"install", c.ReleaseName, c.Chart,
+		"--wait",
+		"--timeout", "600s",
+		"--namespace", c.Namespace,
+	}
+
+	if c.ChartVersion != "" {
+		args = append(args, "--version", c.ChartVersion)
+	}
+
+	for _, v := range c.Values {
+		args = append(args, "--values", v)
+	}
+
+	for _, s := range c.Vars {
+		args = append(args, "--set", fmt.Sprintf("%s=%s", s.Key, s.Value))
+	}
+
+	log.Logf("installing chart %s", c.ReleaseName)
+	cmd := exec.Command("helm", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}
+
+// Uninstall removes the chart aswell as the repo.
+func (c *HelmChart) Uninstall() error {
+	args := []string{"delete", "--namespace", c.Namespace, c.ReleaseName}
+	cmd := exec.Command("helm", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	err := cmd.Run()
+	if err != nil {
+		return err
+	}
+	return c.removeRepo()
+}
+
+func (c *HelmChart) addRepo() error {
+	if c.Repo.Name == "" || c.Repo.URL == "" {
+		return nil
+	}
+	log.Logf("adding repo %s", c.Repo.Name)
+	args := []string{"repo", "add", c.Repo.Name, c.Repo.URL}
+	cmd := exec.Command("helm", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}
+
+func (c *HelmChart) removeRepo() error {
+	if c.Repo.Name == "" || c.Repo.URL == "" {
+		return nil
+	}
+	args := []string{"repo", "remove", c.Repo.Name}
+	cmd := exec.Command("helm", args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}
+
+// Logs fetches the logs from all pods managed by this release
+// and prints them out.
+func (c *HelmChart) Logs() error {
+	kc := c.config.KubeClientSet
+	podList, err := kc.CoreV1().Pods(c.Namespace).List(
+		context.TODO(),
+		metav1.ListOptions{LabelSelector: "app.kubernetes.io/instance=" + c.ReleaseName})
+	if err != nil {
+		return err
+	}
+	log.Logf("logs: found %d pods", len(podList.Items))
+	for i := range podList.Items {
+		pod := podList.Items[i]
+		for _, con := range pod.Spec.Containers {
+			for _, b := range []bool{true, false} {
+				resp := kc.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &corev1.PodLogOptions{
+					Container: con.Name,
+					Previous:  b,
+				}).Do(context.TODO())
+
+				err := resp.Error()
+				if err != nil {
+					continue
+				}
+
+				logs, err := resp.Raw()
+				if err != nil {
+					continue
+				}
+				log.Logf("[%s]: %s", c.ReleaseName, string(logs))
+			}
+		}
+	}
+	return nil
+}

+ 29 - 0
e2e/framework/addon/eso.go

@@ -0,0 +1,29 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package addon
+
+type ESO struct {
+	Addon
+}
+
+func NewESO() *ESO {
+	return &ESO{
+		&HelmChart{
+			Namespace:   "default",
+			ReleaseName: "eso-aws-sm",
+			Chart:       "/k8s/deploy/charts/external-secrets",
+			Values:      []string{"/k8s/eso.values.yaml"},
+		},
+	}
+}

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

@@ -0,0 +1,44 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package addon
+
+import "github.com/external-secrets/external-secrets/e2e/framework/util"
+
+type Localstack struct {
+	Addon
+}
+
+func NewLocalstack() *Localstack {
+	return &Localstack{
+		&HelmChart{
+			Namespace:    "default",
+			ReleaseName:  "localstack",
+			Chart:        "localstack-charts/localstack",
+			ChartVersion: "0.2.0",
+			Repo: ChartRepo{
+				Name: "localstack-charts",
+				URL:  "https://localstack.github.io/helm-charts",
+			},
+			Values: []string{"/k8s/localstack.values.yaml"},
+		},
+	}
+}
+
+func (l *Localstack) Install() error {
+	err := l.Addon.Install()
+	if err != nil {
+		return err
+	}
+	return util.WaitForURL("http://localstack.default/health")
+}

+ 53 - 0
e2e/framework/addon/vault.go

@@ -0,0 +1,53 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package addon
+
+import "github.com/external-secrets/external-secrets/e2e/framework/util"
+
+type Vault struct {
+	Addon
+}
+
+func NewVault() *Vault {
+	return &Vault{
+		&HelmChart{
+			Namespace:    "default",
+			ReleaseName:  "vault",
+			Chart:        "hashicorp/vault",
+			ChartVersion: "0.11.0",
+			Repo: ChartRepo{
+				Name: "hashicorp",
+				URL:  "https://helm.releases.hashicorp.com",
+			},
+			Vars: []StringTuple{
+				{
+					Key:   "server.dev.enabled",
+					Value: "true",
+				},
+				{
+					Key:   "injector.enabled",
+					Value: "false",
+				},
+			},
+		},
+	}
+}
+
+func (l *Vault) Install() error {
+	err := l.Addon.Install()
+	if err != nil {
+		return err
+	}
+	return util.WaitForURL("http://vault.default:8200/ui/")
+}

+ 48 - 0
e2e/framework/eso.go

@@ -0,0 +1,48 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package framework
+
+import (
+	"bytes"
+	"context"
+	"time"
+
+	v1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+)
+
+// WaitForSecretValue waits until a secret comes into existence and compares the secret.Data
+// with the provided values.
+func (f *Framework) WaitForSecretValue(namespace, name string, values map[string][]byte) (*v1.Secret, error) {
+	secret := &v1.Secret{}
+	err := wait.PollImmediate(time.Second*2, time.Minute*2, func() (bool, error) {
+		err := f.CRClient.Get(context.Background(), types.NamespacedName{
+			Namespace: namespace,
+			Name:      name,
+		}, secret)
+		if apierrors.IsNotFound(err) {
+			return false, nil
+		}
+
+		for k, exp := range values {
+			if actual, ok := secret.Data[k]; ok && !bytes.Equal(actual, exp) {
+				return false, nil
+			}
+		}
+		return true, nil
+	})
+	return secret, err
+}

+ 114 - 0
e2e/framework/framework.go

@@ -0,0 +1,114 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package framework
+
+import (
+	"os"
+
+	// nolint
+	. "github.com/onsi/ginkgo"
+	// nolint
+	. "github.com/onsi/gomega"
+	api "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/kubernetes"
+	kscheme "k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/clientcmd"
+	crclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	"github.com/external-secrets/external-secrets/e2e/framework/addon"
+	"github.com/external-secrets/external-secrets/e2e/framework/util"
+)
+
+var Scheme = runtime.NewScheme()
+
+func init() {
+	_ = kscheme.AddToScheme(Scheme)
+	_ = esv1alpha1.AddToScheme(Scheme)
+}
+
+type Framework struct {
+	BaseName string
+
+	// KubeConfig which was used to create the connection.
+	KubeConfig *rest.Config
+
+	// Kubernetes API clientsets
+	KubeClientSet kubernetes.Interface
+
+	// controller-runtime client for newer controllers
+	CRClient crclient.Client
+
+	// Namespace in which all test resources should reside
+	Namespace *api.Namespace
+
+	Addons []addon.Addon
+}
+
+// New returns a new framework instance with defaults.
+func New(baseName string) *Framework {
+	f := &Framework{
+		BaseName: baseName,
+	}
+	f.KubeConfig, f.KubeClientSet, f.CRClient = NewConfig()
+
+	BeforeEach(f.BeforeEach)
+	AfterEach(f.AfterEach)
+
+	return f
+}
+
+// BeforeEach creates a namespace.
+func (f *Framework) BeforeEach() {
+	var err error
+	By("Building a namespace api object")
+	f.Namespace, err = util.CreateKubeNamespace(f.BaseName, f.KubeClientSet)
+	Expect(err).NotTo(HaveOccurred())
+
+	By("Using the namespace " + f.Namespace.Name)
+}
+
+// AfterEach deletes the namespace and cleans up the registered addons.
+func (f *Framework) AfterEach() {
+	By("deleting test namespace")
+	err := util.DeleteKubeNamespace(f.Namespace.Name, f.KubeClientSet)
+	Expect(err).NotTo(HaveOccurred())
+}
+
+// NewConfig loads and returns the kubernetes credentials from the environment.
+// KUBECONFIG env var takes precedence and falls back to in-cluster config.
+func NewConfig() (*rest.Config, *kubernetes.Clientset, crclient.Client) {
+	var kubeConfig *rest.Config
+	var err error
+	kcPath := os.Getenv("KUBECONFIG")
+	if kcPath != "" {
+		kubeConfig, err = clientcmd.BuildConfigFromFlags("", kcPath)
+		Expect(err).NotTo(HaveOccurred())
+	} else {
+		kubeConfig, err = rest.InClusterConfig()
+		Expect(err).NotTo(HaveOccurred())
+	}
+
+	By("creating a kubernetes client")
+	kubeClientSet, err := kubernetes.NewForConfig(kubeConfig)
+	Expect(err).NotTo(HaveOccurred())
+
+	By("creating a controller-runtime client")
+	CRClient, err := crclient.New(kubeConfig, crclient.Options{Scheme: Scheme})
+	Expect(err).NotTo(HaveOccurred())
+
+	return kubeConfig, kubeClientSet, CRClient
+}

+ 25 - 0
e2e/framework/log/log.go

@@ -0,0 +1,25 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package log
+
+import (
+	"fmt"
+
+	"github.com/onsi/ginkgo"
+)
+
+// Logf logs the format string to ginkgo stdout.
+func Logf(format string, args ...interface{}) {
+	fmt.Fprintf(ginkgo.GinkgoWriter, format, args...)
+}

+ 87 - 0
e2e/framework/util/util.go

@@ -0,0 +1,87 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package util
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"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"
+	"k8s.io/client-go/kubernetes"
+)
+
+const (
+	// How often to poll for conditions.
+	Poll = 2 * time.Second
+)
+
+// CreateKubeNamespace creates a new Kubernetes Namespace for a test.
+func CreateKubeNamespace(baseName string, kubeClientSet kubernetes.Interface) (*v1.Namespace, error) {
+	ns := &v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: fmt.Sprintf("e2e-tests-%v-", baseName),
+		},
+	}
+
+	return kubeClientSet.CoreV1().Namespaces().Create(context.TODO(), ns, metav1.CreateOptions{})
+}
+
+// DeleteKubeNamespace will delete a namespace resource.
+func DeleteKubeNamespace(namespace string, kubeClientSet kubernetes.Interface) error {
+	return kubeClientSet.CoreV1().Namespaces().Delete(context.TODO(), namespace, metav1.DeleteOptions{})
+}
+
+// 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 {
+	return wait.PollImmediate(Poll, time.Minute*2, namespaceNotExist(kubeClientSet, namespace))
+}
+
+func namespaceNotExist(c kubernetes.Interface, namespace string) wait.ConditionFunc {
+	return func() (bool, error) {
+		_, err := c.CoreV1().Namespaces().Get(context.TODO(), namespace, metav1.GetOptions{})
+		if apierrors.IsNotFound(err) {
+			return true, nil
+		}
+		if err != nil {
+			return false, err
+		}
+		return false, nil
+	}
+}
+
+// WaitForURL tests the provided url. Once a http 200 is returned the func returns with no error.
+// Timeout is 5min.
+func WaitForURL(url string) error {
+	return wait.PollImmediate(2*time.Second, time.Minute*5, func() (bool, error) {
+		req, err := http.NewRequest(http.MethodGet, url, nil)
+		if err != nil {
+			return false, nil
+		}
+		res, err := http.DefaultClient.Do(req)
+		if err != nil {
+			return false, nil
+		}
+		defer res.Body.Close()
+		if res.StatusCode == http.StatusOK {
+			return true, nil
+		}
+		return false, err
+	})
+}

+ 7 - 0
e2e/k8s/eso.values.yaml

@@ -0,0 +1,7 @@
+installCRDs: false
+image:
+  repository: local/external-secrets
+  tag: test
+extraEnv:
+  - name: AWS_SECRETSMANAGER_ENDPOINT
+    value: "http://localstack.default"

+ 10 - 0
e2e/k8s/localstack.values.yaml

@@ -0,0 +1,10 @@
+service:
+  type: ClusterIP
+  edgeService:
+    targetPort: 80
+debug: true
+extraEnvVars:
+  - name: EDGE_PORT
+    value: "80"
+  - name: SERVICES
+    value: "secretsmanager,ssm,sts"

+ 16 - 0
e2e/kind.yaml

@@ -0,0 +1,16 @@
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+kubeadmConfigPatches:
+- |
+  apiVersion: kubelet.config.k8s.io/v1beta1
+  kind: KubeletConfiguration
+  metadata:
+    name: config
+  # this is only relevant for btrfs uses
+  # https://github.com/kubernetes/kubernetes/issues/80633#issuecomment-550994513
+  featureGates:
+    LocalStorageCapacityIsolation: false
+nodes:
+- role: control-plane
+- role: worker
+- role: worker

+ 56 - 0
e2e/run.sh

@@ -0,0 +1,56 @@
+#!/bin/bash
+
+# Copyright 2019 The Kubernetes 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
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+set -o errexit
+set -o nounset
+set -o pipefail
+
+if ! command -v kind --version &> /dev/null; then
+  echo "kind is not installed. Use the package manager or visit the official site https://kind.sigs.k8s.io/"
+  exit 1
+fi
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd $DIR
+
+echo "Kubernetes cluster:"
+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 \
+  --clusterrole=cluster-admin \
+  --user=admin \
+  --user=kubelet \
+  --serviceaccount=default:external-secrets-e2e || true
+
+echo -e "Waiting service account..."; \
+until kubectl get secret | grep -q -e ^external-secrets-e2e-token; do \
+  echo -e "waiting for api token"; \
+  sleep 3; \
+done
+
+kubectl apply -f ${DIR}/k8s/deploy/crds
+
+echo -e "Starting the e2e test pod"
+FOCUS=${FOCUS:-.*}
+export FOCUS
+
+kubectl run --rm \
+  --attach \
+  --restart=Never \
+  --env="FOCUS=${FOCUS}" \
+  --overrides='{ "apiVersion": "v1", "spec":{"serviceAccountName": "external-secrets-e2e"}}' \
+  e2e --image=local/external-secrets-e2e:test

+ 132 - 0
e2e/suite/aws/secretsmanager.go

@@ -0,0 +1,132 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package aws
+
+import (
+	"context"
+	"fmt"
+
+	// nolint
+	. "github.com/onsi/ginkgo"
+	// nolint
+	. "github.com/onsi/gomega"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/external-secrets/external-secrets/e2e/framework"
+)
+
+var _ = Describe("[aws] ", func() {
+	f := framework.New("eso-aws")
+	var secretStore *esv1alpha1.SecretStore
+	localstackURL := "http://localstack.default"
+
+	BeforeEach(func() {
+		By("creating an secret store for localstack")
+		awsCreds := &v1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      f.Namespace.Name,
+				Namespace: f.Namespace.Name,
+			},
+			StringData: map[string]string{
+				"kid": "foobar",
+				"sak": "foobar",
+			},
+		}
+		err := f.CRClient.Create(context.Background(), awsCreds)
+		Expect(err).ToNot(HaveOccurred())
+		secretStore = &esv1alpha1.SecretStore{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      f.Namespace.Name,
+				Namespace: f.Namespace.Name,
+			},
+			Spec: esv1alpha1.SecretStoreSpec{
+				Provider: &esv1alpha1.SecretStoreProvider{
+					AWS: &esv1alpha1.AWSProvider{
+						Service: esv1alpha1.AWSServiceSecretsManager,
+						Region:  "us-east-1",
+						Auth: &esv1alpha1.AWSAuth{
+							SecretRef: esv1alpha1.AWSAuthSecretRef{
+								AccessKeyID: esmeta.SecretKeySelector{
+									Name: f.Namespace.Name,
+									Key:  "kid",
+								},
+								SecretAccessKey: esmeta.SecretKeySelector{
+									Name: f.Namespace.Name,
+									Key:  "sak",
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+		err = f.CRClient.Create(context.Background(), secretStore)
+		Expect(err).ToNot(HaveOccurred())
+	})
+
+	It("should sync multiple secrets", func() {
+		By("creating a AWS SM Secret")
+		secretKey1 := fmt.Sprintf("%s-%s", f.Namespace.Name, "one")
+		secretKey2 := fmt.Sprintf("%s-%s", f.Namespace.Name, "other")
+		secretValue := "bar"
+		targetSecret := "target-secret"
+		err := CreateAWSSecretsManagerSecret(
+			localstackURL,
+			secretKey1, secretValue)
+		Expect(err).ToNot(HaveOccurred())
+		err = CreateAWSSecretsManagerSecret(
+			localstackURL,
+			secretKey2, secretValue)
+		Expect(err).ToNot(HaveOccurred())
+
+		err = f.CRClient.Create(context.Background(), &esv1alpha1.ExternalSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "simple-sync",
+				Namespace: f.Namespace.Name,
+			},
+			Spec: esv1alpha1.ExternalSecretSpec{
+				SecretStoreRef: esv1alpha1.SecretStoreRef{
+					Name: f.Namespace.Name,
+				},
+				Target: esv1alpha1.ExternalSecretTarget{
+					Name: targetSecret,
+				},
+				Data: []esv1alpha1.ExternalSecretData{
+					{
+						SecretKey: secretKey1,
+						RemoteRef: esv1alpha1.ExternalSecretDataRemoteRef{
+							Key: secretKey1,
+						},
+					},
+					{
+						SecretKey: secretKey2,
+						RemoteRef: esv1alpha1.ExternalSecretDataRemoteRef{
+							Key: secretKey2,
+						},
+					},
+				},
+			},
+		})
+		Expect(err).ToNot(HaveOccurred())
+
+		_, err = f.WaitForSecretValue(f.Namespace.Name, targetSecret, map[string][]byte{
+			secretKey1: []byte(secretValue),
+			secretKey2: []byte(secretValue),
+		})
+		Expect(err).ToNot(HaveOccurred())
+	})
+})

+ 44 - 0
e2e/suite/aws/util.go

@@ -0,0 +1,44 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+limitations under the License.
+*/
+package aws
+
+import (
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/credentials"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/secretsmanager"
+
+	prov "github.com/external-secrets/external-secrets/pkg/provider/aws"
+)
+
+// CreateAWSSecretsManagerSecret creates a sm secret with the given value.
+func CreateAWSSecretsManagerSecret(endpoint, secretName, secretValue string) error {
+	sess, err := session.NewSessionWithOptions(session.Options{
+		Config: aws.Config{
+			Credentials: credentials.NewStaticCredentials("foobar", "foobar", "secret-manager"),
+			EndpointResolver: prov.ResolveEndpointWithServiceMap(map[string]string{
+				"secretsmanager": endpoint,
+			}),
+			Region: aws.String("eu-east-1"),
+		},
+	})
+	if err != nil {
+		return err
+	}
+	sm := secretsmanager.New(sess)
+	_, err = sm.CreateSecret(&secretsmanager.CreateSecretInput{
+		Name:         aws.String(secretName),
+		SecretString: aws.String(secretValue),
+	})
+	return err
+}

+ 21 - 0
e2e/suite/import.go

@@ -0,0 +1,21 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package suite
+
+import (
+
+	// import different e2e test suites.
+	_ "github.com/external-secrets/external-secrets/e2e/suite/aws"
+	_ "github.com/external-secrets/external-secrets/e2e/suite/vault"
+)

+ 128 - 0
e2e/suite/vault/vault.go

@@ -0,0 +1,128 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+limitations under the License.
+*/
+package vault
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	vault "github.com/hashicorp/vault/api"
+
+	// nolint
+	. "github.com/onsi/ginkgo"
+	// nolint
+	. "github.com/onsi/gomega"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/external-secrets/external-secrets/e2e/framework"
+)
+
+var _ = Describe("[vault] ", func() {
+	f := framework.New("eso-vault")
+	var secretStore *esv1alpha1.SecretStore
+
+	BeforeEach(func() {
+		By("creating an secret store for vault")
+		vaultCreds := &v1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      f.Namespace.Name,
+				Namespace: f.Namespace.Name,
+			},
+			StringData: map[string]string{
+				"token": "root", // vault dev-mode default token
+			},
+		}
+		err := f.CRClient.Create(context.Background(), vaultCreds)
+		Expect(err).ToNot(HaveOccurred())
+		secretStore = &esv1alpha1.SecretStore{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      f.Namespace.Name,
+				Namespace: f.Namespace.Name,
+			},
+			Spec: esv1alpha1.SecretStoreSpec{
+				Provider: &esv1alpha1.SecretStoreProvider{
+					Vault: &esv1alpha1.VaultProvider{
+						Version: esv1alpha1.VaultKVStoreV2,
+						Path:    "secret",
+						Server:  "http://vault.default:8200",
+						Auth: esv1alpha1.VaultAuth{
+							TokenSecretRef: &esmeta.SecretKeySelector{
+								Name: f.Namespace.Name,
+								Key:  "token",
+							},
+						},
+					},
+				},
+			},
+		}
+		err = f.CRClient.Create(context.Background(), secretStore)
+		Expect(err).ToNot(HaveOccurred())
+	})
+
+	It("should sync secrets", func() {
+		secretKey := fmt.Sprintf("%s-%s", f.Namespace.Name, "one")
+		secretProp := "example"
+		secretValue := "bar"
+		targetSecret := "target-secret"
+
+		By("creating a vault secret")
+		vc, err := vault.NewClient(&vault.Config{
+			Address: "http://vault.default:8200",
+		})
+		Expect(err).ToNot(HaveOccurred())
+		vc.SetToken("root") // dev-mode default token
+		req := vc.NewRequest(http.MethodPost, fmt.Sprintf("/v1/secret/data/%s", secretKey))
+		err = req.SetJSONBody(map[string]interface{}{
+			"data": map[string]string{
+				secretProp: secretValue,
+			},
+		})
+		Expect(err).ToNot(HaveOccurred())
+		_, err = vc.RawRequestWithContext(context.Background(), req)
+		Expect(err).ToNot(HaveOccurred())
+
+		By("creating ExternalSecret")
+		err = f.CRClient.Create(context.Background(), &esv1alpha1.ExternalSecret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "simple-sync",
+				Namespace: f.Namespace.Name,
+			},
+			Spec: esv1alpha1.ExternalSecretSpec{
+				SecretStoreRef: esv1alpha1.SecretStoreRef{
+					Name: secretStore.Name,
+				},
+				Target: esv1alpha1.ExternalSecretTarget{
+					Name: targetSecret,
+				},
+				Data: []esv1alpha1.ExternalSecretData{
+					{
+						SecretKey: secretKey,
+						RemoteRef: esv1alpha1.ExternalSecretDataRemoteRef{
+							Key:      secretKey,
+							Property: secretProp,
+						},
+					},
+				},
+			},
+		})
+		Expect(err).ToNot(HaveOccurred())
+		_, err = f.WaitForSecretValue(f.Namespace.Name, targetSecret, map[string][]byte{
+			secretKey: []byte(secretValue),
+		})
+		Expect(err).ToNot(HaveOccurred())
+	})
+})

+ 16 - 16
pkg/controllers/externalsecret/externalsecret_controller_test.go

@@ -36,7 +36,7 @@ import (
 var (
 	fakeProvider *fake.Client
 	metric       dto.Metric
-	timeout      = time.Second * 5
+	timeout      = time.Second * 30
 	interval     = time.Millisecond * 250
 )
 
@@ -122,10 +122,10 @@ var _ = Describe("ExternalSecret controller", func() {
 			Expect(externalSecretConditionShouldBe(ExternalSecretName, ExternalSecretNamespace, esv1alpha1.ExternalSecretReady, v1.ConditionFalse, 0.0)).To(BeTrue())
 			Expect(externalSecretConditionShouldBe(ExternalSecretName, ExternalSecretNamespace, esv1alpha1.ExternalSecretReady, v1.ConditionTrue, 1.0)).To(BeTrue())
 
-			Eventually(func() float64 {
+			Eventually(func() bool {
 				Expect(syncCallsTotal.WithLabelValues(ExternalSecretName, ExternalSecretNamespace).Write(&metric)).To(Succeed())
-				return metric.GetCounter().GetValue()
-			}, timeout, interval).Should(Equal(2.0))
+				return metric.GetCounter().GetValue() >= 2.0
+			}, timeout, interval).Should(BeTrue())
 		})
 	})
 
@@ -169,10 +169,10 @@ var _ = Describe("ExternalSecret controller", func() {
 				return nil
 			}, timeout, interval).Should(Succeed())
 
-			Eventually(func() float64 {
+			Eventually(func() bool {
 				Expect(syncCallsTotal.WithLabelValues(ExternalSecretName, ExternalSecretNamespace).Write(&metric)).To(Succeed())
-				return metric.GetCounter().GetValue()
-			}, timeout, interval).Should(Equal(3.0))
+				return metric.GetCounter().GetValue() >= 3.0
+			}, timeout, interval).Should(BeTrue())
 		})
 	})
 
@@ -447,10 +447,10 @@ var _ = Describe("ExternalSecret controller", func() {
 				return true
 			}, timeout, interval).Should(BeTrue())
 
-			Eventually(func() float64 {
+			Eventually(func() bool {
 				Expect(syncCallsError.WithLabelValues(ExternalSecretName, ExternalSecretNamespace).Write(&metric)).To(Succeed())
-				return metric.GetCounter().GetValue()
-			}, timeout, interval).Should(Equal(2.0))
+				return metric.GetCounter().GetValue() >= 2.0
+			}, timeout, interval).Should(BeTrue())
 
 			Expect(externalSecretConditionShouldBe(ExternalSecretName, ExternalSecretNamespace, esv1alpha1.ExternalSecretReady, v1.ConditionFalse, 1.0)).To(BeTrue())
 			Expect(externalSecretConditionShouldBe(ExternalSecretName, ExternalSecretNamespace, esv1alpha1.ExternalSecretReady, v1.ConditionTrue, 0.0)).To(BeTrue())
@@ -501,10 +501,10 @@ var _ = Describe("ExternalSecret controller", func() {
 				return true
 			}, timeout, interval).Should(BeTrue())
 
-			Eventually(func() float64 {
+			Eventually(func() bool {
 				Expect(syncCallsError.WithLabelValues(ExternalSecretName, ExternalSecretNamespace).Write(&metric)).To(Succeed())
-				return metric.GetCounter().GetValue()
-			}, timeout, interval).Should(Equal(2.0))
+				return metric.GetCounter().GetValue() >= 2.0
+			}, timeout, interval).Should(BeTrue())
 
 			Expect(externalSecretConditionShouldBe(ExternalSecretName, ExternalSecretNamespace, esv1alpha1.ExternalSecretReady, v1.ConditionFalse, 1.0)).To(BeTrue())
 			Expect(externalSecretConditionShouldBe(ExternalSecretName, ExternalSecretNamespace, esv1alpha1.ExternalSecretReady, v1.ConditionTrue, 0.0)).To(BeTrue())
@@ -559,10 +559,10 @@ var _ = Describe("ExternalSecret controller", func() {
 				return true
 			}, timeout, interval).Should(BeTrue())
 
-			Eventually(func() float64 {
+			Eventually(func() bool {
 				Expect(syncCallsError.WithLabelValues(ExternalSecretName, ExternalSecretNamespace).Write(&metric)).To(Succeed())
-				return metric.GetCounter().GetValue()
-			}, timeout, interval).Should(Equal(2.0))
+				return metric.GetCounter().GetValue() >= 2.0
+			}, timeout, interval).Should(BeTrue())
 
 			Expect(externalSecretConditionShouldBe(ExternalSecretName, ExternalSecretNamespace, esv1alpha1.ExternalSecretReady, v1.ConditionFalse, 1.0)).To(BeTrue())
 			Expect(externalSecretConditionShouldBe(ExternalSecretName, ExternalSecretNamespace, esv1alpha1.ExternalSecretReady, v1.ConditionTrue, 0.0)).To(BeTrue())

+ 67 - 13
pkg/provider/aws/provider.go

@@ -17,7 +17,9 @@ package aws
 import (
 	"context"
 	"fmt"
+	"os"
 
+	"github.com/aws/aws-sdk-go/aws/endpoints"
 	"github.com/aws/aws-sdk-go/aws/session"
 	v1 "k8s.io/api/core/v1"
 	ctrl "sigs.k8s.io/controller-runtime"
@@ -36,6 +38,25 @@ type Provider struct{}
 
 var log = ctrl.Log.WithName("provider").WithName("aws")
 
+const (
+	SecretsManagerEndpointEnv = "AWS_SECRETSMANAGER_ENDPOINT"
+	STSEndpointEnv            = "AWS_STS_ENDPOINT"
+	SSMEndpointEnv            = "AWS_SSM_ENDPOINT"
+
+	errUnableCreateSession                     = "unable to create session: %w"
+	errUnknownProviderService                  = "unknown AWS Provider Service: %s"
+	errInvalidClusterStoreMissingAKIDNamespace = "invalid ClusterSecretStore: missing AWS AccessKeyID Namespace"
+	errInvalidClusterStoreMissingSAKNamespace  = "invalid ClusterSecretStore: missing AWS SecretAccessKey Namespace"
+	errFetchAKIDSecret                         = "could not fetch accessKeyID secret: %w"
+	errFetchSAKSecret                          = "could not fetch SecretAccessKey secret: %w"
+	errMissingSAK                              = "missing SecretAccessKey"
+	errMissingAKID                             = "missing AccessKeyID"
+	errNilStore                                = "found nil store"
+	errMissingStoreSpec                        = "store is missing spec"
+	errMissingProvider                         = "storeSpec is missing provider"
+	errInvalidProvider                         = "invalid provider spec. Missing AWS field in store %s"
+)
+
 // NewClient constructs a new secrets client based on the provided store.
 func (p *Provider) NewClient(ctx context.Context, store esv1alpha1.GenericStore, kube client.Client, namespace string) (provider.SecretsClient, error) {
 	return newClient(ctx, store, kube, namespace, awssess.DefaultSTSProvider)
@@ -48,7 +69,7 @@ func newClient(ctx context.Context, store esv1alpha1.GenericStore, kube client.C
 	}
 	sess, err := newSession(ctx, store, kube, namespace, assumeRoler)
 	if err != nil {
-		return nil, fmt.Errorf("unable to create session: %w", err)
+		return nil, fmt.Errorf(errUnableCreateSession, err)
 	}
 	switch prov.Service {
 	case esv1alpha1.AWSServiceSecretsManager:
@@ -56,7 +77,7 @@ func newClient(ctx context.Context, store esv1alpha1.GenericStore, kube client.C
 	case esv1alpha1.AWSServiceParameterStore:
 		return parameterstore.New(sess)
 	}
-	return nil, fmt.Errorf("unknown AWS Provider Service: %s", prov.Service)
+	return nil, fmt.Errorf(errUnknownProviderService, prov.Service)
 }
 
 // newSession creates a new aws session based on a store
@@ -77,14 +98,14 @@ func newSession(ctx context.Context, store esv1alpha1.GenericStore, kube client.
 		// only ClusterStore is allowed to set namespace (and then it's required)
 		if store.GetObjectKind().GroupVersionKind().Kind == esv1alpha1.ClusterSecretStoreKind {
 			if prov.Auth.SecretRef.AccessKeyID.Namespace == nil {
-				return nil, fmt.Errorf("invalid ClusterSecretStore: missing AWS AccessKeyID Namespace")
+				return nil, fmt.Errorf(errInvalidClusterStoreMissingAKIDNamespace)
 			}
 			ke.Namespace = *prov.Auth.SecretRef.AccessKeyID.Namespace
 		}
 		akSecret := v1.Secret{}
 		err := kube.Get(ctx, ke, &akSecret)
 		if err != nil {
-			return nil, fmt.Errorf("could not fetch accessKeyID secret: %w", err)
+			return nil, fmt.Errorf(errFetchAKIDSecret, err)
 		}
 		ke = client.ObjectKey{
 			Name:      prov.Auth.SecretRef.SecretAccessKey.Name,
@@ -93,47 +114,80 @@ func newSession(ctx context.Context, store esv1alpha1.GenericStore, kube client.
 		// only ClusterStore is allowed to set namespace (and then it's required)
 		if store.GetObjectKind().GroupVersionKind().Kind == esv1alpha1.ClusterSecretStoreKind {
 			if prov.Auth.SecretRef.SecretAccessKey.Namespace == nil {
-				return nil, fmt.Errorf("invalid ClusterSecretStore: missing AWS SecretAccessKey Namespace")
+				return nil, fmt.Errorf(errInvalidClusterStoreMissingSAKNamespace)
 			}
 			ke.Namespace = *prov.Auth.SecretRef.SecretAccessKey.Namespace
 		}
 		sakSecret := v1.Secret{}
 		err = kube.Get(ctx, ke, &sakSecret)
 		if err != nil {
-			return nil, fmt.Errorf("could not fetch SecretAccessKey secret: %w", err)
+			return nil, fmt.Errorf(errFetchSAKSecret, err)
 		}
 		sak = string(sakSecret.Data[prov.Auth.SecretRef.SecretAccessKey.Key])
 		aks = string(akSecret.Data[prov.Auth.SecretRef.AccessKeyID.Key])
 		if sak == "" {
-			return nil, fmt.Errorf("missing SecretAccessKey")
+			return nil, fmt.Errorf(errMissingSAK)
 		}
 		if aks == "" {
-			return nil, fmt.Errorf("missing AccessKeyID")
+			return nil, fmt.Errorf(errMissingAKID)
 		}
 	}
-	return awssess.New(sak, aks, prov.Region, prov.Role, assumeRoler)
+	session, err := awssess.New(sak, aks, prov.Region, prov.Role, assumeRoler)
+	if err != nil {
+		return nil, err
+	}
+	session.Config.EndpointResolver = ResolveEndpoint()
+	return session, nil
 }
 
 // getAWSProvider does the necessary nil checks on the generic store
 // it returns the aws provider or an error.
 func getAWSProvider(store esv1alpha1.GenericStore) (*esv1alpha1.AWSProvider, error) {
 	if store == nil {
-		return nil, fmt.Errorf("found nil store")
+		return nil, fmt.Errorf(errNilStore)
 	}
 	spc := store.GetSpec()
 	if spc == nil {
-		return nil, fmt.Errorf("store is missing spec")
+		return nil, fmt.Errorf(errMissingStoreSpec)
 	}
 	if spc.Provider == nil {
-		return nil, fmt.Errorf("storeSpec is missing provider")
+		return nil, fmt.Errorf(errMissingProvider)
 	}
 	prov := spc.Provider.AWS
 	if prov == nil {
-		return nil, fmt.Errorf("invalid provider spec. Missing AWS field in store %s", store.GetObjectMeta().String())
+		return nil, fmt.Errorf(errInvalidProvider, store.GetObjectMeta().String())
 	}
 	return prov, nil
 }
 
+// ResolveEndpoint returns a ResolverFunc with
+// customizable endpoints.
+func ResolveEndpoint() endpoints.ResolverFunc {
+	customEndpoints := make(map[string]string)
+	if v := os.Getenv(SecretsManagerEndpointEnv); v != "" {
+		customEndpoints["secretsmanager"] = v
+	}
+	if v := os.Getenv(SSMEndpointEnv); v != "" {
+		customEndpoints["ssm"] = v
+	}
+	if v := os.Getenv(STSEndpointEnv); v != "" {
+		customEndpoints["sts"] = v
+	}
+	return ResolveEndpointWithServiceMap(customEndpoints)
+}
+
+func ResolveEndpointWithServiceMap(customEndpoints map[string]string) endpoints.ResolverFunc {
+	defaultResolver := endpoints.DefaultResolver()
+	return func(service, region string, opts ...func(*endpoints.Options)) (endpoints.ResolvedEndpoint, error) {
+		if ep, ok := customEndpoints[service]; ok {
+			return endpoints.ResolvedEndpoint{
+				URL: ep,
+			}, nil
+		}
+		return defaultResolver.EndpointFor(service, region, opts...)
+	}
+}
+
 func init() {
 	schema.Register(&Provider{}, &esv1alpha1.SecretStoreProvider{
 		AWS: &esv1alpha1.AWSProvider{},

+ 37 - 0
pkg/provider/aws/provider_test.go

@@ -570,6 +570,43 @@ func TestSMAssumeRole(t *testing.T) {
 	assert.Equal(t, creds.SecretAccessKey, "4444")
 }
 
+func TestResolver(t *testing.T) {
+	tbl := []struct {
+		env     string
+		service string
+		url     string
+	}{
+		{
+			env:     SecretsManagerEndpointEnv,
+			service: "secretsmanager",
+			url:     "http://sm.foo",
+		},
+		{
+			env:     SSMEndpointEnv,
+			service: "ssm",
+			url:     "http://ssm.foo",
+		},
+		{
+			env:     STSEndpointEnv,
+			service: "sts",
+			url:     "http://sts.foo",
+		},
+	}
+
+	for _, item := range tbl {
+		os.Setenv(item.env, item.url)
+		defer os.Unsetenv(item.env)
+	}
+
+	f := ResolveEndpoint()
+
+	for _, item := range tbl {
+		ep, err := f.EndpointFor(item.service, "")
+		assert.Nil(t, err)
+		assert.Equal(t, item.url, ep.URL)
+	}
+}
+
 func ErrorContains(out error, want string) bool {
 	if out == nil {
 		return want == ""