From 2008de644e39126dbc8303db6e8b112061f201b6 Mon Sep 17 00:00:00 2001 From: Oleksandr Chumakov Date: Mon, 6 Jul 2026 17:14:36 +0100 Subject: [PATCH] feat: add LABEL_SELECTOR to filter which OnePasswordItems the operator reconciles --- CHANGELOG.md | 2 +- USAGEGUIDE.md | 40 ++++++++++++- cmd/main.go | 26 +++++++++ config/manager/manager.yaml | 5 ++ internal/controller/config.go | 6 ++ .../controller/onepassworditem_controller.go | 13 ++++- .../onepassworditem_predicate_test.go | 58 +++++++++++++++++++ test/e2e/manifests/manager.yaml | 5 ++ 8 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 internal/controller/onepassworditem_predicate_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab16951..4641d203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ # Latest ## Features - * A user-friendly description of a new feature. {issue-number} + * Operator supports the `LABEL_SELECTOR` environment variable to restrict which `OnePasswordItem` resources it reconciles by label, so multiple operators can safely watch the same namespaces. {#276} ## Fixes * A user-friendly description of a fix. {issue-number} diff --git a/USAGEGUIDE.md b/USAGEGUIDE.md index 7cc205b9..32506c4a 100644 --- a/USAGEGUIDE.md +++ b/USAGEGUIDE.md @@ -15,9 +15,10 @@ - [Deploy manually](#2-deploy-manually) 4. [Logging level](#logging-level) 5. [Usage examples](#usage-examples) -6. [How 1Password Items Map to Kubernetes Secrets](#how-1password-items-map-to-kubernetes-secrets) -7. [Configuring Automatic Rolling Restarts of Deployments](#configuring-automatic-rolling-restarts-of-deployments) -8. [Development](#development) +6. [Filtering which OnePasswordItems an operator reconciles](#filtering-which-onepassworditems-an-operator-reconciles) +7. [How 1Password Items Map to Kubernetes Secrets](#how-1password-items-map-to-kubernetes-secrets) +8. [Configuring Automatic Rolling Restarts of Deployments](#configuring-automatic-rolling-restarts-of-deployments) +9. [Development](#development) --- @@ -51,6 +52,7 @@ To further configure the 1Password Kubernetes Operator the following Environment - **WATCH_NAMESPACE:** *(default: watch all namespaces)*: Comma separated list of what Namespaces to watch for changes. - **POLLING_INTERVAL** *(default: 600)*: The number of seconds the 1Password Kubernetes Operator will wait before checking for updates from 1Password. - **AUTO_RESTART** (default: false): If set to true, the operator will restart any deployment using a secret from 1Password. This can be overwritten by namespace, deployment, or individual secret. More details on AUTO_RESTART can be found in the ["Configuring Automatic Rolling Restarts of Deployments"](#configuring-automatic-rolling-restarts-of-deployments) section. +- **LABEL_SELECTOR** *(default: empty — reconcile all items)*: A [Kubernetes label selector](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) (for example `env=prod` or `app in (a,b)`). When set, the operator only reconciles `OnePasswordItem` resources whose labels match the selector. Use distinct selectors when running multiple operators that watch the same namespaces so each manages only its own items. To deploy the operator, simply run the following command: @@ -78,6 +80,7 @@ To further configure the 1Password Kubernetes Operator the following Environment - **POLLING_INTERVAL** *(default: 600)*: The number of seconds the 1Password Kubernetes Operator will wait before checking for updates from 1Password Connect. - **MANAGE_CONNECT** *(default: false)*: If set to true, on deployment of the operator, a default configuration of the OnePassword Connect Service will be deployed to the current namespace. - **AUTO_RESTART** (default: false): If set to true, the operator will restart any deployment using a secret from 1Password Connect. This can be overwritten by namespace, deployment, or individual secret. More details on AUTO_RESTART can be found in the ["Configuring Automatic Rolling Restarts of Deployments"](#configuring-automatic-rolling-restarts-of-deployments) section. +- **LABEL_SELECTOR** *(default: empty — reconcile all items)*: A [Kubernetes label selector](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) (for example `env=prod` or `app in (a,b)`). When set, the operator only reconciles `OnePasswordItem` resources whose labels match the selector. Use distinct selectors when running multiple operators that watch the same namespaces so each manages only its own items. --- @@ -104,6 +107,37 @@ Find usage [examples](https://developer.1password.com/docs/k8s/operator/?deploym --- +## Filtering which OnePasswordItems an operator reconciles + +By default the operator reconciles every `OnePasswordItem` in the namespaces it watches. Set the `LABEL_SELECTOR` environment variable to a [Kubernetes label selector](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) to restrict it to items whose labels match. + +This is useful when multiple operators watch the same namespaces but each 1Password Connect token / service account only has access to its own vaults: give each operator a distinct selector so every `OnePasswordItem` is reconciled by exactly one operator, avoiding cross-operator "token does not have access to vault" errors. + +Configure the operator (Deployment `env`): + +```yaml + env: + - name: LABEL_SELECTOR + value: "operator-owner=team-a" +``` + +Label the items that operator should manage: + +```yaml +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: example-secret + labels: + operator-owner: team-a +spec: + itemPath: "vaults//items/" +``` + +With the selector above, this operator reconciles `example-secret` and ignores any `OnePasswordItem` that does not carry the `operator-owner=team-a` label. Set-based selectors are also supported, for example `operator-owner in (team-a,team-b)`. Leaving `LABEL_SELECTOR` empty (the default) reconciles all items. + +--- + ## How 1Password Items Map to Kubernetes Secrets The contents of the Kubernetes secret will be key-value pairs in which the keys are the fields of the 1Password item and the values are the corresponding values stored in 1Password. diff --git a/cmd/main.go b/cmd/main.go index bfdb7c14..f59cf359 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -42,6 +42,7 @@ import ( // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -73,6 +74,7 @@ const ( envPollingIntervalVariable = "POLLING_INTERVAL" manageConnect = "MANAGE_CONNECT" restartWorkloadsEnvVariable = "AUTO_RESTART" + labelSelectorEnvVariable = "LABEL_SELECTOR" defaultPollingInterval = 600 annotationRegExpString = "^operator\\.1password\\.io\\/[a-zA-Z\\.]+" @@ -160,6 +162,12 @@ func main() { "the manager will watch and manage resources in all namespaces") } + labelSelector, err := getLabelSelector() + if err != nil { + setupLog.Error(err, "unable to parse "+labelSelectorEnvVariable) + os.Exit(1) + } + deploymentNamespace, err := utils.GetOperatorNamespace() if err != nil { setupLog.Error(err, "Failed to get namespace") @@ -305,6 +313,7 @@ func main() { Config: controller.ReconcilerConfig{ EnableAnnotations: enableAnnotations, AllowEmptyValues: allowEmptyValues, + LabelSelector: labelSelector, }, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "OnePasswordItem") @@ -414,6 +423,23 @@ func getWatchNamespace() (string, error) { return ns, nil } +// getLabelSelector returns the label selector the operator should use to filter +// which OnePasswordItem resources it reconciles, parsed from the LABEL_SELECTOR +// environment variable. An unset or empty value returns a nil selector, meaning +// all OnePasswordItem resources are reconciled (the default). +func getLabelSelector() (*metav1.LabelSelector, error) { + value, found := os.LookupEnv(labelSelectorEnvVariable) + if !found || strings.TrimSpace(value) == "" { + return nil, nil + } + + selector, err := metav1.ParseToLabelSelector(value) + if err != nil { + return nil, fmt.Errorf("invalid %s %q: %w", labelSelectorEnvVariable, value, err) + } + return selector, nil +} + func shouldManageConnect() bool { shouldManageConnect, found := os.LookupEnv(manageConnect) if found { diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index e9602da8..0fd5f7ca 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -88,6 +88,11 @@ spec: value: "10" - name: AUTO_RESTART value: "false" + # LABEL_SELECTOR (optional): when set, the operator only reconciles + # OnePasswordItem resources whose labels match this selector, for + # example "operator-owner=team-a". Empty means reconcile all items. + - name: LABEL_SELECTOR + value: "" - name: OP_CONNECT_HOST value: "http://onepassword-connect:8080" - name: OP_CONNECT_TOKEN diff --git a/internal/controller/config.go b/internal/controller/config.go index bfca3036..a5ff4aeb 100644 --- a/internal/controller/config.go +++ b/internal/controller/config.go @@ -1,6 +1,12 @@ package controller +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + type ReconcilerConfig struct { EnableAnnotations bool AllowEmptyValues bool + // LabelSelector, when non-nil, restricts reconciliation to OnePasswordItem + // resources whose labels match the selector. A nil selector reconciles all + // items (the default). + LabelSelector *metav1.LabelSelector } diff --git a/internal/controller/onepassworditem_controller.go b/internal/controller/onepassworditem_controller.go index 5c8de864..c8e5c154 100644 --- a/internal/controller/onepassworditem_controller.go +++ b/internal/controller/onepassworditem_controller.go @@ -42,9 +42,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" ) var logOnePasswordItem = logf.Log.WithName("controller_onepassworditem") @@ -135,8 +137,17 @@ func (r *OnePasswordItemReconciler) Reconcile(ctx context.Context, req ctrl.Requ // SetupWithManager sets up the controller with the Manager. func (r *OnePasswordItemReconciler) SetupWithManager(mgr ctrl.Manager) error { + var forOpts []builder.ForOption + if r.Config.LabelSelector != nil { + pred, err := predicate.LabelSelectorPredicate(*r.Config.LabelSelector) + if err != nil { + return fmt.Errorf("building label selector predicate: %w", err) + } + forOpts = append(forOpts, builder.WithPredicates(pred)) + } + return ctrl.NewControllerManagedBy(mgr). - For(&onepasswordv1.OnePasswordItem{}). + For(&onepasswordv1.OnePasswordItem{}, forOpts...). Named("onepassworditem"). Complete(r) } diff --git a/internal/controller/onepassworditem_predicate_test.go b/internal/controller/onepassworditem_predicate_test.go new file mode 100644 index 00000000..ef2b5d7c --- /dev/null +++ b/internal/controller/onepassworditem_predicate_test.go @@ -0,0 +1,58 @@ +package controller + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + onepasswordv1 "github.com/1Password/onepassword-operator/api/v1" +) + +var _ = Describe("OnePasswordItem label selector predicate", func() { + selector := metav1.LabelSelector{ + MatchLabels: map[string]string{"operator.1password.io/owner": "vouchercodes"}, + } + + newItem := func(labels map[string]string) *onepasswordv1.OnePasswordItem { + return &onepasswordv1.OnePasswordItem{ + ObjectMeta: metav1.ObjectMeta{ + Name: "item", + Namespace: "default", + Labels: labels, + }, + } + } + + It("reconciles items whose labels match the selector", func() { + pred, err := predicate.LabelSelectorPredicate(selector) + Expect(err).ToNot(HaveOccurred()) + + item := newItem(map[string]string{"operator.1password.io/owner": "vouchercodes"}) + Expect(pred.Create(event.CreateEvent{Object: item})).To(BeTrue()) + Expect(pred.Update(event.UpdateEvent{ObjectOld: item, ObjectNew: item})).To(BeTrue()) + Expect(pred.Delete(event.DeleteEvent{Object: item})).To(BeTrue()) + Expect(pred.Generic(event.GenericEvent{Object: item})).To(BeTrue()) + }) + + It("ignores items whose labels do not match the selector", func() { + pred, err := predicate.LabelSelectorPredicate(selector) + Expect(err).ToNot(HaveOccurred()) + + other := newItem(map[string]string{"operator.1password.io/owner": "vc-services"}) + Expect(pred.Create(event.CreateEvent{Object: other})).To(BeFalse()) + Expect(pred.Update(event.UpdateEvent{ObjectOld: other, ObjectNew: other})).To(BeFalse()) + Expect(pred.Delete(event.DeleteEvent{Object: other})).To(BeFalse()) + Expect(pred.Generic(event.GenericEvent{Object: other})).To(BeFalse()) + + unlabeled := newItem(nil) + Expect(pred.Create(event.CreateEvent{Object: unlabeled})).To(BeFalse()) + }) + + It("returns an error for an invalid selector", func() { + _, err := metav1.ParseToLabelSelector("=bad=selector=") + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/test/e2e/manifests/manager.yaml b/test/e2e/manifests/manager.yaml index d76b0d7b..43933eb4 100644 --- a/test/e2e/manifests/manager.yaml +++ b/test/e2e/manifests/manager.yaml @@ -66,6 +66,11 @@ spec: value: "10" - name: AUTO_RESTART value: "false" + # LABEL_SELECTOR (optional): when set, the operator only reconciles + # OnePasswordItem resources whose labels match this selector, for + # example "operator-owner=team-a". Empty means reconcile all items. + - name: LABEL_SELECTOR + value: "" - name: OP_SERVICE_ACCOUNT_TOKEN valueFrom: secretKeyRef: