mirror of
https://github.com/1Password/onepassword-operator.git
synced 2025-10-21 15:08:06 +00:00
Compare commits
68 Commits
f6b267726d
...
0f56cab693
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0f56cab693 | ||
![]() |
a1ab24f244 | ||
![]() |
13e4b16846 | ||
![]() |
94602ddd72 | ||
![]() |
292c6f0e93 | ||
![]() |
0f1293ca95 | ||
![]() |
706ebdd8b8 | ||
![]() |
bd963bcd1d | ||
![]() |
bf6cac81cb | ||
![]() |
9c4849ec2e | ||
![]() |
c2788770fd | ||
![]() |
6baef1b9cf | ||
![]() |
7e08158d2f | ||
![]() |
976909c438 | ||
![]() |
e61ba49018 | ||
![]() |
6492b3cf34 | ||
![]() |
9d08bcc864 | ||
![]() |
f7f5462133 | ||
![]() |
128954cd80 | ||
![]() |
a1cbd40f9e | ||
![]() |
d75a33d524 | ||
![]() |
b1b6c97a88 | ||
![]() |
0c3caf88b6 | ||
![]() |
24edff22d4 | ||
![]() |
8c893270f4 | ||
![]() |
d5f1044571 | ||
![]() |
b40f27b052 | ||
![]() |
cd03a651ad | ||
![]() |
9aac824066 | ||
![]() |
05ad484bd6 | ||
![]() |
71b29d5fe6 | ||
![]() |
c082f9562e | ||
![]() |
57478247cf | ||
![]() |
4836140f66 | ||
![]() |
2b36f16940 | ||
![]() |
bb97134e10 | ||
![]() |
904d269e7b | ||
![]() |
cf9b267eaf | ||
![]() |
4d64beab86 | ||
![]() |
ca051a08cf | ||
![]() |
22a7c8f586 | ||
![]() |
2003d13788 | ||
![]() |
7187f41ef1 | ||
![]() |
d0b11c70f0 | ||
![]() |
9825cb57c9 | ||
![]() |
6bb6088353 | ||
![]() |
5a56fd3330 | ||
![]() |
dcd7eefac0 | ||
![]() |
29b7ed7899 | ||
![]() |
331e8d7bfb | ||
![]() |
c144bd3d01 | ||
![]() |
299689fe13 | ||
![]() |
882d8e951d | ||
![]() |
7885ba649b | ||
![]() |
600adf2670 | ||
![]() |
88b2dfbf67 | ||
![]() |
e167db2357 | ||
![]() |
91a9bb6d63 | ||
![]() |
116c8c92a7 | ||
![]() |
4307e9d713 | ||
![]() |
1759055edd | ||
![]() |
c1e9934088 | ||
![]() |
19b629f2ee | ||
![]() |
174f952691 | ||
![]() |
f8704223c8 | ||
![]() |
5630d788a2 | ||
![]() |
d504e5ef35 | ||
![]() |
7d2596a4aa |
17
.github/pull_request_template.md
vendored
Normal file
17
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
### ✨ Summary
|
||||
<!-- What does this change do? -->
|
||||
|
||||
<!-- What issue does it resolve? -->
|
||||
### 🔗 Resolves:
|
||||
|
||||
### ✅ Checklist
|
||||
- [ ] 🖊️ Commits are signed
|
||||
- [ ] 🧪 Tests added/updated: _(See the [Testing Guide](docs/testing.md) for when to use each type and how to run them)_
|
||||
- [ ] 🔹 Unit
|
||||
- [ ] 🔸 Integration
|
||||
- [ ] 🌐 E2E (Connect)
|
||||
- [ ] 🔑 E2E (Service Account)
|
||||
- [ ] 📚 Docs updated (if behavior changed)
|
||||
|
||||
### 🕵️ Review Notes & ⚠️ Risks
|
||||
<!-- Notes for reviewers, flags, feature gates, rollout considerations, etc. -->
|
48
.github/workflows/test-e2e.yml
vendored
Normal file
48
.github/workflows/test-e2e.yml
vendored
Normal file
@@ -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 }}
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ go.work
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
**/1password-credentials.json
|
||||
**/op-session
|
||||
|
@@ -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
|
||||
|
2
Makefile
2
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.
|
||||
|
@@ -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:
|
||||
|
22
docs/testing.md
Normal file
22
docs/testing.md
Normal file
@@ -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=<token>`
|
||||
3. `export OP_SERVICE_ACCOUNT_TOKEN=<token>`
|
||||
4. `make test-e2e`
|
||||
5. Put `1password-credentials.json` into project root.
|
||||
|
||||
**Run**: `make test-e2e`
|
2
go.mod
2
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
|
||||
|
8
pkg/testhelper/defaults/defaults.go
Normal file
8
pkg/testhelper/defaults/defaults.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package defaults
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
E2EInterval = 1 * time.Second
|
||||
E2ETimeout = 1 * time.Minute
|
||||
)
|
23
pkg/testhelper/kind/kind.go
Normal file
23
pkg/testhelper/kind/kind.go
Normal file
@@ -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())
|
||||
}
|
125
pkg/testhelper/kube/deployment.go
Normal file
125
pkg/testhelper/kube/deployment.go
Normal file
@@ -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())
|
||||
}
|
224
pkg/testhelper/kube/kube.go
Normal file
224
pkg/testhelper/kube/kube.go
Normal file
@@ -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())
|
||||
}
|
41
pkg/testhelper/kube/namespace.go
Normal file
41
pkg/testhelper/kube/namespace.go
Normal file
@@ -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())
|
||||
}
|
47
pkg/testhelper/kube/pod.go
Normal file
47
pkg/testhelper/kube/pod.go
Normal file
@@ -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())
|
||||
}
|
141
pkg/testhelper/kube/secret.go
Normal file
141
pkg/testhelper/kube/secret.go
Normal file
@@ -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())
|
||||
}
|
32
pkg/testhelper/op/op.go
Normal file
32
pkg/testhelper/op/op.go
Normal file
@@ -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
|
||||
}
|
94
pkg/testhelper/system/system.go
Normal file
94
pkg/testhelper/system/system.go
Normal file
@@ -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()
|
||||
}
|
14
test/e2e/e2e_suite_test.go
Normal file
14
test/e2e/e2e_suite_test.go
Normal file
@@ -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")
|
||||
}
|
233
test/e2e/e2e_test.go
Normal file
233
test/e2e/e2e_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
99
test/e2e/manifests/manager.yaml
Normal file
99
test/e2e/manifests/manager.yaml
Normal file
@@ -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
|
6
test/e2e/manifests/secret-for-update.yaml
Normal file
6
test/e2e/manifests/secret-for-update.yaml
Normal file
@@ -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"
|
6
test/e2e/manifests/secret-ignored.yaml
Normal file
6
test/e2e/manifests/secret-ignored.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: secret-ignored
|
||||
spec:
|
||||
itemPath: "vaults/operator-acceptance-tests/items/secret-ignored"
|
6
test/e2e/manifests/secret.yaml
Normal file
6
test/e2e/manifests/secret.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: login
|
||||
spec:
|
||||
itemPath: "vaults/operator-acceptance-tests/items/test-login"
|
Reference in New Issue
Block a user