diff --git a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go index 03e1d3123..bd5abc237 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/jumpstarter_controller.go @@ -1057,7 +1057,7 @@ func (r *JumpstarterReconciler) createRouterDeployment(jumpstarter *operatorv1al Type: corev1.SeccompProfileTypeRuntimeDefault, }, }, - ServiceAccountName: fmt.Sprintf("%s-controller-manager", jumpstarter.Name), + ServiceAccountName: fmt.Sprintf("%s-router-sa", jumpstarter.Name), TopologySpreadConstraints: jumpstarter.Spec.Routers.TopologySpreadConstraints, }, }, diff --git a/controller/deploy/operator/internal/controller/jumpstarter/rbac.go b/controller/deploy/operator/internal/controller/jumpstarter/rbac.go index e03336181..b301fede2 100644 --- a/controller/deploy/operator/internal/controller/jumpstarter/rbac.go +++ b/controller/deploy/operator/internal/controller/jumpstarter/rbac.go @@ -6,7 +6,10 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -61,6 +64,46 @@ func (r *JumpstarterReconciler) reconcileRBAC(ctx context.Context, jumpstarter * "namespace", existingSA.Namespace, "operation", op) + // Router ServiceAccount (uses dedicated minimal Role) + // Note: We intentionally do NOT set controller reference on ServiceAccount to prevent + // it from being garbage collected when the Jumpstarter CR is deleted + desiredRouterSA := r.createRouterServiceAccount(jumpstarter) + + existingRouterSA := &corev1.ServiceAccount{} + existingRouterSA.Name = desiredRouterSA.Name + existingRouterSA.Namespace = desiredRouterSA.Namespace + + op, err = controllerutil.CreateOrUpdate(ctx, r.Client, existingRouterSA, func() error { + if existingRouterSA.CreationTimestamp.IsZero() { + existingRouterSA.Labels = desiredRouterSA.Labels + existingRouterSA.Annotations = desiredRouterSA.Annotations + return nil + } + + if !serviceAccountNeedsUpdate(existingRouterSA, desiredRouterSA) { + log.V(1).Info("Router ServiceAccount is up to date, skipping update", + "name", existingRouterSA.Name, + "namespace", existingRouterSA.Namespace) + return nil + } + + existingRouterSA.Labels = desiredRouterSA.Labels + existingRouterSA.Annotations = desiredRouterSA.Annotations + return nil + }) + + if err != nil { + log.Error(err, "Failed to reconcile Router ServiceAccount", + "name", desiredRouterSA.Name, + "namespace", desiredRouterSA.Namespace) + return err + } + + log.Info("Router ServiceAccount reconciled", + "name", existingRouterSA.Name, + "namespace", existingRouterSA.Namespace, + "operation", op) + // Role desiredRole := r.createRole(jumpstarter) @@ -106,51 +149,157 @@ func (r *JumpstarterReconciler) reconcileRBAC(ctx context.Context, jumpstarter * "operation", op) // RoleBinding + // Note: RoleRef is immutable in Kubernetes. If it changes, we must delete and recreate. desiredRoleBinding := r.createRoleBinding(jumpstarter) + if err := r.reconcileRoleBinding(ctx, jumpstarter, desiredRoleBinding); err != nil { + return err + } - existingRoleBinding := &rbacv1.RoleBinding{} - existingRoleBinding.Name = desiredRoleBinding.Name - existingRoleBinding.Namespace = desiredRoleBinding.Namespace - - op, err = controllerutil.CreateOrUpdate(ctx, r.Client, existingRoleBinding, func() error { - // Check if this is a new role binding or an existing one - if existingRoleBinding.CreationTimestamp.IsZero() { - // RoleBinding is being created, copy all fields from desired - existingRoleBinding.Labels = desiredRoleBinding.Labels - existingRoleBinding.Annotations = desiredRoleBinding.Annotations - existingRoleBinding.Subjects = desiredRoleBinding.Subjects - existingRoleBinding.RoleRef = desiredRoleBinding.RoleRef - return controllerutil.SetControllerReference(jumpstarter, existingRoleBinding, r.Scheme) + // Router Role (minimal permissions: read configmaps) + desiredRouterRole := r.createRouterRole(jumpstarter) + + existingRouterRole := &rbacv1.Role{} + existingRouterRole.Name = desiredRouterRole.Name + existingRouterRole.Namespace = desiredRouterRole.Namespace + + op, err = controllerutil.CreateOrUpdate(ctx, r.Client, existingRouterRole, func() error { + if existingRouterRole.CreationTimestamp.IsZero() { + existingRouterRole.Labels = desiredRouterRole.Labels + existingRouterRole.Annotations = desiredRouterRole.Annotations + existingRouterRole.Rules = desiredRouterRole.Rules + return controllerutil.SetControllerReference(jumpstarter, existingRouterRole, r.Scheme) } - // RoleBinding exists, check if update is needed - if !roleBindingNeedsUpdate(existingRoleBinding, desiredRoleBinding) { - log.V(1).Info("RoleBinding is up to date, skipping update", - "name", existingRoleBinding.Name, - "namespace", existingRoleBinding.Namespace) + if !roleNeedsUpdate(existingRouterRole, desiredRouterRole) { + log.V(1).Info("Router Role is up to date, skipping update", + "name", existingRouterRole.Name, + "namespace", existingRouterRole.Namespace) return nil } - // Update needed - apply changes - existingRoleBinding.Labels = desiredRoleBinding.Labels - existingRoleBinding.Annotations = desiredRoleBinding.Annotations - existingRoleBinding.Subjects = desiredRoleBinding.Subjects - existingRoleBinding.RoleRef = desiredRoleBinding.RoleRef - return controllerutil.SetControllerReference(jumpstarter, existingRoleBinding, r.Scheme) + existingRouterRole.Labels = desiredRouterRole.Labels + existingRouterRole.Annotations = desiredRouterRole.Annotations + existingRouterRole.Rules = desiredRouterRole.Rules + return controllerutil.SetControllerReference(jumpstarter, existingRouterRole, r.Scheme) }) if err != nil { - log.Error(err, "Failed to reconcile RoleBinding", - "name", desiredRoleBinding.Name, - "namespace", desiredRoleBinding.Namespace) + log.Error(err, "Failed to reconcile Router Role", + "name", desiredRouterRole.Name, + "namespace", desiredRouterRole.Namespace) return err } - log.Info("RoleBinding reconciled", - "name", existingRoleBinding.Name, - "namespace", existingRoleBinding.Namespace, + log.Info("Router Role reconciled", + "name", existingRouterRole.Name, + "namespace", existingRouterRole.Namespace, "operation", op) + // Router RoleBinding + // Note: RoleRef is immutable in Kubernetes. If it changes, we must delete and recreate. + desiredRouterRoleBinding := r.createRouterRoleBinding(jumpstarter) + if err := r.reconcileRoleBinding(ctx, jumpstarter, desiredRouterRoleBinding); err != nil { + return err + } + + return nil +} + +// reconcileRoleBinding reconciles a RoleBinding, handling the immutable RoleRef field. +// Kubernetes does not allow updating RoleRef on an existing RoleBinding. If the desired +// RoleRef differs from the existing one, this function deletes the old RoleBinding and +// creates a new one. For all other fields, it uses a standard get-and-update pattern. +func (r *JumpstarterReconciler) reconcileRoleBinding( + ctx context.Context, + jumpstarter *operatorv1alpha1.Jumpstarter, + desired *rbacv1.RoleBinding, +) error { + log := logf.FromContext(ctx) + + existing := &rbacv1.RoleBinding{} + key := client.ObjectKeyFromObject(desired) + err := r.Client.Get(ctx, key, existing) + + if apierrors.IsNotFound(err) { + // RoleBinding does not exist, create it + if err := controllerutil.SetControllerReference(jumpstarter, desired, r.Scheme); err != nil { + return err + } + if err := r.Client.Create(ctx, desired); err != nil { + log.Error(err, "Failed to create RoleBinding", + "name", desired.Name, + "namespace", desired.Namespace) + return err + } + log.Info("RoleBinding reconciled", + "name", desired.Name, + "namespace", desired.Namespace, + "operation", "created") + return nil + } + + if err != nil { + log.Error(err, "Failed to get RoleBinding", + "name", desired.Name, + "namespace", desired.Namespace) + return err + } + + // RoleRef is immutable -- if it differs we must delete and recreate + if !equality.Semantic.DeepEqual(existing.RoleRef, desired.RoleRef) { + log.Info("RoleBinding RoleRef changed, deleting and recreating", + "name", existing.Name, + "namespace", existing.Namespace) + if err := r.Client.Delete(ctx, existing); err != nil { + log.Error(err, "Failed to delete RoleBinding for recreation", + "name", existing.Name, + "namespace", existing.Namespace) + return err + } + if err := controllerutil.SetControllerReference(jumpstarter, desired, r.Scheme); err != nil { + log.Error(err, "Failed to set controller reference after RoleBinding deletion; RoleBinding is absent until next reconciliation", + "name", desired.Name, + "namespace", desired.Namespace) + return err + } + if err := r.Client.Create(ctx, desired); err != nil { + log.Error(err, "Failed to recreate RoleBinding", + "name", desired.Name, + "namespace", desired.Namespace) + return err + } + log.Info("RoleBinding reconciled", + "name", desired.Name, + "namespace", desired.Namespace, + "operation", "recreated") + return nil + } + + // RoleRef unchanged -- update other fields if needed + if !roleBindingNeedsUpdate(existing, desired) { + log.V(1).Info("RoleBinding is up to date, skipping update", + "name", existing.Name, + "namespace", existing.Namespace) + return nil + } + + existing.Labels = desired.Labels + existing.Annotations = desired.Annotations + existing.Subjects = desired.Subjects + if err := controllerutil.SetControllerReference(jumpstarter, existing, r.Scheme); err != nil { + return err + } + if err := r.Client.Update(ctx, existing); err != nil { + log.Error(err, "Failed to update RoleBinding", + "name", existing.Name, + "namespace", existing.Namespace) + return err + } + + log.Info("RoleBinding reconciled", + "name", existing.Name, + "namespace", existing.Namespace, + "operation", "updated") return nil } @@ -169,6 +318,21 @@ func (r *JumpstarterReconciler) createServiceAccount(jumpstarter *operatorv1alph } } +// createRouterServiceAccount creates a dedicated service account for router workloads +func (r *JumpstarterReconciler) createRouterServiceAccount(jumpstarter *operatorv1alpha1.Jumpstarter) *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-router-sa", jumpstarter.Name), + Namespace: jumpstarter.Namespace, + Labels: map[string]string{ + "app": "jumpstarter-router", + "app.kubernetes.io/name": "jumpstarter-router", + "app.kubernetes.io/managed-by": "jumpstarter-operator", + }, + }, + } +} + // createRole creates a role with necessary permissions for the controller func (r *JumpstarterReconciler) createRole(jumpstarter *operatorv1alpha1.Jumpstarter) *rbacv1.Role { return &rbacv1.Role{ @@ -221,6 +385,55 @@ func (r *JumpstarterReconciler) createRole(jumpstarter *operatorv1alpha1.Jumpsta } } +// createRouterRole creates a role with minimal permissions for the router (read configmaps) +func (r *JumpstarterReconciler) createRouterRole(jumpstarter *operatorv1alpha1.Jumpstarter) *rbacv1.Role { + return &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-router-role", jumpstarter.Name), + Namespace: jumpstarter.Namespace, + Labels: map[string]string{ + "app": "jumpstarter-router", + "app.kubernetes.io/name": "jumpstarter-router", + "app.kubernetes.io/managed-by": "jumpstarter-operator", + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + } +} + +// createRouterRoleBinding creates a role binding for the router service account +func (r *JumpstarterReconciler) createRouterRoleBinding(jumpstarter *operatorv1alpha1.Jumpstarter) *rbacv1.RoleBinding { + return &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-router-rolebinding", jumpstarter.Name), + Namespace: jumpstarter.Namespace, + Labels: map[string]string{ + "app": "jumpstarter-router", + "app.kubernetes.io/name": "jumpstarter-router", + "app.kubernetes.io/managed-by": "jumpstarter-operator", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: fmt.Sprintf("%s-router-role", jumpstarter.Name), + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: fmt.Sprintf("%s-router-sa", jumpstarter.Name), + Namespace: jumpstarter.Namespace, + }, + }, + } +} + // createRoleBinding creates a role binding for the controller func (r *JumpstarterReconciler) createRoleBinding(jumpstarter *operatorv1alpha1.Jumpstarter) *rbacv1.RoleBinding { return &rbacv1.RoleBinding{ diff --git a/controller/deploy/operator/internal/controller/jumpstarter/rbac_test.go b/controller/deploy/operator/internal/controller/jumpstarter/rbac_test.go new file mode 100644 index 000000000..a20e84cd3 --- /dev/null +++ b/controller/deploy/operator/internal/controller/jumpstarter/rbac_test.go @@ -0,0 +1,292 @@ +/* +Copyright 2025. The Jumpstarter Authors. + +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 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package jumpstarter + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + operatorv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/deploy/operator/api/v1alpha1" +) + +var _ = Describe("RBAC factory functions", func() { + var ( + r *JumpstarterReconciler + js *operatorv1alpha1.Jumpstarter + ) + + BeforeEach(func() { + r = &JumpstarterReconciler{} + js = &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + }) + + Describe("createRouterServiceAccount", func() { + It("should use the correct name format", func() { + sa := r.createRouterServiceAccount(js) + Expect(sa.Name).To(Equal("test-router-sa")) + Expect(sa.Namespace).To(Equal("default")) + }) + + It("should set router labels", func() { + sa := r.createRouterServiceAccount(js) + Expect(sa.Labels).To(HaveKeyWithValue("app", "jumpstarter-router")) + Expect(sa.Labels).To(HaveKeyWithValue("app.kubernetes.io/name", "jumpstarter-router")) + Expect(sa.Labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "jumpstarter-operator")) + }) + }) + + Describe("createRouterRole", func() { + It("should use the correct name format", func() { + role := r.createRouterRole(js) + Expect(role.Name).To(Equal("test-router-role")) + Expect(role.Namespace).To(Equal("default")) + }) + + It("should set router labels", func() { + role := r.createRouterRole(js) + Expect(role.Labels).To(HaveKeyWithValue("app", "jumpstarter-router")) + Expect(role.Labels).To(HaveKeyWithValue("app.kubernetes.io/name", "jumpstarter-router")) + Expect(role.Labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "jumpstarter-operator")) + }) + + It("should grant read-only access to configmaps only", func() { + role := r.createRouterRole(js) + Expect(role.Rules).To(HaveLen(1)) + Expect(role.Rules[0].APIGroups).To(Equal([]string{""})) + Expect(role.Rules[0].Resources).To(Equal([]string{"configmaps"})) + Expect(role.Rules[0].Verbs).To(ConsistOf("get", "list", "watch")) + }) + + It("should not grant access to secrets", func() { + role := r.createRouterRole(js) + for _, rule := range role.Rules { + Expect(rule.Resources).NotTo(ContainElement("secrets")) + } + }) + }) + + Describe("createRouterRoleBinding", func() { + It("should use the correct name format", func() { + rb := r.createRouterRoleBinding(js) + Expect(rb.Name).To(Equal("test-router-rolebinding")) + Expect(rb.Namespace).To(Equal("default")) + }) + + It("should set router labels", func() { + rb := r.createRouterRoleBinding(js) + Expect(rb.Labels).To(HaveKeyWithValue("app", "jumpstarter-router")) + Expect(rb.Labels).To(HaveKeyWithValue("app.kubernetes.io/name", "jumpstarter-router")) + Expect(rb.Labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "jumpstarter-operator")) + }) + + It("should reference the router role", func() { + rb := r.createRouterRoleBinding(js) + Expect(rb.RoleRef.APIGroup).To(Equal("rbac.authorization.k8s.io")) + Expect(rb.RoleRef.Kind).To(Equal("Role")) + Expect(rb.RoleRef.Name).To(Equal("test-router-role")) + }) + + It("should bind to the router service account", func() { + rb := r.createRouterRoleBinding(js) + Expect(rb.Subjects).To(HaveLen(1)) + Expect(rb.Subjects[0].Kind).To(Equal("ServiceAccount")) + Expect(rb.Subjects[0].Name).To(Equal("test-router-sa")) + Expect(rb.Subjects[0].Namespace).To(Equal("default")) + }) + }) +}) + +var _ = Describe("reconcileRoleBinding", func() { + var ( + r *JumpstarterReconciler + js *operatorv1alpha1.Jumpstarter + ctx context.Context + ) + + BeforeEach(func() { + r = &JumpstarterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + ctx = context.Background() + + js = &operatorv1alpha1.Jumpstarter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rbac-test", + Namespace: "default", + }, + } + // Create the Jumpstarter CR so that SetControllerReference works + err := k8sClient.Get(ctx, types.NamespacedName{Name: js.Name, Namespace: js.Namespace}, &operatorv1alpha1.Jumpstarter{}) + if errors.IsNotFound(err) { + js.Spec = operatorv1alpha1.JumpstarterSpec{ + BaseDomain: "example.com", + Controller: operatorv1alpha1.ControllerConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: 1, + }, + Routers: operatorv1alpha1.RoutersConfig{ + Image: "quay.io/jumpstarter/jumpstarter:latest", + Replicas: 1, + }, + } + Expect(k8sClient.Create(ctx, js)).To(Succeed()) + } + // Re-fetch to get the server-assigned UID + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: js.Name, Namespace: js.Namespace}, js)).To(Succeed()) + }) + + AfterEach(func() { + // Clean up the RoleBinding if it exists + rb := &rbacv1.RoleBinding{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: "rbac-test-router-rolebinding", Namespace: "default"}, rb) + if err == nil { + Expect(k8sClient.Delete(ctx, rb)).To(Succeed()) + } + // Clean up the Jumpstarter CR + resource := &operatorv1alpha1.Jumpstarter{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: js.Name, Namespace: js.Namespace}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should create a RoleBinding when it does not exist", func() { + desired := r.createRouterRoleBinding(js) + err := r.reconcileRoleBinding(ctx, js, desired) + Expect(err).NotTo(HaveOccurred()) + + // Verify it was created + created := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "rbac-test-router-rolebinding", + Namespace: "default", + }, created) + Expect(err).NotTo(HaveOccurred()) + Expect(created.RoleRef.Name).To(Equal("rbac-test-router-role")) + Expect(created.Subjects).To(HaveLen(1)) + Expect(created.Subjects[0].Name).To(Equal("rbac-test-router-sa")) + }) + + It("should be a no-op when the RoleBinding already matches", func() { + // Create it first + desired := r.createRouterRoleBinding(js) + err := r.reconcileRoleBinding(ctx, js, desired) + Expect(err).NotTo(HaveOccurred()) + + // Capture ResourceVersion before the no-op reconciliation + before := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "rbac-test-router-rolebinding", + Namespace: "default", + }, before) + Expect(err).NotTo(HaveOccurred()) + rvBefore := before.ResourceVersion + + // Reconcile again with same desired state -- should be a no-op + desired2 := r.createRouterRoleBinding(js) + err = r.reconcileRoleBinding(ctx, js, desired2) + Expect(err).NotTo(HaveOccurred()) + + // Verify it still exists, is unchanged, and no unnecessary API write occurred + existing := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "rbac-test-router-rolebinding", + Namespace: "default", + }, existing) + Expect(err).NotTo(HaveOccurred()) + Expect(existing.RoleRef.Name).To(Equal("rbac-test-router-role")) + Expect(existing.ResourceVersion).To(Equal(rvBefore), "ResourceVersion should be unchanged when no update is needed") + }) + + It("should update Subjects when they change but RoleRef is unchanged", func() { + // Create it first + desired := r.createRouterRoleBinding(js) + err := r.reconcileRoleBinding(ctx, js, desired) + Expect(err).NotTo(HaveOccurred()) + + // Now reconcile with updated Subjects but same RoleRef + desired2 := r.createRouterRoleBinding(js) + desired2.Subjects = []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: "updated-sa", + Namespace: "default", + }, + } + err = r.reconcileRoleBinding(ctx, js, desired2) + Expect(err).NotTo(HaveOccurred()) + + // Verify the Subjects were updated + existing := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "rbac-test-router-rolebinding", + Namespace: "default", + }, existing) + Expect(err).NotTo(HaveOccurred()) + Expect(existing.Subjects).To(HaveLen(1)) + Expect(existing.Subjects[0].Name).To(Equal("updated-sa")) + Expect(existing.RoleRef.Name).To(Equal("rbac-test-router-role")) + }) + + It("should delete and recreate when RoleRef changes", func() { + // Create it first + desired := r.createRouterRoleBinding(js) + err := r.reconcileRoleBinding(ctx, js, desired) + Expect(err).NotTo(HaveOccurred()) + + // Get the original UID to verify it was recreated + original := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "rbac-test-router-rolebinding", + Namespace: "default", + }, original) + Expect(err).NotTo(HaveOccurred()) + originalUID := original.UID + + // Reconcile with a different RoleRef + desired2 := r.createRouterRoleBinding(js) + desired2.RoleRef = rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "different-role", + } + err = r.reconcileRoleBinding(ctx, js, desired2) + Expect(err).NotTo(HaveOccurred()) + + // Verify it was recreated with the new RoleRef and a new UID + recreated := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "rbac-test-router-rolebinding", + Namespace: "default", + }, recreated) + Expect(err).NotTo(HaveOccurred()) + Expect(recreated.RoleRef.Name).To(Equal("different-role")) + Expect(recreated.UID).NotTo(Equal(originalUID)) + }) +})