From 00918b687010bc8e2847906c7f2563f071ecc934 Mon Sep 17 00:00:00 2001 From: Josh Baird Date: Thu, 18 Jun 2026 13:07:43 -0400 Subject: [PATCH 1/7] feat(rbac): complete namespaced mode to reduce cluster-wide permissions Extends the existing --watch-namespaces support so the operator can run without cluster-wide RBAC on the resources it manages: - Generalize MakeScopedRBACObjects/Names to take a component so the collector and fluentd controllers can create namespaced Role/RoleBinding (previously only fluent-bit honored namespaced mode) - Add a Namespaced flag to the Collector and Fluentd reconcilers, wired to --watch-namespaces, mirroring the FluentBit controller - Clean up the per-instance (Cluster)RoleBinding on deletion (fills the existing TODOs); the shared (Cluster)Role is intentionally preserved - Expose operator.watchNamespaces as a first-class Helm value Refs fluent/fluent-operator#1281 Co-authored-by: Cursor Signed-off-by: Josh Baird --- .../templates/fluent-operator-deployment.yaml | 3 + charts/fluent-operator/values.yaml | 4 + cmd/fluent-manager/main.go | 14 +-- controllers/collector_controller.go | 96 ++++++++++++++++--- controllers/fluent_controller_finalizer.go | 58 ++++++++++- controllers/fluentbit_controller.go | 31 +++--- controllers/fluentd_controller.go | 39 +++++--- pkg/operator/rbac.go | 16 +++- 8 files changed, 209 insertions(+), 52 deletions(-) diff --git a/charts/fluent-operator/templates/fluent-operator-deployment.yaml b/charts/fluent-operator/templates/fluent-operator-deployment.yaml index 6089a2fd1..2417ac4e9 100644 --- a/charts/fluent-operator/templates/fluent-operator-deployment.yaml +++ b/charts/fluent-operator/templates/fluent-operator-deployment.yaml @@ -55,6 +55,9 @@ spec: {{- with .Values.operator.disableComponentControllers }} - --disable-component-controllers={{ . }} {{- end }} + {{- with .Values.operator.watchNamespaces }} + - --watch-namespaces={{ . }} + {{- end }} {{- with .Values.operator.livenessProbe }} livenessProbe: {{- toYaml . | nindent 10 }} diff --git a/charts/fluent-operator/values.yaml b/charts/fluent-operator/values.yaml index 7dc5d56b0..15a524d54 100644 --- a/charts/fluent-operator/values.yaml +++ b/charts/fluent-operator/values.yaml @@ -103,6 +103,10 @@ operator: # myExampleLabel: someValue # -- Disable specific component controllers. Value can be "fluent-bit" or "fluentd" to disable that controller disableComponentControllers: "" + # -- Comma separated list of namespaces the operator should watch and manage resources in. When set, the + # operator scopes its cache and creates namespaced Roles/RoleBindings (instead of cluster-wide) for the + # agents it manages, allowing the operator's own RBAC to be reduced. Defaults to cluster scope when empty. + watchNamespaces: "" # -- Extra arguments for the Fluent Operator controller extraArgs: [] diff --git a/cmd/fluent-manager/main.go b/cmd/fluent-manager/main.go index 407022742..b7b994f76 100644 --- a/cmd/fluent-manager/main.go +++ b/cmd/fluent-manager/main.go @@ -232,9 +232,10 @@ func main() { } if err = (&controllers.CollectorReconciler{ - Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("Collector"), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("Collector"), + Scheme: mgr.GetScheme(), + Namespaced: namespacedController, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Collector") os.Exit(1) @@ -266,9 +267,10 @@ func main() { } if err = (&controllers.FluentdReconciler{ - Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("Fluentd"), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("Fluentd"), + Scheme: mgr.GetScheme(), + Namespaced: namespacedController, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Fluentd") os.Exit(1) diff --git a/controllers/collector_controller.go b/controllers/collector_controller.go index cdb5a0bcf..2a9e038d0 100644 --- a/controllers/collector_controller.go +++ b/controllers/collector_controller.go @@ -38,8 +38,9 @@ import ( // CollectorReconciler reconciles a FluentBit object type CollectorReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme + Log logr.Logger + Scheme *runtime.Scheme + Namespaced bool } // +kubebuilder:rbac:groups=fluentbit.fluent.io,resources=collectors,verbs=get;list;watch;update @@ -49,6 +50,8 @@ type CollectorReconciler struct { // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=create;get;list;watch;patch // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=create;get;list;watch;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;delete;get;list;watch;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;delete;get;list;watch;patch // +kubebuilder:rbac:groups=core,resources=pods,verbs=get // Reconcile is part of the main kubernetes reconciliation loop which aims to @@ -96,19 +99,30 @@ func (r *CollectorReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } // Install RBAC resources for the filter plugin kubernetes - cr, sa, crb := operator.MakeRBACObjects( - co.Name, - co.Namespace, - "collector", - co.Spec.RBACRules, - co.Spec.ServiceAccountAnnotations, - ) - // Deploy Fluent Bit Collector ClusterRole - if _, err := controllerutil.CreateOrPatch(ctx, r.Client, cr, r.mutate(cr, &co)); err != nil { + var role, sa, binding client.Object + if r.Namespaced { + role, sa, binding = operator.MakeScopedRBACObjects( + co.Name, + co.Namespace, + "collector", + co.Spec.RBACRules, + co.Spec.ServiceAccountAnnotations, + ) + } else { + role, sa, binding = operator.MakeRBACObjects( + co.Name, + co.Namespace, + "collector", + co.Spec.RBACRules, + co.Spec.ServiceAccountAnnotations, + ) + } + // Deploy Fluent Bit Collector (Cluster)Role + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, role, r.mutate(role, &co)); err != nil { return ctrl.Result{}, err } - // Deploy Fluent Bit Collector ClusterRoleBinding - if _, err := controllerutil.CreateOrPatch(ctx, r.Client, crb, r.mutate(crb, &co)); err != nil { + // Deploy Fluent Bit Collector (Cluster)RoleBinding + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, binding, r.mutate(binding, &co)); err != nil { return ctrl.Result{}, err } // Deploy Fluent Bit Collector ServiceAccount @@ -175,6 +189,36 @@ func (r *CollectorReconciler) mutate(obj client.Object, co *fluentbitv1alpha2.Co o.Subjects = expected.Subjects return nil } + case *rbacv1.Role: + // The Role is shared across all Collector instances in the namespace, so + // no per-instance controller reference is set on it. + expected, _, _ := operator.MakeScopedRBACObjects(co.Name, + co.Namespace, + "collector", + co.Spec.RBACRules, + co.Spec.ServiceAccountAnnotations, + ) + + return func() error { + o.Rules = expected.Rules + return nil + } + case *rbacv1.RoleBinding: + _, _, expected := operator.MakeScopedRBACObjects(co.Name, + co.Namespace, + "collector", + co.Spec.RBACRules, + co.Spec.ServiceAccountAnnotations, + ) + + return func() error { + o.RoleRef = expected.RoleRef + o.Subjects = expected.Subjects + if err := ctrl.SetControllerReference(co, o, r.Scheme); err != nil { + return err + } + return nil + } case *appsv1.StatefulSet: expected := operator.MakefbStatefulset(*co) @@ -214,7 +258,31 @@ func (r *CollectorReconciler) delete(ctx context.Context, co *fluentbitv1alpha2. if err := r.Delete(ctx, &sa); err != nil && !errors.IsNotFound(err) { return err } - // TODO: clusterrole, clusterrolebinding + + // Only the per-instance (Cluster)RoleBinding is removed here; the (Cluster)Role + // is shared across all Collector instances and must not be deleted. + if r.Namespaced { + _, _, rbName := operator.MakeScopedRBACNames(co.Name, "collector") + rolebinding := rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: rbName, + Namespace: co.Namespace, + }, + } + if err := r.Delete(ctx, &rolebinding); err != nil && !errors.IsNotFound(err) { + return err + } + } else { + _, _, crbName := operator.MakeRBACNames(co.Name, "collector") + crb := rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: crbName, + }, + } + if err := r.Delete(ctx, &crb); err != nil && !errors.IsNotFound(err) { + return err + } + } sts := appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ diff --git a/controllers/fluent_controller_finalizer.go b/controllers/fluent_controller_finalizer.go index b1b0745a9..c3e37339e 100644 --- a/controllers/fluent_controller_finalizer.go +++ b/controllers/fluent_controller_finalizer.go @@ -76,7 +76,31 @@ func (r *FluentdReconciler) delete(ctx context.Context, fd *fluentdv1alpha1.Flue if err := r.Delete(ctx, &sa); err != nil && !errors.IsNotFound(err) { return err } - // TODO: clusterrole, clusterrolebinding + + // Only the per-instance (Cluster)RoleBinding is removed here; the (Cluster)Role + // is shared across all Fluentd instances and must not be deleted. + if r.Namespaced { + _, _, rbName := operator.MakeScopedRBACNames(fd.Name, "fluentd") + rolebinding := rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: rbName, + Namespace: fd.Namespace, + }, + } + if err := r.Delete(ctx, &rolebinding); err != nil && !errors.IsNotFound(err) { + return err + } + } else { + _, _, crbName := operator.MakeRBACNames(fd.Name, "fluentd") + crb := rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: crbName, + }, + } + if err := r.Delete(ctx, &crb); err != nil && !errors.IsNotFound(err) { + return err + } + } sts := appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ @@ -157,6 +181,38 @@ func (r *FluentdReconciler) mutate(obj client.Object, fd *fluentdv1alpha1.Fluent o.Subjects = expected.Subjects return nil } + case *rbacv1.Role: + // The Role is shared across all Fluentd instances in the namespace, so + // no per-instance controller reference is set on it. + expected, _, _ := operator.MakeScopedRBACObjects( + fd.Name, + fd.Namespace, + "fluentd", + fd.Spec.RBACRules, + fd.Spec.ServiceAccountAnnotations, + ) + + return func() error { + o.Rules = expected.Rules + return nil + } + case *rbacv1.RoleBinding: + _, _, expected := operator.MakeScopedRBACObjects( + fd.Name, + fd.Namespace, + "fluentd", + fd.Spec.RBACRules, + fd.Spec.ServiceAccountAnnotations, + ) + + return func() error { + o.RoleRef = expected.RoleRef + o.Subjects = expected.Subjects + if err := ctrl.SetControllerReference(fd, o, r.Scheme); err != nil { + return err + } + return nil + } case *appsv1.StatefulSet: expected := operator.MakeStatefulSet(*fd) diff --git a/controllers/fluentbit_controller.go b/controllers/fluentbit_controller.go index bf1d2788b..314a4e1ec 100644 --- a/controllers/fluentbit_controller.go +++ b/controllers/fluentbit_controller.go @@ -106,6 +106,8 @@ func (r *FluentBitReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( role, sa, binding = operator.MakeScopedRBACObjects( fb.Name, fb.Namespace, + "fluent-bit", + fb.Spec.RBACRules, fb.Spec.ServiceAccountAnnotations, ) } else { @@ -197,7 +199,7 @@ func (r *FluentBitReconciler) mutate(obj client.Object, fb *fluentbitv1alpha2.Fl return nil } case *rbacv1.Role: - expected, _, _ := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, fb.Spec.ServiceAccountAnnotations) + expected, _, _ := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, "fluent-bit", fb.Spec.RBACRules, fb.Spec.ServiceAccountAnnotations) return func() error { o.Rules = expected.Rules @@ -218,7 +220,7 @@ func (r *FluentBitReconciler) mutate(obj client.Object, fb *fluentbitv1alpha2.Fl return nil } case *corev1.ServiceAccount: - _, expected, _ := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, fb.Spec.ServiceAccountAnnotations) + _, expected, _ := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, "fluent-bit", fb.Spec.RBACRules, fb.Spec.ServiceAccountAnnotations) return func() error { o.Annotations = expected.Annotations @@ -228,7 +230,7 @@ func (r *FluentBitReconciler) mutate(obj client.Object, fb *fluentbitv1alpha2.Fl return nil } case *rbacv1.RoleBinding: - _, _, expected := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, fb.Spec.ServiceAccountAnnotations) + _, _, expected := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, "fluent-bit", fb.Spec.RBACRules, fb.Spec.ServiceAccountAnnotations) return func() error { o.Subjects = expected.Subjects o.RoleRef = expected.RoleRef @@ -268,28 +270,31 @@ func (r *FluentBitReconciler) delete(ctx context.Context, fb *fluentbitv1alpha2. } if r.Namespaced { - roleName, _, roleBindingName := operator.MakeScopedRBACNames(fb.Name) - role := rbacv1.Role{ + _, _, roleBindingName := operator.MakeScopedRBACNames(fb.Name, "fluent-bit") + // Only the RoleBinding is per-instance; the Role is shared across all + // FluentBit instances in the namespace, so it must not be deleted here. + rolebinding := rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Name: roleName, + Name: roleBindingName, Namespace: fb.Namespace, }, } - if err := r.Delete(ctx, &role); err != nil && !errors.IsNotFound(err) { + if err := r.Delete(ctx, &rolebinding); err != nil && !errors.IsNotFound(err) { return err } - - rolebinding := rbacv1.RoleBinding{ + } else { + _, _, crbName := operator.MakeRBACNames(fb.Name, "fluent-bit") + // Only the ClusterRoleBinding is per-instance; the ClusterRole is shared + // across all FluentBit instances, so it must not be deleted here. + crb := rbacv1.ClusterRoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Name: roleBindingName, - Namespace: fb.Namespace, + Name: crbName, }, } - if err := r.Delete(ctx, &rolebinding); err != nil && !errors.IsNotFound(err) { + if err := r.Delete(ctx, &crb); err != nil && !errors.IsNotFound(err) { return err } } - // TODO: clusterrole, clusterrolebinding ds := appsv1.DaemonSet{ ObjectMeta: metav1.ObjectMeta{ diff --git a/controllers/fluentd_controller.go b/controllers/fluentd_controller.go index 6c99050b1..38c708ae7 100644 --- a/controllers/fluentd_controller.go +++ b/controllers/fluentd_controller.go @@ -43,8 +43,9 @@ const ( // FluentdReconciler reconciles a Fluentd object type FluentdReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme + Log logr.Logger + Scheme *runtime.Scheme + Namespaced bool } // +kubebuilder:rbac:groups=fluentd.fluent.io,resources=fluentds,verbs=get;list;watch;update @@ -54,6 +55,7 @@ type FluentdReconciler struct { // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get // +kubebuilder:rbac:groups=core,resources=serviceaccounts;services,verbs=get;list;watch;create;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles;clusterrolebindings,verbs=create;get;list;watch;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=create;delete;get;list;watch;patch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -100,19 +102,30 @@ func (r *FluentdReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct } // Install RBAC resources for the filter plugin kubernetes - cr, sa, crb := operator.MakeRBACObjects( - fd.Name, - fd.Namespace, - fluentdLowercase, - fd.Spec.RBACRules, - fd.Spec.ServiceAccountAnnotations, - ) - // Deploy Fluentd ClusterRole - if _, err := controllerutil.CreateOrPatch(ctx, r.Client, cr, r.mutate(cr, &fd)); err != nil { + var role, sa, binding client.Object + if r.Namespaced { + role, sa, binding = operator.MakeScopedRBACObjects( + fd.Name, + fd.Namespace, + fluentdLowercase, + fd.Spec.RBACRules, + fd.Spec.ServiceAccountAnnotations, + ) + } else { + role, sa, binding = operator.MakeRBACObjects( + fd.Name, + fd.Namespace, + fluentdLowercase, + fd.Spec.RBACRules, + fd.Spec.ServiceAccountAnnotations, + ) + } + // Deploy Fluentd (Cluster)Role + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, role, r.mutate(role, &fd)); err != nil { return ctrl.Result{}, err } - // Deploy Fluentd ClusterRoleBinding - if _, err := controllerutil.CreateOrPatch(ctx, r.Client, crb, r.mutate(crb, &fd)); err != nil { + // Deploy Fluentd (Cluster)RoleBinding + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, binding, r.mutate(binding, &fd)); err != nil { return ctrl.Result{}, err } // Deploy Fluentd ServiceAccount diff --git a/pkg/operator/rbac.go b/pkg/operator/rbac.go index c2e1536ea..6e270e321 100644 --- a/pkg/operator/rbac.go +++ b/pkg/operator/rbac.go @@ -62,10 +62,12 @@ func MakeRBACObjects( func MakeScopedRBACObjects( name, - namespace string, + namespace, + component string, + additionalRules []rbacv1.PolicyRule, saAnnotations map[string]string, ) (*rbacv1.Role, *corev1.ServiceAccount, *rbacv1.RoleBinding) { - rName, saName, rbName := MakeScopedRBACNames(name) + rName, saName, rbName := MakeScopedRBACNames(name, component) r := rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ Name: rName, @@ -80,6 +82,10 @@ func MakeScopedRBACObjects( }, } + if additionalRules != nil { + r.Rules = append(r.Rules, additionalRules...) + } + sa := corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: saName, @@ -116,8 +122,8 @@ func MakeRBACNames(name, component string) (string, string, string) { return cr, name, crb } -func MakeScopedRBACNames(name string) (string, string, string) { - r := "fluent:fluent-operator" - rb := fmt.Sprintf("fluent-operator-fluent-bit-%s", name) +func MakeScopedRBACNames(name, component string) (string, string, string) { + r := fmt.Sprintf("fluent-operator-%s", component) + rb := fmt.Sprintf("fluent-operator-%s-%s", component, name) return r, name, rb } From 8ae44281b1a71d8ced917106635edc7ab59417db Mon Sep 17 00:00:00 2001 From: Josh Baird Date: Thu, 18 Jun 2026 13:25:36 -0400 Subject: [PATCH 2/7] refactor(rbac): extract shared scope helpers to reduce duplication Collapse the duplicated namespaced-vs-cluster RBAC branching and the per-instance binding deletion across the FluentBit, Collector, and Fluentd controllers into operator.MakeRBACObjectsForScope and operator.DeletePerInstanceBinding. Co-authored-by: Cursor Signed-off-by: Josh Baird --- controllers/collector_controller.go | 56 +++++---------------- controllers/fluent_controller_finalizer.go | 25 +--------- controllers/fluentbit_controller.go | 55 +++++---------------- controllers/fluentd_controller.go | 31 ++++-------- pkg/operator/rbac.go | 57 +++++++++++++++++++--- 5 files changed, 84 insertions(+), 140 deletions(-) diff --git a/controllers/collector_controller.go b/controllers/collector_controller.go index 2a9e038d0..efbebbcd5 100644 --- a/controllers/collector_controller.go +++ b/controllers/collector_controller.go @@ -98,34 +98,21 @@ func (r *CollectorReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, err } - // Install RBAC resources for the filter plugin kubernetes - var role, sa, binding client.Object - if r.Namespaced { - role, sa, binding = operator.MakeScopedRBACObjects( - co.Name, - co.Namespace, - "collector", - co.Spec.RBACRules, - co.Spec.ServiceAccountAnnotations, - ) - } else { - role, sa, binding = operator.MakeRBACObjects( - co.Name, - co.Namespace, - "collector", - co.Spec.RBACRules, - co.Spec.ServiceAccountAnnotations, - ) - } - // Deploy Fluent Bit Collector (Cluster)Role + // Reconcile the RBAC the agent needs, scoped to a namespace or the cluster. + role, sa, binding := operator.MakeRBACObjectsForScope( + r.Namespaced, + co.Name, + co.Namespace, + "collector", + co.Spec.RBACRules, + co.Spec.ServiceAccountAnnotations, + ) if _, err := controllerutil.CreateOrPatch(ctx, r.Client, role, r.mutate(role, &co)); err != nil { return ctrl.Result{}, err } - // Deploy Fluent Bit Collector (Cluster)RoleBinding if _, err := controllerutil.CreateOrPatch(ctx, r.Client, binding, r.mutate(binding, &co)); err != nil { return ctrl.Result{}, err } - // Deploy Fluent Bit Collector ServiceAccount if _, err := controllerutil.CreateOrPatch(ctx, r.Client, sa, r.mutate(sa, &co)); err != nil { return ctrl.Result{}, err } @@ -259,29 +246,8 @@ func (r *CollectorReconciler) delete(ctx context.Context, co *fluentbitv1alpha2. return err } - // Only the per-instance (Cluster)RoleBinding is removed here; the (Cluster)Role - // is shared across all Collector instances and must not be deleted. - if r.Namespaced { - _, _, rbName := operator.MakeScopedRBACNames(co.Name, "collector") - rolebinding := rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: rbName, - Namespace: co.Namespace, - }, - } - if err := r.Delete(ctx, &rolebinding); err != nil && !errors.IsNotFound(err) { - return err - } - } else { - _, _, crbName := operator.MakeRBACNames(co.Name, "collector") - crb := rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: crbName, - }, - } - if err := r.Delete(ctx, &crb); err != nil && !errors.IsNotFound(err) { - return err - } + if err := operator.DeletePerInstanceBinding(ctx, r.Client, r.Namespaced, co.Name, co.Namespace, "collector"); err != nil { + return err } sts := appsv1.StatefulSet{ diff --git a/controllers/fluent_controller_finalizer.go b/controllers/fluent_controller_finalizer.go index c3e37339e..f5d805a00 100644 --- a/controllers/fluent_controller_finalizer.go +++ b/controllers/fluent_controller_finalizer.go @@ -77,29 +77,8 @@ func (r *FluentdReconciler) delete(ctx context.Context, fd *fluentdv1alpha1.Flue return err } - // Only the per-instance (Cluster)RoleBinding is removed here; the (Cluster)Role - // is shared across all Fluentd instances and must not be deleted. - if r.Namespaced { - _, _, rbName := operator.MakeScopedRBACNames(fd.Name, "fluentd") - rolebinding := rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: rbName, - Namespace: fd.Namespace, - }, - } - if err := r.Delete(ctx, &rolebinding); err != nil && !errors.IsNotFound(err) { - return err - } - } else { - _, _, crbName := operator.MakeRBACNames(fd.Name, "fluentd") - crb := rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: crbName, - }, - } - if err := r.Delete(ctx, &crb); err != nil && !errors.IsNotFound(err) { - return err - } + if err := operator.DeletePerInstanceBinding(ctx, r.Client, r.Namespaced, fd.Name, fd.Namespace, "fluentd"); err != nil { + return err } sts := appsv1.StatefulSet{ diff --git a/controllers/fluentbit_controller.go b/controllers/fluentbit_controller.go index 314a4e1ec..46166435e 100644 --- a/controllers/fluentbit_controller.go +++ b/controllers/fluentbit_controller.go @@ -100,25 +100,15 @@ func (r *FluentBitReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, err } - // Install RBAC resources for the filter plugin kubernetes - var role, sa, binding client.Object - if r.Namespaced { - role, sa, binding = operator.MakeScopedRBACObjects( - fb.Name, - fb.Namespace, - "fluent-bit", - fb.Spec.RBACRules, - fb.Spec.ServiceAccountAnnotations, - ) - } else { - role, sa, binding = operator.MakeRBACObjects( - fb.Name, - fb.Namespace, - "fluent-bit", - fb.Spec.RBACRules, - fb.Spec.ServiceAccountAnnotations, - ) - } + // Reconcile the RBAC the agent needs, scoped to a namespace or the cluster. + role, sa, binding := operator.MakeRBACObjectsForScope( + r.Namespaced, + fb.Name, + fb.Namespace, + "fluent-bit", + fb.Spec.RBACRules, + fb.Spec.ServiceAccountAnnotations, + ) if _, err := controllerutil.CreateOrPatch(ctx, r.Client, role, r.mutate(role, &fb)); err != nil { return ctrl.Result{}, err } @@ -269,31 +259,8 @@ func (r *FluentBitReconciler) delete(ctx context.Context, fb *fluentbitv1alpha2. return err } - if r.Namespaced { - _, _, roleBindingName := operator.MakeScopedRBACNames(fb.Name, "fluent-bit") - // Only the RoleBinding is per-instance; the Role is shared across all - // FluentBit instances in the namespace, so it must not be deleted here. - rolebinding := rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: roleBindingName, - Namespace: fb.Namespace, - }, - } - if err := r.Delete(ctx, &rolebinding); err != nil && !errors.IsNotFound(err) { - return err - } - } else { - _, _, crbName := operator.MakeRBACNames(fb.Name, "fluent-bit") - // Only the ClusterRoleBinding is per-instance; the ClusterRole is shared - // across all FluentBit instances, so it must not be deleted here. - crb := rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: crbName, - }, - } - if err := r.Delete(ctx, &crb); err != nil && !errors.IsNotFound(err) { - return err - } + if err := operator.DeletePerInstanceBinding(ctx, r.Client, r.Namespaced, fb.Name, fb.Namespace, "fluent-bit"); err != nil { + return err } ds := appsv1.DaemonSet{ diff --git a/controllers/fluentd_controller.go b/controllers/fluentd_controller.go index 38c708ae7..8fd0c50d1 100644 --- a/controllers/fluentd_controller.go +++ b/controllers/fluentd_controller.go @@ -101,34 +101,21 @@ func (r *FluentdReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, err } - // Install RBAC resources for the filter plugin kubernetes - var role, sa, binding client.Object - if r.Namespaced { - role, sa, binding = operator.MakeScopedRBACObjects( - fd.Name, - fd.Namespace, - fluentdLowercase, - fd.Spec.RBACRules, - fd.Spec.ServiceAccountAnnotations, - ) - } else { - role, sa, binding = operator.MakeRBACObjects( - fd.Name, - fd.Namespace, - fluentdLowercase, - fd.Spec.RBACRules, - fd.Spec.ServiceAccountAnnotations, - ) - } - // Deploy Fluentd (Cluster)Role + // Reconcile the RBAC the agent needs, scoped to a namespace or the cluster. + role, sa, binding := operator.MakeRBACObjectsForScope( + r.Namespaced, + fd.Name, + fd.Namespace, + fluentdLowercase, + fd.Spec.RBACRules, + fd.Spec.ServiceAccountAnnotations, + ) if _, err := controllerutil.CreateOrPatch(ctx, r.Client, role, r.mutate(role, &fd)); err != nil { return ctrl.Result{}, err } - // Deploy Fluentd (Cluster)RoleBinding if _, err := controllerutil.CreateOrPatch(ctx, r.Client, binding, r.mutate(binding, &fd)); err != nil { return ctrl.Result{}, err } - // Deploy Fluentd ServiceAccount if _, err := controllerutil.CreateOrPatch(ctx, r.Client, sa, r.mutate(sa, &fd)); err != nil { return ctrl.Result{}, err } diff --git a/pkg/operator/rbac.go b/pkg/operator/rbac.go index 6e270e321..1201580ab 100644 --- a/pkg/operator/rbac.go +++ b/pkg/operator/rbac.go @@ -1,11 +1,14 @@ package operator import ( + "context" "fmt" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" ) func MakeRBACObjects( @@ -82,9 +85,7 @@ func MakeScopedRBACObjects( }, } - if additionalRules != nil { - r.Rules = append(r.Rules, additionalRules...) - } + r.Rules = append(r.Rules, additionalRules...) sa := corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ @@ -122,8 +123,52 @@ func MakeRBACNames(name, component string) (string, string, string) { return cr, name, crb } +// MakeScopedRBACNames returns the namespaced Role/ServiceAccount/RoleBinding +// names. They follow the same convention as the cluster-scoped names. func MakeScopedRBACNames(name, component string) (string, string, string) { - r := fmt.Sprintf("fluent-operator-%s", component) - rb := fmt.Sprintf("fluent-operator-%s-%s", component, name) - return r, name, rb + return MakeRBACNames(name, component) +} + +// MakeRBACObjectsForScope builds the RBAC objects an agent needs, returning the +// namespaced (Role/RoleBinding) variants when namespaced is true and the +// cluster-scoped (ClusterRole/ClusterRoleBinding) variants otherwise. +func MakeRBACObjectsForScope( + namespaced bool, + name, namespace, component string, + additionalRules []rbacv1.PolicyRule, + saAnnotations map[string]string, +) (role, sa, binding client.Object) { + if namespaced { + r, s, rb := MakeScopedRBACObjects(name, namespace, component, additionalRules, saAnnotations) + return r, s, rb + } + cr, s, crb := MakeRBACObjects(name, namespace, component, additionalRules, saAnnotations) + return cr, s, crb +} + +// DeletePerInstanceBinding removes the per-instance RoleBinding (namespaced) or +// ClusterRoleBinding (cluster-scoped) created for an agent. The (Cluster)Role is +// shared across all instances of a component and is intentionally left in place. +func DeletePerInstanceBinding( + ctx context.Context, + c client.Client, + namespaced bool, + name, namespace, component string, +) error { + var binding client.Object + if namespaced { + _, _, rbName := MakeScopedRBACNames(name, component) + binding = &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: rbName, Namespace: namespace}, + } + } else { + _, _, crbName := MakeRBACNames(name, component) + binding = &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: crbName}, + } + } + if err := c.Delete(ctx, binding); err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil } From 252b81ce7a68ea8b47375bb6ea31deede50157f6 Mon Sep 17 00:00:00 2001 From: Josh Baird Date: Thu, 18 Jun 2026 13:30:57 -0400 Subject: [PATCH 3/7] style: wrap long RBAC helper calls to satisfy lll linter Wrap the MakeScopedRBACObjects and DeletePerInstanceBinding call sites that exceeded the 120-character line limit. Co-authored-by: Cursor Signed-off-by: Josh Baird --- controllers/collector_controller.go | 4 +++- controllers/fluent_controller_finalizer.go | 4 +++- controllers/fluentbit_controller.go | 28 ++++++++++++++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/controllers/collector_controller.go b/controllers/collector_controller.go index efbebbcd5..7c6d5f010 100644 --- a/controllers/collector_controller.go +++ b/controllers/collector_controller.go @@ -246,7 +246,9 @@ func (r *CollectorReconciler) delete(ctx context.Context, co *fluentbitv1alpha2. return err } - if err := operator.DeletePerInstanceBinding(ctx, r.Client, r.Namespaced, co.Name, co.Namespace, "collector"); err != nil { + if err := operator.DeletePerInstanceBinding( + ctx, r.Client, r.Namespaced, co.Name, co.Namespace, "collector", + ); err != nil { return err } diff --git a/controllers/fluent_controller_finalizer.go b/controllers/fluent_controller_finalizer.go index f5d805a00..1bfc188d2 100644 --- a/controllers/fluent_controller_finalizer.go +++ b/controllers/fluent_controller_finalizer.go @@ -77,7 +77,9 @@ func (r *FluentdReconciler) delete(ctx context.Context, fd *fluentdv1alpha1.Flue return err } - if err := operator.DeletePerInstanceBinding(ctx, r.Client, r.Namespaced, fd.Name, fd.Namespace, "fluentd"); err != nil { + if err := operator.DeletePerInstanceBinding( + ctx, r.Client, r.Namespaced, fd.Name, fd.Namespace, "fluentd", + ); err != nil { return err } diff --git a/controllers/fluentbit_controller.go b/controllers/fluentbit_controller.go index 46166435e..c6966f21c 100644 --- a/controllers/fluentbit_controller.go +++ b/controllers/fluentbit_controller.go @@ -189,7 +189,13 @@ func (r *FluentBitReconciler) mutate(obj client.Object, fb *fluentbitv1alpha2.Fl return nil } case *rbacv1.Role: - expected, _, _ := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, "fluent-bit", fb.Spec.RBACRules, fb.Spec.ServiceAccountAnnotations) + expected, _, _ := operator.MakeScopedRBACObjects( + fb.Name, + fb.Namespace, + "fluent-bit", + fb.Spec.RBACRules, + fb.Spec.ServiceAccountAnnotations, + ) return func() error { o.Rules = expected.Rules @@ -210,7 +216,13 @@ func (r *FluentBitReconciler) mutate(obj client.Object, fb *fluentbitv1alpha2.Fl return nil } case *corev1.ServiceAccount: - _, expected, _ := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, "fluent-bit", fb.Spec.RBACRules, fb.Spec.ServiceAccountAnnotations) + _, expected, _ := operator.MakeScopedRBACObjects( + fb.Name, + fb.Namespace, + "fluent-bit", + fb.Spec.RBACRules, + fb.Spec.ServiceAccountAnnotations, + ) return func() error { o.Annotations = expected.Annotations @@ -220,7 +232,13 @@ func (r *FluentBitReconciler) mutate(obj client.Object, fb *fluentbitv1alpha2.Fl return nil } case *rbacv1.RoleBinding: - _, _, expected := operator.MakeScopedRBACObjects(fb.Name, fb.Namespace, "fluent-bit", fb.Spec.RBACRules, fb.Spec.ServiceAccountAnnotations) + _, _, expected := operator.MakeScopedRBACObjects( + fb.Name, + fb.Namespace, + "fluent-bit", + fb.Spec.RBACRules, + fb.Spec.ServiceAccountAnnotations, + ) return func() error { o.Subjects = expected.Subjects o.RoleRef = expected.RoleRef @@ -259,7 +277,9 @@ func (r *FluentBitReconciler) delete(ctx context.Context, fb *fluentbitv1alpha2. return err } - if err := operator.DeletePerInstanceBinding(ctx, r.Client, r.Namespaced, fb.Name, fb.Namespace, "fluent-bit"); err != nil { + if err := operator.DeletePerInstanceBinding( + ctx, r.Client, r.Namespaced, fb.Name, fb.Namespace, "fluent-bit", + ); err != nil { return err } From 939724757b8b9ba6b1f677b99f2cb6adf0c183d8 Mon Sep 17 00:00:00 2001 From: Josh Baird Date: Thu, 18 Jun 2026 13:49:44 -0400 Subject: [PATCH 4/7] fix(rbac): only clean up the per-instance RoleBinding in namespaced mode The cluster-scoped ClusterRoleBinding references a shared ClusterRole and the operator is not granted delete on cluster-scoped RBAC, so attempting to delete it during finalization failed (forbidden), blocking the finalizer and leaving owned resources (e.g. the Fluentd StatefulSet) uncollected. Restore the prior cluster-mode behavior of leaving the ClusterRoleBinding in place and only delete the per-instance RoleBinding in namespaced mode. Co-authored-by: Cursor Signed-off-by: Josh Baird --- pkg/operator/rbac.go | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/pkg/operator/rbac.go b/pkg/operator/rbac.go index 1201580ab..22e8e87aa 100644 --- a/pkg/operator/rbac.go +++ b/pkg/operator/rbac.go @@ -146,28 +146,25 @@ func MakeRBACObjectsForScope( return cr, s, crb } -// DeletePerInstanceBinding removes the per-instance RoleBinding (namespaced) or -// ClusterRoleBinding (cluster-scoped) created for an agent. The (Cluster)Role is -// shared across all instances of a component and is intentionally left in place. +// DeletePerInstanceBinding removes the per-instance RoleBinding created for an +// agent when running in namespaced mode. In cluster-scoped mode nothing is +// deleted: the per-instance ClusterRoleBinding references a shared ClusterRole, +// and the operator is intentionally not granted delete on cluster-scoped RBAC +// (keeping its footprint minimal), so the binding is left in place. func DeletePerInstanceBinding( ctx context.Context, c client.Client, namespaced bool, name, namespace, component string, ) error { - var binding client.Object - if namespaced { - _, _, rbName := MakeScopedRBACNames(name, component) - binding = &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: rbName, Namespace: namespace}, - } - } else { - _, _, crbName := MakeRBACNames(name, component) - binding = &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: crbName}, - } + if !namespaced { + return nil + } + _, _, rbName := MakeScopedRBACNames(name, component) + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: rbName, Namespace: namespace}, } - if err := c.Delete(ctx, binding); err != nil && !apierrors.IsNotFound(err) { + if err := c.Delete(ctx, rb); err != nil && !apierrors.IsNotFound(err) { return err } return nil From 459576bb520ba6cf731430c6c72f466519ae2fa4 Mon Sep 17 00:00:00 2001 From: Josh Baird Date: Thu, 18 Jun 2026 14:05:38 -0400 Subject: [PATCH 5/7] fix(rbac): don't set owner reference on the shared FluentBit Role In namespaced mode the FluentBit Role (fluent-operator-fluent-bit) is shared across all FluentBit instances in a namespace. Setting a per-instance controller reference on it would fail reconciliation for a second instance or garbage-collect the shared Role when one instance is deleted, breaking siblings. Drop the owner reference to match the Collector and Fluentd Role handling and the delete logic that preserves the shared Role. Co-authored-by: Cursor Signed-off-by: Josh Baird --- controllers/fluentbit_controller.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/controllers/fluentbit_controller.go b/controllers/fluentbit_controller.go index c6966f21c..ebaffb308 100644 --- a/controllers/fluentbit_controller.go +++ b/controllers/fluentbit_controller.go @@ -189,6 +189,8 @@ func (r *FluentBitReconciler) mutate(obj client.Object, fb *fluentbitv1alpha2.Fl return nil } case *rbacv1.Role: + // The Role is shared across all FluentBit instances in the namespace, so + // no per-instance controller reference is set on it. expected, _, _ := operator.MakeScopedRBACObjects( fb.Name, fb.Namespace, @@ -199,9 +201,6 @@ func (r *FluentBitReconciler) mutate(obj client.Object, fb *fluentbitv1alpha2.Fl return func() error { o.Rules = expected.Rules - if err := ctrl.SetControllerReference(fb, o, r.Scheme); err != nil { - return err - } return nil } case *rbacv1.ClusterRole: From 56585f052e7b151746968a5b545f207001ee78bb Mon Sep 17 00:00:00 2001 From: Josh Baird Date: Thu, 18 Jun 2026 14:49:00 -0400 Subject: [PATCH 6/7] fix(rbac): drop delete on roles (least-privilege) The controllers only delete the per-instance RoleBinding (via DeletePerInstanceBinding) in namespaced mode and never delete the shared Role, so granting delete on roles is unnecessary. Split the kubebuilder markers for fluent-bit, collector, and fluentd so roles keeps create;get;list;watch;patch while rolebindings retains delete, and regenerate config/rbac/role.yaml and manifests/setup/setup.yaml. Co-authored-by: Cursor Signed-off-by: Josh Baird --- config/rbac/role.yaml | 2 +- controllers/collector_controller.go | 2 +- controllers/fluentbit_controller.go | 2 +- controllers/fluentd_controller.go | 3 ++- manifests/setup/setup.yaml | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index abcbd7d48..77a5102e3 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -123,6 +123,7 @@ rules: resources: - clusterrolebindings - clusterroles + - roles verbs: - create - get @@ -133,7 +134,6 @@ rules: - rbac.authorization.k8s.io resources: - rolebindings - - roles verbs: - create - delete diff --git a/controllers/collector_controller.go b/controllers/collector_controller.go index 7c6d5f010..e41fd314a 100644 --- a/controllers/collector_controller.go +++ b/controllers/collector_controller.go @@ -50,7 +50,7 @@ type CollectorReconciler struct { // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=create;get;list;watch;patch // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=create;get;list;watch;patch -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;delete;get;list;watch;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;get;list;watch;patch // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;delete;get;list;watch;patch // +kubebuilder:rbac:groups=core,resources=pods,verbs=get diff --git a/controllers/fluentbit_controller.go b/controllers/fluentbit_controller.go index ebaffb308..4989d7080 100644 --- a/controllers/fluentbit_controller.go +++ b/controllers/fluentbit_controller.go @@ -53,7 +53,7 @@ type FluentBitReconciler struct { // +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=create;get;list;watch;patch // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=create;get;list;watch;patch -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;delete;get;list;watch;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;get;list;watch;patch // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;delete;get;list;watch;patch // +kubebuilder:rbac:groups=core,resources=pods,verbs=get diff --git a/controllers/fluentd_controller.go b/controllers/fluentd_controller.go index 8fd0c50d1..0c8e140af 100644 --- a/controllers/fluentd_controller.go +++ b/controllers/fluentd_controller.go @@ -55,7 +55,8 @@ type FluentdReconciler struct { // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get // +kubebuilder:rbac:groups=core,resources=serviceaccounts;services,verbs=get;list;watch;create;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles;clusterrolebindings,verbs=create;get;list;watch;patch -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=create;delete;get;list;watch;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;get;list;watch;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;delete;get;list;watch;patch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. diff --git a/manifests/setup/setup.yaml b/manifests/setup/setup.yaml index fa044f4a2..ac7194c9d 100644 --- a/manifests/setup/setup.yaml +++ b/manifests/setup/setup.yaml @@ -42828,6 +42828,7 @@ rules: resources: - clusterrolebindings - clusterroles + - roles verbs: - create - get @@ -42838,7 +42839,6 @@ rules: - rbac.authorization.k8s.io resources: - rolebindings - - roles verbs: - create - delete From dfb0134e1e41c019171e7f8700dc271b6243285d Mon Sep 17 00:00:00 2001 From: Josh Baird Date: Thu, 18 Jun 2026 17:42:39 -0400 Subject: [PATCH 7/7] fix(helm): quote the rendered --watch-namespaces arg Render the flag via printf|quote so YAML quoting is applied to values that need it (e.g. a space-separated "ns1, ns2" list), without embedding literal quotes into the container argv. Co-authored-by: Cursor Signed-off-by: Josh Baird --- .../fluent-operator/templates/fluent-operator-deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/fluent-operator/templates/fluent-operator-deployment.yaml b/charts/fluent-operator/templates/fluent-operator-deployment.yaml index 2417ac4e9..1c67cde8d 100644 --- a/charts/fluent-operator/templates/fluent-operator-deployment.yaml +++ b/charts/fluent-operator/templates/fluent-operator-deployment.yaml @@ -56,7 +56,7 @@ spec: - --disable-component-controllers={{ . }} {{- end }} {{- with .Values.operator.watchNamespaces }} - - --watch-namespaces={{ . }} + - {{ printf "--watch-namespaces=%s" . | quote }} {{- end }} {{- with .Values.operator.livenessProbe }} livenessProbe: