diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 0000000..65f3a3e --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,48 @@ +name: Test E2E + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: ['**'] # run for PRs targeting any branch (main and others) + +concurrency: + group: e2e-${{ github.event.pull_request.head.ref }} + cancel-in-progress: true # cancel previous job runs for the same branch + +jobs: + e2e-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install dependencies + run: go mod tidy + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: onepassword-operator-test-e2e + + # install cli to interact with item in 1Password to update/read using `testhelper/op` package + - name: Install 1Password CLI + uses: 1password/install-cli-action@v2 + with: + version: 2.32.0 + + - name: Create '1password-credentials.json' file + env: + OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }} + run: | + echo "$OP_CONNECT_CREDENTIALS" > 1password-credentials.json + + - name: Run E2E tests + run: make test-e2e + env: + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }} + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} diff --git a/.gitignore b/.gitignore index e32a86d..ec7068a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ go.work *.swp *.swo *~ + +**/1password-credentials.json +**/op-session diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 753c22d..bd86261 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,17 @@ Thank you for your interest in contributing to the 1Password Kubernetes Operator ## Testing -- For functional testing, run the local version of the operator. From the project root: +All contributions must include tests where applicable. + +- **Unit tests** for pure Go logic. +- **Integration tests** for controller/reconciler logic using envtest. +- **E2E tests** for full cluster behavior with kind. + +👉 See the [Testing Guide](docs/testing.md) for details on when to use each, how to run them locally, and how they are run in CI. + +---- + +For functional testing, run the local version of the operator. From the project root: ```sh # Go to the K8s environment (e.g. minikube) @@ -24,6 +34,8 @@ Thank you for your interest in contributing to the 1Password Kubernetes Operator 1. Rebuild the Docker image by running `make docker-build` 2. Restart deployment `make restart` +---- + - For testing the changes made to the `OnePasswordItem` Custom Resource Definition (CRD), you need to re-generate the object: ```sh make manifests diff --git a/Makefile b/Makefile index 8b5d9b9..79ed7b7 100644 --- a/Makefile +++ b/Makefile @@ -117,7 +117,7 @@ vet: ## Run go vet against code. .PHONY: test test: manifests generate fmt vet setup-envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./... -coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $(shell go list ./... | grep -v /test/e2e) -coverprofile cover.out # TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. # The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. diff --git a/config/connect/deployment.yaml b/config/connect/deployment.yaml index 3f2dd38..cee5e75 100644 --- a/config/connect/deployment.yaml +++ b/config/connect/deployment.yaml @@ -14,6 +14,8 @@ spec: spec: securityContext: runAsNonRoot: true + fsGroup: 999 + fsGroupChangePolicy: OnRootMismatch volumes: - name: shared-data emptyDir: {} @@ -31,10 +33,20 @@ spec: volumeMounts: - mountPath: /home/opuser/.op/data name: shared-data + securityContext: + runAsUser: 0 + runAsNonRoot: false + allowPrivilegeEscalation: false + capabilities: + drop: [ "ALL" ] + add: ["CHOWN", "FOWNER"] containers: - name: connect-api image: 1password/connect-api:latest securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 allowPrivilegeEscalation: false resources: limits: @@ -55,6 +67,9 @@ spec: - name: connect-sync image: 1password/connect-sync:latest securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 allowPrivilegeEscalation: false resources: limits: diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..9380407 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,22 @@ +# Testing + +## Unit & Integration tests +**When**: Unit (pure Go) and integration (controller-runtime envtest). +**Where**: `internal/...`, `pkg/...` +**Add files in**: `*_test.go` next to the code. +**Run**: `make test` + +## E2E tests (kind) +**When**: Full cluster behavior (CRDs, operator image, Connect/SA flows). +**Where**: `test/e2e/...` +**Add files in**: `*_test.go` next to the code. +**Framework**: Ginkgo + `pkg/testhelper`. + +**Local prep**: +1. [Install `kind`](https://kind.sigs.k8s.io/docs/user/quick-start/#installing-with-a-package-manager) to spin up local Kubernetes cluster. +2. `export OP_CONNECT_TOKEN=` +3. `export OP_SERVICE_ACCOUNT_TOKEN=` +4. `make test-e2e` +5. Put `1password-credentials.json` into project root. + +**Run**: `make test-e2e` diff --git a/go.mod b/go.mod index 941b1a8..bcb22b7 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/onsi/gomega v1.36.1 github.com/stretchr/testify v1.10.0 k8s.io/api v0.33.0 + k8s.io/apiextensions-apiserver v0.33.0 k8s.io/apimachinery v0.33.0 k8s.io/client-go v0.33.0 k8s.io/kubectl v0.29.0 @@ -102,7 +103,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.33.0 // indirect k8s.io/apiserver v0.33.0 // indirect k8s.io/component-base v0.33.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/pkg/testhelper/defaults/defaults.go b/pkg/testhelper/defaults/defaults.go new file mode 100644 index 0000000..68532de --- /dev/null +++ b/pkg/testhelper/defaults/defaults.go @@ -0,0 +1,8 @@ +package defaults + +import "time" + +const ( + E2EInterval = 1 * time.Second + E2ETimeout = 1 * time.Minute +) diff --git a/pkg/testhelper/kind/kind.go b/pkg/testhelper/kind/kind.go new file mode 100644 index 0000000..8b03879 --- /dev/null +++ b/pkg/testhelper/kind/kind.go @@ -0,0 +1,23 @@ +package kind + +import ( + "os" + + //nolint:staticcheck // ST1001 + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001 + . "github.com/onsi/gomega" + + "github.com/1Password/onepassword-operator/pkg/testhelper/system" +) + +// LoadImageToKind loads a local docker image to the Kind cluster +func LoadImageToKind(imageName string) { + By("Loading the operator image on Kind") + clusterName := "kind" + if value, ok := os.LookupEnv("KIND_CLUSTER"); ok { + clusterName = value + } + _, err := system.Run("kind", "load", "docker-image", imageName, "--name", clusterName) + Expect(err).NotTo(HaveOccurred()) +} diff --git a/pkg/testhelper/kube/deployment.go b/pkg/testhelper/kube/deployment.go new file mode 100644 index 0000000..79a4a51 --- /dev/null +++ b/pkg/testhelper/kube/deployment.go @@ -0,0 +1,125 @@ +package kube + +import ( + "context" + "time" + + //nolint:staticcheck // ST1001 + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001 + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Deployment struct { + client client.Client + config *Config + name string +} + +func (d *Deployment) Get(ctx context.Context) *appsv1.Deployment { + // Derive a short-lived context so this API call won't hang indefinitely. + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + deployment := &appsv1.Deployment{} + err := d.client.Get(c, client.ObjectKey{Name: d.name, Namespace: d.config.Namespace}, deployment) + Expect(err).ToNot(HaveOccurred()) + + return deployment +} + +func (d *Deployment) ReadEnvVar(ctx context.Context, envVarName string) string { + By("Reading " + envVarName + " value from deployment/" + d.name) + deployment := d.Get(ctx) + + // Search env across all containers + found := "" + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, env := range container.Env { + if env.Name == envVarName && env.Value != "" { + found = env.Value + break + } + } + } + + Expect(found).NotTo(BeEmpty()) + return found +} + +func (d *Deployment) PatchEnvVars(ctx context.Context, upsert []corev1.EnvVar, remove []string) { + By("Patching env variables for deployment/" + d.name) + deployment := d.Get(ctx) + deploymentCopy := deployment.DeepCopy() + container := &deployment.Spec.Template.Spec.Containers[0] + + // Build removal set for quick lookup + toRemove := make(map[string]struct{}, len(remove)) + for _, n := range remove { + toRemove[n] = struct{}{} + } + + // Build upsert map for quick lookup + upserts := make(map[string]corev1.EnvVar, len(upsert)) + for _, e := range upsert { + upserts[e.Name] = e + } + + // Filter existing envs: keep if not in remove and not being upserted + filtered := make([]corev1.EnvVar, 0, len(container.Env)) + for _, e := range container.Env { + if _, ok := toRemove[e.Name]; ok { + continue + } + if newE, ok := upserts[e.Name]; ok { + filtered = append(filtered, newE) // replace existing + delete(upserts, e.Name) // delete from map to not use once again + } else { + filtered = append(filtered, e) + } + } + + // Append any new envs that weren’t already in the container + for _, e := range upserts { + filtered = append(filtered, e) + } + + container.Env = filtered + + // Derive a short-lived context so this API call won't hang indefinitely. + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + err := d.client.Patch(c, deployment, client.MergeFrom(deploymentCopy)) + Expect(err).ToNot(HaveOccurred()) + + // wait for new deployment to roll out + d.WaitDeploymentRolledOut(ctx) +} + +// WaitDeploymentRolledOut waits for deployment to finish a rollout. +func (d *Deployment) WaitDeploymentRolledOut(ctx context.Context) { + By("Waiting for deployment/" + d.name + " to roll out") + + deployment := d.Get(ctx) + targetGen := deployment.Generation + + Eventually(func(g Gomega) error { + newDeployment := d.Get(ctx) + g.Expect(newDeployment.Status.ObservedGeneration).To(BeNumerically(">=", targetGen)) + + desired := int32(1) + if newDeployment.Spec.Replicas != nil { + desired = *newDeployment.Spec.Replicas + } + + g.Expect(newDeployment.Status.UpdatedReplicas).To(Equal(desired)) + g.Expect(newDeployment.Status.AvailableReplicas).To(Equal(desired)) + g.Expect(newDeployment.Status.Replicas).To(Equal(desired)) + + return nil + }, d.config.TestConfig.Timeout, d.config.TestConfig.Interval).Should(Succeed()) +} diff --git a/pkg/testhelper/kube/kube.go b/pkg/testhelper/kube/kube.go new file mode 100644 index 0000000..77bbe68 --- /dev/null +++ b/pkg/testhelper/kube/kube.go @@ -0,0 +1,224 @@ +package kube + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + //nolint:staticcheck // ST1001 + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001 + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apix "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + 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/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + apiv1 "github.com/1Password/onepassword-operator/api/v1" + "github.com/1Password/onepassword-operator/pkg/testhelper/defaults" +) + +type TestConfig struct { + Timeout time.Duration + Interval time.Duration +} + +type Config struct { + Namespace string + ManifestsDir string + TestConfig *TestConfig + CRDs []string +} + +type Kube struct { + Config *Config + Client client.Client + Mapper meta.RESTMapper +} + +func NewKubeClient(config *Config) *Kube { + By("Creating a kubernetes client") + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + home, _ := os.UserHomeDir() + kubeconfig = filepath.Join(home, ".kube", "config") + } + restConfig, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + Expect(err).NotTo(HaveOccurred()) + + // Install CRDs first (so discovery sees them) + installCRDs(context.Background(), restConfig, config.CRDs) + + // Build an http.Client from restConfig + httpClient, err := rest.HTTPClientFor(restConfig) + Expect(err).NotTo(HaveOccurred()) + + // Create a Dynamic RESTMapper that uses restConfig + rm, err := apiutil.NewDynamicRESTMapper(restConfig, httpClient) + Expect(err).NotTo(HaveOccurred()) + + scheme := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(appsv1.AddToScheme(scheme)) + utilruntime.Must(apiv1.AddToScheme(scheme)) // add OnePasswordItem to scheme + + kubernetesClient, err := client.New(restConfig, client.Options{ + Scheme: scheme, + Mapper: rm, + }) + Expect(err).NotTo(HaveOccurred()) + + // update the current context’s namespace in kubeconfig + pathOpts := clientcmd.NewDefaultPathOptions() + cfg, err := pathOpts.GetStartingConfig() + Expect(err).NotTo(HaveOccurred()) + + currentContext := cfg.CurrentContext + Expect(currentContext).NotTo(BeEmpty(), "no current kube context is set in kubeconfig") + + ctx, ok := cfg.Contexts[currentContext] + Expect(ok).To(BeTrue(), fmt.Sprintf("current context %q not found in kubeconfig", currentContext)) + + ctx.Namespace = config.Namespace + err = clientcmd.ModifyConfig(pathOpts, *cfg, true) + Expect(err).NotTo(HaveOccurred()) + + return &Kube{ + Config: config, + Client: kubernetesClient, + Mapper: rm, + } +} + +func (k *Kube) Secret(name string) *Secret { + return &Secret{ + client: k.Client, + config: k.Config, + name: name, + } +} + +func (k *Kube) Deployment(name string) *Deployment { + return &Deployment{ + client: k.Client, + config: k.Config, + name: name, + } +} + +func (k *Kube) Pod(selector map[string]string) *Pod { + return &Pod{ + client: k.Client, + config: k.Config, + selector: selector, + } +} + +func (k *Kube) Namespace(name string) *Namespace { + return &Namespace{ + client: k.Client, + config: k.Config, + name: name, + } +} + +// ApplyOnePasswordItem applies a OnePasswordItem manifest. +func (k *Kube) ApplyOnePasswordItem(ctx context.Context, fileName string) { + By("Applying " + fileName) + + // Derive a short-lived context so this API call won't hang indefinitely. + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + data, err := os.ReadFile(k.Config.ManifestsDir + "/" + fileName) + Expect(err).NotTo(HaveOccurred()) + + // Decode YAML -> JSON -> unstructured.Unstructured + jsonBytes, err := yaml.ToJSON(data) + Expect(err).NotTo(HaveOccurred()) + + var obj unstructured.Unstructured + Expect(obj.UnmarshalJSON(jsonBytes)).To(Succeed()) + + // Default namespace for namespaced resources if not set in YAML + if obj.GetNamespace() == "" && k.Config.Namespace != "" { + gvk := obj.GroupVersionKind() + mapping, mapErr := k.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if mapErr == nil && mapping.Scope.Name() == meta.RESTScopeNameNamespace { + obj.SetNamespace(k.Config.Namespace) + } + } + + // Server-Side Apply (create or update) + patchOpts := []client.PatchOption{ + client.FieldOwner("onepassword-e2e"), + client.ForceOwnership, // to force-take conflicting fields + } + Expect(k.Client.Patch(c, &obj, client.Apply, patchOpts...)).To(Succeed()) +} + +func installCRDs(ctx context.Context, restConfig *rest.Config, crdFiles []string) { + apixClient, err := apix.NewForConfig(restConfig) + Expect(err).NotTo(HaveOccurred()) + + for _, f := range crdFiles { + By("Installing CRD " + f) + b, err := os.ReadFile(filepath.Clean(f)) + Expect(err).NotTo(HaveOccurred()) + + var crd apiextv1.CustomResourceDefinition + err = yaml.Unmarshal(b, &crd) + Expect(err).NotTo(HaveOccurred()) + + // Create or Update + _, err = apixClient.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, &crd, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + existing, getErr := apixClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, crd.Name, metav1.GetOptions{}) + Expect(getErr).NotTo(HaveOccurred()) + + crd.ResourceVersion = existing.ResourceVersion + _, err = apixClient.ApiextensionsV1().CustomResourceDefinitions().Update(ctx, &crd, metav1.UpdateOptions{}) + } + Expect(err).NotTo(HaveOccurred()) + + waitCRDEstablished(ctx, apixClient, crd.Name) + } +} + +// waitCRDEstablished Wait until the CRD reaches Established=True, retrying until the suite timeout. +func waitCRDEstablished(ctx context.Context, apixClient *apix.Clientset, name string) { + By("Waiting for CRD " + name + " to be Established") + + Eventually(func(g Gomega) { + // Short per-attempt timeout so a single Get can't hang the whole Eventually loop. + attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + crd, err := apixClient.ApiextensionsV1(). + CustomResourceDefinitions(). + Get(attemptCtx, name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + + established := false + for _, c := range crd.Status.Conditions { + if c.Type == apiextv1.Established && c.Status == apiextv1.ConditionTrue { + established = true + break + } + } + g.Expect(established).To(BeTrue(), "CRD %q is not Established yet", name) + }, defaults.E2ETimeout, defaults.E2EInterval).Should(Succeed()) +} diff --git a/pkg/testhelper/kube/namespace.go b/pkg/testhelper/kube/namespace.go new file mode 100644 index 0000000..9969d60 --- /dev/null +++ b/pkg/testhelper/kube/namespace.go @@ -0,0 +1,41 @@ +package kube + +import ( + "context" + //nolint:staticcheck // ST1001 + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001 + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Namespace struct { + client client.Client + config *Config + name string +} + +// LabelNamespace applies the given labels to the specified namespace +func (n *Namespace) LabelNamespace(ctx context.Context, labelsMap map[string]string) { + if len(labelsMap) == 0 { + return + } + + By("Setting labelsMap " + labels.Set(labelsMap).String() + " to namespace/" + n.name) + ns := &corev1.Namespace{} + err := n.client.Get(ctx, client.ObjectKey{Name: n.name}, ns) + Expect(err).NotTo(HaveOccurred()) + + if ns.Labels == nil { + ns.Labels = map[string]string{} + } + + for k, v := range labelsMap { + ns.Labels[k] = v + } + + err = n.client.Update(ctx, ns) + Expect(err).NotTo(HaveOccurred()) +} diff --git a/pkg/testhelper/kube/pod.go b/pkg/testhelper/kube/pod.go new file mode 100644 index 0000000..b26dd98 --- /dev/null +++ b/pkg/testhelper/kube/pod.go @@ -0,0 +1,47 @@ +package kube + +import ( + "context" + "time" + + //nolint:staticcheck // ST1001 + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001 + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Pod struct { + client client.Client + config *Config + selector map[string]string +} + +func (p *Pod) WaitingForRunningPod(ctx context.Context) { + By("Waiting for the pod " + labels.Set(p.selector).String() + " to be 'Running'") + + Eventually(func(g Gomega) { + // short per-attempt timeout to avoid hanging calls while Eventually polls + attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + var pods corev1.PodList + listOpts := []client.ListOption{ + client.InNamespace(p.config.Namespace), + client.MatchingLabels(p.selector), + } + g.Expect(p.client.List(attemptCtx, &pods, listOpts...)).To(Succeed()) + g.Expect(pods.Items).NotTo(BeEmpty(), "no pods found with selector %q", labels.Set(p.selector).String()) + + foundRunning := false + for _, p := range pods.Items { + if p.Status.Phase == corev1.PodRunning { + foundRunning = true + break + } + } + g.Expect(foundRunning).To(BeTrue(), "pod not Running yet") + }, p.config.TestConfig.Timeout, p.config.TestConfig.Interval).Should(Succeed()) +} diff --git a/pkg/testhelper/kube/secret.go b/pkg/testhelper/kube/secret.go new file mode 100644 index 0000000..bbed2f4 --- /dev/null +++ b/pkg/testhelper/kube/secret.go @@ -0,0 +1,141 @@ +package kube + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "time" + + //nolint:staticcheck // ST1001 + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001 + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/1Password/onepassword-operator/pkg/testhelper/system" +) + +type Secret struct { + client client.Client + config *Config + name string +} + +// CreateFromEnvVar creates a kubernetes secret from an environment variable +func (s *Secret) CreateFromEnvVar(ctx context.Context, envVar string) *corev1.Secret { + By("Creating '" + s.name + "' secret from environment variable") + + // Derive a short-lived context so this API call won't hang indefinitely. + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + value, ok := os.LookupEnv(envVar) + Expect(ok).To(BeTrue()) + Expect(value).NotTo(BeEmpty()) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: s.name, + Namespace: s.config.Namespace, + }, + StringData: map[string]string{ + "token": value, + }, + } + + err := s.client.Create(c, secret) + Expect(err).NotTo(HaveOccurred()) + + return secret +} + +// CreateFromFile creates a kubernetes secret from a file +func (s *Secret) CreateFromFile(ctx context.Context, fileName string, content []byte) *corev1.Secret { + By("Creating '" + s.name + "' secret from file " + fileName) + + // Derive a short-lived context so this API call won't hang indefinitely. + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: s.name, + Namespace: s.config.Namespace, + }, + Data: map[string][]byte{ + filepath.Base(fileName): content, + }, + } + + err := s.client.Create(c, secret) + Expect(err).NotTo(HaveOccurred()) + + return secret +} + +// CreateOpCredentials creates a kubernetes secret from 1password-credentials.json file in the project root +// encodes it in base64 and saves it to op-session file +func (s *Secret) CreateOpCredentials(ctx context.Context) *corev1.Secret { + rootDir, err := system.GetProjectRoot() + Expect(err).NotTo(HaveOccurred()) + + credentialsFilePath := filepath.Join(rootDir, "1password-credentials.json") + data, err := os.ReadFile(credentialsFilePath) + Expect(err).NotTo(HaveOccurred()) + + encoded := base64.RawURLEncoding.EncodeToString(data) + + return s.CreateFromFile(ctx, "op-session", []byte(encoded)) +} + +// Get retrieves a kubernetes secret +func (s *Secret) Get(ctx context.Context) *corev1.Secret { + By("Getting '" + s.name + "' secret") + + // Derive a short-lived context so this API call won't hang indefinitely. + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + secret := &corev1.Secret{} + err := s.client.Get(c, client.ObjectKey{Name: s.name, Namespace: s.config.Namespace}, secret) + Expect(err).NotTo(HaveOccurred()) + + return secret +} + +// Delete deletes a kubernetes secret +func (s *Secret) Delete(ctx context.Context) { + By("Deleting '" + s.name + "' secret") + + // Derive a short-lived context so this API call won't hang indefinitely. + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: s.name, + Namespace: s.config.Namespace, + }, + } + err := s.client.Delete(c, secret) + Expect(err).NotTo(HaveOccurred()) +} + +// CheckIfExists repeatedly attempts to retrieve the given Secret +// from the cluster until it is found or the test's timeout expires. +func (s *Secret) CheckIfExists(ctx context.Context) { + By("Checking '" + s.name + "' secret") + + Eventually(func(g Gomega) { + // Derive a short-lived context so this API call won't hang indefinitely. + attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + secret := &corev1.Secret{} + err := s.client.Get(attemptCtx, client.ObjectKey{Name: s.name, Namespace: s.config.Namespace}, secret) + g.Expect(err).NotTo(HaveOccurred()) + }, s.config.TestConfig.Timeout, s.config.TestConfig.Interval).Should(Succeed()) +} diff --git a/pkg/testhelper/op/op.go b/pkg/testhelper/op/op.go new file mode 100644 index 0000000..cb25fb1 --- /dev/null +++ b/pkg/testhelper/op/op.go @@ -0,0 +1,32 @@ +package op + +import ( + "fmt" + + "github.com/1Password/onepassword-operator/pkg/testhelper/system" +) + +type Field string + +const ( + FieldUsername = "username" + FieldPassword = "password" +) + +// UpdateItemPassword updates the password of an item in 1Password +func UpdateItemPassword(item string) error { + _, err := system.Run("op", "item", "edit", item, "--generate-password=letters,digits,symbols,32") + if err != nil { + return err + } + return nil +} + +// ReadItemField reads the password of an item in 1Password +func ReadItemField(item, vault string, field Field) (string, error) { + output, err := system.Run("op", "read", fmt.Sprintf("op://%s/%s/%s", vault, item, field)) + if err != nil { + return "", err + } + return output, nil +} diff --git a/pkg/testhelper/system/system.go b/pkg/testhelper/system/system.go new file mode 100644 index 0000000..defe461 --- /dev/null +++ b/pkg/testhelper/system/system.go @@ -0,0 +1,94 @@ +package system + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Run executes the provided command within this context +func Run(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + + rootDir, err := GetProjectRoot() + if err != nil { + return "", err + } + + // Command will run from project root + cmd.Dir = rootDir + + command := strings.Join(cmd.Args, " ") + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) + } + + return string(output), nil +} + +func GetProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + // check if go.mod exists in current dir + modFile := filepath.Join(dir, "go.mod") + if _, err := os.Stat(modFile); err == nil { + return dir, nil + } + + // move one level up + parent := filepath.Dir(dir) + if parent == dir { + // reached filesystem root + return "", fmt.Errorf("project root not found (no go.mod)") + } + dir = parent + } +} + +func ReplaceFile(src, dst string) error { + rootDir, err := GetProjectRoot() + if err != nil { + return err + } + + // Open the source file + sourceFile, err := os.Open(filepath.Join(rootDir, src)) + if err != nil { + return err + } + defer func(sourceFile *os.File) { + cerr := sourceFile.Close() + if err != nil { + err = errors.Join(err, cerr) + } + }(sourceFile) + + // Create (or overwrite) the destination file + destFile, err := os.Create(filepath.Join(rootDir, dst)) + if err != nil { + return err + } + defer func(destFile *os.File) { + cerr := destFile.Close() + if err != nil { + err = errors.Join(err, cerr) + } + }(destFile) + + // Copy contents + if _, err = io.Copy(destFile, sourceFile); err != nil { + return err + } + + // Ensure data is written to disk + return destFile.Sync() +} diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go new file mode 100644 index 0000000..c45bc90 --- /dev/null +++ b/test/e2e/e2e_suite_test.go @@ -0,0 +1,14 @@ +package e2e + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Run e2e tests using the Ginkgo runner. +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "onepassword-operator e2e suite") +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 0000000..4aa5664 --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,233 @@ +package e2e + +import ( + "context" + "path/filepath" + "strconv" + "time" + + //nolint:staticcheck // ST1001 + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001 + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + "github.com/1Password/onepassword-operator/pkg/testhelper/defaults" + "github.com/1Password/onepassword-operator/pkg/testhelper/kind" + "github.com/1Password/onepassword-operator/pkg/testhelper/kube" + "github.com/1Password/onepassword-operator/pkg/testhelper/op" + "github.com/1Password/onepassword-operator/pkg/testhelper/system" +) + +const ( + operatorImageName = "1password/onepassword-operator:latest" + vaultName = "operator-acceptance-tests" +) + +var kubeClient *kube.Kube + +var _ = Describe("Onepassword Operator e2e", Ordered, func() { + ctx := context.Background() + + BeforeAll(func() { + rootDir, err := system.GetProjectRoot() + Expect(err).NotTo(HaveOccurred()) + + kubeClient = kube.NewKubeClient(&kube.Config{ + Namespace: "default", + ManifestsDir: filepath.Join("manifests"), + TestConfig: &kube.TestConfig{ + Timeout: defaults.E2ETimeout, + Interval: defaults.E2EInterval, + }, + CRDs: []string{ + filepath.Join(rootDir, "config", "crd", "bases", "onepassword.com_onepassworditems.yaml"), + }, + }) + + By("Building the Operator image") + _, err = system.Run("make", "docker-build") + Expect(err).NotTo(HaveOccurred()) + + kind.LoadImageToKind(operatorImageName) + + kubeClient.Secret("op-credentials").CreateOpCredentials(ctx) + kubeClient.Secret("op-credentials").CheckIfExists(ctx) + + kubeClient.Secret("onepassword-token").CreateFromEnvVar(ctx, "OP_CONNECT_TOKEN") + kubeClient.Secret("onepassword-token").CheckIfExists(ctx) + + kubeClient.Secret("onepassword-service-account-token").CreateFromEnvVar(ctx, "OP_SERVICE_ACCOUNT_TOKEN") + kubeClient.Secret("onepassword-service-account-token").CheckIfExists(ctx) + + By("Replace manager.yaml") + err = system.ReplaceFile("test/e2e/manifests/manager.yaml", "config/manager/manager.yaml") + Expect(err).NotTo(HaveOccurred()) + + _, err = system.Run("make", "deploy") + Expect(err).NotTo(HaveOccurred()) + kubeClient.Pod(map[string]string{"name": "onepassword-connect-operator"}).WaitingForRunningPod(ctx) + }) + + Context("Use the operator with Service Account", func() { + runCommonTestCases(ctx) + }) + + Context("Use the operator with Connect", func() { + BeforeAll(func() { + kubeClient.Deployment("onepassword-connect-operator").PatchEnvVars(ctx, []corev1.EnvVar{ + {Name: "MANAGE_CONNECT", Value: "true"}, + {Name: "OP_CONNECT_HOST", Value: "http://onepassword-connect:8080"}, + { + Name: "OP_CONNECT_TOKEN", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "onepassword-token", + }, + Key: "token", + }, + }, + }, + }, []string{"OP_SERVICE_ACCOUNT_TOKEN"}) + + kubeClient.Secret("login").Delete(ctx) // remove secret crated in previous test + kubeClient.Secret("secret-ignored").Delete(ctx) // remove secret crated in previous test + kubeClient.Secret("secret-for-update").Delete(ctx) // remove secret crated in previous test + + kubeClient.Pod(map[string]string{"app": "onepassword-connect"}).WaitingForRunningPod(ctx) + }) + + runCommonTestCases(ctx) + }) +}) + +// runCommonTestCases contains test cases that are common to both Connect and Service Account authentication methods. +func runCommonTestCases(ctx context.Context) { + It("Should create kubernetes secret from manifest file", func() { + By("Creating secret `login` from 1Password item") + kubeClient.ApplyOnePasswordItem(ctx, "secret.yaml") + kubeClient.Secret("login").CheckIfExists(ctx) + }) + + It("Kubernetes secret is updated after POOLING_INTERVAL, when updating item in 1Password", func() { + itemName := "secret-for-update" + secretName := itemName + + By("Creating secret `" + secretName + "` from 1Password item") + kubeClient.ApplyOnePasswordItem(ctx, secretName+".yaml") + kubeClient.Secret(secretName).CheckIfExists(ctx) + + By("Reading old password") + secret := kubeClient.Secret(secretName).Get(ctx) + oldPassword, ok := secret.Data["password"] + Expect(ok).To(BeTrue()) + + By("Updating `" + secretName + "` 1Password item") + err := op.UpdateItemPassword(itemName) + Expect(err).NotTo(HaveOccurred()) + + // checking that password was updated + Eventually(func(g Gomega) { + // Derive a short-lived context so this API call won't hang indefinitely. + attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + secret = kubeClient.Secret(secretName).Get(attemptCtx) + g.Expect(err).NotTo(HaveOccurred()) + + newPassword, ok := secret.Data["password"] + g.Expect(ok).To(BeTrue()) + g.Expect(newPassword).NotTo(Equal(oldPassword)) + }, defaults.E2ETimeout, defaults.E2EInterval).Should(Succeed()) + }) + + It("1Password item with `ignore-secret` tag doesn't pull updates to kubernetes secret", func() { + itemName := "secret-ignored" + secretName := itemName + + By("Creating secret `" + secretName + "` from 1Password item") + kubeClient.ApplyOnePasswordItem(ctx, secretName+".yaml") + kubeClient.Secret(secretName).CheckIfExists(ctx) + + By("Reading old password") + secret := kubeClient.Secret(secretName).Get(ctx) + oldPassword, ok := secret.Data["password"] + Expect(ok).To(BeTrue()) + + By("Updating `" + secretName + "` 1Password item") + err := op.UpdateItemPassword(itemName) + Expect(err).NotTo(HaveOccurred()) + + newPassword, err := op.ReadItemField(itemName, vaultName, op.FieldPassword) + Expect(err).NotTo(HaveOccurred()) + Expect(newPassword).NotTo(BeEquivalentTo(oldPassword)) + + // checking that password was NOT updated + Eventually(func(g Gomega) { + // Derive a short-lived context so this API call won't hang indefinitely. + attemptCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + intervalStr := kubeClient.Deployment("onepassword-connect-operator").ReadEnvVar(attemptCtx, "POLLING_INTERVAL") + Expect(intervalStr).NotTo(BeEmpty()) + + i, err := strconv.Atoi(intervalStr) + Expect(err).NotTo(HaveOccurred()) + + // convert to duration in seconds + interval := time.Duration(i) * time.Second + // wait for one polling interval + 2 seconds to make sure updated secret is pulled + time.Sleep(interval + 2*time.Second) + + secret = kubeClient.Secret(secretName).Get(attemptCtx) + g.Expect(err).NotTo(HaveOccurred()) + + currentPassword, ok := secret.Data["password"] + Expect(ok).To(BeTrue()) + Expect(currentPassword).To(BeEquivalentTo(oldPassword)) + Expect(currentPassword).NotTo(BeEquivalentTo(newPassword)) + }, defaults.E2ETimeout, defaults.E2EInterval).Should(Succeed()) + }) + + It("AUTO_RESTART restarts deployments using 1Password secrets after item update", func() { + By("Enabling AUTO_RESTART") + kubeClient.Deployment("onepassword-connect-operator").PatchEnvVars(ctx, []corev1.EnvVar{ + {Name: "AUTO_RESTART", Value: "true"}, + }, nil) + + DeferCleanup(func() { + By("Disabling AUTO_RESTART") + // disable AUTO_RESTART after test + kubeClient.Deployment("onepassword-connect-operator").PatchEnvVars(ctx, []corev1.EnvVar{ + {Name: "AUTO_RESTART", Value: "false"}, + }, nil) + }) + + // Ensure the secret exists (created in earlier test), but apply again safely just in case + kubeClient.ApplyOnePasswordItem(ctx, "secret-for-update.yaml") + kubeClient.Secret("secret-for-update").CheckIfExists(ctx) + + // add custom secret to the operator + kubeClient.Deployment("onepassword-connect-operator").PatchEnvVars(ctx, []corev1.EnvVar{ + { + Name: "CUSTOM_SECRET", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "secret-for-update", + }, + Key: "password", + }, + }, + }, + }, nil) + + By("Updating `secret-for-update` 1Password item") + err := op.UpdateItemPassword("secret-for-update") + Expect(err).NotTo(HaveOccurred()) + + By("Checking the operator is restarted") + kubeClient.Deployment("onepassword-connect-operator").WaitDeploymentRolledOut(ctx) + }) +} diff --git a/test/e2e/manifests/manager.yaml b/test/e2e/manifests/manager.yaml new file mode 100644 index 0000000..d76b0d7 --- /dev/null +++ b/test/e2e/manifests/manager.yaml @@ -0,0 +1,99 @@ +# This manager file is used for e2e tests. +# It will be copied to `config/manager` and be used when deploying the operator in e2e tests +# The purpose of it is to increase e2e tests speed and do not introduce additional changes in original `manager.yaml` + +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: onepassword-connect-operator + app.kubernetes.io/name: namespace + app.kubernetes.io/instance: system + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: onepassword-connect-operator + app.kubernetes.io/part-of: onepassword-connect-operator + app.kubernetes.io/managed-by: kustomize + name: system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: onepassword-connect-operator + namespace: system + labels: + control-plane: controller-manager + app.kubernetes.io/name: deployment + app.kubernetes.io/instance: controller-manager + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: onepassword-connect-operator + app.kubernetes.io/part-of: onepassword-connect-operator + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + name: onepassword-connect-operator + control-plane: onepassword-connect-operator + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + name: onepassword-connect-operator + control-plane: onepassword-connect-operator + spec: + securityContext: + runAsNonRoot: true + containers: + - command: + - /manager + args: + - --leader-elect + - --health-probe-bind-address=:8081 + image: 1password/onepassword-operator:latest + imagePullPolicy: Never + name: manager + env: + - name: OPERATOR_NAME + value: "onepassword-connect-operator" + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: WATCH_NAMESPACE + value: "default" + - name: POLLING_INTERVAL + value: "10" + - name: AUTO_RESTART + value: "false" + - name: OP_SERVICE_ACCOUNT_TOKEN + valueFrom: + secretKeyRef: + name: onepassword-service-account-token + key: token + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + serviceAccountName: onepassword-connect-operator + terminationGracePeriodSeconds: 10 diff --git a/test/e2e/manifests/secret-for-update.yaml b/test/e2e/manifests/secret-for-update.yaml new file mode 100644 index 0000000..75f66d3 --- /dev/null +++ b/test/e2e/manifests/secret-for-update.yaml @@ -0,0 +1,6 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: secret-for-update +spec: + itemPath: "vaults/operator-acceptance-tests/items/secret-for-update" diff --git a/test/e2e/manifests/secret-ignored.yaml b/test/e2e/manifests/secret-ignored.yaml new file mode 100644 index 0000000..847d18f --- /dev/null +++ b/test/e2e/manifests/secret-ignored.yaml @@ -0,0 +1,6 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: secret-ignored +spec: + itemPath: "vaults/operator-acceptance-tests/items/secret-ignored" diff --git a/test/e2e/manifests/secret.yaml b/test/e2e/manifests/secret.yaml new file mode 100644 index 0000000..8e9baa8 --- /dev/null +++ b/test/e2e/manifests/secret.yaml @@ -0,0 +1,6 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: login +spec: + itemPath: "vaults/operator-acceptance-tests/items/test-login"