mirror of
https://github.com/1Password/onepassword-operator.git
synced 2025-10-22 07:28: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
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
**/1password-credentials.json
|
||||||
|
**/op-session
|
||||||
|
@@ -4,7 +4,17 @@ Thank you for your interest in contributing to the 1Password Kubernetes Operator
|
|||||||
|
|
||||||
## Testing
|
## 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
|
```sh
|
||||||
# Go to the K8s environment (e.g. minikube)
|
# 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`
|
1. Rebuild the Docker image by running `make docker-build`
|
||||||
2. Restart deployment `make restart`
|
2. Restart deployment `make restart`
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
- For testing the changes made to the `OnePasswordItem` Custom Resource Definition (CRD), you need to re-generate the object:
|
- For testing the changes made to the `OnePasswordItem` Custom Resource Definition (CRD), you need to re-generate the object:
|
||||||
```sh
|
```sh
|
||||||
make manifests
|
make manifests
|
||||||
|
2
Makefile
2
Makefile
@@ -117,7 +117,7 @@ vet: ## Run go vet against code.
|
|||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: manifests generate fmt vet setup-envtest ## Run tests.
|
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'.
|
# 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.
|
# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally.
|
||||||
|
@@ -14,6 +14,8 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
|
fsGroup: 999
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
volumes:
|
volumes:
|
||||||
- name: shared-data
|
- name: shared-data
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
@@ -31,10 +33,20 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: /home/opuser/.op/data
|
- mountPath: /home/opuser/.op/data
|
||||||
name: shared-data
|
name: shared-data
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
runAsNonRoot: false
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: [ "ALL" ]
|
||||||
|
add: ["CHOWN", "FOWNER"]
|
||||||
containers:
|
containers:
|
||||||
- name: connect-api
|
- name: connect-api
|
||||||
image: 1password/connect-api:latest
|
image: 1password/connect-api:latest
|
||||||
securityContext:
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 999
|
||||||
|
runAsGroup: 999
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -55,6 +67,9 @@ spec:
|
|||||||
- name: connect-sync
|
- name: connect-sync
|
||||||
image: 1password/connect-sync:latest
|
image: 1password/connect-sync:latest
|
||||||
securityContext:
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 999
|
||||||
|
runAsGroup: 999
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
resources:
|
resources:
|
||||||
limits:
|
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/onsi/gomega v1.36.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
k8s.io/api v0.33.0
|
k8s.io/api v0.33.0
|
||||||
|
k8s.io/apiextensions-apiserver v0.33.0
|
||||||
k8s.io/apimachinery v0.33.0
|
k8s.io/apimachinery v0.33.0
|
||||||
k8s.io/client-go v0.33.0
|
k8s.io/client-go v0.33.0
|
||||||
k8s.io/kubectl v0.29.0
|
k8s.io/kubectl v0.29.0
|
||||||
@@ -102,7 +103,6 @@ require (
|
|||||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.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/apiserver v0.33.0 // indirect
|
||||||
k8s.io/component-base v0.33.0 // indirect
|
k8s.io/component-base v0.33.0 // indirect
|
||||||
k8s.io/klog/v2 v2.130.1 // 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