From b50c0ada9ab43f35ab20de613ad18d4ade89132f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:42:19 +0000 Subject: [PATCH 01/23] Add BindingSubjectsEditor for RoleBinding/ClusterRoleBinding subjects Provides typed mutations for the .subjects field of binding resources: Add, Remove, EnsureServiceAccount, RemoveServiceAccount, and Raw. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/bindingsubjects.go | 75 +++++++++++++++ pkg/mutation/editors/bindingsubjects_test.go | 98 ++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 pkg/mutation/editors/bindingsubjects.go create mode 100644 pkg/mutation/editors/bindingsubjects_test.go diff --git a/pkg/mutation/editors/bindingsubjects.go b/pkg/mutation/editors/bindingsubjects.go new file mode 100644 index 00000000..d86be9c5 --- /dev/null +++ b/pkg/mutation/editors/bindingsubjects.go @@ -0,0 +1,75 @@ +package editors + +import ( + rbacv1 "k8s.io/api/rbac/v1" +) + +// BindingSubjectsEditor provides a typed API for mutating the .subjects field +// of a Kubernetes RoleBinding or ClusterRoleBinding. +type BindingSubjectsEditor struct { + subjects *[]rbacv1.Subject +} + +// NewBindingSubjectsEditor creates a new BindingSubjectsEditor wrapping the given +// subjects slice pointer. +// +// The pointer must be non-nil; the slice it points to may be nil. +func NewBindingSubjectsEditor(subjects *[]rbacv1.Subject) *BindingSubjectsEditor { + return &BindingSubjectsEditor{subjects: subjects} +} + +// Raw returns the underlying subjects slice directly, initialising it if necessary. +// +// This is an escape hatch for free-form editing when none of the structured +// methods are sufficient. +func (e *BindingSubjectsEditor) Raw() []rbacv1.Subject { + if *e.subjects == nil { + *e.subjects = []rbacv1.Subject{} + } + return *e.subjects +} + +// Add appends a subject to the binding's subjects list. +// Duplicates are not checked — callers are responsible for ensuring uniqueness +// if required. +func (e *BindingSubjectsEditor) Add(subject rbacv1.Subject) { + *e.subjects = append(*e.subjects, subject) +} + +// Remove removes all subjects matching the given kind, name, and namespace. +// It is a no-op if no matching subject is found. +func (e *BindingSubjectsEditor) Remove(kind, name, namespace string) { + if *e.subjects == nil { + return + } + filtered := make([]rbacv1.Subject, 0, len(*e.subjects)) + for _, s := range *e.subjects { + if s.Kind == kind && s.Name == name && s.Namespace == namespace { + continue + } + filtered = append(filtered, s) + } + *e.subjects = filtered +} + +// EnsureServiceAccount ensures a ServiceAccount subject with the given name and +// namespace exists in the binding's subjects list. If an identical subject +// already exists, this is a no-op. +func (e *BindingSubjectsEditor) EnsureServiceAccount(name, namespace string) { + for _, s := range *e.subjects { + if s.Kind == "ServiceAccount" && s.Name == name && s.Namespace == namespace { + return + } + } + e.Add(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: name, + Namespace: namespace, + }) +} + +// RemoveServiceAccount removes a ServiceAccount subject with the given name and +// namespace from the binding's subjects list. +func (e *BindingSubjectsEditor) RemoveServiceAccount(name, namespace string) { + e.Remove("ServiceAccount", name, namespace) +} diff --git a/pkg/mutation/editors/bindingsubjects_test.go b/pkg/mutation/editors/bindingsubjects_test.go new file mode 100644 index 00000000..4aedda7c --- /dev/null +++ b/pkg/mutation/editors/bindingsubjects_test.go @@ -0,0 +1,98 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" +) + +func TestBindingSubjectsEditor_Raw(t *testing.T) { + t.Run("initialises nil slice", func(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + raw := e.Raw() + require.NotNil(t, raw) + assert.Empty(t, raw) + }) + + t.Run("returns existing slice", func(t *testing.T) { + subjects := []rbacv1.Subject{{Kind: "User", Name: "alice"}} + e := NewBindingSubjectsEditor(&subjects) + raw := e.Raw() + assert.Len(t, raw, 1) + assert.Equal(t, "alice", raw[0].Name) + }) +} + +func TestBindingSubjectsEditor_Add(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + e.Add(rbacv1.Subject{Kind: "User", Name: "alice"}) + e.Add(rbacv1.Subject{Kind: "Group", Name: "devs"}) + assert.Len(t, subjects, 2) + assert.Equal(t, "alice", subjects[0].Name) + assert.Equal(t, "devs", subjects[1].Name) +} + +func TestBindingSubjectsEditor_Remove(t *testing.T) { + t.Run("removes matching subject", func(t *testing.T) { + subjects := []rbacv1.Subject{ + {Kind: "User", Name: "alice", Namespace: "default"}, + {Kind: "User", Name: "bob", Namespace: "default"}, + } + e := NewBindingSubjectsEditor(&subjects) + e.Remove("User", "alice", "default") + assert.Len(t, subjects, 1) + assert.Equal(t, "bob", subjects[0].Name) + }) + + t.Run("no-op when not found", func(t *testing.T) { + subjects := []rbacv1.Subject{ + {Kind: "User", Name: "alice", Namespace: "default"}, + } + e := NewBindingSubjectsEditor(&subjects) + e.Remove("User", "nobody", "default") + assert.Len(t, subjects, 1) + }) + + t.Run("no-op on nil slice", func(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + e.Remove("User", "alice", "default") + assert.Nil(t, subjects) + }) +} + +func TestBindingSubjectsEditor_EnsureServiceAccount(t *testing.T) { + t.Run("adds new service account", func(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + e.EnsureServiceAccount("my-sa", "default") + require.Len(t, subjects, 1) + assert.Equal(t, "ServiceAccount", subjects[0].Kind) + assert.Equal(t, "my-sa", subjects[0].Name) + assert.Equal(t, "default", subjects[0].Namespace) + }) + + t.Run("no-op when already present", func(t *testing.T) { + subjects := []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "my-sa", Namespace: "default"}, + } + e := NewBindingSubjectsEditor(&subjects) + e.EnsureServiceAccount("my-sa", "default") + assert.Len(t, subjects, 1) + }) +} + +func TestBindingSubjectsEditor_RemoveServiceAccount(t *testing.T) { + subjects := []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "my-sa", Namespace: "default"}, + {Kind: "User", Name: "alice"}, + } + e := NewBindingSubjectsEditor(&subjects) + e.RemoveServiceAccount("my-sa", "default") + assert.Len(t, subjects, 1) + assert.Equal(t, "alice", subjects[0].Name) +} From 61415842978ba8825980ae41644c8311fff495c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:42:25 +0000 Subject: [PATCH 02/23] Add ClusterRoleBinding primitive package Static, cluster-scoped primitive with custom validation (name only, no namespace). DefaultFieldApplicator preserves immutable roleRef on updates. Mutator supports metadata and subjects edits via plan-and-apply. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/clusterrolebinding/builder.go | 130 ++++++++++++++ .../clusterrolebinding/builder_test.go | 158 ++++++++++++++++++ pkg/primitives/clusterrolebinding/flavors.go | 26 +++ .../clusterrolebinding/flavors_test.go | 123 ++++++++++++++ pkg/primitives/clusterrolebinding/mutator.go | 108 ++++++++++++ .../clusterrolebinding/mutator_test.go | 151 +++++++++++++++++ pkg/primitives/clusterrolebinding/resource.go | 73 ++++++++ 7 files changed, 769 insertions(+) create mode 100644 pkg/primitives/clusterrolebinding/builder.go create mode 100644 pkg/primitives/clusterrolebinding/builder_test.go create mode 100644 pkg/primitives/clusterrolebinding/flavors.go create mode 100644 pkg/primitives/clusterrolebinding/flavors_test.go create mode 100644 pkg/primitives/clusterrolebinding/mutator.go create mode 100644 pkg/primitives/clusterrolebinding/mutator_test.go create mode 100644 pkg/primitives/clusterrolebinding/resource.go diff --git a/pkg/primitives/clusterrolebinding/builder.go b/pkg/primitives/clusterrolebinding/builder.go new file mode 100644 index 00000000..31b27506 --- /dev/null +++ b/pkg/primitives/clusterrolebinding/builder.go @@ -0,0 +1,130 @@ +package clusterrolebinding + +import ( + "errors" + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + rbacv1 "k8s.io/api/rbac/v1" +) + +// Builder is a configuration helper for creating and customizing a ClusterRoleBinding Resource. +// +// It provides a fluent API for registering mutations, field application flavors, +// and data extractors. Build() validates the configuration and returns an +// initialized Resource ready for use in a reconciliation loop. +type Builder struct { + obj *rbacv1.ClusterRoleBinding + identityFunc func(*rbacv1.ClusterRoleBinding) string + defaultApplicator func(current, desired *rbacv1.ClusterRoleBinding) error + customApplicator func(current, desired *rbacv1.ClusterRoleBinding) error + mutations []feature.Mutation[*Mutator] + flavors []generic.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding] + dataExtractors []func(*rbacv1.ClusterRoleBinding) error +} + +// NewBuilder initializes a new Builder with the provided ClusterRoleBinding object. +// +// The ClusterRoleBinding object serves as the desired base state. During reconciliation +// the Resource will make the cluster's state match this base, modified by any +// registered mutations and flavors. +// +// The provided ClusterRoleBinding must have a Name set, which is validated +// during the Build() call. Namespace is not required as ClusterRoleBinding is +// cluster-scoped. +func NewBuilder(crb *rbacv1.ClusterRoleBinding) *Builder { + identityFunc := func(c *rbacv1.ClusterRoleBinding) string { + return fmt.Sprintf("rbac.authorization.k8s.io/v1/ClusterRoleBinding/%s", c.Name) + } + + return &Builder{ + obj: crb, + identityFunc: identityFunc, + defaultApplicator: DefaultFieldApplicator, + } +} + +// WithMutation registers a mutation for the ClusterRoleBinding. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation, +// after the baseline field applicator and any registered flavors have run. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.mutations = append(b.mutations, feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomFieldApplicator sets a custom strategy for applying the desired +// state to the existing ClusterRoleBinding in the cluster. +// +// The default applicator (DefaultFieldApplicator) replaces the current object +// with a deep copy of the desired object, preserving roleRef on updates since +// it is immutable after creation. +func (b *Builder) WithCustomFieldApplicator( + applicator func(current, desired *rbacv1.ClusterRoleBinding) error, +) *Builder { + b.customApplicator = applicator + return b +} + +// WithFieldApplicationFlavor registers a post-baseline field application flavor. +// +// Flavors run after the baseline applicator (default or custom) in registration +// order. They are typically used to preserve fields from the live cluster object +// that should not be overwritten by the desired state. +// +// A nil flavor is ignored. +func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { + if flavor != nil { + b.flavors = append(b.flavors, generic.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding](flavor)) + } + return b +} + +// WithDataExtractor registers a function to read values from the ClusterRoleBinding +// after it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled ClusterRoleBinding. This +// is useful for surfacing generated or updated entries to other components or +// resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(rbacv1.ClusterRoleBinding) error) *Builder { + if extractor != nil { + b.dataExtractors = append(b.dataExtractors, func(crb *rbacv1.ClusterRoleBinding) error { + return extractor(*crb) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No ClusterRoleBinding object was provided. +// - The ClusterRoleBinding is missing a Name. +func (b *Builder) Build() (*Resource, error) { + if b.obj == nil { + return nil, errors.New("object cannot be nil") + } + if b.obj.Name == "" { + return nil, errors.New("object name cannot be empty") + } + + res := &Resource{ + base: &generic.BaseResource[*rbacv1.ClusterRoleBinding, *Mutator]{ + DesiredObject: b.obj, + IdentityFunc: b.identityFunc, + DefaultFieldApplicator: b.defaultApplicator, + CustomFieldApplicator: b.customApplicator, + NewMutator: NewMutator, + Mutations: b.mutations, + FieldFlavors: b.flavors, + DataExtractors: b.dataExtractors, + }, + } + + return res, nil +} diff --git a/pkg/primitives/clusterrolebinding/builder_test.go b/pkg/primitives/clusterrolebinding/builder_test.go new file mode 100644 index 00000000..a8e9e8f2 --- /dev/null +++ b/pkg/primitives/clusterrolebinding/builder_test.go @@ -0,0 +1,158 @@ +package clusterrolebinding + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder_Build_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + crb *rbacv1.ClusterRoleBinding + expectedErr string + }{ + { + name: "nil clusterrolebinding", + crb: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + crb: &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "valid clusterrolebinding", + crb: &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.crb).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "rbac.authorization.k8s.io/v1/ClusterRoleBinding/test-crb", res.Identity()) + } + }) + } +} + +func TestBuilder_Build_NoNamespaceRequired(t *testing.T) { + t.Parallel() + // ClusterRoleBinding is cluster-scoped; no namespace is required. + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, + } + res, err := NewBuilder(crb).Build() + require.NoError(t, err) + require.NotNil(t, res) +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, + } + res, err := NewBuilder(crb). + WithMutation(Mutation{Name: "test-mutation"}). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) +} + +func TestBuilder_WithCustomFieldApplicator(t *testing.T) { + t.Parallel() + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, + } + called := false + applicator := func(_, _ *rbacv1.ClusterRoleBinding) error { + called = true + return nil + } + res, err := NewBuilder(crb). + WithCustomFieldApplicator(applicator). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.CustomFieldApplicator) + _ = res.base.CustomFieldApplicator(nil, nil) + assert.True(t, called) +} + +func TestBuilder_WithFieldApplicationFlavor(t *testing.T) { + t.Parallel() + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, + } + res, err := NewBuilder(crb). + WithFieldApplicationFlavor(PreserveCurrentLabels). + WithFieldApplicationFlavor(nil). // nil must be ignored + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 1) +} + +func TestBuilder_WithDataExtractor(t *testing.T) { + t.Parallel() + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, + } + called := false + extractor := func(_ rbacv1.ClusterRoleBinding) error { + called = true + return nil + } + res, err := NewBuilder(crb). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&rbacv1.ClusterRoleBinding{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, + } + res, err := NewBuilder(crb). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, + } + res, err := NewBuilder(crb). + WithDataExtractor(func(_ rbacv1.ClusterRoleBinding) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&rbacv1.ClusterRoleBinding{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/clusterrolebinding/flavors.go b/pkg/primitives/clusterrolebinding/flavors.go new file mode 100644 index 00000000..a068c5f8 --- /dev/null +++ b/pkg/primitives/clusterrolebinding/flavors.go @@ -0,0 +1,26 @@ +package clusterrolebinding + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/flavors" + rbacv1 "k8s.io/api/rbac/v1" +) + +// FieldApplicationFlavor defines a function signature for applying flavors to a +// ClusterRoleBinding resource. A flavor is called after the baseline field +// applicator has run and can be used to preserve or merge fields from the live +// cluster object. +type FieldApplicationFlavor flavors.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding] + +// PreserveCurrentLabels ensures that any labels present on the current live +// ClusterRoleBinding but missing from the applied (desired) object are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentLabels(applied, current, desired *rbacv1.ClusterRoleBinding) error { + return flavors.PreserveCurrentLabels[*rbacv1.ClusterRoleBinding]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live ClusterRoleBinding but missing from the applied (desired) object are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentAnnotations(applied, current, desired *rbacv1.ClusterRoleBinding) error { + return flavors.PreserveCurrentAnnotations[*rbacv1.ClusterRoleBinding]()(applied, current, desired) +} diff --git a/pkg/primitives/clusterrolebinding/flavors_test.go b/pkg/primitives/clusterrolebinding/flavors_test.go new file mode 100644 index 00000000..b69369ad --- /dev/null +++ b/pkg/primitives/clusterrolebinding/flavors_test.go @@ -0,0 +1,123 @@ +package clusterrolebinding + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPreserveCurrentLabels(t *testing.T) { + t.Run("adds missing labels from current", func(t *testing.T) { + applied := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}} + current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} + + require.NoError(t, PreserveCurrentLabels(applied, current, nil)) + assert.Equal(t, "applied", applied.Labels["keep"]) + assert.Equal(t, "current", applied.Labels["extra"]) + }) + + t.Run("applied value wins on overlap", func(t *testing.T) { + applied := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"key": "applied"}}} + current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"key": "current"}}} + + require.NoError(t, PreserveCurrentLabels(applied, current, nil)) + assert.Equal(t, "applied", applied.Labels["key"]) + }) + + t.Run("handles nil applied labels", func(t *testing.T) { + applied := &rbacv1.ClusterRoleBinding{} + current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} + + require.NoError(t, PreserveCurrentLabels(applied, current, nil)) + assert.Equal(t, "current", applied.Labels["extra"]) + }) +} + +func TestPreserveCurrentAnnotations(t *testing.T) { + t.Run("adds missing annotations from current", func(t *testing.T) { + applied := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} + current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} + + require.NoError(t, PreserveCurrentAnnotations(applied, current, nil)) + assert.Equal(t, "applied", applied.Annotations["keep"]) + assert.Equal(t, "current", applied.Annotations["extra"]) + }) + + t.Run("applied value wins on overlap", func(t *testing.T) { + applied := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"key": "applied"}}} + current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"key": "current"}}} + + require.NoError(t, PreserveCurrentAnnotations(applied, current, nil)) + assert.Equal(t, "applied", applied.Annotations["key"]) + }) +} + +func TestFlavors_Integration(t *testing.T) { + desired := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-crb", + Labels: map[string]string{"app": "desired"}, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "test-role", + }, + } + + t.Run("PreserveCurrentLabels via Mutate", func(t *testing.T) { + res, err := NewBuilder(desired). + WithFieldApplicationFlavor(PreserveCurrentLabels). + Build() + require.NoError(t, err) + + current := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"external": "keep", "app": "old"}, + }, + } + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, "desired", current.Labels["app"]) + assert.Equal(t, "keep", current.Labels["external"]) + }) + + t.Run("flavors run in registration order", func(t *testing.T) { + var order []string + flavor1 := func(_, _, _ *rbacv1.ClusterRoleBinding) error { + order = append(order, "flavor1") + return nil + } + flavor2 := func(_, _, _ *rbacv1.ClusterRoleBinding) error { + order = append(order, "flavor2") + return nil + } + + res, err := NewBuilder(desired). + WithFieldApplicationFlavor(flavor1). + WithFieldApplicationFlavor(flavor2). + Build() + require.NoError(t, err) + + require.NoError(t, res.Mutate(&rbacv1.ClusterRoleBinding{})) + assert.Equal(t, []string{"flavor1", "flavor2"}, order) + }) + + t.Run("flavor error is returned", func(t *testing.T) { + flavorErr := errors.New("flavor boom") + res, err := NewBuilder(desired). + WithFieldApplicationFlavor(func(_, _, _ *rbacv1.ClusterRoleBinding) error { + return flavorErr + }). + Build() + require.NoError(t, err) + + err = res.Mutate(&rbacv1.ClusterRoleBinding{}) + require.Error(t, err) + assert.True(t, errors.Is(err, flavorErr)) + }) +} diff --git a/pkg/primitives/clusterrolebinding/mutator.go b/pkg/primitives/clusterrolebinding/mutator.go new file mode 100644 index 00000000..21d4b80d --- /dev/null +++ b/pkg/primitives/clusterrolebinding/mutator.go @@ -0,0 +1,108 @@ +// Package clusterrolebinding provides a builder and resource for managing Kubernetes ClusterRoleBindings. +package clusterrolebinding + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + rbacv1 "k8s.io/api/rbac/v1" +) + +// Mutation defines a mutation that is applied to a clusterrolebinding Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + subjectEdits []func(*editors.BindingSubjectsEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes ClusterRoleBinding. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the ClusterRoleBinding in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + crb *rbacv1.ClusterRoleBinding + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given ClusterRoleBinding. +func NewMutator(crb *rbacv1.ClusterRoleBinding) *Mutator { + m := &Mutator{crb: crb} + m.beginFeature() + return m +} + +// beginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +func (m *Mutator) beginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the ClusterRoleBinding's own metadata. +// +// Metadata edits are applied before subject edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditSubjects records a mutation for the ClusterRoleBinding's .subjects field +// via a BindingSubjectsEditor. +// +// The editor provides structured operations (Add, Remove, EnsureServiceAccount) +// as well as Raw() for free-form access. Subject edits are applied after metadata +// edits within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) EditSubjects(edit func(*editors.BindingSubjectsEditor) error) { + if edit == nil { + return + } + m.active.subjectEdits = append(m.active.subjectEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying ClusterRoleBinding. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Subject edits — EditSubjects (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the ClusterRoleBinding as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.crb.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Subject edits + if len(plan.subjectEdits) > 0 { + editor := editors.NewBindingSubjectsEditor(&m.crb.Subjects) + for _, edit := range plan.subjectEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/clusterrolebinding/mutator_test.go b/pkg/primitives/clusterrolebinding/mutator_test.go new file mode 100644 index 00000000..277e4df9 --- /dev/null +++ b/pkg/primitives/clusterrolebinding/mutator_test.go @@ -0,0 +1,151 @@ +package clusterrolebinding + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestCRB() *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-crb", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "test-role", + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", crb.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EditSubjects --- + +func TestMutator_EditSubjects(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("my-sa", "default") + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, crb.Subjects, 1) + assert.Equal(t, "ServiceAccount", crb.Subjects[0].Kind) + assert.Equal(t, "my-sa", crb.Subjects[0].Name) + assert.Equal(t, "default", crb.Subjects[0].Namespace) +} + +func TestMutator_EditSubjects_Nil(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + m.EditSubjects(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditSubjects_Add(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.Add(rbacv1.Subject{Kind: "User", Name: "alice", APIGroup: "rbac.authorization.k8s.io"}) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, crb.Subjects, 1) + assert.Equal(t, "alice", crb.Subjects[0].Name) +} + +func TestMutator_EditSubjects_Remove(t *testing.T) { + crb := newTestCRB() + crb.Subjects = []rbacv1.Subject{ + {Kind: "User", Name: "alice"}, + {Kind: "User", Name: "bob"}, + } + m := NewMutator(crb) + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.Remove("User", "alice", "") + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, crb.Subjects, 1) + assert.Equal(t, "bob", crb.Subjects[0].Name) +} + +func TestMutator_EditSubjects_Error(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + m.EditSubjects(func(_ *editors.BindingSubjectsEditor) error { + return assert.AnError + }) + assert.Error(t, m.Apply()) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before subject edits. + crb := newTestCRB() + m := NewMutator(crb) + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("sa1", "ns1") + return nil + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", crb.Labels["order"]) + require.Len(t, crb.Subjects, 1) + assert.Equal(t, "sa1", crb.Subjects[0].Name) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("sa1", "ns1") + return nil + }) + m.beginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("sa2", "ns2") + return nil + }) + require.NoError(t, m.Apply()) + + require.Len(t, crb.Subjects, 2) + assert.Equal(t, "sa1", crb.Subjects[0].Name) + assert.Equal(t, "sa2", crb.Subjects[1].Name) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/clusterrolebinding/resource.go b/pkg/primitives/clusterrolebinding/resource.go new file mode 100644 index 00000000..9d01a625 --- /dev/null +++ b/pkg/primitives/clusterrolebinding/resource.go @@ -0,0 +1,73 @@ +package clusterrolebinding + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + rbacv1 "k8s.io/api/rbac/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DefaultFieldApplicator replaces current with a deep copy of desired, preserving +// roleRef on updates. +// +// roleRef is immutable after creation in the Kubernetes RBAC API. When the current +// object has a ResourceVersion (indicating it already exists in the cluster), the +// applicator restores the original roleRef after copying. +func DefaultFieldApplicator(current, desired *rbacv1.ClusterRoleBinding) error { + roleRef := current.RoleRef + *current = *desired.DeepCopy() + if current.ResourceVersion != "" { + current.RoleRef = roleRef + } + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes ClusterRoleBinding +// within a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - component.DataExtractable: for exporting values after successful reconciliation. +// +// ClusterRoleBinding resources are static: they do not model convergence health, +// grace periods, or suspension. Use a workload or task primitive for resources +// that require those concepts. +type Resource struct { + base *generic.BaseResource[*rbacv1.ClusterRoleBinding, *Mutator] +} + +// Identity returns a unique identifier for the ClusterRoleBinding in the format +// "rbac.authorization.k8s.io/v1/ClusterRoleBinding/". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes ClusterRoleBinding object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes ClusterRoleBinding into the +// desired state. +// +// The mutation process follows this order: +// 1. Field application: the current object is updated to reflect the desired base state, +// using either DefaultFieldApplicator or a custom applicator if one is configured. +// 2. Field application flavors: any registered flavors are applied in registration order. +// 3. Feature mutations: all registered feature-gated mutations are applied in order. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled ClusterRoleBinding. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the ClusterRoleBinding. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} From 882894744e8cde6e1f8918bbdcb1773d6a9dd63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:43:17 +0000 Subject: [PATCH 03/23] Add ClusterRoleBinding primitive documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/clusterrolebinding.md | 234 ++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/primitives/clusterrolebinding.md diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md new file mode 100644 index 00000000..f08a669c --- /dev/null +++ b/docs/primitives/clusterrolebinding.md @@ -0,0 +1,234 @@ +# ClusterRoleBinding Primitive + +The `clusterrolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes `ClusterRoleBinding` resources. It integrates with the component lifecycle and provides a structured mutation API for managing `.subjects` entries and object metadata. + +## Capabilities + +| Capability | Detail | +|-----------------------|------------------------------------------------------------------------------------------------------| +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Cluster-scoped** | No namespace required — only Name is validated during Build() | +| **Immutable roleRef** | `DefaultFieldApplicator` preserves `roleRef` on updates since it is immutable after creation | +| **Mutation pipeline** | Typed editors for `.subjects` entries and object metadata, with a raw escape hatch for free-form access | +| **Flavors** | Preserves externally-managed fields — labels and annotations not owned by the operator | +| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRoleBinding after each sync cycle | + +## Building a ClusterRoleBinding Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrolebinding" + +base := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-cluster-admin", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "cluster-admin", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: "app-sa", + Namespace: "default", + }, + }, +} + +resource, err := clusterrolebinding.NewBuilder(base). + WithFieldApplicationFlavor(clusterrolebinding.PreserveCurrentLabels). + WithMutation(MySubjectMutation(owner.Spec.Version)). + Build() +``` + +## Default Field Application + +`DefaultFieldApplicator` replaces the current ClusterRoleBinding with a deep copy of the desired object, preserving `roleRef` on updates. The `roleRef` field is immutable after creation in the Kubernetes RBAC API — attempting to change it results in an API error. + +When the current object already exists in the cluster (has a non-empty `ResourceVersion`), the applicator restores the original `roleRef` after copying. On initial creation, the desired `roleRef` is used as-is. + +Use `WithCustomFieldApplicator` when you need different field application behaviour: + +```go +resource, err := clusterrolebinding.NewBuilder(base). + WithCustomFieldApplicator(func(current, desired *rbacv1.ClusterRoleBinding) error { + // Custom merge logic + current.Subjects = desired.DeepCopy().Subjects + return nil + }). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `ClusterRoleBinding` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. + +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature with no version constraints and no `When()` conditions is also always enabled: + +```go +func MySubjectMutation(version string) clusterrolebinding.Mutation { + return clusterrolebinding.Mutation{ + Name: "my-subjects", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *clusterrolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("my-sa", "default") + return nil + }) + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by another, register the dependency first. + +### Boolean-gated mutations + +```go +func ConditionalSubjectMutation(version string, addExtraSubject bool) clusterrolebinding.Mutation { + return clusterrolebinding.Mutation{ + Name: "conditional-subject", + Feature: feature.NewResourceFeature(version, nil).When(addExtraSubject), + Mutate: func(m *clusterrolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("extra-sa", "monitoring") + return nil + }) + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are recorded: + +| Step | Category | What it affects | +|------|-----------------|---------------------------------------------------------| +| 1 | Metadata edits | Labels and annotations on the `ClusterRoleBinding` | +| 2 | Subject edits | `.subjects` entries — Add, Remove, EnsureServiceAccount | + +Within each category, edits are applied in their registration order. Later features observe the ClusterRoleBinding as modified by all previous features. + +## Editors + +### BindingSubjectsEditor + +The primary API for modifying `.subjects` entries. Use `m.EditSubjects` for full control: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("my-sa", "default") + e.Remove("User", "old-user", "") + return nil +}) +``` + +#### EnsureServiceAccount + +Ensures a `ServiceAccount` subject with the given name and namespace exists. If an identical subject already exists, this is a no-op: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("app-sa", "production") + return nil +}) +``` + +#### Add + +Appends a subject to the list without checking for duplicates: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.Add(rbacv1.Subject{ + Kind: "Group", + Name: "developers", + APIGroup: "rbac.authorization.k8s.io", + }) + return nil +}) +``` + +#### Remove and RemoveServiceAccount + +`Remove` removes all subjects matching the given kind, name, and namespace. `RemoveServiceAccount` is a convenience wrapper for removing `ServiceAccount` subjects: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.Remove("User", "old-user", "") + e.RemoveServiceAccount("deprecated-sa", "default") + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns the underlying `[]rbacv1.Subject` for free-form editing: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + raw := e.Raw() + for i := range raw { + if raw[i].Kind == "ServiceAccount" { + raw[i].Namespace = "updated-namespace" + } + } + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/managed-by", "my-operator") + e.EnsureAnnotation("description", "cluster-wide admin binding") + return nil +}) +``` + +## Flavors + +Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external controllers or other tools. + +### PreserveCurrentLabels + +Preserves labels present on the live object but absent from the applied desired state. Applied labels win on overlap. + +```go +resource, err := clusterrolebinding.NewBuilder(base). + WithFieldApplicationFlavor(clusterrolebinding.PreserveCurrentLabels). + Build() +``` + +### PreserveCurrentAnnotations + +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. + +```go +resource, err := clusterrolebinding.NewBuilder(base). + WithFieldApplicationFlavor(clusterrolebinding.PreserveCurrentAnnotations). + Build() +``` + +Multiple flavors can be registered and run in registration order. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. + +**`roleRef` is immutable.** The default field applicator preserves the existing `roleRef` on updates. To change a `roleRef`, delete the ClusterRoleBinding and recreate it — the Kubernetes API does not support in-place updates to this field. + +**Cluster-scoped resources have no namespace.** Unlike namespaced primitives, ClusterRoleBinding does not require or validate a namespace. The identity format is `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. + +**Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first. From daee41d7c6f4196fbf5ed49534ab3c4cbc213a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:47:33 +0000 Subject: [PATCH 04/23] Add ClusterRoleBinding primitive example Demonstrates building and mutating a ClusterRoleBinding through multiple spec variations with feature-gated subject mutations, version labels, and field application flavors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../clusterrolebinding-primitive/README.md | 29 +++++ .../app/controller.go | 62 +++++++++++ .../features/mutations.go | 45 ++++++++ examples/clusterrolebinding-primitive/main.go | 100 ++++++++++++++++++ .../resources/clusterrolebinding.go | 62 +++++++++++ 5 files changed, 298 insertions(+) create mode 100644 examples/clusterrolebinding-primitive/README.md create mode 100644 examples/clusterrolebinding-primitive/app/controller.go create mode 100644 examples/clusterrolebinding-primitive/features/mutations.go create mode 100644 examples/clusterrolebinding-primitive/main.go create mode 100644 examples/clusterrolebinding-primitive/resources/clusterrolebinding.go diff --git a/examples/clusterrolebinding-primitive/README.md b/examples/clusterrolebinding-primitive/README.md new file mode 100644 index 00000000..eea884b9 --- /dev/null +++ b/examples/clusterrolebinding-primitive/README.md @@ -0,0 +1,29 @@ +# ClusterRoleBinding Primitive Example + +This example demonstrates the usage of the `clusterrolebinding` primitive within the operator component framework. It shows how to manage a Kubernetes ClusterRoleBinding as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a ClusterRoleBinding with a roleRef and base subjects. +- **Feature Mutations**: Adding subjects conditionally via feature-gated mutations using `EditSubjects`. +- **Metadata Mutations**: Setting version labels on the ClusterRoleBinding via `EditObjectMetadata`. +- **Field Flavors**: Preserving labels managed by external controllers using `PreserveCurrentLabels`. +- **Data Extraction**: Inspecting ClusterRoleBinding state after each reconcile cycle. + +## Directory Structure + +- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from `examples/shared/app`. +- `features/`: Contains modular feature definitions: + - `mutations.go`: version labelling and feature-gated monitoring subject addition. +- `resources/`: Contains the central `NewClusterRoleBindingResource` factory that assembles all features using `clusterrolebinding.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/clusterrolebinding-primitive/main.go +``` + +This will: +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile through three spec variations, printing the reconciled ClusterRoleBinding state after each cycle. +4. Print the resulting status conditions. diff --git a/examples/clusterrolebinding-primitive/app/controller.go b/examples/clusterrolebinding-primitive/app/controller.go new file mode 100644 index 00000000..e6f330e4 --- /dev/null +++ b/examples/clusterrolebinding-primitive/app/controller.go @@ -0,0 +1,62 @@ +// Package app provides a sample controller using the clusterrolebinding primitive. +// +// Note: ClusterRoleBinding is cluster-scoped. The component framework sets a +// controller owner reference, which requires the owner to also be cluster-scoped. +// In production, use a cluster-scoped CRD as the owner. This example demonstrates +// the controller pattern for reference; the main.go entry point exercises the +// primitive API directly to avoid the cluster-scoped owner requirement. +package app + +import ( + "context" + + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +// +// In production usage with a cluster-scoped resource, the owner (ExampleApp) should +// also be cluster-scoped to allow controller owner references to be set correctly. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewClusterRoleBindingResource is a factory function to create the clusterrolebinding resource. + NewClusterRoleBindingResource func(*sharedapp.ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *sharedapp.ExampleApp) error { + // 1. Build the clusterrolebinding resource for this owner. + crbResource, err := r.NewClusterRoleBindingResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the clusterrolebinding. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(crbResource, component.ResourceOptions{}). + Build() + if err != nil { + return err + } + + // 3. Execute the component reconciliation. + resCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + return comp.Reconcile(ctx, resCtx) +} diff --git a/examples/clusterrolebinding-primitive/features/mutations.go b/examples/clusterrolebinding-primitive/features/mutations.go new file mode 100644 index 00000000..d1e23540 --- /dev/null +++ b/examples/clusterrolebinding-primitive/features/mutations.go @@ -0,0 +1,45 @@ +// Package features provides sample mutations for the clusterrolebinding primitive example. +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrolebinding" + rbacv1 "k8s.io/api/rbac/v1" +) + +// VersionLabelMutation sets the app.kubernetes.io/version label on the +// ClusterRoleBinding. It is always enabled. +func VersionLabelMutation(version string) clusterrolebinding.Mutation { + return clusterrolebinding.Mutation{ + Name: "version-label", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *clusterrolebinding.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// MonitoringSubjectMutation adds a monitoring service account as a subject +// when metrics are enabled. +func MonitoringSubjectMutation(version string, enableMetrics bool) clusterrolebinding.Mutation { + return clusterrolebinding.Mutation{ + Name: "monitoring-subject", + Feature: feature.NewResourceFeature(version, nil).When(enableMetrics), + Mutate: func(m *clusterrolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.Add(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "monitoring-agent", + Namespace: "monitoring", + }) + return nil + }) + return nil + }, + } +} diff --git a/examples/clusterrolebinding-primitive/main.go b/examples/clusterrolebinding-primitive/main.go new file mode 100644 index 00000000..d2f75c97 --- /dev/null +++ b/examples/clusterrolebinding-primitive/main.go @@ -0,0 +1,100 @@ +// Package main is the entry point for the clusterrolebinding primitive example. +package main + +import ( + "fmt" + "os" + + "github.com/sourcehawk/operator-component-framework/examples/clusterrolebinding-primitive/features" + "github.com/sourcehawk/operator-component-framework/examples/clusterrolebinding-primitive/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func main() { + // 1. Create an example Owner object. + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{ + Version: "1.2.3", + EnableMetrics: true, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + // 2. Demonstrate building and mutating a ClusterRoleBinding through + // multiple spec variations. Each step builds a fresh resource and + // applies mutations to a simulated current object. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.2.3", + EnableMetrics: true, + }, + { + Version: "1.2.4", // Version upgrade + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableMetrics: false, // Disable metrics — monitoring subject removed + }, + } + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v ---\n", + i+1, spec.Version, spec.EnableMetrics) + + owner.Spec = spec + + // Build the resource from the factory. + resource, err := resources.NewClusterRoleBindingResource(owner) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to build resource: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Identity: %s\n", resource.Identity()) + + // Simulate a current cluster object. + current := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-binding", + ResourceVersion: "12345", + Labels: map[string]string{"external-controller": "managed"}, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: owner.Name + "-role", + }, + } + + // Apply mutations to the current object. + if err := resource.Mutate(current); err != nil { + fmt.Fprintf(os.Stderr, "mutation failed: %v\n", err) + os.Exit(1) + } + + // Print the result. + fmt.Printf("Labels: %v\n", current.Labels) + fmt.Printf("RoleRef: %s/%s\n", current.RoleRef.Kind, current.RoleRef.Name) + fmt.Printf("Subjects (%d):\n", len(current.Subjects)) + for _, s := range current.Subjects { + fmt.Printf(" - %s %s/%s\n", s.Kind, s.Namespace, s.Name) + } + + // Extract data. + if err := resource.ExtractData(); err != nil { + fmt.Fprintf(os.Stderr, "data extraction failed: %v\n", err) + os.Exit(1) + } + } + + // 3. Demonstrate the version label mutation independently. + fmt.Println("\n--- Standalone mutation demo ---") + mutation := features.VersionLabelMutation("2.0.0") + fmt.Printf("Mutation name: %s\n", mutation.Name) + + fmt.Println("\nExample completed successfully!") +} diff --git a/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go b/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go new file mode 100644 index 00000000..a3f32cd7 --- /dev/null +++ b/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go @@ -0,0 +1,62 @@ +// Package resources provides resource implementations for the clusterrolebinding primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/clusterrolebinding-primitive/features" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrolebinding" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewClusterRoleBindingResource constructs a clusterrolebinding primitive resource +// with all the features. +func NewClusterRoleBindingResource(owner *sharedapp.ExampleApp) (*clusterrolebinding.Resource, error) { + // 1. Create the base ClusterRoleBinding object. + base := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-binding", + Labels: map[string]string{ + "app": owner.Name, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: owner.Name + "-role", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: owner.Name, + Namespace: owner.Namespace, + }, + }, + } + + // 2. Initialize the clusterrolebinding builder. + builder := clusterrolebinding.NewBuilder(base) + + // 3. Register mutations in dependency order. + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.MonitoringSubjectMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Preserve labels added by external controllers. + builder.WithFieldApplicationFlavor(clusterrolebinding.PreserveCurrentLabels) + + // 5. Extract data from the reconciled ClusterRoleBinding. + builder.WithDataExtractor(func(crb rbacv1.ClusterRoleBinding) error { + fmt.Printf("Reconciled ClusterRoleBinding: %s\n", crb.Name) + fmt.Printf(" RoleRef: %s/%s\n", crb.RoleRef.Kind, crb.RoleRef.Name) + fmt.Printf(" Subjects (%d):\n", len(crb.Subjects)) + for _, s := range crb.Subjects { + fmt.Printf(" - %s %s/%s\n", s.Kind, s.Namespace, s.Name) + } + return nil + }) + + // 6. Build the final resource. + return builder.Build() +} From c20bed0458518fe74c2e3789e9bf708c4dd626ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 03:28:27 +0000 Subject: [PATCH 05/23] format --- pkg/primitives/clusterrolebinding/builder.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/primitives/clusterrolebinding/builder.go b/pkg/primitives/clusterrolebinding/builder.go index 31b27506..0417a412 100644 --- a/pkg/primitives/clusterrolebinding/builder.go +++ b/pkg/primitives/clusterrolebinding/builder.go @@ -15,13 +15,13 @@ import ( // and data extractors. Build() validates the configuration and returns an // initialized Resource ready for use in a reconciliation loop. type Builder struct { - obj *rbacv1.ClusterRoleBinding - identityFunc func(*rbacv1.ClusterRoleBinding) string - defaultApplicator func(current, desired *rbacv1.ClusterRoleBinding) error - customApplicator func(current, desired *rbacv1.ClusterRoleBinding) error - mutations []feature.Mutation[*Mutator] - flavors []generic.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding] - dataExtractors []func(*rbacv1.ClusterRoleBinding) error + obj *rbacv1.ClusterRoleBinding + identityFunc func(*rbacv1.ClusterRoleBinding) string + defaultApplicator func(current, desired *rbacv1.ClusterRoleBinding) error + customApplicator func(current, desired *rbacv1.ClusterRoleBinding) error + mutations []feature.Mutation[*Mutator] + flavors []generic.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding] + dataExtractors []func(*rbacv1.ClusterRoleBinding) error } // NewBuilder initializes a new Builder with the provided ClusterRoleBinding object. From 28ddf454a9dbae7c4eefe4dc7eb3cb6828dff81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:15:05 +0000 Subject: [PATCH 06/23] address copilot comments --- docs/primitives/clusterrolebinding.md | 8 +- .../clusterrolebinding-primitive/README.md | 9 +-- pkg/mutation/editors/bindingsubjects.go | 9 ++- pkg/mutation/editors/bindingsubjects_test.go | 15 +++- pkg/primitives/clusterrolebinding/resource.go | 3 +- .../clusterrolebinding/resource_test.go | 76 +++++++++++++++++++ 6 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 pkg/primitives/clusterrolebinding/resource_test.go diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md index f08a669c..f7c01c97 100644 --- a/docs/primitives/clusterrolebinding.md +++ b/docs/primitives/clusterrolebinding.md @@ -169,14 +169,14 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { #### Raw Escape Hatch -`Raw()` returns the underlying `[]rbacv1.Subject` for free-form editing: +`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` for free-form editing: ```go m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { raw := e.Raw() - for i := range raw { - if raw[i].Kind == "ServiceAccount" { - raw[i].Namespace = "updated-namespace" + for i := range *raw { + if (*raw)[i].Kind == "ServiceAccount" { + (*raw)[i].Namespace = "updated-namespace" } } return nil diff --git a/examples/clusterrolebinding-primitive/README.md b/examples/clusterrolebinding-primitive/README.md index eea884b9..74e51aca 100644 --- a/examples/clusterrolebinding-primitive/README.md +++ b/examples/clusterrolebinding-primitive/README.md @@ -14,7 +14,7 @@ This example demonstrates the usage of the `clusterrolebinding` primitive within - `features/`: Contains modular feature definitions: - `mutations.go`: version labelling and feature-gated monitoring subject addition. - `resources/`: Contains the central `NewClusterRoleBindingResource` factory that assembles all features using `clusterrolebinding.Builder`. -- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. +- `main.go`: A standalone entry point that demonstrates building and mutating a ClusterRoleBinding through multiple spec variations. ## Running the Example @@ -23,7 +23,6 @@ go run examples/clusterrolebinding-primitive/main.go ``` This will: -1. Initialize a fake Kubernetes client. -2. Create an `ExampleApp` owner object. -3. Reconcile through three spec variations, printing the reconciled ClusterRoleBinding state after each cycle. -4. Print the resulting status conditions. +1. Create an in-memory `ExampleApp` owner object. +2. For each of three spec variations, build a fresh resource and apply mutations to a simulated current ClusterRoleBinding. +3. Print the reconciled ClusterRoleBinding state (labels, roleRef, subjects) after each mutation cycle. diff --git a/pkg/mutation/editors/bindingsubjects.go b/pkg/mutation/editors/bindingsubjects.go index d86be9c5..1131a276 100644 --- a/pkg/mutation/editors/bindingsubjects.go +++ b/pkg/mutation/editors/bindingsubjects.go @@ -18,15 +18,16 @@ func NewBindingSubjectsEditor(subjects *[]rbacv1.Subject) *BindingSubjectsEditor return &BindingSubjectsEditor{subjects: subjects} } -// Raw returns the underlying subjects slice directly, initialising it if necessary. +// Raw returns a pointer to the underlying subjects slice, initialising it if necessary. // // This is an escape hatch for free-form editing when none of the structured -// methods are sufficient. -func (e *BindingSubjectsEditor) Raw() []rbacv1.Subject { +// methods are sufficient. A pointer is returned so that operations which change +// the slice header (e.g., append, re-slicing) are reflected in the editor. +func (e *BindingSubjectsEditor) Raw() *[]rbacv1.Subject { if *e.subjects == nil { *e.subjects = []rbacv1.Subject{} } - return *e.subjects + return e.subjects } // Add appends a subject to the binding's subjects list. diff --git a/pkg/mutation/editors/bindingsubjects_test.go b/pkg/mutation/editors/bindingsubjects_test.go index 4aedda7c..a796bc14 100644 --- a/pkg/mutation/editors/bindingsubjects_test.go +++ b/pkg/mutation/editors/bindingsubjects_test.go @@ -14,15 +14,24 @@ func TestBindingSubjectsEditor_Raw(t *testing.T) { e := NewBindingSubjectsEditor(&subjects) raw := e.Raw() require.NotNil(t, raw) - assert.Empty(t, raw) + assert.Empty(t, *raw) }) t.Run("returns existing slice", func(t *testing.T) { subjects := []rbacv1.Subject{{Kind: "User", Name: "alice"}} e := NewBindingSubjectsEditor(&subjects) raw := e.Raw() - assert.Len(t, raw, 1) - assert.Equal(t, "alice", raw[0].Name) + assert.Len(t, *raw, 1) + assert.Equal(t, "alice", (*raw)[0].Name) + }) + + t.Run("append through pointer propagates", func(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + raw := e.Raw() + *raw = append(*raw, rbacv1.Subject{Kind: "User", Name: "bob"}) + assert.Len(t, subjects, 1) + assert.Equal(t, "bob", subjects[0].Name) }) } diff --git a/pkg/primitives/clusterrolebinding/resource.go b/pkg/primitives/clusterrolebinding/resource.go index 9d01a625..afc10530 100644 --- a/pkg/primitives/clusterrolebinding/resource.go +++ b/pkg/primitives/clusterrolebinding/resource.go @@ -14,8 +14,9 @@ import ( // applicator restores the original roleRef after copying. func DefaultFieldApplicator(current, desired *rbacv1.ClusterRoleBinding) error { roleRef := current.RoleRef + resourceVersion := current.ResourceVersion *current = *desired.DeepCopy() - if current.ResourceVersion != "" { + if resourceVersion != "" { current.RoleRef = roleRef } return nil diff --git a/pkg/primitives/clusterrolebinding/resource_test.go b/pkg/primitives/clusterrolebinding/resource_test.go new file mode 100644 index 00000000..99ef36a9 --- /dev/null +++ b/pkg/primitives/clusterrolebinding/resource_test.go @@ -0,0 +1,76 @@ +package clusterrolebinding + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDefaultFieldApplicator(t *testing.T) { + t.Run("create: applies desired roleRef when current has no ResourceVersion", func(t *testing.T) { + current := &rbacv1.ClusterRoleBinding{} + desired := &rbacv1.ClusterRoleBinding{ + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "desired-role", + }, + Subjects: []rbacv1.Subject{ + {Kind: "User", Name: "alice"}, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + assert.Equal(t, "desired-role", current.RoleRef.Name) + assert.Len(t, current.Subjects, 1) + }) + + t.Run("update: preserves current roleRef when ResourceVersion is set", func(t *testing.T) { + current := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "12345", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "original-role", + }, + } + desired := &rbacv1.ClusterRoleBinding{ + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "new-role", + }, + Subjects: []rbacv1.Subject{ + {Kind: "User", Name: "bob"}, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + assert.Equal(t, "original-role", current.RoleRef.Name, "roleRef must be preserved on update") + assert.Len(t, current.Subjects, 1) + assert.Equal(t, "bob", current.Subjects[0].Name) + }) + + t.Run("does not mutate desired", func(t *testing.T) { + current := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "1", + }, + RoleRef: rbacv1.RoleRef{Name: "current-role"}, + } + desired := &rbacv1.ClusterRoleBinding{ + RoleRef: rbacv1.RoleRef{Name: "desired-role"}, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + assert.Equal(t, "desired-role", desired.RoleRef.Name, "desired must not be modified") + }) +} From c27ce01194a65848d63094bf185f3c8b025ebed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:32:08 +0000 Subject: [PATCH 07/23] Address Copilot review: namespace validation and StaticResource type - Reject non-empty namespace in Build() since ClusterRoleBinding is cluster-scoped (with test case) - Use generic.StaticResource instead of generic.BaseResource for consistency with other static primitives like ConfigMap Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/clusterrolebinding/builder.go | 23 +++++++++++-------- .../clusterrolebinding/builder_test.go | 7 ++++++ pkg/primitives/clusterrolebinding/resource.go | 2 +- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/pkg/primitives/clusterrolebinding/builder.go b/pkg/primitives/clusterrolebinding/builder.go index 0417a412..8fa7acc6 100644 --- a/pkg/primitives/clusterrolebinding/builder.go +++ b/pkg/primitives/clusterrolebinding/builder.go @@ -112,17 +112,22 @@ func (b *Builder) Build() (*Resource, error) { if b.obj.Name == "" { return nil, errors.New("object name cannot be empty") } + if b.obj.Namespace != "" { + return nil, errors.New("object namespace must be empty for cluster-scoped resource") + } res := &Resource{ - base: &generic.BaseResource[*rbacv1.ClusterRoleBinding, *Mutator]{ - DesiredObject: b.obj, - IdentityFunc: b.identityFunc, - DefaultFieldApplicator: b.defaultApplicator, - CustomFieldApplicator: b.customApplicator, - NewMutator: NewMutator, - Mutations: b.mutations, - FieldFlavors: b.flavors, - DataExtractors: b.dataExtractors, + base: &generic.StaticResource[*rbacv1.ClusterRoleBinding, *Mutator]{ + BaseResource: generic.BaseResource[*rbacv1.ClusterRoleBinding, *Mutator]{ + DesiredObject: b.obj, + IdentityFunc: b.identityFunc, + DefaultFieldApplicator: b.defaultApplicator, + CustomFieldApplicator: b.customApplicator, + NewMutator: NewMutator, + Mutations: b.mutations, + FieldFlavors: b.flavors, + DataExtractors: b.dataExtractors, + }, }, } diff --git a/pkg/primitives/clusterrolebinding/builder_test.go b/pkg/primitives/clusterrolebinding/builder_test.go index a8e9e8f2..fa1cf3c8 100644 --- a/pkg/primitives/clusterrolebinding/builder_test.go +++ b/pkg/primitives/clusterrolebinding/builder_test.go @@ -30,6 +30,13 @@ func TestBuilder_Build_Validation(t *testing.T) { }, expectedErr: "object name cannot be empty", }, + { + name: "non-empty namespace", + crb: &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-crb", Namespace: "default"}, + }, + expectedErr: "object namespace must be empty for cluster-scoped resource", + }, { name: "valid clusterrolebinding", crb: &rbacv1.ClusterRoleBinding{ diff --git a/pkg/primitives/clusterrolebinding/resource.go b/pkg/primitives/clusterrolebinding/resource.go index afc10530..e8dc62a7 100644 --- a/pkg/primitives/clusterrolebinding/resource.go +++ b/pkg/primitives/clusterrolebinding/resource.go @@ -33,7 +33,7 @@ func DefaultFieldApplicator(current, desired *rbacv1.ClusterRoleBinding) error { // grace periods, or suspension. Use a workload or task primitive for resources // that require those concepts. type Resource struct { - base *generic.BaseResource[*rbacv1.ClusterRoleBinding, *Mutator] + base *generic.StaticResource[*rbacv1.ClusterRoleBinding, *Mutator] } // Identity returns a unique identifier for the ClusterRoleBinding in the format From eebdf9d133a9029e2cc9e2f0c1f20f2ee6a732b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:25:39 +0000 Subject: [PATCH 08/23] Preserve ResourceVersion in DefaultFieldApplicator on updates The applicator was saving resourceVersion but not restoring it after DeepCopy, which would cause update conflicts in controller-runtime's CreateOrUpdate for pre-existing objects. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/clusterrolebinding/resource.go | 5 +++-- pkg/primitives/clusterrolebinding/resource_test.go | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/clusterrolebinding/resource.go b/pkg/primitives/clusterrolebinding/resource.go index e8dc62a7..414e641e 100644 --- a/pkg/primitives/clusterrolebinding/resource.go +++ b/pkg/primitives/clusterrolebinding/resource.go @@ -7,17 +7,18 @@ import ( ) // DefaultFieldApplicator replaces current with a deep copy of desired, preserving -// roleRef on updates. +// roleRef and metadata.resourceVersion on updates. // // roleRef is immutable after creation in the Kubernetes RBAC API. When the current // object has a ResourceVersion (indicating it already exists in the cluster), the -// applicator restores the original roleRef after copying. +// applicator restores the original roleRef and ResourceVersion after copying. func DefaultFieldApplicator(current, desired *rbacv1.ClusterRoleBinding) error { roleRef := current.RoleRef resourceVersion := current.ResourceVersion *current = *desired.DeepCopy() if resourceVersion != "" { current.RoleRef = roleRef + current.ResourceVersion = resourceVersion } return nil } diff --git a/pkg/primitives/clusterrolebinding/resource_test.go b/pkg/primitives/clusterrolebinding/resource_test.go index 99ef36a9..c39b5e7a 100644 --- a/pkg/primitives/clusterrolebinding/resource_test.go +++ b/pkg/primitives/clusterrolebinding/resource_test.go @@ -54,6 +54,7 @@ func TestDefaultFieldApplicator(t *testing.T) { err := DefaultFieldApplicator(current, desired) require.NoError(t, err) assert.Equal(t, "original-role", current.RoleRef.Name, "roleRef must be preserved on update") + assert.Equal(t, "12345", current.ResourceVersion, "ResourceVersion must be preserved on update") assert.Len(t, current.Subjects, 1) assert.Equal(t, "bob", current.Subjects[0].Name) }) From a527376b957d4d99b832e75bf83c5842d6f3dc45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:50:55 +0000 Subject: [PATCH 09/23] preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/clusterrolebinding/resource.go | 20 ++-- .../clusterrolebinding/resource_test.go | 96 +++++++++++++++++++ 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/pkg/primitives/clusterrolebinding/resource.go b/pkg/primitives/clusterrolebinding/resource.go index 414e641e..d21c7b6e 100644 --- a/pkg/primitives/clusterrolebinding/resource.go +++ b/pkg/primitives/clusterrolebinding/resource.go @@ -6,19 +6,17 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired, preserving -// roleRef and metadata.resourceVersion on updates. -// -// roleRef is immutable after creation in the Kubernetes RBAC API. When the current -// object has a ResourceVersion (indicating it already exists in the cluster), the -// applicator restores the original roleRef and ResourceVersion after copying. +// DefaultFieldApplicator replaces current with a deep copy of desired while +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) +// and shared-controller fields (OwnerReferences, Finalizers) from the original +// current object. It also preserves roleRef on updates because roleRef is +// immutable after creation in the Kubernetes RBAC API. func DefaultFieldApplicator(current, desired *rbacv1.ClusterRoleBinding) error { - roleRef := current.RoleRef - resourceVersion := current.ResourceVersion + original := current.DeepCopy() *current = *desired.DeepCopy() - if resourceVersion != "" { - current.RoleRef = roleRef - current.ResourceVersion = resourceVersion + generic.PreserveServerManagedFields(current, original) + if original.ResourceVersion != "" { + current.RoleRef = original.RoleRef } return nil } diff --git a/pkg/primitives/clusterrolebinding/resource_test.go b/pkg/primitives/clusterrolebinding/resource_test.go index c39b5e7a..ec79bc97 100644 --- a/pkg/primitives/clusterrolebinding/resource_test.go +++ b/pkg/primitives/clusterrolebinding/resource_test.go @@ -59,6 +59,53 @@ func TestDefaultFieldApplicator(t *testing.T) { assert.Equal(t, "bob", current.Subjects[0].Name) }) + t.Run("update: preserves server-managed and shared-controller fields", func(t *testing.T) { + current := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "12345", + UID: "abc-def", + Generation: 3, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, + }, + Finalizers: []string{"finalizer.example.com"}, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "original-role", + }, + } + desired := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "test"}, + }, + Subjects: []rbacv1.Subject{ + {Kind: "User", Name: "alice"}, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired fields are applied + assert.Equal(t, "test", current.Labels["app"]) + assert.Len(t, current.Subjects, 1) + + // Server-managed fields are preserved + assert.Equal(t, "12345", current.ResourceVersion) + assert.Equal(t, "abc-def", string(current.UID)) + assert.Equal(t, int64(3), current.Generation) + + // Shared-controller fields are preserved + assert.Len(t, current.OwnerReferences, 1) + assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) + assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) + + // roleRef is preserved on update + assert.Equal(t, "original-role", current.RoleRef.Name) + }) + t.Run("does not mutate desired", func(t *testing.T) { current := &rbacv1.ClusterRoleBinding{ ObjectMeta: metav1.ObjectMeta{ @@ -75,3 +122,52 @@ func TestDefaultFieldApplicator(t *testing.T) { assert.Equal(t, "desired-role", desired.RoleRef.Name, "desired must not be modified") }) } + +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + ResourceVersion: "12345", + UID: "abc-def", + Generation: 3, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, + }, + Finalizers: []string{"finalizer.example.com"}, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "original-role", + }, + } + desired := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Labels: map[string]string{"app": "test"}, + }, + Subjects: []rbacv1.Subject{ + {Kind: "User", Name: "alice"}, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec and labels are applied + assert.Equal(t, "test", current.Labels["app"]) + assert.Len(t, current.Subjects, 1) + + // Server-managed fields are preserved + assert.Equal(t, "12345", current.ResourceVersion) + assert.Equal(t, "abc-def", string(current.UID)) + assert.Equal(t, int64(3), current.Generation) + + // Shared-controller fields are preserved + assert.Len(t, current.OwnerReferences, 1) + assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) + assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) + + // roleRef is preserved on update (immutable in RBAC API) + assert.Equal(t, "original-role", current.RoleRef.Name) +} From a9ce97791819ba35b5f91925b8031a48e8b2f92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 03:24:52 +0000 Subject: [PATCH 10/23] Refactor builder to use generic.NewStaticBuilder and address review comments - Switch ClusterRoleBinding builder to wrap generic.NewStaticBuilder with MarkClusterScoped(), matching the pattern used by ConfigMap and other primitives. This eliminates duplicated validation and framework wiring. - Update controller.go comments to reflect current framework behavior: owner references are skipped (not required) for cluster-scoped resources with namespace-scoped owners, with GC implications. - Update docs capabilities table to clarify that namespace must be empty. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/clusterrolebinding.md | 2 +- .../app/controller.go | 17 +++--- pkg/primitives/clusterrolebinding/builder.go | 61 ++++++------------- .../clusterrolebinding/builder_test.go | 2 +- 4 files changed, 32 insertions(+), 50 deletions(-) diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md index f7c01c97..49f95d4c 100644 --- a/docs/primitives/clusterrolebinding.md +++ b/docs/primitives/clusterrolebinding.md @@ -7,7 +7,7 @@ The `clusterrolebinding` primitive is the framework's built-in static abstractio | Capability | Detail | |-----------------------|------------------------------------------------------------------------------------------------------| | **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | -| **Cluster-scoped** | No namespace required — only Name is validated during Build() | +| **Cluster-scoped** | Cluster-scoped resource — Build() validates Name and requires metadata.namespace to be empty (errors if set) | | **Immutable roleRef** | `DefaultFieldApplicator` preserves `roleRef` on updates since it is immutable after creation | | **Mutation pipeline** | Typed editors for `.subjects` entries and object metadata, with a raw escape hatch for free-form access | | **Flavors** | Preserves externally-managed fields — labels and annotations not owned by the operator | diff --git a/examples/clusterrolebinding-primitive/app/controller.go b/examples/clusterrolebinding-primitive/app/controller.go index e6f330e4..617ced24 100644 --- a/examples/clusterrolebinding-primitive/app/controller.go +++ b/examples/clusterrolebinding-primitive/app/controller.go @@ -1,10 +1,12 @@ // Package app provides a sample controller using the clusterrolebinding primitive. // -// Note: ClusterRoleBinding is cluster-scoped. The component framework sets a -// controller owner reference, which requires the owner to also be cluster-scoped. -// In production, use a cluster-scoped CRD as the owner. This example demonstrates -// the controller pattern for reference; the main.go entry point exercises the -// primitive API directly to avoid the cluster-scoped owner requirement. +// Note: ClusterRoleBinding is cluster-scoped. When the owner is namespace-scoped, +// the component framework automatically skips setting a controller owner reference +// (since Kubernetes does not allow cross-scope owner references) and logs an info +// message. This means the ClusterRoleBinding will not be garbage-collected when +// the owner is deleted — operators should implement their own cleanup logic if +// needed. In production, using a cluster-scoped CRD as the owner avoids this +// limitation entirely. package app import ( @@ -19,8 +21,9 @@ import ( // ExampleController reconciles an ExampleApp object using the component framework. // -// In production usage with a cluster-scoped resource, the owner (ExampleApp) should -// also be cluster-scoped to allow controller owner references to be set correctly. +// When the owner is namespace-scoped, the framework skips the controller owner +// reference for cluster-scoped resources. Use a cluster-scoped owner CRD in +// production if automatic garbage collection is required. type ExampleController struct { client.Client Scheme *runtime.Scheme diff --git a/pkg/primitives/clusterrolebinding/builder.go b/pkg/primitives/clusterrolebinding/builder.go index 8fa7acc6..c188fbf1 100644 --- a/pkg/primitives/clusterrolebinding/builder.go +++ b/pkg/primitives/clusterrolebinding/builder.go @@ -1,7 +1,6 @@ package clusterrolebinding import ( - "errors" "fmt" "github.com/sourcehawk/operator-component-framework/internal/generic" @@ -15,13 +14,7 @@ import ( // and data extractors. Build() validates the configuration and returns an // initialized Resource ready for use in a reconciliation loop. type Builder struct { - obj *rbacv1.ClusterRoleBinding - identityFunc func(*rbacv1.ClusterRoleBinding) string - defaultApplicator func(current, desired *rbacv1.ClusterRoleBinding) error - customApplicator func(current, desired *rbacv1.ClusterRoleBinding) error - mutations []feature.Mutation[*Mutator] - flavors []generic.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding] - dataExtractors []func(*rbacv1.ClusterRoleBinding) error + base *generic.StaticBuilder[*rbacv1.ClusterRoleBinding, *Mutator] } // NewBuilder initializes a new Builder with the provided ClusterRoleBinding object. @@ -31,17 +24,23 @@ type Builder struct { // registered mutations and flavors. // // The provided ClusterRoleBinding must have a Name set, which is validated -// during the Build() call. Namespace is not required as ClusterRoleBinding is +// during the Build() call. Namespace must be empty as ClusterRoleBinding is // cluster-scoped. func NewBuilder(crb *rbacv1.ClusterRoleBinding) *Builder { identityFunc := func(c *rbacv1.ClusterRoleBinding) string { return fmt.Sprintf("rbac.authorization.k8s.io/v1/ClusterRoleBinding/%s", c.Name) } + base := generic.NewStaticBuilder[*rbacv1.ClusterRoleBinding, *Mutator]( + crb, + identityFunc, + DefaultFieldApplicator, + NewMutator, + ) + base.MarkClusterScoped() + return &Builder{ - obj: crb, - identityFunc: identityFunc, - defaultApplicator: DefaultFieldApplicator, + base: base, } } @@ -52,7 +51,7 @@ func NewBuilder(crb *rbacv1.ClusterRoleBinding) *Builder { // A mutation with a nil Feature is applied unconditionally; one with a non-nil // Feature is applied only when that feature is enabled. func (b *Builder) WithMutation(m Mutation) *Builder { - b.mutations = append(b.mutations, feature.Mutation[*Mutator](m)) + b.base.WithMutation(feature.Mutation[*Mutator](m)) return b } @@ -65,7 +64,7 @@ func (b *Builder) WithMutation(m Mutation) *Builder { func (b *Builder) WithCustomFieldApplicator( applicator func(current, desired *rbacv1.ClusterRoleBinding) error, ) *Builder { - b.customApplicator = applicator + b.base.WithCustomFieldApplicator(applicator) return b } @@ -78,7 +77,7 @@ func (b *Builder) WithCustomFieldApplicator( // A nil flavor is ignored. func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { if flavor != nil { - b.flavors = append(b.flavors, generic.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding](flavor)) + b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding](flavor)) } return b } @@ -93,7 +92,7 @@ func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Bui // A nil extractor is ignored. func (b *Builder) WithDataExtractor(extractor func(rbacv1.ClusterRoleBinding) error) *Builder { if extractor != nil { - b.dataExtractors = append(b.dataExtractors, func(crb *rbacv1.ClusterRoleBinding) error { + b.base.WithDataExtractor(func(crb *rbacv1.ClusterRoleBinding) error { return extractor(*crb) }) } @@ -105,31 +104,11 @@ func (b *Builder) WithDataExtractor(extractor func(rbacv1.ClusterRoleBinding) er // It returns an error if: // - No ClusterRoleBinding object was provided. // - The ClusterRoleBinding is missing a Name. +// - The ClusterRoleBinding has a non-empty Namespace. func (b *Builder) Build() (*Resource, error) { - if b.obj == nil { - return nil, errors.New("object cannot be nil") - } - if b.obj.Name == "" { - return nil, errors.New("object name cannot be empty") - } - if b.obj.Namespace != "" { - return nil, errors.New("object namespace must be empty for cluster-scoped resource") + genericRes, err := b.base.Build() + if err != nil { + return nil, err } - - res := &Resource{ - base: &generic.StaticResource[*rbacv1.ClusterRoleBinding, *Mutator]{ - BaseResource: generic.BaseResource[*rbacv1.ClusterRoleBinding, *Mutator]{ - DesiredObject: b.obj, - IdentityFunc: b.identityFunc, - DefaultFieldApplicator: b.defaultApplicator, - CustomFieldApplicator: b.customApplicator, - NewMutator: NewMutator, - Mutations: b.mutations, - FieldFlavors: b.flavors, - DataExtractors: b.dataExtractors, - }, - }, - } - - return res, nil + return &Resource{base: genericRes}, nil } diff --git a/pkg/primitives/clusterrolebinding/builder_test.go b/pkg/primitives/clusterrolebinding/builder_test.go index fa1cf3c8..f4a9cad4 100644 --- a/pkg/primitives/clusterrolebinding/builder_test.go +++ b/pkg/primitives/clusterrolebinding/builder_test.go @@ -35,7 +35,7 @@ func TestBuilder_Build_Validation(t *testing.T) { crb: &rbacv1.ClusterRoleBinding{ ObjectMeta: metav1.ObjectMeta{Name: "test-crb", Namespace: "default"}, }, - expectedErr: "object namespace must be empty for cluster-scoped resource", + expectedErr: "cluster-scoped object must not have a namespace", }, { name: "valid clusterrolebinding", From 82dd2d856ede8b3c56d7cd68a3981c223741a983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 15:59:54 +0000 Subject: [PATCH 11/23] Update ClusterRoleBinding docs to document server-managed field preservation Align the Default Field Application section with the deployment primitive docs pattern. Explicitly mention that server-managed metadata and shared-controller fields are preserved, and note that ClusterRoleBinding has no Status subresource so no status preservation is needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/clusterrolebinding.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md index 49f95d4c..7d1410df 100644 --- a/docs/primitives/clusterrolebinding.md +++ b/docs/primitives/clusterrolebinding.md @@ -44,9 +44,9 @@ resource, err := clusterrolebinding.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current ClusterRoleBinding with a deep copy of the desired object, preserving `roleRef` on updates. The `roleRef` field is immutable after creation in the Kubernetes RBAC API — attempting to change it results in an API error. +`DefaultFieldApplicator` replaces the current ClusterRoleBinding with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and `roleRef` from the original live object. ClusterRoleBinding does not have a Status subresource, so no status preservation is needed. -When the current object already exists in the cluster (has a non-empty `ResourceVersion`), the applicator restores the original `roleRef` after copying. On initial creation, the desired `roleRef` is used as-is. +The `roleRef` field is immutable after creation in the Kubernetes RBAC API — attempting to change it results in an API error. When the current object already exists in the cluster (has a non-empty `ResourceVersion`), the applicator restores the original `roleRef` after copying. On initial creation, the desired `roleRef` is used as-is. Use `WithCustomFieldApplicator` when you need different field application behaviour: From 1a69ad85b7dfd3f9e7515f6d1dfcc1f739432482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:43:26 +0000 Subject: [PATCH 12/23] Fix ClusterRoleBinding mutator to not call beginFeature in constructor Initialize the first feature plan inline instead of calling beginFeature(), which would cause a duplicate empty feature when the generic ApplyMutations helper also calls beginFeature() for each mutation. Aligns with the pattern used by deployment and configmap mutators. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/clusterrolebinding/mutator.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/clusterrolebinding/mutator.go b/pkg/primitives/clusterrolebinding/mutator.go index 21d4b80d..1c167d59 100644 --- a/pkg/primitives/clusterrolebinding/mutator.go +++ b/pkg/primitives/clusterrolebinding/mutator.go @@ -34,8 +34,11 @@ type Mutator struct { // NewMutator creates a new Mutator for the given ClusterRoleBinding. func NewMutator(crb *rbacv1.ClusterRoleBinding) *Mutator { - m := &Mutator{crb: crb} - m.beginFeature() + m := &Mutator{ + crb: crb, + plans: []featurePlan{{}}, + } + m.active = &m.plans[0] return m } From 189eeab99f1706e971c3d10d9ae97ef33e0fe2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:58:08 +0000 Subject: [PATCH 13/23] Add ClusterRoleBinding to primitives index and run-examples target Add clusterrolebinding-primitive to the Makefile run-examples target so CI exercises it alongside other examples. Also add the primitive and BindingSubjectsEditor to the docs/primitives.md index tables. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 1 + docs/primitives.md | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 78dcae45..89cef547 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,7 @@ run-examples: ## Run all examples to verify they execute without error. go run ./examples/deployment-primitive/. go run ./examples/configmap-primitive/. go run ./examples/custom-resource-implementation/. + go run ./examples/clusterrolebinding-primitive/. # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist diff --git a/docs/primitives.md b/docs/primitives.md index 13fde6a6..76a5745d 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -108,6 +108,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource: | `DeploymentSpecEditor` | Replicas, update strategy, label selectors | | `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | | `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | +| `BindingSubjectsEditor`| Subjects on RoleBinding or ClusterRoleBinding — add, remove, replace | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping the mutation scoped to that editor's target. @@ -128,8 +129,9 @@ Selectors are evaluated against the container list *after* any presence operatio | Primitive | Category | Documentation | |--------------------------------------|------------|-----------------------------------------| -| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | -| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | ## Usage Examples From d142266d60592edc0202f4210f0547bb3918b2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 20:19:00 +0000 Subject: [PATCH 14/23] Export BeginFeature() to match updated FeatureMutator interface The FeatureMutator interface in internal/generic/resource_workload.go now requires an exported BeginFeature() method. Update the ClusterRoleBinding mutator and its tests to use the exported name. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/clusterrolebinding/mutator.go | 4 ++-- pkg/primitives/clusterrolebinding/mutator_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/clusterrolebinding/mutator.go b/pkg/primitives/clusterrolebinding/mutator.go index 1c167d59..6b1df8d9 100644 --- a/pkg/primitives/clusterrolebinding/mutator.go +++ b/pkg/primitives/clusterrolebinding/mutator.go @@ -42,9 +42,9 @@ func NewMutator(crb *rbacv1.ClusterRoleBinding) *Mutator { return m } -// beginFeature starts a new feature planning scope. All subsequent mutation +// BeginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan. -func (m *Mutator) beginFeature() { +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/clusterrolebinding/mutator_test.go b/pkg/primitives/clusterrolebinding/mutator_test.go index 277e4df9..37c6d5ab 100644 --- a/pkg/primitives/clusterrolebinding/mutator_test.go +++ b/pkg/primitives/clusterrolebinding/mutator_test.go @@ -132,7 +132,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { e.EnsureServiceAccount("sa1", "ns1") return nil }) - m.beginFeature() + m.BeginFeature() m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { e.EnsureServiceAccount("sa2", "ns2") return nil From 2f6e17b3069e22cd5472b4f3aae7249f77ea3302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:29:40 +0000 Subject: [PATCH 15/23] Format markdown files with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 26 +++---- docs/primitives/clusterrolebinding.md | 73 ++++++++++++------- .../clusterrolebinding-primitive/README.md | 18 +++-- 3 files changed, 72 insertions(+), 45 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index a3b37539..cecdb229 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -124,14 +124,14 @@ This design: Editors provide scoped, typed APIs for modifying specific parts of a resource: -| Editor | Scope | -| ---------------------- | ----------------------------------------------------------------------- | -| `ContainerEditor` | Environment variables, arguments, resource limits, ports | -| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | -| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | -| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | -| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | -| `BindingSubjectsEditor`| Subjects on RoleBinding or ClusterRoleBinding — add, remove, replace | +| Editor | Scope | +| ----------------------- | ----------------------------------------------------------------------- | +| `ContainerEditor` | Environment variables, arguments, resource limits, ports | +| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | +| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | +| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | +| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | +| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — add, remove, replace | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping the mutation scoped to that editor's target. @@ -152,11 +152,11 @@ have been applied. This means a single mutation can safely add a container and t ## Built-in Primitives -| Primitive | Category | Documentation | -| ------------------------------------ | -------- | --------------------------------------------------------- | -| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | -| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | -| `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | +| Primitive | Category | Documentation | +| ----------------------------------- | -------- | --------------------------------------------------------- | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | ## Usage Examples diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md index 7d1410df..e346e190 100644 --- a/docs/primitives/clusterrolebinding.md +++ b/docs/primitives/clusterrolebinding.md @@ -1,17 +1,19 @@ # ClusterRoleBinding Primitive -The `clusterrolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes `ClusterRoleBinding` resources. It integrates with the component lifecycle and provides a structured mutation API for managing `.subjects` entries and object metadata. +The `clusterrolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes +`ClusterRoleBinding` resources. It integrates with the component lifecycle and provides a structured mutation API for +managing `.subjects` entries and object metadata. ## Capabilities -| Capability | Detail | -|-----------------------|------------------------------------------------------------------------------------------------------| -| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| Capability | Detail | +| --------------------- | ------------------------------------------------------------------------------------------------------------ | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | | **Cluster-scoped** | Cluster-scoped resource — Build() validates Name and requires metadata.namespace to be empty (errors if set) | -| **Immutable roleRef** | `DefaultFieldApplicator` preserves `roleRef` on updates since it is immutable after creation | -| **Mutation pipeline** | Typed editors for `.subjects` entries and object metadata, with a raw escape hatch for free-form access | -| **Flavors** | Preserves externally-managed fields — labels and annotations not owned by the operator | -| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRoleBinding after each sync cycle | +| **Immutable roleRef** | `DefaultFieldApplicator` preserves `roleRef` on updates since it is immutable after creation | +| **Mutation pipeline** | Typed editors for `.subjects` entries and object metadata, with a raw escape hatch for free-form access | +| **Flavors** | Preserves externally-managed fields — labels and annotations not owned by the operator | +| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRoleBinding after each sync cycle | ## Building a ClusterRoleBinding Primitive @@ -44,9 +46,14 @@ resource, err := clusterrolebinding.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current ClusterRoleBinding with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and `roleRef` from the original live object. ClusterRoleBinding does not have a Status subresource, so no status preservation is needed. +`DefaultFieldApplicator` replaces the current ClusterRoleBinding with a deep copy of the desired object, then restores +server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and +`roleRef` from the original live object. ClusterRoleBinding does not have a Status subresource, so no status +preservation is needed. -The `roleRef` field is immutable after creation in the Kubernetes RBAC API — attempting to change it results in an API error. When the current object already exists in the cluster (has a non-empty `ResourceVersion`), the applicator restores the original `roleRef` after copying. On initial creation, the desired `roleRef` is used as-is. +The `roleRef` field is immutable after creation in the Kubernetes RBAC API — attempting to change it results in an API +error. When the current object already exists in the cluster (has a non-empty `ResourceVersion`), the applicator +restores the original `roleRef` after copying. On initial creation, the desired `roleRef` is used as-is. Use `WithCustomFieldApplicator` when you need different field application behaviour: @@ -62,9 +69,11 @@ resource, err := clusterrolebinding.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `ClusterRoleBinding` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying a `ClusterRoleBinding` beyond its baseline. Each mutation is a named +function that receives a `*Mutator` and records edit intent through typed editors. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature with no version constraints and no `When()` conditions is also always enabled: +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature +with no version constraints and no `When()` conditions is also always enabled: ```go func MySubjectMutation(version string) clusterrolebinding.Mutation { @@ -82,7 +91,8 @@ func MySubjectMutation(version string) clusterrolebinding.Mutation { } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by another, register the dependency first. +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by +another, register the dependency first. ### Boolean-gated mutations @@ -106,14 +116,16 @@ All version constraints and `When()` conditions must be satisfied for a mutation ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are recorded: +Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are +recorded: -| Step | Category | What it affects | -|------|-----------------|---------------------------------------------------------| -| 1 | Metadata edits | Labels and annotations on the `ClusterRoleBinding` | -| 2 | Subject edits | `.subjects` entries — Add, Remove, EnsureServiceAccount | +| Step | Category | What it affects | +| ---- | -------------- | ------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `ClusterRoleBinding` | +| 2 | Subject edits | `.subjects` entries — Add, Remove, EnsureServiceAccount | -Within each category, edits are applied in their registration order. Later features observe the ClusterRoleBinding as modified by all previous features. +Within each category, edits are applied in their registration order. Later features observe the ClusterRoleBinding as +modified by all previous features. ## Editors @@ -131,7 +143,8 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { #### EnsureServiceAccount -Ensures a `ServiceAccount` subject with the given name and namespace exists. If an identical subject already exists, this is a no-op: +Ensures a `ServiceAccount` subject with the given name and namespace exists. If an identical subject already exists, +this is a no-op: ```go m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { @@ -157,7 +170,8 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { #### Remove and RemoveServiceAccount -`Remove` removes all subjects matching the given kind, name, and namespace. `RemoveServiceAccount` is a convenience wrapper for removing `ServiceAccount` subjects: +`Remove` removes all subjects matching the given kind, name, and namespace. `RemoveServiceAccount` is a convenience +wrapper for removing `ServiceAccount` subjects: ```go m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { @@ -199,7 +213,8 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Flavors -Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external controllers or other tools. +Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external +controllers or other tools. ### PreserveCurrentLabels @@ -213,7 +228,8 @@ resource, err := clusterrolebinding.NewBuilder(base). ### PreserveCurrentAnnotations -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on +overlap. ```go resource, err := clusterrolebinding.NewBuilder(base). @@ -225,10 +241,15 @@ Multiple flavors can be registered and run in registration order. ## Guidance -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +boolean conditions. -**`roleRef` is immutable.** The default field applicator preserves the existing `roleRef` on updates. To change a `roleRef`, delete the ClusterRoleBinding and recreate it — the Kubernetes API does not support in-place updates to this field. +**`roleRef` is immutable.** The default field applicator preserves the existing `roleRef` on updates. To change a +`roleRef`, delete the ClusterRoleBinding and recreate it — the Kubernetes API does not support in-place updates to this +field. -**Cluster-scoped resources have no namespace.** Unlike namespaced primitives, ClusterRoleBinding does not require or validate a namespace. The identity format is `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. +**Cluster-scoped resources have no namespace.** Unlike namespaced primitives, ClusterRoleBinding does not require or +validate a namespace. The identity format is `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. **Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first. diff --git a/examples/clusterrolebinding-primitive/README.md b/examples/clusterrolebinding-primitive/README.md index 74e51aca..81c7845a 100644 --- a/examples/clusterrolebinding-primitive/README.md +++ b/examples/clusterrolebinding-primitive/README.md @@ -1,6 +1,7 @@ # ClusterRoleBinding Primitive Example -This example demonstrates the usage of the `clusterrolebinding` primitive within the operator component framework. It shows how to manage a Kubernetes ClusterRoleBinding as a component of a larger application, utilising features like: +This example demonstrates the usage of the `clusterrolebinding` primitive within the operator component framework. It +shows how to manage a Kubernetes ClusterRoleBinding as a component of a larger application, utilising features like: - **Base Construction**: Initializing a ClusterRoleBinding with a roleRef and base subjects. - **Feature Mutations**: Adding subjects conditionally via feature-gated mutations using `EditSubjects`. @@ -10,11 +11,14 @@ This example demonstrates the usage of the `clusterrolebinding` primitive within ## Directory Structure -- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from `examples/shared/app`. +- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from + `examples/shared/app`. - `features/`: Contains modular feature definitions: - - `mutations.go`: version labelling and feature-gated monitoring subject addition. -- `resources/`: Contains the central `NewClusterRoleBindingResource` factory that assembles all features using `clusterrolebinding.Builder`. -- `main.go`: A standalone entry point that demonstrates building and mutating a ClusterRoleBinding through multiple spec variations. + - `mutations.go`: version labelling and feature-gated monitoring subject addition. +- `resources/`: Contains the central `NewClusterRoleBindingResource` factory that assembles all features using + `clusterrolebinding.Builder`. +- `main.go`: A standalone entry point that demonstrates building and mutating a ClusterRoleBinding through multiple spec + variations. ## Running the Example @@ -23,6 +27,8 @@ go run examples/clusterrolebinding-primitive/main.go ``` This will: + 1. Create an in-memory `ExampleApp` owner object. -2. For each of three spec variations, build a fresh resource and apply mutations to a simulated current ClusterRoleBinding. +2. For each of three spec variations, build a fresh resource and apply mutations to a simulated current + ClusterRoleBinding. 3. Print the reconciled ClusterRoleBinding state (labels, roleRef, subjects) after each mutation cycle. From a01595706fd6600d254bc6b1fe2cdd8e1b8dc01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:55:25 +0000 Subject: [PATCH 16/23] Do not initialize an empty plan on ClusterRoleBinding mutator construction Align with configmap/deployment primitives: NewMutator no longer creates an initial feature plan. BeginFeature must be called before registering mutations. Updates all tests to call BeginFeature and adds constructor invariant tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/clusterrolebinding/mutator.go | 8 +-- .../clusterrolebinding/mutator_test.go | 68 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/pkg/primitives/clusterrolebinding/mutator.go b/pkg/primitives/clusterrolebinding/mutator.go index 6b1df8d9..d66ceb0c 100644 --- a/pkg/primitives/clusterrolebinding/mutator.go +++ b/pkg/primitives/clusterrolebinding/mutator.go @@ -33,13 +33,11 @@ type Mutator struct { } // NewMutator creates a new Mutator for the given ClusterRoleBinding. +// BeginFeature must be called before registering any mutations. func NewMutator(crb *rbacv1.ClusterRoleBinding) *Mutator { - m := &Mutator{ - crb: crb, - plans: []featurePlan{{}}, + return &Mutator{ + crb: crb, } - m.active = &m.plans[0] - return m } // BeginFeature starts a new feature planning scope. All subsequent mutation diff --git a/pkg/primitives/clusterrolebinding/mutator_test.go b/pkg/primitives/clusterrolebinding/mutator_test.go index 37c6d5ab..c99d05fa 100644 --- a/pkg/primitives/clusterrolebinding/mutator_test.go +++ b/pkg/primitives/clusterrolebinding/mutator_test.go @@ -28,6 +28,7 @@ func newTestCRB() *rbacv1.ClusterRoleBinding { func TestMutator_EditObjectMetadata(t *testing.T) { crb := newTestCRB() m := NewMutator(crb) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureLabel("app", "myapp") return nil @@ -39,6 +40,7 @@ func TestMutator_EditObjectMetadata(t *testing.T) { func TestMutator_EditObjectMetadata_Nil(t *testing.T) { crb := newTestCRB() m := NewMutator(crb) + m.BeginFeature() m.EditObjectMetadata(nil) assert.NoError(t, m.Apply()) } @@ -48,6 +50,7 @@ func TestMutator_EditObjectMetadata_Nil(t *testing.T) { func TestMutator_EditSubjects(t *testing.T) { crb := newTestCRB() m := NewMutator(crb) + m.BeginFeature() m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { e.EnsureServiceAccount("my-sa", "default") return nil @@ -62,6 +65,7 @@ func TestMutator_EditSubjects(t *testing.T) { func TestMutator_EditSubjects_Nil(t *testing.T) { crb := newTestCRB() m := NewMutator(crb) + m.BeginFeature() m.EditSubjects(nil) assert.NoError(t, m.Apply()) } @@ -69,6 +73,7 @@ func TestMutator_EditSubjects_Nil(t *testing.T) { func TestMutator_EditSubjects_Add(t *testing.T) { crb := newTestCRB() m := NewMutator(crb) + m.BeginFeature() m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { e.Add(rbacv1.Subject{Kind: "User", Name: "alice", APIGroup: "rbac.authorization.k8s.io"}) return nil @@ -85,6 +90,7 @@ func TestMutator_EditSubjects_Remove(t *testing.T) { {Kind: "User", Name: "bob"}, } m := NewMutator(crb) + m.BeginFeature() m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { e.Remove("User", "alice", "") return nil @@ -97,6 +103,7 @@ func TestMutator_EditSubjects_Remove(t *testing.T) { func TestMutator_EditSubjects_Error(t *testing.T) { crb := newTestCRB() m := NewMutator(crb) + m.BeginFeature() m.EditSubjects(func(_ *editors.BindingSubjectsEditor) error { return assert.AnError }) @@ -109,6 +116,7 @@ func TestMutator_OperationOrder(t *testing.T) { // Within a feature: metadata edits run before subject edits. crb := newTestCRB() m := NewMutator(crb) + m.BeginFeature() // Register in reverse logical order to confirm Apply() enforces category ordering. m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { e.EnsureServiceAccount("sa1", "ns1") @@ -128,6 +136,7 @@ func TestMutator_OperationOrder(t *testing.T) { func TestMutator_MultipleFeatures(t *testing.T) { crb := newTestCRB() m := NewMutator(crb) + m.BeginFeature() m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { e.EnsureServiceAccount("sa1", "ns1") return nil @@ -144,6 +153,65 @@ func TestMutator_MultipleFeatures(t *testing.T) { assert.Equal(t, "sa2", crb.Subjects[1].Name) } +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + + m.BeginFeature() + require.Len(t, m.plans, 1, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[0], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 2) + assert.Equal(t, &m.plans[1], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("sa0", "ns0") + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("sa1", "ns1") + return nil + }) + + require.Len(t, m.plans, 2) + assert.Len(t, m.plans[0].subjectEdits, 1, "first plan must have one subject edit") + assert.Len(t, m.plans[1].subjectEdits, 1, "second plan must have one subject edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + crb := newTestCRB() + m := NewMutator(crb) + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("sa1", "ns1") + return nil + }) + + require.NoError(t, m.Apply()) + require.Len(t, m.plans, 1, "only one plan must exist when no additional BeginFeature is called") +} + // --- ObjectMutator interface --- func TestMutator_ImplementsObjectMutator(_ *testing.T) { From 660628d59ee891f5bf897eacef4795a493eebb41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 18:10:50 +0000 Subject: [PATCH 17/23] Fix BindingSubjectsEditor docs to match actual API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs table incorrectly described BindingSubjectsEditor as supporting "replace" — the actual API provides add, remove, ensure SA, and raw access. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/primitives.md b/docs/primitives.md index cecdb229..3a8a64c5 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -131,7 +131,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource: | `DeploymentSpecEditor` | Replicas, update strategy, label selectors | | `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | | `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | -| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — add, remove, replace | +| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — add, remove, ensure SA, raw | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping the mutation scoped to that editor's target. From afcf01a0cf791b634b8ad445e3bb7191d7dd9d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 18:17:24 +0000 Subject: [PATCH 18/23] fix lint --- docs/primitives.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index 3a8a64c5..b60597f9 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -124,13 +124,13 @@ This design: Editors provide scoped, typed APIs for modifying specific parts of a resource: -| Editor | Scope | -| ----------------------- | ----------------------------------------------------------------------- | -| `ContainerEditor` | Environment variables, arguments, resource limits, ports | -| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | -| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | -| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | -| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | +| Editor | Scope | +| ----------------------- | --------------------------------------------------------------------------- | +| `ContainerEditor` | Environment variables, arguments, resource limits, ports | +| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | +| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | +| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | +| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | | `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — add, remove, ensure SA, raw | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the From 214d7f9f5847ac92536558c2e88bbd8e8509103b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:07:08 +0000 Subject: [PATCH 19/23] Remove field applicators and flavors from clusterrolebinding primitive Align with the framework's switch to Server-Side Apply: remove DefaultFieldApplicator, WithCustomFieldApplicator, WithFieldApplicationFlavor, flavors.go/flavors_test.go, and drop the defaultApplicator parameter from NewStaticBuilder. Update tests to use Object() output instead of empty structs. Strip SSA/field-applicator/flavor sections from primitive docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/clusterrolebinding.md | 58 ----- pkg/primitives/clusterrolebinding/builder.go | 39 +--- .../clusterrolebinding/builder_test.go | 32 --- pkg/primitives/clusterrolebinding/flavors.go | 26 --- .../clusterrolebinding/flavors_test.go | 123 ----------- pkg/primitives/clusterrolebinding/resource.go | 21 +- .../clusterrolebinding/resource_test.go | 206 ++++++------------ 7 files changed, 68 insertions(+), 437 deletions(-) delete mode 100644 pkg/primitives/clusterrolebinding/flavors.go delete mode 100644 pkg/primitives/clusterrolebinding/flavors_test.go diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md index e346e190..74c0e311 100644 --- a/docs/primitives/clusterrolebinding.md +++ b/docs/primitives/clusterrolebinding.md @@ -10,9 +10,7 @@ managing `.subjects` entries and object metadata. | --------------------- | ------------------------------------------------------------------------------------------------------------ | | **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | | **Cluster-scoped** | Cluster-scoped resource — Build() validates Name and requires metadata.namespace to be empty (errors if set) | -| **Immutable roleRef** | `DefaultFieldApplicator` preserves `roleRef` on updates since it is immutable after creation | | **Mutation pipeline** | Typed editors for `.subjects` entries and object metadata, with a raw escape hatch for free-form access | -| **Flavors** | Preserves externally-managed fields — labels and annotations not owned by the operator | | **Data extraction** | Reads generated or updated values back from the reconciled ClusterRoleBinding after each sync cycle | ## Building a ClusterRoleBinding Primitive @@ -39,34 +37,10 @@ base := &rbacv1.ClusterRoleBinding{ } resource, err := clusterrolebinding.NewBuilder(base). - WithFieldApplicationFlavor(clusterrolebinding.PreserveCurrentLabels). WithMutation(MySubjectMutation(owner.Spec.Version)). Build() ``` -## Default Field Application - -`DefaultFieldApplicator` replaces the current ClusterRoleBinding with a deep copy of the desired object, then restores -server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and -`roleRef` from the original live object. ClusterRoleBinding does not have a Status subresource, so no status -preservation is needed. - -The `roleRef` field is immutable after creation in the Kubernetes RBAC API — attempting to change it results in an API -error. When the current object already exists in the cluster (has a non-empty `ResourceVersion`), the applicator -restores the original `roleRef` after copying. On initial creation, the desired `roleRef` is used as-is. - -Use `WithCustomFieldApplicator` when you need different field application behaviour: - -```go -resource, err := clusterrolebinding.NewBuilder(base). - WithCustomFieldApplicator(func(current, desired *rbacv1.ClusterRoleBinding) error { - // Custom merge logic - current.Subjects = desired.DeepCopy().Subjects - return nil - }). - Build() -``` - ## Mutations Mutations are the primary mechanism for modifying a `ClusterRoleBinding` beyond its baseline. Each mutation is a named @@ -211,44 +185,12 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { }) ``` -## Flavors - -Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external -controllers or other tools. - -### PreserveCurrentLabels - -Preserves labels present on the live object but absent from the applied desired state. Applied labels win on overlap. - -```go -resource, err := clusterrolebinding.NewBuilder(base). - WithFieldApplicationFlavor(clusterrolebinding.PreserveCurrentLabels). - Build() -``` - -### PreserveCurrentAnnotations - -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on -overlap. - -```go -resource, err := clusterrolebinding.NewBuilder(base). - WithFieldApplicationFlavor(clusterrolebinding.PreserveCurrentAnnotations). - Build() -``` - -Multiple flavors can be registered and run in registration order. - ## Guidance **`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. -**`roleRef` is immutable.** The default field applicator preserves the existing `roleRef` on updates. To change a -`roleRef`, delete the ClusterRoleBinding and recreate it — the Kubernetes API does not support in-place updates to this -field. - **Cluster-scoped resources have no namespace.** Unlike namespaced primitives, ClusterRoleBinding does not require or validate a namespace. The identity format is `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. diff --git a/pkg/primitives/clusterrolebinding/builder.go b/pkg/primitives/clusterrolebinding/builder.go index c188fbf1..11a1914b 100644 --- a/pkg/primitives/clusterrolebinding/builder.go +++ b/pkg/primitives/clusterrolebinding/builder.go @@ -10,9 +10,9 @@ import ( // Builder is a configuration helper for creating and customizing a ClusterRoleBinding Resource. // -// It provides a fluent API for registering mutations, field application flavors, -// and data extractors. Build() validates the configuration and returns an -// initialized Resource ready for use in a reconciliation loop. +// It provides a fluent API for registering mutations and data extractors. +// Build() validates the configuration and returns an initialized Resource +// ready for use in a reconciliation loop. type Builder struct { base *generic.StaticBuilder[*rbacv1.ClusterRoleBinding, *Mutator] } @@ -21,7 +21,7 @@ type Builder struct { // // The ClusterRoleBinding object serves as the desired base state. During reconciliation // the Resource will make the cluster's state match this base, modified by any -// registered mutations and flavors. +// registered mutations. // // The provided ClusterRoleBinding must have a Name set, which is validated // during the Build() call. Namespace must be empty as ClusterRoleBinding is @@ -34,7 +34,6 @@ func NewBuilder(crb *rbacv1.ClusterRoleBinding) *Builder { base := generic.NewStaticBuilder[*rbacv1.ClusterRoleBinding, *Mutator]( crb, identityFunc, - DefaultFieldApplicator, NewMutator, ) base.MarkClusterScoped() @@ -46,8 +45,7 @@ func NewBuilder(crb *rbacv1.ClusterRoleBinding) *Builder { // WithMutation registers a mutation for the ClusterRoleBinding. // -// Mutations are applied sequentially during the Mutate() phase of reconciliation, -// after the baseline field applicator and any registered flavors have run. +// Mutations are applied sequentially during the Mutate() phase of reconciliation. // A mutation with a nil Feature is applied unconditionally; one with a non-nil // Feature is applied only when that feature is enabled. func (b *Builder) WithMutation(m Mutation) *Builder { @@ -55,33 +53,6 @@ func (b *Builder) WithMutation(m Mutation) *Builder { return b } -// WithCustomFieldApplicator sets a custom strategy for applying the desired -// state to the existing ClusterRoleBinding in the cluster. -// -// The default applicator (DefaultFieldApplicator) replaces the current object -// with a deep copy of the desired object, preserving roleRef on updates since -// it is immutable after creation. -func (b *Builder) WithCustomFieldApplicator( - applicator func(current, desired *rbacv1.ClusterRoleBinding) error, -) *Builder { - b.base.WithCustomFieldApplicator(applicator) - return b -} - -// WithFieldApplicationFlavor registers a post-baseline field application flavor. -// -// Flavors run after the baseline applicator (default or custom) in registration -// order. They are typically used to preserve fields from the live cluster object -// that should not be overwritten by the desired state. -// -// A nil flavor is ignored. -func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { - if flavor != nil { - b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding](flavor)) - } - return b -} - // WithDataExtractor registers a function to read values from the ClusterRoleBinding // after it has been successfully reconciled. // diff --git a/pkg/primitives/clusterrolebinding/builder_test.go b/pkg/primitives/clusterrolebinding/builder_test.go index f4a9cad4..93a17120 100644 --- a/pkg/primitives/clusterrolebinding/builder_test.go +++ b/pkg/primitives/clusterrolebinding/builder_test.go @@ -85,38 +85,6 @@ func TestBuilder_WithMutation(t *testing.T) { assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) } -func TestBuilder_WithCustomFieldApplicator(t *testing.T) { - t.Parallel() - crb := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, - } - called := false - applicator := func(_, _ *rbacv1.ClusterRoleBinding) error { - called = true - return nil - } - res, err := NewBuilder(crb). - WithCustomFieldApplicator(applicator). - Build() - require.NoError(t, err) - require.NotNil(t, res.base.CustomFieldApplicator) - _ = res.base.CustomFieldApplicator(nil, nil) - assert.True(t, called) -} - -func TestBuilder_WithFieldApplicationFlavor(t *testing.T) { - t.Parallel() - crb := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: "test-crb"}, - } - res, err := NewBuilder(crb). - WithFieldApplicationFlavor(PreserveCurrentLabels). - WithFieldApplicationFlavor(nil). // nil must be ignored - Build() - require.NoError(t, err) - assert.Len(t, res.base.FieldFlavors, 1) -} - func TestBuilder_WithDataExtractor(t *testing.T) { t.Parallel() crb := &rbacv1.ClusterRoleBinding{ diff --git a/pkg/primitives/clusterrolebinding/flavors.go b/pkg/primitives/clusterrolebinding/flavors.go deleted file mode 100644 index a068c5f8..00000000 --- a/pkg/primitives/clusterrolebinding/flavors.go +++ /dev/null @@ -1,26 +0,0 @@ -package clusterrolebinding - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/flavors" - rbacv1 "k8s.io/api/rbac/v1" -) - -// FieldApplicationFlavor defines a function signature for applying flavors to a -// ClusterRoleBinding resource. A flavor is called after the baseline field -// applicator has run and can be used to preserve or merge fields from the live -// cluster object. -type FieldApplicationFlavor flavors.FieldApplicationFlavor[*rbacv1.ClusterRoleBinding] - -// PreserveCurrentLabels ensures that any labels present on the current live -// ClusterRoleBinding but missing from the applied (desired) object are preserved. -// If a label exists in both, the applied value wins. -func PreserveCurrentLabels(applied, current, desired *rbacv1.ClusterRoleBinding) error { - return flavors.PreserveCurrentLabels[*rbacv1.ClusterRoleBinding]()(applied, current, desired) -} - -// PreserveCurrentAnnotations ensures that any annotations present on the current -// live ClusterRoleBinding but missing from the applied (desired) object are preserved. -// If an annotation exists in both, the applied value wins. -func PreserveCurrentAnnotations(applied, current, desired *rbacv1.ClusterRoleBinding) error { - return flavors.PreserveCurrentAnnotations[*rbacv1.ClusterRoleBinding]()(applied, current, desired) -} diff --git a/pkg/primitives/clusterrolebinding/flavors_test.go b/pkg/primitives/clusterrolebinding/flavors_test.go deleted file mode 100644 index b69369ad..00000000 --- a/pkg/primitives/clusterrolebinding/flavors_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package clusterrolebinding - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestPreserveCurrentLabels(t *testing.T) { - t.Run("adds missing labels from current", func(t *testing.T) { - applied := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}} - current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} - - require.NoError(t, PreserveCurrentLabels(applied, current, nil)) - assert.Equal(t, "applied", applied.Labels["keep"]) - assert.Equal(t, "current", applied.Labels["extra"]) - }) - - t.Run("applied value wins on overlap", func(t *testing.T) { - applied := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"key": "applied"}}} - current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"key": "current"}}} - - require.NoError(t, PreserveCurrentLabels(applied, current, nil)) - assert.Equal(t, "applied", applied.Labels["key"]) - }) - - t.Run("handles nil applied labels", func(t *testing.T) { - applied := &rbacv1.ClusterRoleBinding{} - current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} - - require.NoError(t, PreserveCurrentLabels(applied, current, nil)) - assert.Equal(t, "current", applied.Labels["extra"]) - }) -} - -func TestPreserveCurrentAnnotations(t *testing.T) { - t.Run("adds missing annotations from current", func(t *testing.T) { - applied := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} - current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} - - require.NoError(t, PreserveCurrentAnnotations(applied, current, nil)) - assert.Equal(t, "applied", applied.Annotations["keep"]) - assert.Equal(t, "current", applied.Annotations["extra"]) - }) - - t.Run("applied value wins on overlap", func(t *testing.T) { - applied := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"key": "applied"}}} - current := &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"key": "current"}}} - - require.NoError(t, PreserveCurrentAnnotations(applied, current, nil)) - assert.Equal(t, "applied", applied.Annotations["key"]) - }) -} - -func TestFlavors_Integration(t *testing.T) { - desired := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-crb", - Labels: map[string]string{"app": "desired"}, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: "test-role", - }, - } - - t.Run("PreserveCurrentLabels via Mutate", func(t *testing.T) { - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(PreserveCurrentLabels). - Build() - require.NoError(t, err) - - current := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"external": "keep", "app": "old"}, - }, - } - require.NoError(t, res.Mutate(current)) - - assert.Equal(t, "desired", current.Labels["app"]) - assert.Equal(t, "keep", current.Labels["external"]) - }) - - t.Run("flavors run in registration order", func(t *testing.T) { - var order []string - flavor1 := func(_, _, _ *rbacv1.ClusterRoleBinding) error { - order = append(order, "flavor1") - return nil - } - flavor2 := func(_, _, _ *rbacv1.ClusterRoleBinding) error { - order = append(order, "flavor2") - return nil - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(flavor1). - WithFieldApplicationFlavor(flavor2). - Build() - require.NoError(t, err) - - require.NoError(t, res.Mutate(&rbacv1.ClusterRoleBinding{})) - assert.Equal(t, []string{"flavor1", "flavor2"}, order) - }) - - t.Run("flavor error is returned", func(t *testing.T) { - flavorErr := errors.New("flavor boom") - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(func(_, _, _ *rbacv1.ClusterRoleBinding) error { - return flavorErr - }). - Build() - require.NoError(t, err) - - err = res.Mutate(&rbacv1.ClusterRoleBinding{}) - require.Error(t, err) - assert.True(t, errors.Is(err, flavorErr)) - }) -} diff --git a/pkg/primitives/clusterrolebinding/resource.go b/pkg/primitives/clusterrolebinding/resource.go index d21c7b6e..6fecfbc3 100644 --- a/pkg/primitives/clusterrolebinding/resource.go +++ b/pkg/primitives/clusterrolebinding/resource.go @@ -6,21 +6,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) -// and shared-controller fields (OwnerReferences, Finalizers) from the original -// current object. It also preserves roleRef on updates because roleRef is -// immutable after creation in the Kubernetes RBAC API. -func DefaultFieldApplicator(current, desired *rbacv1.ClusterRoleBinding) error { - original := current.DeepCopy() - *current = *desired.DeepCopy() - generic.PreserveServerManagedFields(current, original) - if original.ResourceVersion != "" { - current.RoleRef = original.RoleRef - } - return nil -} - // Resource is a high-level abstraction for managing a Kubernetes ClusterRoleBinding // within a controller's reconciliation loop. // @@ -53,10 +38,8 @@ func (r *Resource) Object() (client.Object, error) { // desired state. // // The mutation process follows this order: -// 1. Field application: the current object is updated to reflect the desired base state, -// using either DefaultFieldApplicator or a custom applicator if one is configured. -// 2. Field application flavors: any registered flavors are applied in registration order. -// 3. Feature mutations: all registered feature-gated mutations are applied in order. +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. // // This method is invoked by the framework during the Update phase of reconciliation. func (r *Resource) Mutate(current client.Object) error { diff --git a/pkg/primitives/clusterrolebinding/resource_test.go b/pkg/primitives/clusterrolebinding/resource_test.go index ec79bc97..573cc1f0 100644 --- a/pkg/primitives/clusterrolebinding/resource_test.go +++ b/pkg/primitives/clusterrolebinding/resource_test.go @@ -3,171 +3,87 @@ package clusterrolebinding import ( "testing" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func TestDefaultFieldApplicator(t *testing.T) { - t.Run("create: applies desired roleRef when current has no ResourceVersion", func(t *testing.T) { - current := &rbacv1.ClusterRoleBinding{} - desired := &rbacv1.ClusterRoleBinding{ - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: "desired-role", - }, - Subjects: []rbacv1.Subject{ - {Kind: "User", Name: "alice"}, - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - assert.Equal(t, "desired-role", current.RoleRef.Name) - assert.Len(t, current.Subjects, 1) - }) - - t.Run("update: preserves current roleRef when ResourceVersion is set", func(t *testing.T) { - current := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: "12345", - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: "original-role", - }, - } - desired := &rbacv1.ClusterRoleBinding{ - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: "new-role", - }, - Subjects: []rbacv1.Subject{ - {Kind: "User", Name: "bob"}, - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - assert.Equal(t, "original-role", current.RoleRef.Name, "roleRef must be preserved on update") - assert.Equal(t, "12345", current.ResourceVersion, "ResourceVersion must be preserved on update") - assert.Len(t, current.Subjects, 1) - assert.Equal(t, "bob", current.Subjects[0].Name) - }) - - t.Run("update: preserves server-managed and shared-controller fields", func(t *testing.T) { - current := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: "12345", - UID: "abc-def", - Generation: 3, - OwnerReferences: []metav1.OwnerReference{ - {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, - }, - Finalizers: []string{"finalizer.example.com"}, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: "original-role", - }, - } - desired := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "test"}, - }, - Subjects: []rbacv1.Subject{ - {Kind: "User", Name: "alice"}, - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - // Desired fields are applied - assert.Equal(t, "test", current.Labels["app"]) - assert.Len(t, current.Subjects, 1) - - // Server-managed fields are preserved - assert.Equal(t, "12345", current.ResourceVersion) - assert.Equal(t, "abc-def", string(current.UID)) - assert.Equal(t, int64(3), current.Generation) - - // Shared-controller fields are preserved - assert.Len(t, current.OwnerReferences, 1) - assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) - assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) - - // roleRef is preserved on update - assert.Equal(t, "original-role", current.RoleRef.Name) - }) - - t.Run("does not mutate desired", func(t *testing.T) { - current := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: "1", - }, - RoleRef: rbacv1.RoleRef{Name: "current-role"}, - } - desired := &rbacv1.ClusterRoleBinding{ - RoleRef: rbacv1.RoleRef{Name: "desired-role"}, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - assert.Equal(t, "desired-role", desired.RoleRef.Name, "desired must not be modified") - }) -} - -func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { - current := &rbacv1.ClusterRoleBinding{ +func newValidCRB() *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Name: "test", - ResourceVersion: "12345", - UID: "abc-def", - Generation: 3, - OwnerReferences: []metav1.OwnerReference{ - {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, - }, - Finalizers: []string{"finalizer.example.com"}, + Name: "test-crb", }, RoleRef: rbacv1.RoleRef{ APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", - Name: "original-role", - }, - } - desired := &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Labels: map[string]string{"app": "test"}, + Name: "test-role", }, Subjects: []rbacv1.Subject{ {Kind: "User", Name: "alice"}, }, } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidCRB()).Build() + require.NoError(t, err) + assert.Equal(t, "rbac.authorization.k8s.io/v1/ClusterRoleBinding/test-crb", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + crb := newValidCRB() + res, err := NewBuilder(crb).Build() + require.NoError(t, err) - err := DefaultFieldApplicator(current, desired) + obj, err := res.Object() require.NoError(t, err) - // Desired spec and labels are applied - assert.Equal(t, "test", current.Labels["app"]) - assert.Len(t, current.Subjects, 1) + got, ok := obj.(*rbacv1.ClusterRoleBinding) + require.True(t, ok) + assert.Equal(t, crb.Name, got.Name) - // Server-managed fields are preserved - assert.Equal(t, "12345", current.ResourceVersion) - assert.Equal(t, "abc-def", string(current.UID)) - assert.Equal(t, int64(3), current.Generation) + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-crb", crb.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidCRB() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) - // Shared-controller fields are preserved - assert.Len(t, current.OwnerReferences, 1) - assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) - assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*rbacv1.ClusterRoleBinding) + assert.Equal(t, "test-role", got.RoleRef.Name) + assert.Len(t, got.Subjects, 1) + assert.Equal(t, "alice", got.Subjects[0].Name) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidCRB() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-subject", + Mutate: func(m *Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("extra-sa", "default") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - // roleRef is preserved on update (immutable in RBAC API) - assert.Equal(t, "original-role", current.RoleRef.Name) + got := obj.(*rbacv1.ClusterRoleBinding) + assert.Len(t, got.Subjects, 2) } From a1cb596d367a2088d7967b2edf472d97bfb81e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:46:30 +0000 Subject: [PATCH 20/23] Fix example to remove references to deleted field applicator API The previous commit removed WithFieldApplicationFlavor and PreserveCurrentLabels from the clusterrolebinding primitive but missed updating the example, causing the lint CI check to fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/clusterrolebinding.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go b/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go index a3f32cd7..d402c1c2 100644 --- a/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go +++ b/examples/clusterrolebinding-primitive/resources/clusterrolebinding.go @@ -43,10 +43,7 @@ func NewClusterRoleBindingResource(owner *sharedapp.ExampleApp) (*clusterrolebin builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) builder.WithMutation(features.MonitoringSubjectMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - // 4. Preserve labels added by external controllers. - builder.WithFieldApplicationFlavor(clusterrolebinding.PreserveCurrentLabels) - - // 5. Extract data from the reconciled ClusterRoleBinding. + // 4. Extract data from the reconciled ClusterRoleBinding. builder.WithDataExtractor(func(crb rbacv1.ClusterRoleBinding) error { fmt.Printf("Reconciled ClusterRoleBinding: %s\n", crb.Name) fmt.Printf(" RoleRef: %s/%s\n", crb.RoleRef.Kind, crb.RoleRef.Name) @@ -57,6 +54,6 @@ func NewClusterRoleBindingResource(owner *sharedapp.ExampleApp) (*clusterrolebin return nil }) - // 6. Build the final resource. + // 5. Build the final resource. return builder.Build() } From 9e856e74192f22e2c3bb52008ec6887984889ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:02:36 +0000 Subject: [PATCH 21/23] Remove stale field flavors reference from clusterrolebinding example README The PreserveCurrentLabels field flavor was removed in a previous commit but the README still documented it as a feature of the example. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/clusterrolebinding-primitive/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/clusterrolebinding-primitive/README.md b/examples/clusterrolebinding-primitive/README.md index 81c7845a..94799b1a 100644 --- a/examples/clusterrolebinding-primitive/README.md +++ b/examples/clusterrolebinding-primitive/README.md @@ -6,7 +6,6 @@ shows how to manage a Kubernetes ClusterRoleBinding as a component of a larger a - **Base Construction**: Initializing a ClusterRoleBinding with a roleRef and base subjects. - **Feature Mutations**: Adding subjects conditionally via feature-gated mutations using `EditSubjects`. - **Metadata Mutations**: Setting version labels on the ClusterRoleBinding via `EditObjectMetadata`. -- **Field Flavors**: Preserving labels managed by external controllers using `PreserveCurrentLabels`. - **Data Extraction**: Inspecting ClusterRoleBinding state after each reconcile cycle. ## Directory Structure From 80a9c0085c83f239566890d279763cb13a8e861a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:15:51 +0000 Subject: [PATCH 22/23] Address PR review: safe mutator API, side-effect-free Raw(), test improvements - Lazily initialize feature plan in EditObjectMetadata/EditSubjects to prevent nil dereference when BeginFeature() has not been called - Remove side-effect from BindingSubjectsEditor.Raw() (no longer converts nil slice to empty slice on read) - Use safe type assertions (comma-ok + require.True) in resource tests - Add test exercising EditSubjects without prior BeginFeature call - Use EnsureServiceAccount instead of Add in example for idempotency Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/mutations.go | 7 +------ pkg/mutation/editors/bindingsubjects.go | 5 +---- pkg/primitives/clusterrolebinding/mutator.go | 14 +++++++++++++ .../clusterrolebinding/resource_test.go | 21 +++++++++++++++++-- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/examples/clusterrolebinding-primitive/features/mutations.go b/examples/clusterrolebinding-primitive/features/mutations.go index d1e23540..37e47f1b 100644 --- a/examples/clusterrolebinding-primitive/features/mutations.go +++ b/examples/clusterrolebinding-primitive/features/mutations.go @@ -5,7 +5,6 @@ import ( "github.com/sourcehawk/operator-component-framework/pkg/feature" "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrolebinding" - rbacv1 "k8s.io/api/rbac/v1" ) // VersionLabelMutation sets the app.kubernetes.io/version label on the @@ -32,11 +31,7 @@ func MonitoringSubjectMutation(version string, enableMetrics bool) clusterrolebi Feature: feature.NewResourceFeature(version, nil).When(enableMetrics), Mutate: func(m *clusterrolebinding.Mutator) error { m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { - e.Add(rbacv1.Subject{ - Kind: "ServiceAccount", - Name: "monitoring-agent", - Namespace: "monitoring", - }) + e.EnsureServiceAccount("monitoring-agent", "monitoring") return nil }) return nil diff --git a/pkg/mutation/editors/bindingsubjects.go b/pkg/mutation/editors/bindingsubjects.go index 1131a276..be4d158f 100644 --- a/pkg/mutation/editors/bindingsubjects.go +++ b/pkg/mutation/editors/bindingsubjects.go @@ -18,15 +18,12 @@ func NewBindingSubjectsEditor(subjects *[]rbacv1.Subject) *BindingSubjectsEditor return &BindingSubjectsEditor{subjects: subjects} } -// Raw returns a pointer to the underlying subjects slice, initialising it if necessary. +// Raw returns a pointer to the underlying subjects slice without modifying it. // // This is an escape hatch for free-form editing when none of the structured // methods are sufficient. A pointer is returned so that operations which change // the slice header (e.g., append, re-slicing) are reflected in the editor. func (e *BindingSubjectsEditor) Raw() *[]rbacv1.Subject { - if *e.subjects == nil { - *e.subjects = []rbacv1.Subject{} - } return e.subjects } diff --git a/pkg/primitives/clusterrolebinding/mutator.go b/pkg/primitives/clusterrolebinding/mutator.go index d66ceb0c..82884b45 100644 --- a/pkg/primitives/clusterrolebinding/mutator.go +++ b/pkg/primitives/clusterrolebinding/mutator.go @@ -51,10 +51,13 @@ func (m *Mutator) BeginFeature() { // // Metadata edits are applied before subject edits within the same feature. // A nil edit function is ignored. +// +// If BeginFeature has not been called, a new feature plan is started automatically. func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { if edit == nil { return } + m.ensureActive() m.active.metadataEdits = append(m.active.metadataEdits, edit) } @@ -66,13 +69,24 @@ func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) // edits within the same feature, in registration order. // // A nil edit function is ignored. +// +// If BeginFeature has not been called, a new feature plan is started automatically. func (m *Mutator) EditSubjects(edit func(*editors.BindingSubjectsEditor) error) { if edit == nil { return } + m.ensureActive() m.active.subjectEdits = append(m.active.subjectEdits, edit) } +// ensureActive lazily creates a feature plan if none is active, making the +// Edit methods safe to call without a prior BeginFeature(). +func (m *Mutator) ensureActive() { + if m.active == nil { + m.BeginFeature() + } +} + // Apply executes all recorded mutation intents on the underlying ClusterRoleBinding. // // Execution order across all registered features: diff --git a/pkg/primitives/clusterrolebinding/resource_test.go b/pkg/primitives/clusterrolebinding/resource_test.go index 573cc1f0..1a71fd6e 100644 --- a/pkg/primitives/clusterrolebinding/resource_test.go +++ b/pkg/primitives/clusterrolebinding/resource_test.go @@ -58,7 +58,8 @@ func TestResource_Mutate(t *testing.T) { require.NoError(t, err) require.NoError(t, res.Mutate(obj)) - got := obj.(*rbacv1.ClusterRoleBinding) + got, ok := obj.(*rbacv1.ClusterRoleBinding) + require.True(t, ok) assert.Equal(t, "test-role", got.RoleRef.Name) assert.Len(t, got.Subjects, 1) assert.Equal(t, "alice", got.Subjects[0].Name) @@ -84,6 +85,22 @@ func TestResource_Mutate_WithMutation(t *testing.T) { require.NoError(t, err) require.NoError(t, res.Mutate(obj)) - got := obj.(*rbacv1.ClusterRoleBinding) + got, ok := obj.(*rbacv1.ClusterRoleBinding) + require.True(t, ok) assert.Len(t, got.Subjects, 2) } + +func TestMutator_EditSubjects_WithoutBeginFeature(t *testing.T) { + crb := newValidCRB() + m := NewMutator(crb) + + // EditSubjects should not panic even without a prior BeginFeature call. + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureServiceAccount("lazy-sa", "default") + return nil + }) + + require.NoError(t, m.Apply()) + assert.Len(t, crb.Subjects, 2) + assert.Equal(t, "lazy-sa", crb.Subjects[1].Name) +} From 888e1f35989519f94647e11557c2e566a0bd6e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:08:52 +0000 Subject: [PATCH 23/23] resolve --- docs/primitives/clusterrolebinding.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md index 5d370d79..f673d050 100644 --- a/docs/primitives/clusterrolebinding.md +++ b/docs/primitives/clusterrolebinding.md @@ -117,8 +117,8 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { #### EnsureSubject -Upserts a subject in the subjects list. A subject is identified by the combination of Kind, Name, and Namespace. -If a matching subject already exists it is replaced; otherwise the new subject is appended: +Upserts a subject in the subjects list. A subject is identified by the combination of Kind, Name, and Namespace. If a +matching subject already exists it is replaced; otherwise the new subject is appended: ```go m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {