From 9bdfe8a8048f0c80a5dd5793ac5fa126f5e16a63 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Tue, 28 Apr 2026 22:11:45 +0200 Subject: [PATCH] feat(chart): allow disabling chart RBAC and scoping operator to namespaces Adds two Helm values to address #468: - `rbac.create` (default true) skips chart-managed RBAC when false so operators can supply their own (e.g. via a centrally managed policy) - `watchNamespaces` (default empty) restricts the operator to a fixed list of namespaces. The chart switches from ClusterRole/ClusterRoleBinding to per-namespace Role/RoleBinding, and the manager passes `--watch-namespaces` so its informer cache only watches those namespaces (plus the operator's own namespace, for backup credentials). The manager rules are now sourced from a single `openclaw-operator.managerRules` named template so the same set is rendered into either the ClusterRole or per-namespace Roles. The `hack/check-helm-rbac-sync.sh` guard now parses that helper and asserts rbac.yaml still includes it, so a stale chart still fails CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 36 +++++ .../openclaw-operator/templates/_helpers.tpl | 89 +++++++++++ .../templates/deployment.yaml | 3 + .../templates/metrics-rbac.yaml | 2 +- charts/openclaw-operator/templates/rbac.yaml | 143 +++++++----------- charts/openclaw-operator/values.yaml | 18 +++ cmd/main.go | 55 ++++++- cmd/main_test.go | 40 +++++ hack/check-helm-rbac-sync.sh | 30 +++- 9 files changed, 312 insertions(+), 104 deletions(-) create mode 100644 cmd/main_test.go diff --git a/README.md b/README.md index 4f4779f8..718cfdd3 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,42 @@ make deploy IMG=ghcr.io/openclaw-rocks/openclaw-operator:latest +
+Restrict the operator to specific namespaces + +To run the operator with namespaced RBAC instead of cluster-wide permissions, +list the namespaces it should watch. The chart switches from +`ClusterRole`/`ClusterRoleBinding` to per-namespace `Role`/`RoleBinding`, and +passes `--watch-namespaces` to the operator so its informer cache is scoped +to that list (plus the operator's own namespace, for backup credentials). + +```bash +helm install openclaw-operator \ + oci://ghcr.io/openclaw-rocks/charts/openclaw-operator \ + --namespace openclaw-operator-system \ + --create-namespace \ + --set 'watchNamespaces={team-a,team-b}' +``` + +Each listed namespace must already exist; the chart does not create them. + +To bring your own RBAC entirely (e.g. managed by a separate controller or +SecurityCenter policy), disable chart-managed RBAC: + +```bash +helm install openclaw-operator \ + oci://ghcr.io/openclaw-rocks/charts/openclaw-operator \ + --namespace openclaw-operator-system \ + --create-namespace \ + --set rbac.create=false +``` + +The kubebuilder markers in `internal/controller/` and the manager rules helper +at `charts/openclaw-operator/templates/_helpers.tpl` document the minimum +permission set the operator requires. + +
+ ### 2. Create a secret with your API keys ```yaml diff --git a/charts/openclaw-operator/templates/_helpers.tpl b/charts/openclaw-operator/templates/_helpers.tpl index 815186ac..00998907 100644 --- a/charts/openclaw-operator/templates/_helpers.tpl +++ b/charts/openclaw-operator/templates/_helpers.tpl @@ -58,3 +58,92 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{/* +Manager rules. Single source of truth so the same set of permissions is +rendered into the cluster-scoped ClusterRole or per-namespace Role. +hack/check-helm-rbac-sync.sh asserts this set is a superset of the +kubebuilder-generated config/rbac/role.yaml. +*/}} +{{- define "openclaw-operator.managerRules" -}} +# Core API resources +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch"] +- apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: [""] + resources: ["serviceaccounts"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] +# Apps API +- apiGroups: ["apps"] + resources: ["statefulsets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "delete"] +# Batch API (backup/restore Jobs, periodic backup CronJobs) +- apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "watch", "create", "delete"] +- apiGroups: ["batch"] + resources: ["cronjobs"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# RBAC +- apiGroups: ["rbac.authorization.k8s.io"] + resources: ["roles", "rolebindings"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Networking +- apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies", "ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Policy +- apiGroups: ["policy"] + resources: ["poddisruptionbudgets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Autoscaling +- apiGroups: ["autoscaling"] + resources: ["horizontalpodautoscalers"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Monitoring +- apiGroups: ["monitoring.coreos.com"] + resources: ["servicemonitors", "prometheusrules"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# OpenClaw CRDs +- apiGroups: ["openclaw.rocks"] + resources: ["openclawinstances"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["openclaw.rocks"] + resources: ["openclawinstances/status"] + verbs: ["get", "update", "patch"] +- apiGroups: ["openclaw.rocks"] + resources: ["openclawinstances/finalizers"] + verbs: ["update"] +# OpenClawSelfConfig CRD +- apiGroups: ["openclaw.rocks"] + resources: ["openclawselfconfigs"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["openclaw.rocks"] + resources: ["openclawselfconfigs/status"] + verbs: ["get", "update", "patch"] +- apiGroups: ["openclaw.rocks"] + resources: ["openclawselfconfigs/finalizers"] + verbs: ["update"] +# OpenClawClusterDefaults singleton (#457) +- apiGroups: ["openclaw.rocks"] + resources: ["openclawclusterdefaults"] + verbs: ["get", "list", "watch"] +{{- end }} diff --git a/charts/openclaw-operator/templates/deployment.yaml b/charts/openclaw-operator/templates/deployment.yaml index 6865db99..71357aef 100644 --- a/charts/openclaw-operator/templates/deployment.yaml +++ b/charts/openclaw-operator/templates/deployment.yaml @@ -50,6 +50,9 @@ spec: - --metrics-bind-address=0 {{- end }} - --zap-log-level={{ .Values.logLevel }} + {{- if gt (len .Values.watchNamespaces) 0 }} + - --watch-namespaces={{ join "," .Values.watchNamespaces }} + {{- end }} {{- if .Values.otlp.enabled }} {{- if not .Values.otlp.endpoint }} {{- fail "otlp.endpoint is required when otlp.enabled is true" }} diff --git a/charts/openclaw-operator/templates/metrics-rbac.yaml b/charts/openclaw-operator/templates/metrics-rbac.yaml index 950b7eac..40801cce 100644 --- a/charts/openclaw-operator/templates/metrics-rbac.yaml +++ b/charts/openclaw-operator/templates/metrics-rbac.yaml @@ -1,4 +1,4 @@ -{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} +{{- if and .Values.rbac.create .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: diff --git a/charts/openclaw-operator/templates/rbac.yaml b/charts/openclaw-operator/templates/rbac.yaml index b3bbd73d..51fc3943 100644 --- a/charts/openclaw-operator/templates/rbac.yaml +++ b/charts/openclaw-operator/templates/rbac.yaml @@ -1,105 +1,65 @@ +{{- if .Values.rbac.create -}} +{{- $managerName := printf "%s-manager-role" (include "openclaw-operator.fullname" .) -}} +{{- $managerBinding := printf "%s-manager-rolebinding" (include "openclaw-operator.fullname" .) -}} +{{- $saName := include "openclaw-operator.serviceAccountName" . -}} +{{- $saNamespace := .Release.Namespace -}} +{{- $labels := include "openclaw-operator.labels" . -}} +{{- if gt (len .Values.watchNamespaces) 0 -}} +{{- range $i, $ns := .Values.watchNamespaces }} +{{- if gt $i 0 }} +--- +{{- end }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ $managerName }} + namespace: {{ $ns }} + labels: + {{- $labels | nindent 4 }} +rules: + {{- include "openclaw-operator.managerRules" $ | nindent 2 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ $managerBinding }} + namespace: {{ $ns }} + labels: + {{- $labels | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ $managerName }} +subjects: + - kind: ServiceAccount + name: {{ $saName }} + namespace: {{ $saNamespace }} +{{- end }} +{{- else }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: {{ include "openclaw-operator.fullname" . }}-manager-role + name: {{ $managerName }} labels: - {{- include "openclaw-operator.labels" . | nindent 4 }} + {{- $labels | nindent 4 }} rules: - # Core API resources - - apiGroups: [""] - resources: ["configmaps"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "list", "watch", "create", "update", "patch"] - - apiGroups: [""] - resources: ["services"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [""] - resources: ["serviceaccounts"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [""] - resources: ["events"] - verbs: ["create", "patch"] - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] - # Apps API - - apiGroups: ["apps"] - resources: ["statefulsets"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get", "list", "watch", "delete"] - # Batch API (backup/restore Jobs, periodic backup CronJobs) - - apiGroups: ["batch"] - resources: ["jobs"] - verbs: ["get", "list", "watch", "create", "delete"] - - apiGroups: ["batch"] - resources: ["cronjobs"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # RBAC - - apiGroups: ["rbac.authorization.k8s.io"] - resources: ["roles", "rolebindings"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # Networking - - apiGroups: ["networking.k8s.io"] - resources: ["networkpolicies", "ingresses"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # Policy - - apiGroups: ["policy"] - resources: ["poddisruptionbudgets"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # Autoscaling - - apiGroups: ["autoscaling"] - resources: ["horizontalpodautoscalers"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # Monitoring - - apiGroups: ["monitoring.coreos.com"] - resources: ["servicemonitors", "prometheusrules"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # OpenClaw CRDs - - apiGroups: ["openclaw.rocks"] - resources: ["openclawinstances"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["openclaw.rocks"] - resources: ["openclawinstances/status"] - verbs: ["get", "update", "patch"] - - apiGroups: ["openclaw.rocks"] - resources: ["openclawinstances/finalizers"] - verbs: ["update"] - # OpenClawSelfConfig CRD - - apiGroups: ["openclaw.rocks"] - resources: ["openclawselfconfigs"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["openclaw.rocks"] - resources: ["openclawselfconfigs/status"] - verbs: ["get", "update", "patch"] - - apiGroups: ["openclaw.rocks"] - resources: ["openclawselfconfigs/finalizers"] - verbs: ["update"] - # OpenClawClusterDefaults singleton (#457) - - apiGroups: ["openclaw.rocks"] - resources: ["openclawclusterdefaults"] - verbs: ["get", "list", "watch"] + {{- include "openclaw-operator.managerRules" . | nindent 2 }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: {{ include "openclaw-operator.fullname" . }}-manager-rolebinding + name: {{ $managerBinding }} labels: - {{- include "openclaw-operator.labels" . | nindent 4 }} + {{- $labels | nindent 4 }} roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: {{ include "openclaw-operator.fullname" . }}-manager-role + name: {{ $managerName }} subjects: - kind: ServiceAccount - name: {{ include "openclaw-operator.serviceAccountName" . }} - namespace: {{ .Release.Namespace }} + name: {{ $saName }} + namespace: {{ $saNamespace }} +{{- end }} {{- if .Values.leaderElection.enabled }} --- apiVersion: rbac.authorization.k8s.io/v1 @@ -107,7 +67,7 @@ kind: Role metadata: name: {{ include "openclaw-operator.fullname" . }}-leader-election-role labels: - {{- include "openclaw-operator.labels" . | nindent 4 }} + {{- $labels | nindent 4 }} rules: - apiGroups: [""] resources: ["configmaps"] @@ -124,13 +84,14 @@ kind: RoleBinding metadata: name: {{ include "openclaw-operator.fullname" . }}-leader-election-rolebinding labels: - {{- include "openclaw-operator.labels" . | nindent 4 }} + {{- $labels | nindent 4 }} roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: {{ include "openclaw-operator.fullname" . }}-leader-election-role subjects: - kind: ServiceAccount - name: {{ include "openclaw-operator.serviceAccountName" . }} - namespace: {{ .Release.Namespace }} + name: {{ $saName }} + namespace: {{ $saNamespace }} +{{- end }} {{- end }} diff --git a/charts/openclaw-operator/values.yaml b/charts/openclaw-operator/values.yaml index 385681f8..2e499614 100644 --- a/charts/openclaw-operator/values.yaml +++ b/charts/openclaw-operator/values.yaml @@ -20,6 +20,24 @@ serviceAccount: annotations: {} name: "" +# RBAC configuration +rbac: + # Whether the chart should render the operator's RBAC (ClusterRole/Role + # and bindings). Set to false to disable and supply your own out-of-band. + create: true + +# Restrict the operator to a fixed list of namespaces. When empty (default), +# the operator watches OpenClawInstance resources cluster-wide and the chart +# renders cluster-scoped RBAC. When set to one or more namespaces, the +# operator watches only those namespaces and the chart renders a namespaced +# Role/RoleBinding pair in each one (instead of a ClusterRole/ClusterRoleBinding). +# Each listed namespace must already exist (the chart does not create them). +watchNamespaces: [] +# Example: +# watchNamespaces: +# - team-a +# - team-b + # Pod annotations podAnnotations: {} diff --git a/cmd/main.go b/cmd/main.go index c36e1431..d2faf81a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,6 +23,7 @@ import ( "fmt" "net/http" "os" + "strings" "sync/atomic" "time" @@ -39,6 +40,7 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" 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" "sigs.k8s.io/controller-runtime/pkg/metrics" @@ -73,6 +75,7 @@ func main() { var enableHTTP2 bool var otlpEndpoint string var otlpInsecure bool + var watchNamespacesFlag string var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable.") @@ -82,6 +85,7 @@ func main() { flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers.") flag.StringVar(&otlpEndpoint, "otlp-endpoint", "", "OTLP gRPC endpoint for metrics export (e.g. collector.observability.svc:4317). Also respects OTEL_EXPORTER_OTLP_ENDPOINT env var.") flag.BoolVar(&otlpInsecure, "otlp-insecure", true, "If set, OTLP exporter connects without TLS.") + flag.StringVar(&watchNamespacesFlag, "watch-namespaces", "", "Comma-separated list of namespaces to watch. If empty, the operator watches all namespaces (cluster-scoped). Set this when running with namespaced RBAC.") opts := zap.Options{ Development: true, @@ -127,17 +131,13 @@ func main() { metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization } - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + mgrOpts := ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "openclaw-operator.openclaw.rocks", - }) - if err != nil { - setupLog.Error(err, "unable to start manager") - os.Exit(1) } operatorNamespace := os.Getenv("POD_NAMESPACE") @@ -145,6 +145,28 @@ func main() { operatorNamespace = "openclaw-operator-system" } + watchNamespaces := parseWatchNamespaces(watchNamespacesFlag) + if len(watchNamespaces) > 0 { + nsCfg := make(map[string]cache.Config, len(watchNamespaces)+1) + for _, ns := range watchNamespaces { + nsCfg[ns] = cache.Config{} + } + // Always include the operator's own namespace so it can read its + // backup credentials Secret and other operator-scoped resources + // (e.g. s3-backup-credentials). + if _, ok := nsCfg[operatorNamespace]; !ok { + nsCfg[operatorNamespace] = cache.Config{} + } + mgrOpts.Cache = cache.Options{DefaultNamespaces: nsCfg} + setupLog.Info("restricting watch to namespaces", "namespaces", watchNamespaces, "operatorNamespace", operatorNamespace) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + versionResolver := registry.NewResolver(5 * time.Minute) skillPackResolver := skillpacks.NewResolver(5*time.Minute, os.Getenv("GITHUB_TOKEN")) @@ -230,6 +252,29 @@ func main() { } } +// parseWatchNamespaces splits the --watch-namespaces flag value into a +// deduplicated list of namespace names, dropping empty entries. +func parseWatchNamespaces(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + seen := make(map[string]struct{}) + out := make([]string, 0) + for _, ns := range strings.Split(raw, ",") { + ns = strings.TrimSpace(ns) + if ns == "" { + continue + } + if _, ok := seen[ns]; ok { + continue + } + seen[ns] = struct{}{} + out = append(out, ns) + } + return out +} + // setupOTLPMetrics configures an OTLP gRPC metrics exporter that bridges all // Prometheus metrics registered with controller-runtime's default registry. // This includes both built-in controller-runtime metrics (workqueue, client, diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 00000000..f9f30a0a --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,40 @@ +/* +Copyright 2026 OpenClaw.rocks + +Licensed 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 +*/ + +package main + +import ( + "reflect" + "testing" +) + +func TestParseWatchNamespaces(t *testing.T) { + cases := []struct { + name string + in string + want []string + }{ + {name: "empty", in: "", want: nil}, + {name: "whitespace only", in: " ", want: nil}, + {name: "single", in: "team-a", want: []string{"team-a"}}, + {name: "multiple", in: "team-a,team-b,team-c", want: []string{"team-a", "team-b", "team-c"}}, + {name: "trims spaces", in: " team-a , team-b ", want: []string{"team-a", "team-b"}}, + {name: "drops empty entries", in: "team-a,,team-b,", want: []string{"team-a", "team-b"}}, + {name: "deduplicates", in: "team-a,team-b,team-a", want: []string{"team-a", "team-b"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseWatchNamespaces(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("parseWatchNamespaces(%q) = %#v, want %#v", tc.in, got, tc.want) + } + }) + } +} diff --git a/hack/check-helm-rbac-sync.sh b/hack/check-helm-rbac-sync.sh index be1ae71e..a08e1f22 100755 --- a/hack/check-helm-rbac-sync.sh +++ b/hack/check-helm-rbac-sync.sh @@ -13,15 +13,20 @@ set -euo pipefail GENERATED="config/rbac/role.yaml" HELM="charts/openclaw-operator/templates/rbac.yaml" +HELPERS="charts/openclaw-operator/templates/_helpers.tpl" if [ ! -f "$GENERATED" ]; then - echo "::error::Generated RBAC not found at $GENERATED — run 'make manifests' first" + echo "::error::Generated RBAC not found at $GENERATED -- run 'make manifests' first" exit 1 fi if [ ! -f "$HELM" ]; then echo "::error::Helm chart RBAC not found at $HELM" exit 1 fi +if [ ! -f "$HELPERS" ]; then + echo "::error::Helm chart helpers not found at $HELPERS" + exit 1 +fi # Parse kubebuilder-generated role.yaml (multi-line YAML) into # sorted "apiGroup|resource|verb" triples, one per line. @@ -57,13 +62,16 @@ parse_generated() { ' "$GENERATED" | sort -u } -# Parse Helm chart ClusterRole (inline JSON arrays) into the same -# "apiGroup|resource|verb" triple format. -# Reads only the first YAML document (before ---) and skips Helm templates. +# Parse the manager rules block out of _helpers.tpl. The block lives between +# `{{- define "openclaw-operator.managerRules" -}}` and the matching `{{- end }}`. +# rbac.yaml renders these rules into either a ClusterRole or per-namespace Roles +# via `{{ include "openclaw-operator.managerRules" . | nindent 2 }}`, so the +# helper is the single source of truth for what permissions the operator gets. parse_helm() { awk ' - /^---/ { exit } - /\{\{/ { next } + /\{\{-? *define .openclaw-operator\.managerRules. *-?\}\}/ { in_rules = 1; next } + in_rules && /\{\{-? *end *-?\}\}/ { exit } + !in_rules { next } /^\s*#/ { next } /apiGroups:/ { @@ -94,9 +102,17 @@ parse_helm() { for (v = 1; v <= nverbs; v++) print groups[g] "|" resources[r] "|" verbs[v] } - ' "$HELM" | sort -u + ' "$HELPERS" | sort -u } +# Sanity-check that rbac.yaml actually renders the helper. If someone removes +# the include we want the check to fail loudly rather than silently accept +# matching helper rules that no template references. +if ! grep -q 'openclaw-operator\.managerRules' "$HELM"; then + echo "::error::$HELM does not include the openclaw-operator.managerRules helper" + exit 1 +fi + GENERATED_TRIPLES=$(parse_generated) HELM_TRIPLES=$(parse_helm)