From 97cc67314ed52e3de198352b4443de41415f43fe Mon Sep 17 00:00:00 2001 From: Timmy Mathew Date: Tue, 23 Jun 2026 01:53:05 +0200 Subject: [PATCH] feat: Multi Namespace support by camel-k operator, Namespace watcher and fixed cluster wide RBAC issue --- docs/modules/ROOT/nav.adoc | 1 + .../pages/installation/advanced/advanced.adoc | 8 +- .../advanced/multi-namespace.adoc | 117 +++++++ .../templates/operator-deployment.yaml | 14 +- helm/camel-k/templates/rbacs-common.yaml | 6 +- .../templates/rbacs-namespace-watcher.yaml | 57 ++++ helm/camel-k/values.yaml | 17 + pkg/cmd/operator/namespace_watcher.go | 306 ++++++++++++++++++ pkg/cmd/operator/namespace_watcher_test.go | 176 ++++++++++ pkg/cmd/operator/operator.go | 81 ++++- pkg/platform/operator.go | 59 +++- pkg/platform/operator_test.go | 100 ++++++ .../rbac/namespace-watcher/kustomization.yaml | 5 + ...luster-role-binding-namespace-watcher.yaml | 30 ++ ...erator-cluster-role-namespace-watcher.yaml | 36 +++ script/gen_helm.sh | 9 + script/install_namespace_rbac.sh | 117 +++++++ 17 files changed, 1111 insertions(+), 28 deletions(-) create mode 100644 docs/modules/ROOT/pages/installation/advanced/multi-namespace.adoc create mode 100644 helm/camel-k/templates/rbacs-namespace-watcher.yaml create mode 100644 pkg/cmd/operator/namespace_watcher.go create mode 100644 pkg/cmd/operator/namespace_watcher_test.go create mode 100644 pkg/platform/operator_test.go create mode 100644 pkg/resources/config/rbac/namespace-watcher/kustomization.yaml create mode 100644 pkg/resources/config/rbac/namespace-watcher/operator-cluster-role-binding-namespace-watcher.yaml create mode 100644 pkg/resources/config/rbac/namespace-watcher/operator-cluster-role-namespace-watcher.yaml create mode 100755 script/install_namespace_rbac.sh diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index cbd7b145df..9dc06d6627 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -14,6 +14,7 @@ *** xref:installation/advanced/jdk-version.adoc[JDK version] *** xref:installation/advanced/platform-architecture.adoc[Platform architecture] *** xref:installation/advanced/resources.adoc[Resource management] +*** xref:installation/advanced/multi-namespace.adoc[Watching multiple namespaces] *** xref:installation/advanced/multi.adoc[Multiple Operators] *** xref:installation/advanced/http-proxy.adoc[HTTP Proxy] *** xref:installation/advanced/offline.adoc[Offline] diff --git a/docs/modules/ROOT/pages/installation/advanced/advanced.adoc b/docs/modules/ROOT/pages/installation/advanced/advanced.adoc index 76d53f2344..7af81ffb6b 100644 --- a/docs/modules/ROOT/pages/installation/advanced/advanced.adoc +++ b/docs/modules/ROOT/pages/installation/advanced/advanced.adoc @@ -15,6 +15,8 @@ You can install one or more Camel K operators (which must share CRDs). In such c Camel K operator can work in **global** (descoped) mode by watching all namespaces or **namespaced** (scoped). This behavior is controlled by the WATCH_NAMESPACE environment variable. If set to an empty value, then the operator will watch for resources in all namespaces. If it set to a given namespace, then it will only watch for resources in such a namespace. +`WATCH_NAMESPACE` also accepts a **comma-separated list** of namespaces (e.g. `team-a,team-b`), in which case the operator watches exactly those namespaces (plus its own). For a multi-tenant cluster where the set of namespaces changes over time, the operator can instead **discover namespaces dynamically by label** through the `WATCH_NAMESPACE_SELECTOR` environment variable. Both modes keep the operator's RBAC scoped to the watched namespaces instead of granting cluster-wide access. See xref:installation/advanced/multi-namespace.adoc[Watching multiple namespaces] for the full setup. + == Resource management We provide certain configuration to better "operationalize" the Camel K Operator. More detailed information on the xref:installation/advanced/resources.adoc[resource management] page. @@ -43,7 +45,11 @@ The following environment variables can be configured on the operator Deployment |`WATCH_NAMESPACE` |_empty_ -|The namespace(s) the operator watches for Integration resources. When not set or empty, the operator watches all namespaces. +|The namespace(s) the operator watches for Integration resources. When not set or empty, the operator watches all namespaces (global mode). A single namespace or a comma-separated list (e.g. `team-a,team-b`) scopes the operator to those namespaces (plus its own). See xref:installation/advanced/multi-namespace.adoc[Watching multiple namespaces]. + +|`WATCH_NAMESPACE_SELECTOR` +|_empty_ +|A Kubernetes label selector (e.g. `camel-k-enabled=true`). When set, the operator dynamically discovers and watches every namespace whose labels match, in addition to those listed in `WATCH_NAMESPACE`, and restarts itself when that set changes. Requires read-only access to namespace metadata. See xref:installation/advanced/multi-namespace.adoc[Watching multiple namespaces]. |`NAMESPACE` |_auto-detected_ diff --git a/docs/modules/ROOT/pages/installation/advanced/multi-namespace.adoc b/docs/modules/ROOT/pages/installation/advanced/multi-namespace.adoc new file mode 100644 index 0000000000..41655a7b93 --- /dev/null +++ b/docs/modules/ROOT/pages/installation/advanced/multi-namespace.adoc @@ -0,0 +1,117 @@ +[[advanced-installation-multi-namespace]] += Watching multiple namespaces + +The Camel K operator traditionally runs in one of two modes: **global** (it watches every namespace +through cluster-wide RBAC) or **single-namespace** (it watches only its own namespace). On a large +multi-tenant cluster neither is ideal: global mode gives the operator cluster-wide access to every +namespace's secrets and workloads, while single-namespace mode cannot cover many teams. + +The operator can instead watch an explicit, opt-in **set** of namespaces — either listed statically +or discovered dynamically by a namespace label — while keeping its RBAC scoped to only those +namespaces. This page describes both modes. + +NOTE: Multi-namespace mode is a form of the namespaced (non-global) operator: keep `global` set to +`false`. It is fully backward compatible — leaving the new settings empty preserves the default +single-namespace behavior. + +[[multi-namespace-static]] +== Static list of namespaces + +Set `WATCH_NAMESPACE` to a comma-separated list of namespaces. The operator watches exactly those +namespaces, plus its own (where the `IntegrationPlatform` and leader-election lease live). + +With Helm: + +[source,shell] +---- +helm install camel-k ./helm/camel-k -n camel-k-system \ + --set operator.global=false \ + --set "operator.watchNamespaces={team-a,team-b}" +---- + +This mode needs **no** cluster-scoped RBAC. Install the namespaced operator RBAC into each watched +namespace (see <>). Adding or removing a namespace from the list requires a +`helm upgrade` (the operator restarts to pick up the new list). + +[[multi-namespace-dynamic]] +== Dynamic discovery by label + +Set `WATCH_NAMESPACE_SELECTOR` to a Kubernetes label selector. The operator discovers every +namespace whose labels match, watches them (in addition to anything listed in `WATCH_NAMESPACE`), +and **restarts itself automatically** whenever that set changes — for example when a namespace gains +or loses the label, or is deleted. + +[source,shell] +---- +helm install camel-k ./helm/camel-k -n camel-k-system \ + --set operator.global=false \ + --set operator.watchNamespaceSelector="camel-k-enabled=true" +---- + +To onboard a team namespace, **install the RBAC first, then label it** (the label is the signal that +the namespace is ready): + +[source,shell] +---- +# 1. Install the namespaced operator RBAC, bound to the operator's ServiceAccount. +./script/install_namespace_rbac.sh team-a camel-k-system + +# 2. Label the namespace so the operator starts watching it. +kubectl label namespace team-a camel-k-enabled=true +---- + +Removing the label (or deleting the namespace) makes the operator stop watching it. Integrations +already running in a namespace whose label was removed keep running; the operator simply stops +managing them until the label is restored. + +=== How dynamic discovery works (and its limits) + +controller-runtime does not allow adding or removing namespaces from a running cache. To get a +dynamic set, the operator watches `Namespace` objects matching the selector and, when the effective +set changes, **gracefully restarts** (it releases the leader lease, exits, and Kubernetes restarts +it), recomputing the watched set at startup. Reconciliation is level-based, so the brief restart +loses no work. + +* The restart is automatic; you do not need to do anything beyond labelling the namespace. +* A short re-evaluation runs periodically as a safety net, so installing the RBAC *after* labelling + still self-heals without re-labelling. +* If a namespace is labelled but lacks the operator RBAC, it is skipped with a warning instead of + blocking the operator. + +[[multi-namespace-security]] +== Security model + +Dynamic discovery requires the operator to read namespace metadata cluster-wide. This is the **only** +cluster-scoped permission it gets: a `ClusterRole` granting `get`, `list`, `watch` on `namespaces` +and nothing else. Namespace objects contain no secrets, so this does not expose any tenant data. + +All access to Integrations, Pods, Deployments, secrets, etc. remains **namespaced**: it is granted +only by the `Role`/`RoleBinding` you install in each watched namespace. A secret read in any +namespace where you have not installed that RBAC is denied. Removing a namespace's RBaC (or never +adding it) means the operator simply cannot act there. + +The static-list mode needs no cluster-scoped permission at all. + +[[multi-namespace-rbac]] +== Installing the per-namespace RBAC + +The operator needs the namespaced operator `Role` and a `RoleBinding` (bound to the operator's +ServiceAccount in the operator namespace) in every watched namespace. The helper script renders the +canonical namespaced RBAC and applies it to a target namespace: + +[source,shell] +---- +./script/install_namespace_rbac.sh [operator-namespace] [operator-sa] +# e.g. +./script/install_namespace_rbac.sh team-a camel-k-system camel-k-operator +---- + +For the dynamic-discovery cluster role (granting read-only access to namespace metadata), the Helm +chart renders it automatically when `operator.watchNamespaceSelector` is set. For a Kustomize-based +install, apply `pkg/resources/config/rbac/namespace-watcher` and bind it to your operator +ServiceAccount. + +NOTE: The operator never creates RBAC in other namespaces itself — doing so would require it to hold +cluster-wide rights to manage roles and bindings, which is exactly the cross-namespace escalation the +project threat model guards against. Installing the per-namespace RBAC is a deliberate, opt-in +administrative action. diff --git a/helm/camel-k/templates/operator-deployment.yaml b/helm/camel-k/templates/operator-deployment.yaml index cf74f6fee6..7b4c6f9a91 100644 --- a/helm/camel-k/templates/operator-deployment.yaml +++ b/helm/camel-k/templates/operator-deployment.yaml @@ -52,13 +52,21 @@ spec: - operator env: - name: WATCH_NAMESPACE - {{- if eq .Values.operator.global "false" }} + {{- if eq .Values.operator.global "true" }} + value: "" + {{- else if .Values.operator.watchNamespaces }} + value: {{ join "," .Values.operator.watchNamespaces | quote }} + {{- else if .Values.operator.watchNamespaceSelector }} + value: "" + {{- else }} valueFrom: fieldRef: fieldPath: metadata.namespace - {{- else }} - value: "" {{- end }} + {{- if and (eq .Values.operator.global "false") .Values.operator.watchNamespaceSelector }} + - name: WATCH_NAMESPACE_SELECTOR + value: {{ .Values.operator.watchNamespaceSelector | quote }} + {{- end }} - name: LOG_LEVEL value: {{ .Values.operator.logLevel }} - name: OPERATOR_NAME diff --git a/helm/camel-k/templates/rbacs-common.yaml b/helm/camel-k/templates/rbacs-common.yaml index 58f22d5b04..a3a1218926 100644 --- a/helm/camel-k/templates/rbacs-common.yaml +++ b/helm/camel-k/templates/rbacs-common.yaml @@ -120,7 +120,7 @@ kind: ClusterRole metadata: labels: app: camel-k - name: registry-reader + name: {{ include "camel-k.fullname" . }}-registry-reader rules: - apiGroups: - "" @@ -179,11 +179,11 @@ kind: ClusterRoleBinding metadata: labels: app: camel-k - name: registry-reader-binding + name: {{ include "camel-k.fullname" . }}-registry-reader-binding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: registry-reader + name: {{ include "camel-k.fullname" . }}-registry-reader subjects: - kind: ServiceAccount name: {{ include "camel-k.fullname" . }}-operator diff --git a/helm/camel-k/templates/rbacs-namespace-watcher.yaml b/helm/camel-k/templates/rbacs-namespace-watcher.yaml new file mode 100644 index 0000000000..7fd3e4e845 --- /dev/null +++ b/helm/camel-k/templates/rbacs-namespace-watcher.yaml @@ -0,0 +1,57 @@ +# --------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# --------------------------------------------------------------------------- + +# Rendered only for a non-global operator that uses dynamic namespace discovery +# (operator.watchNamespaceSelector). It grants the operator ServiceAccount read-only access to +# namespace metadata cluster-wide so it can discover which namespaces carry the watch label. This +# is the ONLY cluster-scoped permission the operator needs in this mode; it does NOT grant access +# to secrets, pods, or any workload in any namespace. Per-namespace access stays governed by the +# namespaced Role/RoleBinding installed in each watched namespace. +{{- if and (eq .Values.operator.global "false") .Values.operator.watchNamespaceSelector }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "camel-k.fullname" . }}-operator-namespace-watcher + labels: + app: camel-k + {{- include "camel-k.labels" . | nindent 4 }} +rules: +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "camel-k.fullname" . }}-operator-namespace-watcher + labels: + app: camel-k + {{- include "camel-k.labels" . | nindent 4 }} +subjects: +- kind: ServiceAccount + name: {{ include "camel-k.fullname" . }}-operator + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "camel-k.fullname" . }}-operator-namespace-watcher +{{- end }} diff --git a/helm/camel-k/values.yaml b/helm/camel-k/values.yaml index 5dd7dfed57..d80c083638 100644 --- a/helm/camel-k/values.yaml +++ b/helm/camel-k/values.yaml @@ -25,6 +25,23 @@ fullnameOverride: "" operator: image: docker.io/apache/camel-k:2.11.0-SNAPSHOT global: "false" + # watchNamespaces lets a non-global ("global: false") operator watch an explicit set of + # namespaces instead of just its own. List the namespaces here, e.g.: + # watchNamespaces: + # - team-a + # - team-b + # The operator always also watches its own (release) namespace. Each listed namespace must have + # the namespaced operator RBAC installed in it (see the multi-namespace installation docs). + # Leave empty for the default single-namespace behavior. Ignored when global is "true". + watchNamespaces: [] + # watchNamespaceSelector enables dynamic, label-based namespace discovery for a non-global + # operator. When set (e.g. "camel-k-enabled=true"), the operator watches every namespace whose + # labels match the selector and restarts itself whenever that set changes. This renders a + # minimal cluster-scoped ClusterRole granting read-only access to namespace metadata only + # (get/list/watch on namespaces) bound to the operator ServiceAccount — no cluster-wide access + # to secrets or workloads. Each matched namespace must have the namespaced operator RBAC + # installed in it. Leave empty to disable dynamic discovery. Ignored when global is "true". + watchNamespaceSelector: "" resources: {} securityContext: {} tolerations: [] diff --git a/pkg/cmd/operator/namespace_watcher.go b/pkg/cmd/operator/namespace_watcher.go new file mode 100644 index 0000000000..ee5e4dd4ec --- /dev/null +++ b/pkg/cmd/operator/namespace_watcher.go @@ -0,0 +1,306 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package operator + +import ( + "context" + "sort" + "time" + + authorizationv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + toolscache "k8s.io/client-go/tools/cache" + "sigs.k8s.io/controller-runtime/pkg/cache" + ctrl "sigs.k8s.io/controller-runtime/pkg/client" + + v1 "github.com/apache/camel-k/v2/pkg/apis/camel/v1" +) + +const ( + // namespaceWatcherDebounce is how long the watcher waits after a namespace event before + // re-evaluating, so that a burst of events (e.g. labelling many namespaces at once) results + // in a single operator restart instead of one per event. + namespaceWatcherDebounce = 5 * time.Second + // namespaceWatcherResyncInterval is the safety-net period at which the watcher recomputes the + // desired namespace set even without an event. It lets an out-of-order setup (namespace + // labelled before its RBAC was installed) self-heal without manual intervention. + namespaceWatcherResyncInterval = 2 * time.Minute +) + +// namespaceWatcher watches Namespace objects matching a label selector cluster-wide and requests +// a graceful operator restart whenever the set of namespaces the operator should (and can) watch +// changes. controller-runtime does not allow adding or removing namespaces from a running cache, +// so reconfiguration is achieved by restarting: the operator recomputes its watched set at +// startup. The watcher only ever reads Namespace objects (metadata), never workload resources. +type namespaceWatcher struct { + // config is the in-cluster REST config used to build the cluster-scoped Namespace cache. + config *rest.Config + // scheme must know corev1.Namespace. + scheme *runtime.Scheme + // reviewer issues SelfSubjectAccessReviews to pre-flight per-namespace access. + reviewer ctrl.Client + // selector selects which namespaces are candidates for watching. + selector labels.Selector + // operatorNamespace is always watched and never subject to dynamic removal. + operatorNamespace string + // staticNamespaces are namespaces explicitly configured via WATCH_NAMESPACE. + staticNamespaces []string + // current is the immutable set of namespaces the running manager was started with. When the + // freshly computed desired set differs from this, a restart is requested. + current map[string]bool + // requestRestart cancels the manager context to trigger a graceful restart. + requestRestart context.CancelFunc + + debounce time.Duration + resync time.Duration +} + +// Start implements manager.Runnable. It blocks until the context is cancelled or a restart is +// requested. It is leader-election gated by default (mgr.Add treats non-cache runnables as +// requiring leadership), so only the active operator instance watches namespaces and triggers +// restarts. +func (w *namespaceWatcher) Start(ctx context.Context) error { + debounce := w.debounce + if debounce <= 0 { + debounce = namespaceWatcherDebounce + } + resync := w.resync + if resync <= 0 { + resync = namespaceWatcherResyncInterval + } + + nsCache, err := cache.New(w.config, cache.Options{ + Scheme: w.scheme, + ByObject: map[ctrl.Object]cache.ByObject{ + // Namespace is cluster-scoped: leave Namespaces nil, filter by label only. + &corev1.Namespace{}: {Label: w.selector}, + }, + }) + if err != nil { + return err + } + + informer, err := nsCache.GetInformer(ctx, &corev1.Namespace{}) + if err != nil { + return err + } + + trigger := make(chan struct{}, 1) + ping := func() { + select { + case trigger <- struct{}{}: + default: + } + } + // In a label-filtered informer, gaining the label surfaces as an Add and losing it (or the + // namespace being deleted) surfaces as a Delete. Both may change the watched set. + if _, err := informer.AddEventHandler(toolscache.ResourceEventHandlerFuncs{ + AddFunc: func(_ interface{}) { ping() }, + DeleteFunc: func(_ interface{}) { ping() }, + }); err != nil { + return err + } + + cacheCtx, cacheCancel := context.WithCancel(ctx) + defer cacheCancel() + go func() { + if startErr := nsCache.Start(cacheCtx); startErr != nil { + log.Error(startErr, "namespace watcher cache stopped with an error") + } + }() + if !nsCache.WaitForCacheSync(cacheCtx) { + // Context cancelled before the cache synced; nothing more to do. + return nil + } + + log.Info("Namespace watcher started", "selector", w.selector.String(), + "watching", sortedKeys(w.current)) + + ticker := time.NewTicker(resync) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + ping() + case <-trigger: + // Debounce to coalesce bursts of namespace events into a single evaluation. + if !sleep(ctx, debounce) { + return nil + } + drain(trigger) + if w.changed(ctx) { + log.Info("Watched namespace set changed, restarting the operator to apply it") + w.requestRestart() + + return nil + } + } + } +} + +// changed recomputes the desired (accessible) namespace set and reports whether it differs from +// the set the operator is currently running with. Transient API errors are treated as "no change" +// to avoid restart loops on momentary connectivity issues. +func (w *namespaceWatcher) changed(ctx context.Context) bool { + desired, err := computeWatchedNamespaces(ctx, w.reviewer, w.config, w.scheme, + w.operatorNamespace, w.staticNamespaces, w.selector) + if err != nil { + log.Error(err, "could not recompute the watched namespace set; keeping the current one") + + return false + } + + if len(desired) != len(w.current) { + return true + } + for ns := range desired { + if !w.current[ns] { + return true + } + } + + return false +} + +// computeWatchedNamespaces returns the set of namespaces the operator should watch: the operator +// namespace, the statically configured namespaces, and (when a selector is provided) every +// namespace matching the selector. Namespaces other than the operator namespace are included only +// if the operator actually has permission to watch Integrations there, so that a namespace lacking +// the required RBAC is skipped (with a warning) rather than blocking the manager cache from syncing. +func computeWatchedNamespaces( + ctx context.Context, + reviewer ctrl.Client, + config *rest.Config, + scheme *runtime.Scheme, + operatorNamespace string, + staticNamespaces []string, + selector labels.Selector, +) (map[string]bool, error) { + candidates := make(map[string]bool) + if operatorNamespace != "" { + candidates[operatorNamespace] = true + } + for _, ns := range staticNamespaces { + candidates[ns] = true + } + + if selector != nil { + discoverer := reviewer + if discoverer == nil { + c, err := ctrl.New(config, ctrl.Options{Scheme: scheme}) + if err != nil { + return nil, err + } + discoverer = c + } + list := &corev1.NamespaceList{} + if err := discoverer.List(ctx, list, ctrl.MatchingLabelsSelector{Selector: selector}); err != nil { + return nil, err + } + for i := range list.Items { + candidates[list.Items[i].Name] = true + } + } + + watched := make(map[string]bool, len(candidates)) + for ns := range candidates { + // The operator namespace is always watched; the operator must be able to operate there. + if ns == operatorNamespace { + watched[ns] = true + + continue + } + allowed, err := canWatchIntegrations(ctx, reviewer, ns) + if err != nil { + // If we cannot determine access, be conservative and skip the namespace so a + // permission problem cannot block the whole cache from syncing. + log.Error(err, "could not verify operator access to namespace; skipping it", "namespace", ns) + + continue + } + if !allowed { + log.Info("Skipping namespace: operator lacks RBAC to watch Integrations there "+ + "(install the namespaced Role/RoleBinding to enable it)", "namespace", ns) + + continue + } + watched[ns] = true + } + + return watched, nil +} + +// canWatchIntegrations reports whether the operator's ServiceAccount may watch Integrations in the +// given namespace, using a SelfSubjectAccessReview (which any authenticated principal may create). +func canWatchIntegrations(ctx context.Context, reviewer ctrl.Client, namespace string) (bool, error) { + review := &authorizationv1.SelfSubjectAccessReview{ + Spec: authorizationv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Namespace: namespace, + Group: v1.SchemeGroupVersion.Group, + Resource: "integrations", + Verb: "watch", + }, + }, + } + if err := reviewer.Create(ctx, review); err != nil { + return false, err + } + + return review.Status.Allowed, nil +} + +// sortedKeys returns the keys of the set sorted, for stable logging. +func sortedKeys(set map[string]bool) []string { + keys := make([]string, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Strings(keys) + + return keys +} + +// sleep waits for d or until ctx is done. It returns false if the context was cancelled. +func sleep(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +// drain empties any pending value from the trigger channel. +func drain(ch <-chan struct{}) { + for { + select { + case <-ch: + default: + return + } + } +} diff --git a/pkg/cmd/operator/namespace_watcher_test.go b/pkg/cmd/operator/namespace_watcher_test.go new file mode 100644 index 0000000000..349ba4e4c4 --- /dev/null +++ b/pkg/cmd/operator/namespace_watcher_test.go @@ -0,0 +1,176 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package operator + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + authorizationv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func namespace(name string, labelSet map[string]string) *corev1.Namespace { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labelSet}, + } +} + +// newReviewer builds a fake client preloaded with the given namespaces. SelfSubjectAccessReview +// creations are answered locally: a namespace is "allowed" unless it appears in denied. +func newReviewer(namespaces []*corev1.Namespace, denied map[string]bool) ctrl.Client { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = authorizationv1.AddToScheme(scheme) + + objs := make([]ctrl.Object, 0, len(namespaces)) + for _, ns := range namespaces { + objs = append(objs, ns) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithInterceptorFuncs(interceptor.Funcs{ + Create: func(ctx context.Context, c ctrl.WithWatch, obj ctrl.Object, opts ...ctrl.CreateOption) error { + if ssar, ok := obj.(*authorizationv1.SelfSubjectAccessReview); ok { + ns := ssar.Spec.ResourceAttributes.Namespace + ssar.Status.Allowed = !denied[ns] + + return nil + } + + return c.Create(ctx, obj, opts...) + }, + }). + Build() +} + +func mustSelector(t *testing.T, s string) labels.Selector { + t.Helper() + sel, err := labels.Parse(s) + require.NoError(t, err) + + return sel +} + +func TestComputeWatchedNamespaces(t *testing.T) { + enabled := map[string]string{"camel-k-enabled": "true"} + + t.Run("operator namespace is always included, even without an explicit SSAR allow", func(t *testing.T) { + reviewer := newReviewer(nil, map[string]bool{"camel-k": true}) + got, err := computeWatchedNamespaces(context.Background(), reviewer, nil, nil, "camel-k", nil, nil) + require.NoError(t, err) + assert.Equal(t, map[string]bool{"camel-k": true}, got) + }) + + t.Run("static namespaces are included when accessible", func(t *testing.T) { + reviewer := newReviewer(nil, nil) + got, err := computeWatchedNamespaces(context.Background(), reviewer, nil, nil, + "camel-k", []string{"team-a", "team-b"}, nil) + require.NoError(t, err) + assert.Equal(t, map[string]bool{"camel-k": true, "team-a": true, "team-b": true}, got) + }) + + t.Run("inaccessible namespaces are skipped, not failed", func(t *testing.T) { + reviewer := newReviewer(nil, map[string]bool{"team-b": true}) + got, err := computeWatchedNamespaces(context.Background(), reviewer, nil, nil, + "camel-k", []string{"team-a", "team-b"}, nil) + require.NoError(t, err) + assert.Equal(t, map[string]bool{"camel-k": true, "team-a": true}, got) + }) + + t.Run("selector discovers labelled and accessible namespaces", func(t *testing.T) { + namespaces := []*corev1.Namespace{ + namespace("team-a", enabled), + namespace("team-b", enabled), + namespace("team-c", nil), // not labelled -> not discovered + } + reviewer := newReviewer(namespaces, map[string]bool{"team-b": true}) // team-b labelled but no RBAC + got, err := computeWatchedNamespaces(context.Background(), reviewer, nil, nil, + "camel-k", nil, mustSelector(t, "camel-k-enabled=true")) + require.NoError(t, err) + assert.Equal(t, map[string]bool{"camel-k": true, "team-a": true}, got) + }) + + t.Run("static list and selector are unioned", func(t *testing.T) { + namespaces := []*corev1.Namespace{namespace("dyn", enabled)} + reviewer := newReviewer(namespaces, nil) + got, err := computeWatchedNamespaces(context.Background(), reviewer, nil, nil, + "camel-k", []string{"static"}, mustSelector(t, "camel-k-enabled=true")) + require.NoError(t, err) + assert.Equal(t, map[string]bool{"camel-k": true, "static": true, "dyn": true}, got) + }) +} + +func TestNamespaceWatcherChanged(t *testing.T) { + enabled := map[string]string{"camel-k-enabled": "true"} + selector := mustSelector(t, "camel-k-enabled=true") + + newWatcher := func(reviewer ctrl.Client, current map[string]bool) *namespaceWatcher { + return &namespaceWatcher{ + reviewer: reviewer, + selector: selector, + operatorNamespace: "camel-k", + current: current, + } + } + + t.Run("no change when desired equals current", func(t *testing.T) { + reviewer := newReviewer([]*corev1.Namespace{namespace("team-a", enabled)}, nil) + w := newWatcher(reviewer, map[string]bool{"camel-k": true, "team-a": true}) + assert.False(t, w.changed(context.Background())) + }) + + t.Run("change when a new namespace is labelled", func(t *testing.T) { + reviewer := newReviewer([]*corev1.Namespace{ + namespace("team-a", enabled), + namespace("team-b", enabled), + }, nil) + w := newWatcher(reviewer, map[string]bool{"camel-k": true, "team-a": true}) + assert.True(t, w.changed(context.Background())) + }) + + t.Run("change when a watched namespace disappears", func(t *testing.T) { + reviewer := newReviewer([]*corev1.Namespace{namespace("team-a", enabled)}, nil) + w := newWatcher(reviewer, map[string]bool{"camel-k": true, "team-a": true, "team-gone": true}) + assert.True(t, w.changed(context.Background())) + }) +} + +func TestToCacheNamespaces(t *testing.T) { + assert.Empty(t, toCacheNamespaces(nil)) + got := toCacheNamespaces(map[string]bool{"a": true, "b": true}) + assert.Len(t, got, 2) + assert.Contains(t, got, "a") + assert.Contains(t, got, "b") +} + +func TestSortedKeys(t *testing.T) { + assert.Equal(t, []string{"a", "b", "c"}, sortedKeys(map[string]bool{"c": true, "a": true, "b": true})) + assert.Empty(t, sortedKeys(nil)) +} diff --git a/pkg/cmd/operator/operator.go b/pkg/cmd/operator/operator.go index 92f463e48f..00d01a4321 100644 --- a/pkg/cmd/operator/operator.go +++ b/pkg/cmd/operator/operator.go @@ -129,10 +129,28 @@ func Run(healthPort, monitoringPort int32, leaderElection bool, leaderElectionID printVersion() - watchNamespace, err := getWatchNamespace() - exitOnError(err, "failed to get watch namespace") + // WATCH_NAMESPACE must be present (it may be empty, which selects global mode). + if _, err := getWatchNamespace(); err != nil { + exitOnError(err, "failed to get watch namespace") + } + // watchNamespaces is the explicit list configured via WATCH_NAMESPACE (single or comma-separated). + watchNamespaces := platform.GetWatchNamespaces() + + // watchSelector, when set, enables dynamic discovery of namespaces to watch by label. + var watchSelector labels.Selector + if selectorStr := platform.GetWatchNamespaceSelector(); selectorStr != "" { + parsed, err := labels.Parse(selectorStr) + exitOnError(err, "invalid "+platform.OperatorWatchNamespaceSelectorEnvVariable) + watchSelector = parsed + } ctx := signals.SetupSignalHandler() + // managerCtx lets the operator gracefully restart itself (recomputing its watched namespaces + // at startup) when the set of dynamically discovered namespaces changes. Cancelling it stops + // the manager cleanly without terminating the process abnormally; the process then exits 0 and + // is restarted by Kubernetes. + managerCtx, restartOperator := context.WithCancel(ctx) + defer restartOperator() cfg, err := config.GetConfig() exitOnError(err, "cannot get client config") @@ -146,10 +164,12 @@ func Run(healthPort, monitoringPort int32, leaderElection bool, leaderElectionID operatorNamespace := platform.GetOperatorNamespace() if operatorNamespace == "" { - // Fallback to using the watch namespace when the operator is not in-cluster. + // Fallback to using a watched namespace when the operator is not in-cluster. // It does not support local (off-cluster) operator watching resources globally, // in which case it's not possible to determine a namespace. - operatorNamespace = watchNamespace + if len(watchNamespaces) > 0 { + operatorNamespace = watchNamespaces[0] + } if operatorNamespace == "" { leaderElection = false log.Info("unable to determine namespace for leader election") @@ -164,6 +184,17 @@ func Run(healthPort, monitoringPort int32, leaderElection bool, leaderElectionID log.Info("Leader election is disabled!") } + // Determine the set of namespaces to watch. In global mode this stays nil (watch everything); + // otherwise it is the operator namespace plus any statically configured (WATCH_NAMESPACE) + // and/or dynamically discovered (WATCH_NAMESPACE_SELECTOR) namespaces the operator can access. + var watchedNamespaces map[string]bool + if !platform.IsCurrentOperatorGlobal() { + watchedNamespaces, err = computeWatchedNamespaces(ctx, bootstrapClient, cfg, bootstrapClient.GetScheme(), + operatorNamespace, watchNamespaces, watchSelector) + exitOnError(err, "cannot determine the namespaces to watch") + log.Info("Operator is watching a defined set of namespaces", "namespaces", sortedKeys(watchedNamespaces)) + } + hasIntegrationLabel, err := labels.NewRequirement(v1.IntegrationLabel, selection.Exists, []string{}) exitOnError(err, "cannot create Integration label selector") labelsSelector := labels.NewSelector().Add(*hasIntegrationLabel) @@ -173,10 +204,7 @@ func Run(healthPort, monitoringPort int32, leaderElection bool, leaderElectionID } if !platform.IsCurrentOperatorGlobal() { - selector = cache.ByObject{ - Label: labelsSelector, - Namespaces: getNamespacesSelector(operatorNamespace, watchNamespace), - } + selector.Namespaces = toCacheNamespaces(watchedNamespaces) } selectors := map[ctrl.Object]cache.ByObject{ @@ -198,7 +226,7 @@ func Run(healthPort, monitoringPort int32, leaderElection bool, leaderElectionID } if !platform.IsCurrentOperatorGlobal() { - options.DefaultNamespaces = getNamespacesSelector(operatorNamespace, watchNamespace) + options.DefaultNamespaces = toCacheNamespaces(watchedNamespaces) } mgr, err := manager.New(cfg, manager.Options{ @@ -220,10 +248,26 @@ func Run(healthPort, monitoringPort int32, leaderElection bool, leaderElectionID exitOnError(err, "") exitOnError(controller.AddToManager(ctx, mgr, ctrlClient), "") + // When dynamic namespace discovery is enabled, watch Namespace objects matching the selector + // and gracefully restart the operator whenever the watchable namespace set changes. + if watchSelector != nil { + exitOnError(mgr.Add(&namespaceWatcher{ + config: cfg, + scheme: mgr.GetScheme(), + reviewer: bootstrapClient, + selector: watchSelector, + operatorNamespace: operatorNamespace, + staticNamespaces: watchNamespaces, + current: watchedNamespaces, + requestRestart: restartOperator, + }), "cannot add the dynamic namespace watcher") + log.Info("Dynamic namespace discovery is enabled", "selector", watchSelector.String()) + } + log.Info("Installing operator resources") installCtx, installCancel := context.WithTimeout(ctx, 1*time.Minute) defer installCancel() - install.OperatorStartupOptionalTools(installCtx, bootstrapClient, watchNamespace, operatorNamespace, log) + install.OperatorStartupOptionalTools(installCtx, bootstrapClient, platform.GetOperatorWatchNamespace(), operatorNamespace, log) synthEnvVal, synth := os.LookupEnv("CAMEL_K_SYNTHETIC_INTEGRATIONS") if synth && synthEnvVal == "true" { @@ -232,15 +276,18 @@ func Run(healthPort, monitoringPort int32, leaderElection bool, leaderElectionID exitOnError(synthetic.ManageSyntheticIntegrations(ctx, ctrlClient, mgr.GetCache()), "synthetic Integration manager error") } log.Info("Starting the manager") - exitOnError(mgr.Start(ctx), "manager exited non-zero") + // managerCtx is cancelled either by a process signal (graceful shutdown) or by the namespace + // watcher requesting a restart. In both cases Start returns nil and the process exits 0; on a + // restart request Kubernetes brings the operator back up and it recomputes its watched set. + exitOnError(mgr.Start(managerCtx), "manager exited non-zero") } -func getNamespacesSelector(operatorNamespace string, watchNamespace string) map[string]cache.Config { - namespacesSelector := map[string]cache.Config{ - operatorNamespace: {}, - } - if operatorNamespace != watchNamespace { - namespacesSelector[watchNamespace] = cache.Config{} +// toCacheNamespaces converts a set of namespace names into the per-namespace cache configuration +// expected by controller-runtime. An empty/nil input yields an empty map. +func toCacheNamespaces(namespaces map[string]bool) map[string]cache.Config { + namespacesSelector := make(map[string]cache.Config, len(namespaces)) + for ns := range namespaces { + namespacesSelector[ns] = cache.Config{} } return namespacesSelector diff --git a/pkg/platform/operator.go b/pkg/platform/operator.go index ad9275a670..800466ce53 100644 --- a/pkg/platform/operator.go +++ b/pkg/platform/operator.go @@ -35,9 +35,14 @@ import ( const ( OperatorWatchNamespaceEnvVariable = "WATCH_NAMESPACE" - operatorNamespaceEnvVariable = "NAMESPACE" - operatorPodNameEnvVariable = "POD_NAME" - OperatorBuildStrategyEnvVar = "BUILD_STRATEGY" + // OperatorWatchNamespaceSelectorEnvVariable holds a Kubernetes label selector. When set, the + // operator dynamically discovers and watches every namespace whose labels match the selector, + // in addition to any namespaces listed in WATCH_NAMESPACE. It is the gate for the dynamic + // multi-namespace mode. + OperatorWatchNamespaceSelectorEnvVariable = "WATCH_NAMESPACE_SELECTOR" + operatorNamespaceEnvVariable = "NAMESPACE" + operatorPodNameEnvVariable = "POD_NAME" + OperatorBuildStrategyEnvVar = "BUILD_STRATEGY" ) const OperatorLockName = "camel-k-lock" @@ -45,7 +50,17 @@ const OperatorLockName = "camel-k-lock" var OperatorImage string // IsCurrentOperatorGlobal returns true if the operator is configured to watch all namespaces. +// +// The operator is global only when it has no explicit scope at all: WATCH_NAMESPACE is empty/unset +// AND no WATCH_NAMESPACE_SELECTOR is set. A non-empty WATCH_NAMESPACE (single or comma-separated +// list) or a non-empty selector both put the operator in a (multi-)namespace-scoped, i.e. local, mode. func IsCurrentOperatorGlobal() bool { + if selector, envSet := os.LookupEnv(OperatorWatchNamespaceSelectorEnvVariable); envSet && strings.TrimSpace(selector) != "" { + log.Debug("Operator is local to namespaces matching a label selector") + + return false + } + if watchNamespace, envSet := os.LookupEnv(OperatorWatchNamespaceEnvVariable); !envSet || strings.TrimSpace(watchNamespace) == "" { log.Debug("Operator is global to all namespaces") @@ -57,7 +72,8 @@ func IsCurrentOperatorGlobal() bool { return false } -// GetOperatorWatchNamespace returns the namespace the operator watches. +// GetOperatorWatchNamespace returns the raw value of the WATCH_NAMESPACE environment variable. +// It may be empty (global), a single namespace, or a comma-separated list of namespaces. func GetOperatorWatchNamespace() string { if namespace, envSet := os.LookupEnv(OperatorWatchNamespaceEnvVariable); envSet { return namespace @@ -66,6 +82,41 @@ func GetOperatorWatchNamespace() string { return "" } +// GetWatchNamespaces returns the explicit, de-duplicated list of namespaces the operator is +// statically configured to watch via WATCH_NAMESPACE. WATCH_NAMESPACE may contain a single +// namespace or a comma-separated list; surrounding whitespace and empty entries are ignored. +// An empty result means no namespace was statically configured (global mode, or selector-only +// dynamic mode). +func GetWatchNamespaces() []string { + raw := GetOperatorWatchNamespace() + if strings.TrimSpace(raw) == "" { + return nil + } + + seen := make(map[string]bool) + namespaces := make([]string, 0) + for _, ns := range strings.Split(raw, ",") { + ns = strings.TrimSpace(ns) + if ns == "" || seen[ns] { + continue + } + seen[ns] = true + namespaces = append(namespaces, ns) + } + + return namespaces +} + +// GetWatchNamespaceSelector returns the trimmed WATCH_NAMESPACE_SELECTOR label selector used to +// dynamically discover namespaces to watch. An empty result means dynamic discovery is disabled. +func GetWatchNamespaceSelector() string { + if selector, envSet := os.LookupEnv(OperatorWatchNamespaceSelectorEnvVariable); envSet { + return strings.TrimSpace(selector) + } + + return "" +} + // GetOperatorNamespace returns the namespace where the current operator is located (if set). func GetOperatorNamespace() string { if podNamespace, envSet := os.LookupEnv(operatorNamespaceEnvVariable); envSet { diff --git a/pkg/platform/operator_test.go b/pkg/platform/operator_test.go new file mode 100644 index 0000000000..1e6c610eff --- /dev/null +++ b/pkg/platform/operator_test.go @@ -0,0 +1,100 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package platform + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +// setEnv sets (or, when value is nil, unsets) an environment variable for the duration of the test. +func setEnv(t *testing.T, key string, value *string) { + t.Helper() + orig, had := os.LookupEnv(key) + t.Cleanup(func() { + if had { + _ = os.Setenv(key, orig) + } else { + _ = os.Unsetenv(key) + } + }) + if value == nil { + _ = os.Unsetenv(key) + } else { + _ = os.Setenv(key, *value) + } +} + +func ptr(s string) *string { return &s } + +func TestIsCurrentOperatorGlobal(t *testing.T) { + tests := []struct { + name string + watch *string + selector *string + global bool + }{ + {name: "unset watch, no selector -> global", watch: nil, selector: nil, global: true}, + {name: "empty watch, no selector -> global", watch: ptr(""), selector: nil, global: true}, + {name: "blank watch, no selector -> global", watch: ptr(" "), selector: nil, global: true}, + {name: "single namespace -> local", watch: ptr("ns1"), selector: nil, global: false}, + {name: "multi namespace -> local", watch: ptr("ns1,ns2"), selector: nil, global: false}, + {name: "selector only -> local", watch: ptr(""), selector: ptr("camel-k-enabled=true"), global: false}, + {name: "blank selector behaves as unset -> global", watch: ptr(""), selector: ptr(" "), global: true}, + {name: "selector with list -> local", watch: ptr("ns1"), selector: ptr("a=b"), global: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setEnv(t, OperatorWatchNamespaceEnvVariable, tc.watch) + setEnv(t, OperatorWatchNamespaceSelectorEnvVariable, tc.selector) + assert.Equal(t, tc.global, IsCurrentOperatorGlobal()) + }) + } +} + +func TestGetWatchNamespaces(t *testing.T) { + tests := []struct { + name string + watch *string + expected []string + }{ + {name: "unset -> nil", watch: nil, expected: nil}, + {name: "empty -> nil", watch: ptr(""), expected: nil}, + {name: "single", watch: ptr("ns1"), expected: []string{"ns1"}}, + {name: "list", watch: ptr("ns1,ns2,ns3"), expected: []string{"ns1", "ns2", "ns3"}}, + {name: "trims whitespace", watch: ptr(" ns1 , ns2 "), expected: []string{"ns1", "ns2"}}, + {name: "drops empties", watch: ptr("ns1,,ns2,"), expected: []string{"ns1", "ns2"}}, + {name: "de-duplicates preserving order", watch: ptr("ns1,ns2,ns1"), expected: []string{"ns1", "ns2"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setEnv(t, OperatorWatchNamespaceEnvVariable, tc.watch) + assert.Equal(t, tc.expected, GetWatchNamespaces()) + }) + } +} + +func TestGetWatchNamespaceSelector(t *testing.T) { + setEnv(t, OperatorWatchNamespaceSelectorEnvVariable, nil) + assert.Equal(t, "", GetWatchNamespaceSelector()) + + setEnv(t, OperatorWatchNamespaceSelectorEnvVariable, ptr(" camel-k-enabled=true ")) + assert.Equal(t, "camel-k-enabled=true", GetWatchNamespaceSelector()) +} diff --git a/pkg/resources/config/rbac/namespace-watcher/kustomization.yaml b/pkg/resources/config/rbac/namespace-watcher/kustomization.yaml new file mode 100644 index 0000000000..83f461304c --- /dev/null +++ b/pkg/resources/config/rbac/namespace-watcher/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- operator-cluster-role-namespace-watcher.yaml +- operator-cluster-role-binding-namespace-watcher.yaml diff --git a/pkg/resources/config/rbac/namespace-watcher/operator-cluster-role-binding-namespace-watcher.yaml b/pkg/resources/config/rbac/namespace-watcher/operator-cluster-role-binding-namespace-watcher.yaml new file mode 100644 index 0000000000..86b1a32904 --- /dev/null +++ b/pkg/resources/config/rbac/namespace-watcher/operator-cluster-role-binding-namespace-watcher.yaml @@ -0,0 +1,30 @@ +# --------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# --------------------------------------------------------------------------- + +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: camel-k-operator-namespace-watcher + labels: + app: "camel-k" +subjects: +- kind: ServiceAccount + name: camel-k-operator +roleRef: + kind: ClusterRole + name: camel-k-operator-namespace-watcher + apiGroup: rbac.authorization.k8s.io diff --git a/pkg/resources/config/rbac/namespace-watcher/operator-cluster-role-namespace-watcher.yaml b/pkg/resources/config/rbac/namespace-watcher/operator-cluster-role-namespace-watcher.yaml new file mode 100644 index 0000000000..23a07bb8bb --- /dev/null +++ b/pkg/resources/config/rbac/namespace-watcher/operator-cluster-role-namespace-watcher.yaml @@ -0,0 +1,36 @@ +# --------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# --------------------------------------------------------------------------- + +# Minimal cluster-scoped role for the dynamic multi-namespace mode. It grants the operator +# read-only access to namespace metadata so it can discover which namespaces carry the watch +# label. It does NOT grant access to secrets, pods, or any workload resource in any namespace — +# those remain governed by the namespaced operator Role installed per watched namespace. +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: camel-k-operator-namespace-watcher + labels: + app: "camel-k" +rules: +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch diff --git a/script/gen_helm.sh b/script/gen_helm.sh index b1dbec10c0..c2e987d6f1 100755 --- a/script/gen_helm.sh +++ b/script/gen_helm.sh @@ -39,3 +39,12 @@ printf "{{- end }}\n" >> ./helm/camel-k/templates/rbacs-descoped.yaml sed -i -E "s/^([[:space:]]*)name: camel-k-(.*)$/\1name: {{ include \"camel-k.fullname\" . }}-\2/" ./helm/camel-k/templates/rbacs-common.yaml sed -i -E "s/^([[:space:]]*)name: camel-k-(.*)$/\1name: {{ include \"camel-k.fullname\" . }}-\2/" ./helm/camel-k/templates/rbacs-namespaced.yaml sed -i -E "s/^([[:space:]]*)name: camel-k-(.*)$/\1name: {{ include \"camel-k.fullname\" . }}-\2/" ./helm/camel-k/templates/rbacs-descoped.yaml + +# [Helm] Camel-K Operator sharding for the cluster-scoped registry-reader ClusterRole/ClusterRoleBinding. +# Their kustomize source names (registry-reader, registry-reader-binding) are not prefixed "camel-k-", so +# the generic rule above skips them and they would collide between operator shards. Prefix them with the +# release fullname too. The $-anchors keep "registry-reader" from also matching "registry-reader-binding", +# and rewriting "name: registry-reader" updates both the ClusterRole metadata.name and the binding's +# roleRef.name consistently, so the binding keeps pointing at the renamed ClusterRole. +sed -i -E "s/^([[:space:]]*)name: registry-reader-binding$/\1name: {{ include \"camel-k.fullname\" . }}-registry-reader-binding/" ./helm/camel-k/templates/rbacs-common.yaml +sed -i -E "s/^([[:space:]]*)name: registry-reader$/\1name: {{ include \"camel-k.fullname\" . }}-registry-reader/" ./helm/camel-k/templates/rbacs-common.yaml diff --git a/script/install_namespace_rbac.sh b/script/install_namespace_rbac.sh new file mode 100755 index 0000000000..f4d33dcb33 --- /dev/null +++ b/script/install_namespace_rbac.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +# --------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# --------------------------------------------------------------------------- + +# Installs the namespaced Camel K operator RBAC (Role + RoleBinding) into a namespace that an +# operator running in a *different* namespace should watch. This is the per-namespace step of the +# multi-namespace / dynamic-namespace installation: it grants a remote operator the rights it needs +# in this namespace, while keeping its access scoped to exactly the namespaces you opt in. +# +# The RoleBinding subjects are set to the operator ServiceAccount(s) in the operator namespace, so the +# Role is granted to the remote operator(s) (not to a ServiceAccount in the watched namespace). +# +# Multiple operator ServiceAccounts can be passed as a comma-separated list. This is the "all shards +# watch every namespace, reconcile by operator.id" model: every operator shard shares the same +# namespaced Role in a watched namespace via a single multi-subject RoleBinding. (Work is partitioned +# at runtime by the camel.apache.org/operator.id annotation, not by RBAC.) +# +# Usage: +# ./script/install_namespace_rbac.sh [operator-namespace] [operator-sa[,operator-sa...]] +# +# watched-namespace (required) namespace to install the operator RBAC into. +# operator-namespace (optional) namespace where the operator(s) run. Default: camel-k. +# operator-sa (optional) operator ServiceAccount name, or a comma-separated list of them +# (one per shard). Default: camel-k-operator. +# +# Examples: +# # single operator +# ./script/install_namespace_rbac.sh team-a camel-k camel-k-operator +# # two shards both able to watch team-a +# ./script/install_namespace_rbac.sh team-a camel-k camel-k-shard-1-operator,camel-k-shard-2-operator +# +# Requires: kubectl and kustomize on the PATH, and rights to create Roles/RoleBindings in the +# target namespace. + +set -e + +location=$(dirname "$0") +rootdir=$(realpath "${location}/../") + +watched_namespace="$1" +operator_namespace="${2:-camel-k}" +operator_sas="${3:-camel-k-operator}" + +if [ -z "${watched_namespace}" ]; then + echo "Error: watched namespace is required." + echo "Usage: $0 [operator-namespace] [operator-sa[,operator-sa...]]" + exit 1 +fi + +# Build the RoleBinding subjects list: one ServiceAccount entry per (comma-separated) operator shard, +# each pinned to the operator namespace. Indented to sit under the JSON6902 patch "value:" key below. +subjects_block="" +sa_summary="" +IFS=',' read -ra _sa_arr <<< "${operator_sas}" +for _sa in "${_sa_arr[@]}"; do + _sa="$(echo -n "${_sa}" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" + [ -z "${_sa}" ] && continue + subjects_block="${subjects_block} - kind: ServiceAccount + name: ${_sa} + namespace: ${operator_namespace} +" + sa_summary="${sa_summary}${sa_summary:+, }${_sa}" +done + +if [ -z "${subjects_block}" ]; then + echo "Error: at least one operator ServiceAccount is required." + exit 1 +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "${tmpdir}"' EXIT + +# Kustomize requires relative resource paths, so copy the canonical namespaced RBAC into the temp +# directory and reference it relatively. This keeps a single source of truth for the role rules. +mkdir -p "${tmpdir}/base" +cp "${rootdir}/pkg/resources/config/rbac/namespaced/"*.yaml "${tmpdir}/base/" + +cat > "${tmpdir}/kustomization.yaml" <