diff --git a/Makefile b/Makefile index 67dde702..79675115 100644 --- a/Makefile +++ b/Makefile @@ -122,6 +122,7 @@ build-examples: ## Build all example binaries. run-examples: ## Run all examples to verify they execute without error. go run ./examples/deployment-primitive/. go run ./examples/configmap-primitive/. + go run ./examples/rolebinding-primitive/. go run ./examples/custom-resource-implementation/. ##@ E2E Testing diff --git a/docs/primitives/rolebinding.md b/docs/primitives/rolebinding.md new file mode 100644 index 00000000..c524060b --- /dev/null +++ b/docs/primitives/rolebinding.md @@ -0,0 +1,204 @@ +# RoleBinding Primitive + +The `rolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes `RoleBinding` +resources. It integrates with the component lifecycle and provides a structured mutation API for managing subjects and +object metadata. + +## Capabilities + +| Capability | Detail | +| --------------------- | -------------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for subjects and object metadata, with a raw escape hatch for free-form access | +| **Immutable roleRef** | `roleRef` must be set on the base object and cannot be changed after creation (requires delete/recreate) | +| **Data extraction** | Reads generated or updated values back from the reconciled RoleBinding after each sync cycle | + +## Building a RoleBinding Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/rolebinding" + +base := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-rolebinding", + Namespace: owner.Namespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "app-role", + }, + Subjects: []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "app-sa", Namespace: owner.Namespace}, + }, +} + +resource, err := rolebinding.NewBuilder(base). + WithMutation(MySubjectMutation(owner.Spec.Version)). + Build() +``` + +`roleRef` must be set on the base object passed to `NewBuilder`. It is immutable after creation in Kubernetes and is not +modifiable via the mutation API. + +## Mutations + +Mutations are the primary mechanism for modifying a `RoleBinding` 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 AddServiceAccountMutation(version, saName, saNamespace string) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "add-service-account", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *rolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: saName, + Namespace: saNamespace, + }) + return nil + }) + return nil + }, + } +} +``` + +### Boolean-gated mutations + +```go +func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "monitoring-subject", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *rolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "monitoring-agent", + Namespace: "monitoring", + }) + return nil + }) + return nil + }, + } +} +``` + +### Version-gated mutations + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacySubjectMutation(version string) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "legacy-subject", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *rolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "User", + Name: "legacy-admin", + }) + 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 RoleBinding | +| 2 | Subject edits | `.subjects` entries via BindingSubjectsEditor | + +Within each category, edits are applied in their registration order. Later features observe the RoleBinding as modified +by all previous features. + +## Editors + +### BindingSubjectsEditor + +The primary API for modifying the subjects list. Use `m.EditSubjects` for full control: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "my-sa", + Namespace: "default", + }) + e.RemoveSubject("ServiceAccount", "old-sa", "default") + return nil +}) +``` + +#### EnsureSubject + +`EnsureSubject` upserts a subject by the combination of `Kind`, `Name`, and `Namespace`. If a matching subject already +exists, it is replaced; otherwise the new subject is appended. + +#### RemoveSubject + +`RemoveSubject` removes a subject identified by kind, name, and namespace. It is a no-op if no matching subject exists. + +#### Raw + +`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` slice for free-form access when the structured methods +are insufficient: + +```go +m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + raw := e.Raw() + *raw = append(*raw, rbacv1.Subject{ + Kind: "Group", + Name: "developers", + }) + 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("operator.example.io/version", version) + return nil +}) +``` + +## Guidance + +**Set `roleRef` on the base object, not via mutations.** Kubernetes makes `roleRef` immutable after creation. To change +a `roleRef`, delete and recreate the RoleBinding. + +**`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. + +**Use `EnsureSubject` for idempotent subject management.** `EnsureSubject` upserts by Kind+Name+Namespace, making it +safe to call on every reconciliation without creating duplicates. + +**Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first. diff --git a/examples/configmap-primitive/README.md b/examples/configmap-primitive/README.md index 21e5d50d..79f4c083 100644 --- a/examples/configmap-primitive/README.md +++ b/examples/configmap-primitive/README.md @@ -6,7 +6,6 @@ to manage a Kubernetes ConfigMap as a component of a larger application, utilisi - **Base Construction**: Initializing a ConfigMap with basic metadata. - **Feature Mutations**: Composing YAML configuration from independent, feature-gated mutations using `MergeYAML`. - **Metadata Mutations**: Setting version labels on the ConfigMap via `EditObjectMetadata`. -- **Field Flavors**: Preserving `.data` entries managed by external controllers using `PreserveExternalEntries`. - **Data Extraction**: Harvesting ConfigMap entries after each reconcile cycle. ## Directory Structure @@ -15,7 +14,6 @@ to manage a Kubernetes ConfigMap as a component of a larger application, utilisi `examples/shared/app`. - `features/`: Contains modular feature definitions: - `mutations.go`: base config, version labelling, and feature-gated tracing and metrics sections. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries. - `resources/`: Contains the central `NewConfigMapResource` factory that assembles all features using `configmap.Builder`. - `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. diff --git a/examples/deployment-primitive/README.md b/examples/deployment-primitive/README.md index e4f4402f..a9b0f27f 100644 --- a/examples/deployment-primitive/README.md +++ b/examples/deployment-primitive/README.md @@ -6,8 +6,6 @@ to manage a Kubernetes Deployment as a component of a larger application, utiliz - **Base Construction**: Initializing a Deployment with basic metadata and spec. - **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual - edits). - **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health assessment during rollouts (`GraceStatus`). - **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations. @@ -18,7 +16,6 @@ to manage a Kubernetes Deployment as a component of a larger application, utiliz - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - `mutations.go`: sidecar injection, env vars, and version-based image updates. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields. - `status.go`: implementation of custom handlers for convergence, grace, and suspension. - `resources/`: Contains the central `NewDeploymentResource` factory that assembles all features using the `deployment.Builder`. diff --git a/examples/deployment-primitive/features/status.go b/examples/deployment-primitive/features/status.go index aa8c363a..7e5aa3a0 100644 --- a/examples/deployment-primitive/features/status.go +++ b/examples/deployment-primitive/features/status.go @@ -2,7 +2,6 @@ package features import ( "fmt" - "time" "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" @@ -53,17 +52,12 @@ func CustomSuspendMutation() func(*deployment.Mutator) error { return err } - // Additionally, record when the deployment was first suspended. - // Only set if absent so the timestamp is stable across reconcile cycles. - // This works because PreserveCurrentAnnotations (registered as a flavor) - // restores live-cluster annotations before mutations run — so on the second - // and subsequent reconciles while suspended the annotation is already present - // and is left unchanged. + // Additionally, mark the deployment as suspended via an annotation. + // Note: mutators operate on a freshly-built desired object each reconcile, + // not the live server state. Stateful comparisons (e.g., "only set this if + // it doesn't already exist") won't work here since the object is always new. m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { - raw := meta.Raw() - if _, exists := raw.Annotations["example.io/suspended-at"]; !exists { - meta.EnsureAnnotation("example.io/suspended-at", time.Now().UTC().Format(time.RFC3339)) - } + meta.EnsureAnnotation("example.io/suspended", "true") return nil }) diff --git a/examples/rolebinding-primitive/README.md b/examples/rolebinding-primitive/README.md new file mode 100644 index 00000000..8459c5b5 --- /dev/null +++ b/examples/rolebinding-primitive/README.md @@ -0,0 +1,32 @@ +# RoleBinding Primitive Example + +This example demonstrates the usage of the `rolebinding` primitive within the operator component framework. It shows how +to manage a Kubernetes RoleBinding as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a RoleBinding with an immutable `roleRef` and basic metadata. +- **Feature Mutations**: Composing subjects from independent, feature-gated mutations using `EditSubjects`. +- **Metadata Mutations**: Setting version labels on the RoleBinding via `EditObjectMetadata`. +- **Data Extraction**: Inspecting subjects and roleRef 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`: base subject binding, version labelling, and feature-gated monitoring subject. +- `resources/`: Contains the central `NewRoleBindingResource` factory that assembles all features using + `rolebinding.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/rolebinding-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 subjects after each cycle. +4. Print the resulting status conditions. diff --git a/examples/rolebinding-primitive/app/controller.go b/examples/rolebinding-primitive/app/controller.go new file mode 100644 index 00000000..48c86317 --- /dev/null +++ b/examples/rolebinding-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the rolebinding primitive. +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. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewRoleBindingResource is a factory function to create the rolebinding resource. + // This allows us to inject the resource construction logic. + NewRoleBindingResource 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 rolebinding resource for this owner. + rbResource, err := r.NewRoleBindingResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the rolebinding. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(rbResource, 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/rolebinding-primitive/features/mutations.go b/examples/rolebinding-primitive/features/mutations.go new file mode 100644 index 00000000..cc3b2fb0 --- /dev/null +++ b/examples/rolebinding-primitive/features/mutations.go @@ -0,0 +1,65 @@ +// Package features provides sample mutations for the rolebinding 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/rolebinding" + rbacv1 "k8s.io/api/rbac/v1" +) + +// BaseSubjectsMutation adds the application's primary service account as a +// subject. It is always enabled. +func BaseSubjectsMutation(version, saName, saNamespace string) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "base-subjects", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *rolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: saName, + Namespace: saNamespace, + }) + return nil + }) + return nil + }, + } +} + +// VersionLabelMutation sets the app.kubernetes.io/version label on the +// RoleBinding. It is always enabled. +func VersionLabelMutation(version string) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "version-label", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *rolebinding.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. +// It is enabled when enableMonitoring is true. +func MonitoringSubjectMutation(version string, enableMonitoring bool) rolebinding.Mutation { + return rolebinding.Mutation{ + Name: "monitoring-subject", + Feature: feature.NewResourceFeature(version, nil).When(enableMonitoring), + Mutate: func(m *rolebinding.Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "monitoring-agent", + Namespace: "monitoring", + }) + return nil + }) + return nil + }, + } +} diff --git a/examples/rolebinding-primitive/main.go b/examples/rolebinding-primitive/main.go new file mode 100644 index 00000000..ba1aba33 --- /dev/null +++ b/examples/rolebinding-primitive/main.go @@ -0,0 +1,106 @@ +// Package main is the entry point for the rolebinding primitive example. +package main + +import ( + "context" + "fmt" + "os" + + ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" + "github.com/sourcehawk/operator-component-framework/examples/rolebinding-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/rolebinding-primitive/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client. + scheme := runtime.NewScheme() + if err := sharedapp.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := rbacv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add rbac/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&sharedapp.ExampleApp{}). + Build() + + // 2. 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" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize the controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + NewRoleBindingResource: resources.NewRoleBindingResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate how + // subject mutations compose from independent feature mutations. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.2.3", + EnableMetrics: true, // monitoring subject enabled + }, + { + Version: "1.2.4", // Version upgrade + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableMetrics: false, // Disable monitoring subject + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Monitoring=%v ---\n", + i+1, spec.Version, spec.EnableMetrics) + + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/rolebinding-primitive/resources/rolebinding.go b/examples/rolebinding-primitive/resources/rolebinding.go new file mode 100644 index 00000000..67ac4ce1 --- /dev/null +++ b/examples/rolebinding-primitive/resources/rolebinding.go @@ -0,0 +1,60 @@ +// Package resources provides resource implementations for the rolebinding primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/rolebinding-primitive/features" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/rolebinding" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewRoleBindingResource constructs a rolebinding primitive resource with all the features. +func NewRoleBindingResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base RoleBinding object. + // + // roleRef is set here because it is immutable after creation. + // Subjects are managed via mutations. + base := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-binding", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: owner.Name + "-role", + }, + } + + // 2. Initialize the rolebinding builder. + builder := rolebinding.NewBuilder(base) + + // 3. Register mutations in dependency order. + builder.WithMutation(features.BaseSubjectsMutation( + owner.Spec.Version, owner.Name+"-sa", owner.Namespace, + )) + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.MonitoringSubjectMutation( + owner.Spec.Version, owner.Spec.EnableMetrics, + )) + + // 4. Extract data from the reconciled RoleBinding. + builder.WithDataExtractor(func(rb rbacv1.RoleBinding) error { + fmt.Printf("Reconciled RoleBinding: %s\n", rb.Name) + fmt.Printf(" RoleRef: %s/%s\n", rb.RoleRef.Kind, rb.RoleRef.Name) + for _, s := range rb.Subjects { + fmt.Printf(" Subject: %s/%s (ns: %s)\n", s.Kind, s.Name, s.Namespace) + } + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/internal/generic/resource_integration.go b/internal/generic/resource_integration.go index d25d1c9d..5ef9f2c5 100644 --- a/internal/generic/resource_integration.go +++ b/internal/generic/resource_integration.go @@ -12,7 +12,6 @@ import ( // // It provides shared behavior for: // - baseline field application -// - field application flavors // - feature mutations // - data extraction // diff --git a/internal/generic/resource_static.go b/internal/generic/resource_static.go index f8cc15d1..771cf74a 100644 --- a/internal/generic/resource_static.go +++ b/internal/generic/resource_static.go @@ -7,7 +7,6 @@ import "sigs.k8s.io/controller-runtime/pkg/client" // // It supports: // - default or custom baseline field application -// - post-baseline field application flavors // - feature mutations // - data extraction after reconciliation // diff --git a/internal/generic/resource_task.go b/internal/generic/resource_task.go index 783fdf12..b6a077d3 100644 --- a/internal/generic/resource_task.go +++ b/internal/generic/resource_task.go @@ -12,7 +12,6 @@ import ( // // It provides shared behavior for: // - baseline field application -// - field application flavors // - feature mutations // - suspension mutations // - data extraction diff --git a/internal/generic/resource_workload.go b/internal/generic/resource_workload.go index af7d317d..54287b8e 100644 --- a/internal/generic/resource_workload.go +++ b/internal/generic/resource_workload.go @@ -25,7 +25,6 @@ type FeatureMutator interface { // // It provides shared behavior for: // - baseline field application -// - field application flavors // - feature mutations // - suspension mutations // - data extraction diff --git a/pkg/mutation/editors/bindingsubjects.go b/pkg/mutation/editors/bindingsubjects.go new file mode 100644 index 00000000..d6f7d37b --- /dev/null +++ b/pkg/mutation/editors/bindingsubjects.go @@ -0,0 +1,65 @@ +package editors + +import rbacv1 "k8s.io/api/rbac/v1" + +// BindingSubjectsEditor provides a typed API for mutating the subjects list +// of a Kubernetes RoleBinding or ClusterRoleBinding. +// +// It exposes structured operations (EnsureSubject, RemoveSubject) as well as +// Raw() for free-form access when none of the structured methods are sufficient. +type BindingSubjectsEditor struct { + subjects *[]rbacv1.Subject +} + +// NewBindingSubjectsEditor creates a new BindingSubjectsEditor wrapping the +// given subjects slice pointer. +// +// The pointer may refer to a nil slice; methods that add subjects initialise +// it automatically. If a nil pointer is provided, an empty slice is allocated +// and used internally. +func NewBindingSubjectsEditor(subjects *[]rbacv1.Subject) *BindingSubjectsEditor { + if subjects == nil { + empty := make([]rbacv1.Subject, 0) + subjects = &empty + } + return &BindingSubjectsEditor{subjects: subjects} +} + +// 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. +func (e *BindingSubjectsEditor) EnsureSubject(subject rbacv1.Subject) { + for i, s := range *e.subjects { + if s.Kind == subject.Kind && s.Name == subject.Name && s.Namespace == subject.Namespace { + (*e.subjects)[i] = subject + return + } + } + *e.subjects = append(*e.subjects, subject) +} + +// RemoveSubject removes a subject identified by kind, name, and namespace +// from the subjects list. It is a no-op if no matching subject exists. +func (e *BindingSubjectsEditor) RemoveSubject(kind, name, namespace string) { + subjects := *e.subjects + filtered := subjects[:0] + for _, s := range subjects { + if s.Kind == kind && s.Name == name && s.Namespace == namespace { + continue + } + filtered = append(filtered, s) + } + // Zero trailing elements to avoid retaining references to removed subjects. + for i := len(filtered); i < len(subjects); i++ { + subjects[i] = rbacv1.Subject{} + } + *e.subjects = filtered +} + +// Raw returns a pointer to the underlying subjects slice, allowing +// free-form modifications when the structured methods are insufficient. +func (e *BindingSubjectsEditor) Raw() *[]rbacv1.Subject { + return e.subjects +} diff --git a/pkg/mutation/editors/bindingsubjects_test.go b/pkg/mutation/editors/bindingsubjects_test.go new file mode 100644 index 00000000..7e817418 --- /dev/null +++ b/pkg/mutation/editors/bindingsubjects_test.go @@ -0,0 +1,121 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" +) + +func TestBindingSubjectsEditor_EnsureSubject_Append(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "my-sa", + Namespace: "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) +} + +func TestBindingSubjectsEditor_EnsureSubject_Upsert(t *testing.T) { + subjects := []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "my-sa", Namespace: "default", APIGroup: ""}, + } + e := NewBindingSubjectsEditor(&subjects) + + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "my-sa", + Namespace: "default", + APIGroup: "rbac.authorization.k8s.io", + }) + + require.Len(t, subjects, 1) + assert.Equal(t, "rbac.authorization.k8s.io", subjects[0].APIGroup) +} + +func TestBindingSubjectsEditor_EnsureSubject_MultipleSubjects(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + + e.EnsureSubject(rbacv1.Subject{Kind: "ServiceAccount", Name: "sa-1", Namespace: "ns-1"}) + e.EnsureSubject(rbacv1.Subject{Kind: "User", Name: "admin", Namespace: ""}) + + require.Len(t, subjects, 2) + assert.Equal(t, "sa-1", subjects[0].Name) + assert.Equal(t, "admin", subjects[1].Name) +} + +func TestBindingSubjectsEditor_RemoveSubject(t *testing.T) { + subjects := []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "keep", Namespace: "default"}, + {Kind: "ServiceAccount", Name: "remove", Namespace: "default"}, + {Kind: "User", Name: "admin", Namespace: ""}, + } + e := NewBindingSubjectsEditor(&subjects) + + e.RemoveSubject("ServiceAccount", "remove", "default") + + require.Len(t, subjects, 2) + assert.Equal(t, "keep", subjects[0].Name) + assert.Equal(t, "admin", subjects[1].Name) +} + +func TestBindingSubjectsEditor_RemoveSubject_NotPresent(t *testing.T) { + subjects := []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "keep", Namespace: "default"}, + } + e := NewBindingSubjectsEditor(&subjects) + + e.RemoveSubject("ServiceAccount", "missing", "default") + + require.Len(t, subjects, 1) + assert.Equal(t, "keep", subjects[0].Name) +} + +func TestBindingSubjectsEditor_RemoveSubject_EmptySlice(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + + e.RemoveSubject("ServiceAccount", "missing", "default") + + assert.Empty(t, subjects) +} + +func TestBindingSubjectsEditor_Raw(t *testing.T) { + subjects := []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "my-sa", Namespace: "default"}, + } + e := NewBindingSubjectsEditor(&subjects) + + raw := e.Raw() + require.NotNil(t, raw) + assert.Same(t, &subjects, raw) +} + +func TestBindingSubjectsEditor_Raw_NilSlice(t *testing.T) { + var subjects []rbacv1.Subject + e := NewBindingSubjectsEditor(&subjects) + + raw := e.Raw() + require.NotNil(t, raw) +} + +func TestBindingSubjectsEditor_NilPointer(t *testing.T) { + e := NewBindingSubjectsEditor(nil) + + // Should not panic; operations work on the internal slice. + e.EnsureSubject(rbacv1.Subject{Kind: "ServiceAccount", Name: "sa", Namespace: "ns"}) + e.RemoveSubject("ServiceAccount", "sa", "ns") + + raw := e.Raw() + require.NotNil(t, raw) + assert.Empty(t, *raw) +} diff --git a/pkg/primitives/configmap/hash.go b/pkg/primitives/configmap/hash.go index abe073f7..48a6a74a 100644 --- a/pkg/primitives/configmap/hash.go +++ b/pkg/primitives/configmap/hash.go @@ -60,9 +60,8 @@ func DataHash(cm corev1.ConfigMap) (string, error) { // the cluster, based on the base object and all registered mutations. // // The hash covers only operator-controlled fields (.data and .binaryData after -// applying the baseline and mutations). Fields preserved by flavors from the live -// cluster state (e.g. PreserveExternalEntries) are intentionally excluded — only -// changes to operator-owned content will change the hash. +// applying the baseline and mutations). Only changes to operator-owned content +// will change the hash. // // This enables a single-pass checksum pattern: compute the hash before // reconciliation and pass it directly to the deployment resource factory, avoiding diff --git a/pkg/primitives/deployment/builder.go b/pkg/primitives/deployment/builder.go index 0000cf12..02818b77 100644 --- a/pkg/primitives/deployment/builder.go +++ b/pkg/primitives/deployment/builder.go @@ -22,8 +22,8 @@ type Builder struct { // NewBuilder initializes a new Builder with the provided Deployment object. // // The Deployment object passed here serves as the "desired base state". During -// reconciliation, the Resource will attempt to make the cluster's state match -// this base state, modified by any registered mutations. +// reconciliation, the framework uses Server-Side Apply to make the cluster's +// state match this base state, modified by any registered mutations. // // The provided deployment must have at least a Name and Namespace set, which // is validated during the Build() call. diff --git a/pkg/primitives/rolebinding/builder.go b/pkg/primitives/rolebinding/builder.go new file mode 100644 index 00000000..206ae847 --- /dev/null +++ b/pkg/primitives/rolebinding/builder.go @@ -0,0 +1,90 @@ +package rolebinding + +import ( + "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 RoleBinding Resource. +// +// 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.RoleBinding, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided RoleBinding object. +// +// The RoleBinding 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. +// +// roleRef must be set on the provided RoleBinding object. It is immutable after +// creation and is not modifiable via the mutation API. +// +// The provided RoleBinding must have both Name and Namespace set, which is +// validated during the Build() call. +func NewBuilder(rb *rbacv1.RoleBinding) *Builder { + identityFunc := func(r *rbacv1.RoleBinding) string { + return fmt.Sprintf("rbac.authorization.k8s.io/v1/RoleBinding/%s/%s", r.Namespace, r.Name) + } + + return &Builder{ + base: generic.NewStaticBuilder[*rbacv1.RoleBinding, *Mutator]( + rb, + identityFunc, + NewMutator, + ), + } +} + +// WithMutation registers a mutation for the RoleBinding. +// +// 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 { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithDataExtractor registers a function to read values from the RoleBinding +// after it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled RoleBinding. 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.RoleBinding) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(rb *rbacv1.RoleBinding) error { + return extractor(*rb) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No RoleBinding object was provided. +// - The RoleBinding is missing a Name or Namespace. +// - The RoleRef is missing APIGroup, Kind, or Name. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + + ref := genericRes.DesiredObject.RoleRef + if ref.APIGroup == "" || ref.Kind == "" || ref.Name == "" { + return nil, fmt.Errorf("roleRef must have non-empty APIGroup, Kind, and Name") + } + + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/rolebinding/builder_test.go b/pkg/primitives/rolebinding/builder_test.go new file mode 100644 index 00000000..07e655ed --- /dev/null +++ b/pkg/primitives/rolebinding/builder_test.go @@ -0,0 +1,153 @@ +package rolebinding + +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 + rb *rbacv1.RoleBinding + expectedErr string + }{ + { + name: "nil rolebinding", + rb: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + rb: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + rb: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rb"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "empty roleRef", + rb: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rb", Namespace: "test-ns"}, + }, + expectedErr: "roleRef must have non-empty APIGroup, Kind, and Name", + }, + { + name: "partial roleRef missing kind", + rb: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rb", Namespace: "test-ns"}, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Name: "my-role", + }, + }, + expectedErr: "roleRef must have non-empty APIGroup, Kind, and Name", + }, + { + name: "valid rolebinding", + rb: &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rb", Namespace: "test-ns"}, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "my-role", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.rb).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/RoleBinding/test-ns/test-rb", res.Identity()) + } + }) + } +} + +func testRoleRef() rbacv1.RoleRef { + return rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: "my-role"} +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rb", Namespace: "test-ns"}, + RoleRef: testRoleRef(), + } + res, err := NewBuilder(rb). + 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_WithDataExtractor(t *testing.T) { + t.Parallel() + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rb", Namespace: "test-ns"}, + RoleRef: testRoleRef(), + } + called := false + extractor := func(_ rbacv1.RoleBinding) error { + called = true + return nil + } + res, err := NewBuilder(rb). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&rbacv1.RoleBinding{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rb", Namespace: "test-ns"}, + RoleRef: testRoleRef(), + } + res, err := NewBuilder(rb). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rb", Namespace: "test-ns"}, + RoleRef: testRoleRef(), + } + res, err := NewBuilder(rb). + WithDataExtractor(func(_ rbacv1.RoleBinding) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&rbacv1.RoleBinding{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/rolebinding/mutator.go b/pkg/primitives/rolebinding/mutator.go new file mode 100644 index 00000000..b9ffaf2a --- /dev/null +++ b/pkg/primitives/rolebinding/mutator.go @@ -0,0 +1,109 @@ +// Package rolebinding provides a builder and resource for managing Kubernetes RoleBindings. +package rolebinding + +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 rolebinding 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 RoleBinding. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the RoleBinding 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 { + rb *rbacv1.RoleBinding + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given RoleBinding. +// BeginFeature must be called before registering any mutations. +func NewMutator(rb *rbacv1.RoleBinding) *Mutator { + return &Mutator{ + rb: rb, + } +} + +// 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 RoleBinding'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 RoleBinding's subjects list via a +// BindingSubjectsEditor. +// +// The editor provides structured operations (EnsureSubject, RemoveSubject) 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 RoleBinding. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Subject edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the RoleBinding 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.rb.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.rb.Subjects) + for _, edit := range plan.subjectEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/rolebinding/mutator_test.go b/pkg/primitives/rolebinding/mutator_test.go new file mode 100644 index 00000000..bcb6f8df --- /dev/null +++ b/pkg/primitives/rolebinding/mutator_test.go @@ -0,0 +1,255 @@ +package rolebinding + +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 newTestRB() *rbacv1.RoleBinding { + return &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rb", + Namespace: "default", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "my-role", + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", rb.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EditSubjects --- + +func TestMutator_EditSubjects_EnsureSubject(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "my-sa", + Namespace: "default", + }) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, rb.Subjects, 1) + assert.Equal(t, "my-sa", rb.Subjects[0].Name) +} + +func TestMutator_EditSubjects_RemoveSubject(t *testing.T) { + rb := newTestRB() + rb.Subjects = []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "keep", Namespace: "default"}, + {Kind: "ServiceAccount", Name: "remove", Namespace: "default"}, + } + m := NewMutator(rb) + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.RemoveSubject("ServiceAccount", "remove", "default") + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, rb.Subjects, 1) + assert.Equal(t, "keep", rb.Subjects[0].Name) +} + +func TestMutator_EditSubjects_RawAccess(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + raw := e.Raw() + *raw = append(*raw, rbacv1.Subject{ + Kind: "User", + Name: "admin", + }) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, rb.Subjects, 1) + assert.Equal(t, "admin", rb.Subjects[0].Name) +} + +func TestMutator_EditSubjects_Nil(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditSubjects(nil) + assert.NoError(t, m.Apply()) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before subject edits. + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "my-sa", + Namespace: "default", + }) + return nil + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", rb.Labels["order"]) + require.Len(t, rb.Subjects, 1) + assert.Equal(t, "my-sa", rb.Subjects[0].Name) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "sa-1", + Namespace: "default", + }) + return nil + }) + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "sa-2", + Namespace: "default", + }) + return nil + }) + require.NoError(t, m.Apply()) + + require.Len(t, rb.Subjects, 2) + assert.Equal(t, "sa-1", rb.Subjects[0].Name) + assert.Equal(t, "sa-2", rb.Subjects[1].Name) +} + +func TestMutator_EditSubjects_ErrorPropagated(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditSubjects(func(_ *editors.BindingSubjectsEditor) error { + return assert.AnError + }) + err := m.Apply() + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestMutator_EditObjectMetadata_ErrorPropagated(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + return assert.AnError + }) + err := m.Apply() + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + + 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) { + rb := newTestRB() + m := NewMutator(rb) + + 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) { + rb := newTestRB() + m := NewMutator(rb) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{Kind: "ServiceAccount", Name: "sa-0", Namespace: "default"}) + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{Kind: "ServiceAccount", Name: "sa-1", Namespace: "default"}) + return nil + }) + + // The initial plan should have exactly one subject edit + assert.Len(t, m.plans[0].subjectEdits, 1, "initial plan should have one edit") + // The second plan should also have exactly one subject edit + assert.Len(t, m.plans[1].subjectEdits, 1, "second plan should have one edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + rb := newTestRB() + m := NewMutator(rb) + m.BeginFeature() + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{Kind: "ServiceAccount", Name: "my-sa", Namespace: "default"}) + return nil + }) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + require.Len(t, rb.Subjects, 1) + assert.Equal(t, "my-sa", rb.Subjects[0].Name) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/rolebinding/resource.go b/pkg/primitives/rolebinding/resource.go new file mode 100644 index 00000000..b710c9a8 --- /dev/null +++ b/pkg/primitives/rolebinding/resource.go @@ -0,0 +1,51 @@ +package rolebinding + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + rbacv1 "k8s.io/api/rbac/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes RoleBinding +// 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. +// +// RoleBinding resources are static: they do not model convergence health, +// grace periods, or suspension. +type Resource struct { + base *generic.StaticResource[*rbacv1.RoleBinding, *Mutator] +} + +// Identity returns a unique identifier for the RoleBinding in the format +// "rbac.authorization.k8s.io/v1/RoleBinding//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes RoleBinding 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 provided Kubernetes RoleBinding into the desired state. +// +// Feature mutations are applied in registration 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 RoleBinding. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the RoleBinding. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/rolebinding/resource_test.go b/pkg/primitives/rolebinding/resource_test.go new file mode 100644 index 00000000..6a20975a --- /dev/null +++ b/pkg/primitives/rolebinding/resource_test.go @@ -0,0 +1,127 @@ +package rolebinding + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "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 newValidRB() *rbacv1.RoleBinding { + return &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rb", + Namespace: "test-ns", + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "my-role", + }, + Subjects: []rbacv1.Subject{ + {Kind: "ServiceAccount", Name: "sa", Namespace: "test-ns"}, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidRB()).Build() + require.NoError(t, err) + assert.Equal(t, "rbac.authorization.k8s.io/v1/RoleBinding/test-ns/test-rb", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + rb := newValidRB() + res, err := NewBuilder(rb).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*rbacv1.RoleBinding) + require.True(t, ok) + assert.Equal(t, rb.Name, got.Name) + assert.Equal(t, rb.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-rb", rb.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidRB() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*rbacv1.RoleBinding) + assert.Equal(t, "sa", got.Subjects[0].Name) + assert.Equal(t, "my-role", got.RoleRef.Name) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidRB() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-subject", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditSubjects(func(e *editors.BindingSubjectsEditor) error { + e.EnsureSubject(rbacv1.Subject{ + Kind: "ServiceAccount", + Name: "from-mutation", + Namespace: "test-ns", + }) + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*rbacv1.RoleBinding) + assert.Equal(t, "sa", got.Subjects[0].Name) + assert.Equal(t, "from-mutation", got.Subjects[1].Name) +} + +func TestResource_ExtractData(t *testing.T) { + rb := newValidRB() + + var extracted string + res, err := NewBuilder(rb). + WithDataExtractor(func(r rbacv1.RoleBinding) error { + extracted = r.Subjects[0].Name + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "sa", extracted) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidRB()). + WithDataExtractor(func(_ rbacv1.RoleBinding) error { + return errors.New("extract error") + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.Error(t, err) + assert.Contains(t, err.Error(), "extract error") +} diff --git a/rolebinding-primitive b/rolebinding-primitive new file mode 100755 index 00000000..37dd499b Binary files /dev/null and b/rolebinding-primitive differ