From d36ae3ef73f79c90f3505e3afdf6abdb71ff7323 Mon Sep 17 00:00:00 2001 From: Erwan Lavenant Date: Mon, 23 Feb 2026 10:26:07 +0100 Subject: [PATCH 1/2] Add support to watchNamespace --- cmd/main.go | 37 +++++++++++++++++++ .../templates/_helpers.tpl | 22 +++++++++++ .../templates/manager.yaml | 4 ++ .../templates/rbac.yaml | 32 ++++++++++++---- helm/temporal-worker-controller/values.yaml | 10 +++++ 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 767d203f..7aefdfaa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,6 +10,7 @@ import ( "fmt" "log/slog" "os" + "strings" temporaliov1alpha1 "github.com/temporalio/temporal-worker-controller/api/v1alpha1" "github.com/temporalio/temporal-worker-controller/internal/controller" @@ -24,6 +25,7 @@ import ( // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -45,8 +47,12 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string + var watchNamespace string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.StringVar(&watchNamespace,"watch-namespace","", + "Namespace(s) that the controller watches. Can be a single namespace or a comma-separated list. "+ + "If empty, the controller watches all namespaces.", ) flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") @@ -56,6 +62,25 @@ func main() { opts.BindFlags(flag.CommandLine) flag.Parse() + // If flag is not set, fall back to environment variable + if watchNamespace == "" { + watchNamespace = os.Getenv("WATCH_NAMESPACE") + } + + // Parse comma-separated namespaces into []string, trimming whitespace and dropping empty entries + var watchNamespaces []string + if watchNamespace != "" { + parts := strings.Split(watchNamespace, ",") + watchNamespaces = make([]string, 0, len(parts)) + for _, p := range parts { + ns := strings.TrimSpace(p) + if ns == "" { + continue + } + watchNamespaces = append(watchNamespaces, ns) + } + } + //ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) ctrl.SetLogger(zap.New(zap.JSONEncoder())) @@ -65,6 +90,17 @@ func main() { os.Exit(1) } + if len(watchNamespaces) > 0 { + setupLog.Info("running controller in namespace-scoped mode", "namespaces", watchNamespaces) + + defaultNamespaces := map[string]cache.Config{} + for _, ns := range watchNamespaces { + defaultNamespaces[ns] = cache.Config{} + } + + cacheOptions.DefaultNamespaces = defaultNamespaces + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Cache: cacheOptions, @@ -86,6 +122,7 @@ func main() { // after the manager stops then its usage might be unsafe. // LeaderElectionReleaseOnCancel: true, }) + if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) diff --git a/helm/temporal-worker-controller/templates/_helpers.tpl b/helm/temporal-worker-controller/templates/_helpers.tpl index 5b143384..ba851463 100644 --- a/helm/temporal-worker-controller/templates/_helpers.tpl +++ b/helm/temporal-worker-controller/templates/_helpers.tpl @@ -18,3 +18,25 @@ Used for matchLabels (Deployments, Services, affinities, etc.) app.kubernetes.io/name: temporal-worker-controller app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} + +{{/* +Return Role or ClusterRole depending on ownNamespace +*/}} +{{- define "temporal-worker-controller.rbac.roleKind" -}} +{{- if .Values.rbac.ownNamespace -}} +Role +{{- else -}} +ClusterRole +{{- end -}} +{{- end -}} + +{{/* +Return RoleBinding or ClusterRoleBinding depending on ownNamespace +*/}} +{{- define "temporal-worker-controller.rbac.roleBindingKind" -}} +{{- if .Values.rbac.ownNamespace -}} +RoleBinding +{{- else -}} +ClusterRoleBinding +{{- end -}} +{{- end -}} diff --git a/helm/temporal-worker-controller/templates/manager.yaml b/helm/temporal-worker-controller/templates/manager.yaml index a6e66c82..eebf1a39 100644 --- a/helm/temporal-worker-controller/templates/manager.yaml +++ b/helm/temporal-worker-controller/templates/manager.yaml @@ -98,6 +98,10 @@ spec: {{- end }} - name: ALLOWED_KINDS value: {{ join "," $allKinds | quote }} + {{- with .Values.watchNamespace }} + - name: WATCH_NAMESPACE + value: {{ . | quote }} + {{- end }} args: - --leader-elect {{- if .Values.metrics.enabled }} diff --git a/helm/temporal-worker-controller/templates/rbac.yaml b/helm/temporal-worker-controller/templates/rbac.yaml index 83614c3f..9dcd2d9d 100644 --- a/helm/temporal-worker-controller/templates/rbac.yaml +++ b/helm/temporal-worker-controller/templates/rbac.yaml @@ -59,9 +59,12 @@ subjects: namespace: {{ .Release.Namespace }} --- apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} metadata: name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-role + {{- if .Values.rbac.ownNamespace }} + namespace: {{ .Release.Namespace }} + {{- end }} rules: # GENERATED RULES BEGIN - do not edit; run 'make manifests' to update - apiGroups: @@ -175,15 +178,18 @@ rules: {{- end }} --- apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: {{ include "temporal-worker-controller.rbac.roleBindingKind" . }} metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-rolebinding + {{- if .Values.rbac.ownNamespace }} + namespace: {{ .Release.Namespace }} + {{- end }} roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-role subjects: - kind: ServiceAccount @@ -297,12 +303,15 @@ rules: # Deprecated editor/viewer roles (migration window — remove in v1.8.0) # permissions for end users to edit temporalconnections. apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-temporalconnection-editor-role + {{- if .Values.rbac.ownNamespace }} + namespace: {{ .Release.Namespace }} + {{- end }} rules: - apiGroups: - temporal.io @@ -325,12 +334,15 @@ rules: --- # permissions for end users to view temporalconnections. apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-temporalconnection-viewer-role + {{- if .Values.rbac.ownNamespace }} + namespace: {{ .Release.Namespace }} + {{- end }} rules: - apiGroups: - temporal.io @@ -349,12 +361,15 @@ rules: --- # permissions for end users to edit temporalworkerdeployments. apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-temporalworkerdeployment-editor-role + {{- if .Values.rbac.ownNamespace }} + namespace: {{ .Release.Namespace }} + {{- end }} rules: - apiGroups: - temporal.io @@ -377,12 +392,15 @@ rules: --- # permissions for end users to view temporalworkerdeployments. apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-temporalworkerdeployment-viewer-role + {{- if .Values.rbac.ownNamespace }} + namespace: {{ .Release.Namespace }} + {{- end }} rules: - apiGroups: - temporal.io diff --git a/helm/temporal-worker-controller/values.yaml b/helm/temporal-worker-controller/values.yaml index 22466977..d35a840b 100644 --- a/helm/temporal-worker-controller/values.yaml +++ b/helm/temporal-worker-controller/values.yaml @@ -40,6 +40,16 @@ priorityClassName: "" rbac: # Specifies whether RBAC resources should be created create: true + # Specifies whether the RBAC resources should be namespace-scoped or not + # Set to true to create Roles and RoleBindings in the release namespace instead of ClusterRoles and ClusterRoleBindings + ownNamespace: false + +# Comma-separated list of namespaces the controller should watch. +# Examples: +# watchNamespace: "foo" +# watchNamespace: "foo,bar" +# If empty, the controller watches all namespaces (cluster-wide). +watchNamespace: "" serviceAccount: # Specifies whether a ServiceAccount should be created From 8eaac41787cc6efe9280d1d36a39da01c3dcdad6 Mon Sep 17 00:00:00 2001 From: Nicolas Douchin Date: Wed, 1 Jul 2026 14:55:06 +0200 Subject: [PATCH 2/2] feat: sync watch scope and RBAC via rbac.restrictWatchNamespaces Collapse the separate watchNamespace and rbac.ownNamespace knobs into a single rbac.restrictWatchNamespaces list, per the review of PR #205. The list drives both the controller's watch scope (WATCH_NAMESPACES -> cache DefaultNamespaces) and the RBAC created, mirroring how workerResourceTemplate.allowedResources is a single source for both code behavior and permissions. Empty (the default) keeps cluster-wide behavior unchanged. When set, the manager role is bound per-namespace via RoleBindings, plus a minimal cluster-scoped role for the two grants that cannot be namespaced (namespaces get, subjectaccessreviews create). The generated manager ClusterRole rules are left untouched so 'make manifests' stays clean. Co-authored-by: Erwan Lavenant --- cmd/main.go | 47 +++------ .../templates/_helpers.tpl | 32 +++--- .../templates/manager.yaml | 6 +- .../templates/rbac.yaml | 97 ++++++++++++++----- .../templates/webhook.yaml | 6 ++ .../values.schema.json | 7 ++ helm/temporal-worker-controller/values.yaml | 17 ++-- internal/controller/cache.go | 39 +++++++- internal/controller/cache_test.go | 53 +++++++++- 9 files changed, 208 insertions(+), 96 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 7aefdfaa..c05624e0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,7 +10,6 @@ import ( "fmt" "log/slog" "os" - "strings" temporaliov1alpha1 "github.com/temporalio/temporal-worker-controller/api/v1alpha1" "github.com/temporalio/temporal-worker-controller/internal/controller" @@ -25,7 +24,6 @@ import ( // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -47,12 +45,12 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string - var watchNamespace string + var watchNamespaces string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") - flag.StringVar(&watchNamespace,"watch-namespace","", - "Namespace(s) that the controller watches. Can be a single namespace or a comma-separated list. "+ - "If empty, the controller watches all namespaces.", ) + flag.StringVar(&watchNamespaces, "watch-namespaces", "", + "Comma-separated list of namespaces the controller watches. "+ + "If empty, the controller watches all namespaces.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") @@ -62,45 +60,24 @@ func main() { opts.BindFlags(flag.CommandLine) flag.Parse() - // If flag is not set, fall back to environment variable - if watchNamespace == "" { - watchNamespace = os.Getenv("WATCH_NAMESPACE") - } - - // Parse comma-separated namespaces into []string, trimming whitespace and dropping empty entries - var watchNamespaces []string - if watchNamespace != "" { - parts := strings.Split(watchNamespace, ",") - watchNamespaces = make([]string, 0, len(parts)) - for _, p := range parts { - ns := strings.TrimSpace(p) - if ns == "" { - continue - } - watchNamespaces = append(watchNamespaces, ns) - } + if watchNamespaces == "" { + watchNamespaces = os.Getenv("WATCH_NAMESPACES") } + namespaces := controller.ParseWatchNamespaces(watchNamespaces) //ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) ctrl.SetLogger(zap.New(zap.JSONEncoder())) - cacheOptions, err := controller.NewCacheOptions() + if len(namespaces) > 0 { + setupLog.Info("running controller in namespace-scoped mode", "namespaces", namespaces) + } + + cacheOptions, err := controller.NewCacheOptions(namespaces) if err != nil { setupLog.Error(err, "unable to build manager cache options") os.Exit(1) } - if len(watchNamespaces) > 0 { - setupLog.Info("running controller in namespace-scoped mode", "namespaces", watchNamespaces) - - defaultNamespaces := map[string]cache.Config{} - for _, ns := range watchNamespaces { - defaultNamespaces[ns] = cache.Config{} - } - - cacheOptions.DefaultNamespaces = defaultNamespaces - } - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Cache: cacheOptions, diff --git a/helm/temporal-worker-controller/templates/_helpers.tpl b/helm/temporal-worker-controller/templates/_helpers.tpl index ba851463..41fb9871 100644 --- a/helm/temporal-worker-controller/templates/_helpers.tpl +++ b/helm/temporal-worker-controller/templates/_helpers.tpl @@ -20,23 +20,17 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* -Return Role or ClusterRole depending on ownNamespace +Namespace selector restricting admission webhooks to the watched namespaces. +Rendered only when rbac.restrictWatchNamespaces is set; keys off the +kubernetes.io/metadata.name label the API server sets on every namespace. */}} -{{- define "temporal-worker-controller.rbac.roleKind" -}} -{{- if .Values.rbac.ownNamespace -}} -Role -{{- else -}} -ClusterRole -{{- end -}} -{{- end -}} - -{{/* -Return RoleBinding or ClusterRoleBinding depending on ownNamespace -*/}} -{{- define "temporal-worker-controller.rbac.roleBindingKind" -}} -{{- if .Values.rbac.ownNamespace -}} -RoleBinding -{{- else -}} -ClusterRoleBinding -{{- end -}} -{{- end -}} +{{- define "temporal-worker-controller.webhookNamespaceSelector" -}} +namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: In + values: + {{- range .Values.rbac.restrictWatchNamespaces }} + - {{ . | quote }} + {{- end }} +{{- end }} diff --git a/helm/temporal-worker-controller/templates/manager.yaml b/helm/temporal-worker-controller/templates/manager.yaml index eebf1a39..06bac819 100644 --- a/helm/temporal-worker-controller/templates/manager.yaml +++ b/helm/temporal-worker-controller/templates/manager.yaml @@ -98,9 +98,9 @@ spec: {{- end }} - name: ALLOWED_KINDS value: {{ join "," $allKinds | quote }} - {{- with .Values.watchNamespace }} - - name: WATCH_NAMESPACE - value: {{ . | quote }} + {{- with .Values.rbac.restrictWatchNamespaces }} + - name: WATCH_NAMESPACES + value: {{ join "," . | quote }} {{- end }} args: - --leader-elect diff --git a/helm/temporal-worker-controller/templates/rbac.yaml b/helm/temporal-worker-controller/templates/rbac.yaml index 9dcd2d9d..cf3e9ba2 100644 --- a/helm/temporal-worker-controller/templates/rbac.yaml +++ b/helm/temporal-worker-controller/templates/rbac.yaml @@ -59,12 +59,9 @@ subjects: namespace: {{ .Release.Namespace }} --- apiVersion: rbac.authorization.k8s.io/v1 -kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} +kind: ClusterRole metadata: name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-role - {{- if .Values.rbac.ownNamespace }} - namespace: {{ .Release.Namespace }} - {{- end }} rules: # GENERATED RULES BEGIN - do not edit; run 'make manifests' to update - apiGroups: @@ -177,24 +174,86 @@ rules: - update {{- end }} --- +{{- if .Values.rbac.restrictWatchNamespaces }} +# Namespace-scoped mode: the manager role is bound only within the watched +# namespaces (RoleBindings below). The two grants that must stay cluster-wide are +# split into a minimal ClusterRole: namespaces "get" (restricted to the release +# namespace, for the controller identity-suffix lookup) and subjectaccessreviews +# "create" (the always-on validating webhook). +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/component: rbac + {{- include "temporal-worker-controller.labels" . | nindent 4 }} + name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-cluster-role +rules: + - apiGroups: + - "" + resources: + - namespaces + resourceNames: + - {{ .Release.Namespace }} + verbs: + - get + - apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/component: rbac + {{- include "temporal-worker-controller.labels" . | nindent 4 }} + name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-cluster-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-cluster-role +subjects: + - kind: ServiceAccount + name: {{ .Values.serviceAccount.name | default (printf "%s-service-account" .Release.Name) }} + namespace: {{ .Release.Namespace }} +{{- range .Values.rbac.restrictWatchNamespaces }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/component: rbac + {{- include "temporal-worker-controller.labels" $ | nindent 4 }} + name: {{ $.Release.Name }}-{{ $.Release.Namespace }}-manager-rolebinding + namespace: {{ . }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ $.Release.Name }}-{{ $.Release.Namespace }}-manager-role +subjects: + - kind: ServiceAccount + name: {{ $.Values.serviceAccount.name | default (printf "%s-service-account" $.Release.Name) }} + namespace: {{ $.Release.Namespace }} +{{- end }} +{{- else }} apiVersion: rbac.authorization.k8s.io/v1 -kind: {{ include "temporal-worker-controller.rbac.roleBindingKind" . }} +kind: ClusterRoleBinding metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-rolebinding - {{- if .Values.rbac.ownNamespace }} - namespace: {{ .Release.Namespace }} - {{- end }} roleRef: apiGroup: rbac.authorization.k8s.io - kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} + kind: ClusterRole name: {{ .Release.Name }}-{{ .Release.Namespace }}-manager-role subjects: - kind: ServiceAccount name: {{ .Values.serviceAccount.name | default (printf "%s-service-account" .Release.Name) }} namespace: {{ .Release.Namespace }} +{{- end }} --- # permissions for end users to edit connections (new name). apiVersion: rbac.authorization.k8s.io/v1 @@ -303,15 +362,12 @@ rules: # Deprecated editor/viewer roles (migration window — remove in v1.8.0) # permissions for end users to edit temporalconnections. apiVersion: rbac.authorization.k8s.io/v1 -kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} +kind: ClusterRole metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-temporalconnection-editor-role - {{- if .Values.rbac.ownNamespace }} - namespace: {{ .Release.Namespace }} - {{- end }} rules: - apiGroups: - temporal.io @@ -334,15 +390,12 @@ rules: --- # permissions for end users to view temporalconnections. apiVersion: rbac.authorization.k8s.io/v1 -kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} +kind: ClusterRole metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-temporalconnection-viewer-role - {{- if .Values.rbac.ownNamespace }} - namespace: {{ .Release.Namespace }} - {{- end }} rules: - apiGroups: - temporal.io @@ -361,15 +414,12 @@ rules: --- # permissions for end users to edit temporalworkerdeployments. apiVersion: rbac.authorization.k8s.io/v1 -kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} +kind: ClusterRole metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-temporalworkerdeployment-editor-role - {{- if .Values.rbac.ownNamespace }} - namespace: {{ .Release.Namespace }} - {{- end }} rules: - apiGroups: - temporal.io @@ -392,15 +442,12 @@ rules: --- # permissions for end users to view temporalworkerdeployments. apiVersion: rbac.authorization.k8s.io/v1 -kind: {{ include "temporal-worker-controller.rbac.roleKind" . }} +kind: ClusterRole metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" . | nindent 4 }} name: {{ .Release.Name }}-{{ .Release.Namespace }}-temporalworkerdeployment-viewer-role - {{- if .Values.rbac.ownNamespace }} - namespace: {{ .Release.Namespace }} - {{- end }} rules: - apiGroups: - temporal.io diff --git a/helm/temporal-worker-controller/templates/webhook.yaml b/helm/temporal-worker-controller/templates/webhook.yaml index 9acd8b74..c159d5c1 100644 --- a/helm/temporal-worker-controller/templates/webhook.yaml +++ b/helm/temporal-worker-controller/templates/webhook.yaml @@ -48,6 +48,9 @@ webhooks: operations: ["CREATE", "UPDATE", "DELETE"] resources: ["workerresourcetemplates"] sideEffects: None + {{- if .Values.rbac.restrictWatchNamespaces }} + {{- include "temporal-worker-controller.webhookNamespaceSelector" . | nindent 4 }} + {{- end }} {{- if .Values.webhook.enabled }} --- # ValidatingWebhookConfiguration for WorkerDeployment. @@ -80,4 +83,7 @@ webhooks: operations: ["CREATE", "UPDATE"] resources: ["workerdeployments"] sideEffects: None + {{- if .Values.rbac.restrictWatchNamespaces }} + {{- include "temporal-worker-controller.webhookNamespaceSelector" . | nindent 4 }} + {{- end }} {{- end }} diff --git a/helm/temporal-worker-controller/values.schema.json b/helm/temporal-worker-controller/values.schema.json index fab85d20..30001aff 100644 --- a/helm/temporal-worker-controller/values.schema.json +++ b/helm/temporal-worker-controller/values.schema.json @@ -109,6 +109,13 @@ "create": { "type": "boolean", "description": "Whether to create RBAC resources" + }, + "restrictWatchNamespaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Namespaces the controller watches and is granted RBAC in. Empty (the default) watches all namespaces with cluster-wide RBAC; when set, the controller is scoped to these namespaces via per-namespace RoleBindings plus a minimal cluster-scoped role." } } }, diff --git a/helm/temporal-worker-controller/values.yaml b/helm/temporal-worker-controller/values.yaml index d35a840b..d4246c31 100644 --- a/helm/temporal-worker-controller/values.yaml +++ b/helm/temporal-worker-controller/values.yaml @@ -40,16 +40,13 @@ priorityClassName: "" rbac: # Specifies whether RBAC resources should be created create: true - # Specifies whether the RBAC resources should be namespace-scoped or not - # Set to true to create Roles and RoleBindings in the release namespace instead of ClusterRoles and ClusterRoleBindings - ownNamespace: false - -# Comma-separated list of namespaces the controller should watch. -# Examples: -# watchNamespace: "foo" -# watchNamespace: "foo,bar" -# If empty, the controller watches all namespaces (cluster-wide). -watchNamespace: "" + # restrictWatchNamespaces keeps the namespaces the controller watches and the + # namespaces its RBAC grants access to in sync. Empty (the default) watches all + # namespaces with cluster-wide RBAC. When set, the controller watches only these + # namespaces (via WATCH_NAMESPACES) and is granted the manager role through a + # RoleBinding in each listed namespace, plus a minimal cluster-scoped role for the + # two grants that cannot be namespaced (namespaces get, subjectaccessreviews create). + restrictWatchNamespaces: [] serviceAccount: # Specifies whether a ServiceAccount should be created diff --git a/internal/controller/cache.go b/internal/controller/cache.go index e0b5730b..bf0a9b1d 100644 --- a/internal/controller/cache.go +++ b/internal/controller/cache.go @@ -5,6 +5,8 @@ package controller import ( + "strings" + "github.com/temporalio/temporal-worker-controller/internal/k8s" appsv1 "k8s.io/api/apps/v1" "k8s.io/apimachinery/pkg/labels" @@ -19,17 +21,48 @@ import ( // but controller-runtime still lists, watches, and retains cached Deployment // objects before those events reach the controller. Restricting the manager cache // prevents unrelated cluster Deployments from growing the controller's memory use. -func NewCacheOptions() (cache.Options, error) { +// +// When watchNamespaces is non-empty the cache (and therefore the controller's +// watches) is restricted to those namespaces; empty means all namespaces. +func NewCacheOptions(watchNamespaces []string) (cache.Options, error) { deploymentLabelReq, err := labels.NewRequirement(k8s.WorkerDeploymentNameLabel, selection.Exists, nil) if err != nil { return cache.Options{}, err } - return cache.Options{ + opts := cache.Options{ ByObject: map[client.Object]cache.ByObject{ &appsv1.Deployment{}: { Label: labels.NewSelector().Add(*deploymentLabelReq), }, }, - }, nil + } + + if len(watchNamespaces) > 0 { + defaultNamespaces := make(map[string]cache.Config, len(watchNamespaces)) + for _, ns := range watchNamespaces { + defaultNamespaces[ns] = cache.Config{} + } + opts.DefaultNamespaces = defaultNamespaces + } + + return opts, nil +} + +// ParseWatchNamespaces splits a comma-separated namespace list (from the +// --watch-namespaces flag or WATCH_NAMESPACES env var) into a slice, trimming +// whitespace and dropping empty entries. An empty input returns nil, which +// NewCacheOptions treats as "watch all namespaces". +func ParseWatchNamespaces(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + namespaces := make([]string, 0, len(parts)) + for _, p := range parts { + if ns := strings.TrimSpace(p); ns != "" { + namespaces = append(namespaces, ns) + } + } + return namespaces } diff --git a/internal/controller/cache_test.go b/internal/controller/cache_test.go index df709dd2..8474c291 100644 --- a/internal/controller/cache_test.go +++ b/internal/controller/cache_test.go @@ -5,6 +5,7 @@ package controller import ( + "reflect" "testing" "github.com/temporalio/temporal-worker-controller/internal/k8s" @@ -13,7 +14,7 @@ import ( ) func TestNewCacheOptionsScopesDeploymentsByWorkerLabel(t *testing.T) { - opts, err := NewCacheOptions() + opts, err := NewCacheOptions(nil) if err != nil { t.Fatalf("NewCacheOptions returned error: %v", err) } @@ -40,3 +41,53 @@ func TestNewCacheOptionsScopesDeploymentsByWorkerLabel(t *testing.T) { t.Fatal("expected selector not to match unlabeled Deployment") } } + +func TestNewCacheOptionsScopesToWatchNamespaces(t *testing.T) { + opts, err := NewCacheOptions([]string{"ns-a", "ns-b"}) + if err != nil { + t.Fatalf("NewCacheOptions returned error: %v", err) + } + + if len(opts.DefaultNamespaces) != 2 { + t.Fatalf("expected 2 default namespaces, got %d", len(opts.DefaultNamespaces)) + } + for _, ns := range []string{"ns-a", "ns-b"} { + if _, ok := opts.DefaultNamespaces[ns]; !ok { + t.Fatalf("expected namespace %q in DefaultNamespaces", ns) + } + } +} + +func TestNewCacheOptionsWatchesAllNamespacesWhenEmpty(t *testing.T) { + opts, err := NewCacheOptions(nil) + if err != nil { + t.Fatalf("NewCacheOptions returned error: %v", err) + } + + if len(opts.DefaultNamespaces) != 0 { + t.Fatalf("expected no default namespaces, got %d", len(opts.DefaultNamespaces)) + } +} + +func TestParseWatchNamespaces(t *testing.T) { + tests := []struct { + name string + raw string + want []string + }{ + {name: "empty returns nil", raw: "", want: nil}, + {name: "single namespace", raw: "ns-a", want: []string{"ns-a"}}, + {name: "comma separated", raw: "ns-a,ns-b", want: []string{"ns-a", "ns-b"}}, + {name: "trims whitespace and drops empties", raw: " ns-a , , ns-b ,", want: []string{"ns-a", "ns-b"}}, + {name: "separators only returns empty slice", raw: " , , ", want: []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseWatchNamespaces(tt.raw) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("ParseWatchNamespaces(%q) = %#v, want %#v", tt.raw, got, tt.want) + } + }) + } +}