diff --git a/docs/primitives.md b/docs/primitives.md index 985288f1..0bd97777 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -114,13 +114,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 | +| 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 | +| `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, raw access | +| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | 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. @@ -141,10 +142,15 @@ 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) | +| Primitive | Category | Documentation | +| ---------------------------- | -------- | ------------------------------------------- | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) | + +The `clusterrole` primitive is exercised by the `examples/clusterrole-primitive` example. Because it requires +cluster-scoped RBAC and may need elevated permissions, it is intentionally not included in the default +`make run-examples` target used for CI/local smoke runs. ## Usage Examples diff --git a/docs/primitives/clusterrole.md b/docs/primitives/clusterrole.md new file mode 100644 index 00000000..4630c53c --- /dev/null +++ b/docs/primitives/clusterrole.md @@ -0,0 +1,292 @@ +# ClusterRole Primitive + +The `clusterrole` primitive is the framework's built-in static abstraction for managing Kubernetes `ClusterRole` +resources. It integrates with the component lifecycle and provides a structured mutation API for managing `.rules`, +`.aggregationRule`, and object metadata. + +ClusterRole is cluster-scoped: it has no namespace. The builder validates that the Name is set and that Namespace is +empty — setting a namespace on a cluster-scoped resource is rejected. + +> **Ownership limitation:** During reconciliation, the framework attempts to set a controller reference on managed +> objects, but only when the owner and dependent scopes are compatible. When a namespaced owner manages a cluster-scoped +> resource such as a `ClusterRole`, the owner reference is skipped (and this is logged) instead of causing the reconcile +> to fail. In this case, the `ClusterRole` is **not** owned by the custom resource for Kubernetes garbage-collection or +> ownership semantics, so it will not be automatically deleted when the owner is removed; you must handle its lifecycle +> explicitly or use a cluster-scoped owner if automatic cleanup is required. + +## Capabilities + +| Capability | Detail | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors (`PolicyRulesEditor`) for `.rules` and object metadata, with aggregation rule support and a raw escape hatch | +| **Cluster-scoped** | No namespace required — identity format is `rbac.authorization.k8s.io/v1/ClusterRole/` | +| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRole after each sync cycle | + +## Building a ClusterRole Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrole" + +base := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-operator-role", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, +} + +resource, err := clusterrole.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `ClusterRole` 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: + +```go +func PodReadMutation() clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "pod-read", + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. + +### Boolean-gated mutations + +```go +func SecretAccessMutation(version string, needsSecrets bool) clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "secret-access", + Feature: feature.NewResourceFeature(version, nil).When(needsSecrets), + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list"}, + }) + return nil + }, + } +} +``` + +### Version-gated mutations + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyRBACMutation(version string) clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "legacy-rbac", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"extensions"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list"}, + }) + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +The Mutator maintains feature boundaries: each feature's mutations are planned together and applied in the order the +features were registered. Within each feature, edits are applied in a fixed category order: + +| Step | Category | What it affects | +| ---- | ---------------- | ------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `ClusterRole` | +| 2 | Rules edits | `.rules` entries — EditRules, AddRule | +| 3 | Aggregation rule | `.aggregationRule` — SetAggregationRule | + +Within each category, edits are applied in their registration order. For aggregation rules, the last +`SetAggregationRule` call wins within each feature. Later features observe the ClusterRole as modified by all previous +features. + +## Editors + +### PolicyRulesEditor + +The primary API for modifying `.rules` entries. Use `m.EditRules` for full control: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil +}) +``` + +#### AddRule + +`AddRule` appends a PolicyRule to the rules slice: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }) + return nil +}) +``` + +#### RemoveRuleByIndex + +`RemoveRuleByIndex` removes the rule at the given index. It is a no-op if the index is out of bounds: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.RemoveRuleByIndex(0) // remove the first rule + return nil +}) +``` + +#### Clear + +`Clear` removes all rules: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.Clear() + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns a pointer to the underlying `[]rbacv1.PolicyRule` for free-form editing: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + raw := e.Raw() + *raw = append(*raw, customRules...) + 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/version", version) + e.EnsureAnnotation("managed-by", "my-operator") + return nil +}) +``` + +## Convenience Methods + +The `Mutator` exposes a convenience wrapper for the most common `.rules` operation: + +| Method | Equivalent to | +| --------------- | ------------------------------- | +| `AddRule(rule)` | `EditRules` → `e.AddRule(rule)` | + +Use `AddRule` for simple, single-rule mutations. Use `EditRules` when you need multiple operations or raw access in a +single edit block. + +## SetAggregationRule + +`SetAggregationRule` sets the ClusterRole's `.aggregationRule` field. An aggregation rule causes the API server to +combine rules from ClusterRoles whose labels match the provided selectors, instead of using `.rules` directly: + +```go +m.SetAggregationRule(&rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"rbac.example.com/aggregate-to-admin": "true"}}, + }, +}) +``` + +Setting the aggregation rule to nil clears it. Within a single feature, the last `SetAggregationRule` call wins. + +## Full Example: Feature-Composed RBAC + +```go +func CoreRulesMutation() clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "core-rules", + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"pods", "services", "configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil + }, + } +} + +func CRDAccessMutation(version string, manageCRDs bool) clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "crd-access", + Feature: feature.NewResourceFeature(version, nil).When(manageCRDs), + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil + }, + } +} + +resource, err := clusterrole.NewBuilder(base). + WithMutation(CoreRulesMutation()). + WithMutation(CRDAccessMutation(owner.Spec.Version, owner.Spec.ManageCRDs)). + Build() +``` + +When `ManageCRDs` is true, the final rules include both core and CRD access rules. When false, only the core rules are +written. Neither mutation needs to know about the other. + +## 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. + +**Use `SetAggregationRule` for composite roles.** When you want the API server to aggregate rules from multiple +ClusterRoles based on label selectors, use `SetAggregationRule` instead of managing `.rules` directly. The two +approaches are mutually exclusive in the Kubernetes API — the API server ignores `.rules` when `.aggregationRule` is +set. + +**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. diff --git a/examples/clusterrole-primitive/README.md b/examples/clusterrole-primitive/README.md new file mode 100644 index 00000000..1d3457c3 --- /dev/null +++ b/examples/clusterrole-primitive/README.md @@ -0,0 +1,32 @@ +# ClusterRole Primitive Example + +This example demonstrates the usage of the `clusterrole` primitive within the operator component framework. It shows how +to manage a Kubernetes ClusterRole as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a cluster-scoped ClusterRole with basic metadata. +- **Feature Mutations**: Composing RBAC rules from independent, feature-gated mutations using `AddRule`. +- **Metadata Mutations**: Setting version labels on the ClusterRole via `EditObjectMetadata`. +- **Data Extraction**: Inspecting ClusterRole rules 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`: core rules, version labelling, and feature-gated secret and deployment access. +- `resources/`: Contains the central `NewClusterRoleResource` factory that assembles all features using + `clusterrole.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/clusterrole-primitive/main.go +``` + +This will: + +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile through four spec variations, printing the composed rules after each cycle. +4. Print the resulting status conditions. diff --git a/examples/clusterrole-primitive/app/controller.go b/examples/clusterrole-primitive/app/controller.go new file mode 100644 index 00000000..1fbf376f --- /dev/null +++ b/examples/clusterrole-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the clusterrole 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 + + // NewClusterRoleResource is a factory function to create the clusterrole resource. + // This allows us to inject the resource construction logic. + NewClusterRoleResource 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 clusterrole resource for this owner. + crResource, err := r.NewClusterRoleResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the clusterrole. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(crResource, 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/clusterrole-primitive/features/mutations.go b/examples/clusterrole-primitive/features/mutations.go new file mode 100644 index 00000000..02052a7a --- /dev/null +++ b/examples/clusterrole-primitive/features/mutations.go @@ -0,0 +1,74 @@ +// Package features provides sample mutations for the clusterrole 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/clusterrole" + rbacv1 "k8s.io/api/rbac/v1" +) + +// CoreRulesMutation grants read access to core resources (pods, services, configmaps). +// It is always enabled — Feature is nil so it applies unconditionally. +func CoreRulesMutation() clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "core-rules", + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"pods", "services", "configmaps"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil + }, + } +} + +// VersionLabelMutation sets the app.kubernetes.io/version label on the ClusterRole. +// It is always enabled — Feature is nil so it applies unconditionally. +func VersionLabelMutation(version string) clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "version-label", + Mutate: func(m *clusterrole.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// SecretAccessMutation grants read access to secrets. +// It is enabled when needsSecrets is true. +func SecretAccessMutation(version string, needsSecrets bool) clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "secret-access", + Feature: feature.NewResourceFeature(version, nil).When(needsSecrets), + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list"}, + }) + return nil + }, + } +} + +// DeploymentAccessMutation grants read/write access to deployments. +// It is enabled when manageDeployments is true. +func DeploymentAccessMutation(version string, manageDeployments bool) clusterrole.Mutation { + return clusterrole.Mutation{ + Name: "deployment-access", + Feature: feature.NewResourceFeature(version, nil).When(manageDeployments), + Mutate: func(m *clusterrole.Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch"}, + }) + return nil + }, + } +} diff --git a/examples/clusterrole-primitive/main.go b/examples/clusterrole-primitive/main.go new file mode 100644 index 00000000..fa4a5504 --- /dev/null +++ b/examples/clusterrole-primitive/main.go @@ -0,0 +1,122 @@ +// Package main is the entry point for the clusterrole 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/clusterrole-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/clusterrole-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. + // NOTE: This example uses a namespaced owner for simplicity with the fake client. + // The framework detects the scope mismatch between a namespaced owner and + // a cluster-scoped dependent (ClusterRole) and skips setting the controller + // reference (logging a message), so reconciliation still proceeds but without + // garbage collection or owner-based adoption for the ClusterRole. If you want + // owner references and GC/adoption for ClusterRoles in production, use a + // cluster-scoped owner CRD. + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + 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, + }, + NewClusterRoleResource: resources.NewClusterRoleResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate how + // feature-gated mutations compose RBAC rules from independent features. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable secret access + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: false, // Disable deployment access too + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, SecretAccess=%v, DeploymentAccess=%v ---\n", + i+1, spec.Version, spec.EnableTracing, 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/clusterrole-primitive/resources/clusterrole.go b/examples/clusterrole-primitive/resources/clusterrole.go new file mode 100644 index 00000000..56417487 --- /dev/null +++ b/examples/clusterrole-primitive/resources/clusterrole.go @@ -0,0 +1,52 @@ +// Package resources provides resource implementations for the clusterrole primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/clusterrole-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/clusterrole" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewClusterRoleResource constructs a clusterrole primitive resource with all the features. +func NewClusterRoleResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base ClusterRole object. + // ClusterRole is cluster-scoped — no namespace is set. + base := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-role", + Labels: map[string]string{ + "app": owner.Name, + }, + }, + } + + // 2. Initialize the clusterrole builder. + builder := clusterrole.NewBuilder(base) + + // 3. Register mutations in dependency order. + // CoreRulesMutation and VersionLabelMutation always run first. + // SecretAccess and DeploymentAccess are feature-gated. + builder.WithMutation(features.CoreRulesMutation()) + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableTracing)) + builder.WithMutation(features.DeploymentAccessMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Extract data from the reconciled ClusterRole. + builder.WithDataExtractor(func(cr rbacv1.ClusterRole) error { + fmt.Printf("Reconciled ClusterRole: %s\n", cr.Name) + fmt.Printf(" Rules: %d\n", len(cr.Rules)) + for i, rule := range cr.Rules { + fmt.Printf(" [%d] APIGroups=%v Resources=%v Verbs=%v\n", + i, rule.APIGroups, rule.Resources, rule.Verbs) + } + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/policyrules.go b/pkg/mutation/editors/policyrules.go new file mode 100644 index 00000000..91ba7bfd --- /dev/null +++ b/pkg/mutation/editors/policyrules.go @@ -0,0 +1,62 @@ +package editors + +import ( + "slices" + + rbacv1 "k8s.io/api/rbac/v1" +) + +// PolicyRulesEditor provides a typed API for mutating the .rules field of +// a Kubernetes Role or ClusterRole. +// +// It exposes structured operations (AddRule, RemoveRuleByIndex, Clear) as well +// as Raw() for free-form access when none of the structured methods are sufficient. +type PolicyRulesEditor struct { + rules *[]rbacv1.PolicyRule +} + +// NewPolicyRulesEditor creates a new PolicyRulesEditor wrapping the given rules +// slice pointer. +// +// The pointer itself must be non-nil — passing a nil pointer is a programmer +// error and will cause a panic. It may refer to a nil slice; methods that +// append rules initialise it automatically. Pass a non-nil pointer (e.g. +// &cr.Rules) so that writes are reflected on the object. +func NewPolicyRulesEditor(rules *[]rbacv1.PolicyRule) *PolicyRulesEditor { + if rules == nil { + panic("NewPolicyRulesEditor: rules must be a non-nil pointer") + } + return &PolicyRulesEditor{rules: rules} +} + +// Raw returns a pointer to the underlying []rbacv1.PolicyRule, initialising the +// slice if necessary. +// +// This is an escape hatch for free-form editing when none of the structured +// methods are sufficient. +func (e *PolicyRulesEditor) Raw() *[]rbacv1.PolicyRule { + if *e.rules == nil { + *e.rules = []rbacv1.PolicyRule{} + } + return e.rules +} + +// AddRule appends a PolicyRule to the rules slice. +func (e *PolicyRulesEditor) AddRule(rule rbacv1.PolicyRule) { + *e.rules = append(*e.rules, rule) +} + +// RemoveRuleByIndex removes the rule at the given index. +// +// It is a no-op if the index is out of bounds. +func (e *PolicyRulesEditor) RemoveRuleByIndex(index int) { + if index < 0 || index >= len(*e.rules) { + return + } + *e.rules = slices.Delete(*e.rules, index, index+1) +} + +// Clear removes all rules. +func (e *PolicyRulesEditor) Clear() { + *e.rules = nil +} diff --git a/pkg/mutation/editors/policyrules_test.go b/pkg/mutation/editors/policyrules_test.go new file mode 100644 index 00000000..cab20647 --- /dev/null +++ b/pkg/mutation/editors/policyrules_test.go @@ -0,0 +1,106 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + rbacv1 "k8s.io/api/rbac/v1" +) + +func TestPolicyRulesEditor_AddRule(t *testing.T) { + var rules []rbacv1.PolicyRule + e := NewPolicyRulesEditor(&rules) + + rule := rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list"}, + } + e.AddRule(rule) + + require.Len(t, rules, 1) + assert.Equal(t, rule, rules[0]) +} + +func TestPolicyRulesEditor_AddRule_Appends(t *testing.T) { + rules := []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + } + e := NewPolicyRulesEditor(&rules) + + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"list"}, + }) + + assert.Len(t, rules, 2) + assert.Equal(t, "secrets", rules[1].Resources[0]) +} + +func TestPolicyRulesEditor_RemoveRuleByIndex(t *testing.T) { + rules := []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + {APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"list"}}, + {APIGroups: []string{""}, Resources: []string{"services"}, Verbs: []string{"watch"}}, + } + e := NewPolicyRulesEditor(&rules) + + e.RemoveRuleByIndex(1) + + require.Len(t, rules, 2) + assert.Equal(t, "pods", rules[0].Resources[0]) + assert.Equal(t, "services", rules[1].Resources[0]) +} + +func TestPolicyRulesEditor_RemoveRuleByIndex_OutOfBounds(t *testing.T) { + rules := []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + } + e := NewPolicyRulesEditor(&rules) + + e.RemoveRuleByIndex(-1) + e.RemoveRuleByIndex(5) + + assert.Len(t, rules, 1) +} + +func TestPolicyRulesEditor_Clear(t *testing.T) { + rules := []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + } + e := NewPolicyRulesEditor(&rules) + + e.Clear() + + assert.Nil(t, rules) +} + +func TestPolicyRulesEditor_Raw(t *testing.T) { + var rules []rbacv1.PolicyRule + e := NewPolicyRulesEditor(&rules) + + raw := e.Raw() + require.NotNil(t, raw) + assert.Empty(t, *raw) + + *raw = append(*raw, rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}, + }) + + assert.Len(t, rules, 1) + assert.Equal(t, "configmaps", rules[0].Resources[0]) +} + +func TestNewPolicyRulesEditor_PanicsOnNilPointer(t *testing.T) { + assert.PanicsWithValue(t, "NewPolicyRulesEditor: rules must be a non-nil pointer", func() { + NewPolicyRulesEditor(nil) + }) +} + +func TestPolicyRulesEditor_Raw_NilInitialized(t *testing.T) { + var rules []rbacv1.PolicyRule + e := NewPolicyRulesEditor(&rules) + + raw := e.Raw() + assert.NotNil(t, *raw) +} diff --git a/pkg/primitives/clusterrole/builder.go b/pkg/primitives/clusterrole/builder.go new file mode 100644 index 00000000..5f08293b --- /dev/null +++ b/pkg/primitives/clusterrole/builder.go @@ -0,0 +1,80 @@ +package clusterrole + +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 ClusterRole 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.ClusterRole, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided ClusterRole object. +// +// The ClusterRole 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. +// +// The provided ClusterRole must have Name set (ClusterRole is cluster-scoped and +// does not use a namespace), which is validated during the Build() call. +func NewBuilder(cr *rbacv1.ClusterRole) *Builder { + identityFunc := func(cr *rbacv1.ClusterRole) string { + return fmt.Sprintf("rbac.authorization.k8s.io/v1/ClusterRole/%s", cr.Name) + } + + sb := generic.NewStaticBuilder[*rbacv1.ClusterRole, *Mutator]( + cr, + identityFunc, + NewMutator, + ) + sb.MarkClusterScoped() + + return &Builder{base: sb} +} + +// WithMutation registers a mutation for the ClusterRole. +// +// 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 ClusterRole after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled ClusterRole. This is useful +// for surfacing generated or updated fields to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(rbacv1.ClusterRole) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(cr *rbacv1.ClusterRole) error { + return extractor(*cr) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No ClusterRole object was provided. +// - The ClusterRole is missing a Name. +func (b *Builder) Build() (*Resource, error) { + res, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: res}, nil +} diff --git a/pkg/primitives/clusterrole/builder_test.go b/pkg/primitives/clusterrole/builder_test.go new file mode 100644 index 00000000..d3c7283a --- /dev/null +++ b/pkg/primitives/clusterrole/builder_test.go @@ -0,0 +1,151 @@ +package clusterrole + +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 + cr *rbacv1.ClusterRole + expectedErr string + }{ + { + name: "nil clusterrole", + cr: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + cr: &rbacv1.ClusterRole{}, + expectedErr: "object name cannot be empty", + }, + { + name: "valid clusterrole", + cr: &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cr"}, + }, + }, + { + name: "rejects namespace on cluster-scoped resource", + cr: &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cr", Namespace: "oops"}, + }, + expectedErr: "cluster-scoped object must not have a namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.cr).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/ClusterRole/test-cr", res.Identity()) + } + }) + } +} + +func TestBuilder_Build_NoNamespaceRequired(t *testing.T) { + t.Parallel() + cr := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-scoped"}, + } + res, err := NewBuilder(cr).Build() + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "rbac.authorization.k8s.io/v1/ClusterRole/cluster-scoped", res.Identity()) +} + +func TestBuilder_Build_DoesNotSetNamespace(t *testing.T) { + t.Parallel() + cr := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cr"}, + } + _, err := NewBuilder(cr).Build() + require.NoError(t, err) + assert.Empty(t, cr.Namespace, "namespace should not be set by Build") +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + cr := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cr"}, + } + res, err := NewBuilder(cr). + WithMutation(Mutation{ + Name: "test-mutation", + Mutate: func(m *Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get"}, + }) + return nil + }, + }). + 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() + cr := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cr"}, + } + called := false + extractor := func(_ rbacv1.ClusterRole) error { + called = true + return nil + } + res, err := NewBuilder(cr). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&rbacv1.ClusterRole{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + cr := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cr"}, + } + res, err := NewBuilder(cr). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + cr := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cr"}, + } + res, err := NewBuilder(cr). + WithDataExtractor(func(_ rbacv1.ClusterRole) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&rbacv1.ClusterRole{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/clusterrole/mutator.go b/pkg/primitives/clusterrole/mutator.go new file mode 100644 index 00000000..e609c907 --- /dev/null +++ b/pkg/primitives/clusterrole/mutator.go @@ -0,0 +1,153 @@ +// Package clusterrole provides a builder and resource for managing Kubernetes ClusterRoles. +package clusterrole + +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 ClusterRole Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + rulesEdits []func(*editors.PolicyRulesEditor) error + aggregationRuleSets []*rbacv1.AggregationRule +} + +// Mutator is a high-level helper for modifying a Kubernetes ClusterRole. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the ClusterRole 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. Within each +// feature, edits are applied in category order: metadata, then rules, then +// aggregation rule. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + cr *rbacv1.ClusterRole + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given ClusterRole. +// BeginFeature must be called before registering any mutations. +func NewMutator(cr *rbacv1.ClusterRole) *Mutator { + return &Mutator{ + cr: cr, + } +} + +// 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] +} + +func (m *Mutator) requireActive() { + if m.active == nil { + panic("clusterrole.Mutator: BeginFeature must be called before registering mutations") + } +} + +// EditObjectMetadata records a mutation for the ClusterRole's own metadata. +// +// Metadata edits are applied before rules 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.requireActive() + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditRules records a mutation for the ClusterRole's .rules field via a +// PolicyRulesEditor. +// +// The editor provides structured operations (AddRule, RemoveRuleByIndex, Clear) +// as well as Raw() for free-form access. Rules edits are applied after metadata +// edits within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) EditRules(edit func(*editors.PolicyRulesEditor) error) { + if edit == nil { + return + } + m.requireActive() + m.active.rulesEdits = append(m.active.rulesEdits, edit) +} + +// AddRule records that a PolicyRule should be appended to .rules. +// +// Convenience wrapper over EditRules. +func (m *Mutator) AddRule(rule rbacv1.PolicyRule) { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rule) + return nil + }) +} + +// SetAggregationRule records that the ClusterRole's .aggregationRule should be +// set to the given value. +// +// An aggregation rule causes the API server to combine rules from ClusterRoles +// whose labels match the provided selectors, instead of using .rules directly. +// If called multiple times within the same feature, the last call wins. +// +// A nil value clears the aggregation rule. +func (m *Mutator) SetAggregationRule(rule *rbacv1.AggregationRule) { + var copied *rbacv1.AggregationRule + if rule != nil { + copied = rule.DeepCopy() + } + m.requireActive() + m.active.aggregationRuleSets = append(m.active.aggregationRuleSets, copied) +} + +// Apply executes all recorded mutation intents on the underlying ClusterRole. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Rules edits — EditRules, AddRule (in registration order within each feature) +// 3. Aggregation rule — SetAggregationRule (last call wins within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the ClusterRole 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.cr.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Rules edits + if len(plan.rulesEdits) > 0 { + editor := editors.NewPolicyRulesEditor(&m.cr.Rules) + for _, edit := range plan.rulesEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 3. Aggregation rule (last call wins within this feature) + if len(plan.aggregationRuleSets) > 0 { + m.cr.AggregationRule = plan.aggregationRuleSets[len(plan.aggregationRuleSets)-1] + } + } + + return nil +} diff --git a/pkg/primitives/clusterrole/mutator_test.go b/pkg/primitives/clusterrole/mutator_test.go new file mode 100644 index 00000000..3be26e12 --- /dev/null +++ b/pkg/primitives/clusterrole/mutator_test.go @@ -0,0 +1,341 @@ +package clusterrole + +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 newTestCR(rules []rbacv1.PolicyRule) *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cr", + }, + Rules: rules, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", cr.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EditRules --- + +func TestMutator_EditRules(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, cr.Rules, 1) + assert.Equal(t, "pods", cr.Rules[0].Resources[0]) +} + +func TestMutator_EditRules_Nil(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + m.EditRules(nil) + assert.NoError(t, m.Apply()) +} + +// --- AddRule --- + +func TestMutator_AddRule(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, Resources: []string{"deployments"}, Verbs: []string{"get", "list"}, + }) + require.NoError(t, m.Apply()) + require.Len(t, cr.Rules, 1) + assert.Equal(t, "deployments", cr.Rules[0].Resources[0]) +} + +func TestMutator_AddRule_Appends(t *testing.T) { + existing := []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + } + cr := newTestCR(existing) + m := NewMutator(cr) + m.BeginFeature() + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"services"}, Verbs: []string{"list"}, + }) + require.NoError(t, m.Apply()) + assert.Len(t, cr.Rules, 2) +} + +// --- SetAggregationRule --- + +func TestMutator_SetAggregationRule(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + aggRule := &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"aggregate": "true"}}, + }, + } + m.SetAggregationRule(aggRule) + require.NoError(t, m.Apply()) + require.NotNil(t, cr.AggregationRule) + assert.Len(t, cr.AggregationRule.ClusterRoleSelectors, 1) + assert.Equal(t, "true", cr.AggregationRule.ClusterRoleSelectors[0].MatchLabels["aggregate"]) +} + +func TestMutator_SetAggregationRule_Nil(t *testing.T) { + cr := newTestCR(nil) + cr.AggregationRule = &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"old": "rule"}}, + }, + } + m := NewMutator(cr) + m.BeginFeature() + m.SetAggregationRule(nil) + require.NoError(t, m.Apply()) + assert.Nil(t, cr.AggregationRule) +} + +func TestMutator_SetAggregationRule_LastWins(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + m.SetAggregationRule(&rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"first": "true"}}, + }, + }) + m.SetAggregationRule(&rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"second": "true"}}, + }, + }) + require.NoError(t, m.Apply()) + require.NotNil(t, cr.AggregationRule) + assert.Equal(t, "true", cr.AggregationRule.ClusterRoleSelectors[0].MatchLabels["second"]) +} + +func TestMutator_SetAggregationRule_Immutable(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + aggRule := &rbacv1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"aggregate": "true"}}, + }, + } + m.SetAggregationRule(aggRule) + + // Mutate the original after registration — should not affect the mutator. + aggRule.ClusterRoleSelectors[0].MatchLabels["aggregate"] = "mutated" + + require.NoError(t, m.Apply()) + require.NotNil(t, cr.AggregationRule) + assert.Equal(t, "true", cr.AggregationRule.ClusterRoleSelectors[0].MatchLabels["aggregate"], + "mutating the original after SetAggregationRule should not affect the applied result") +} + +// --- Execution order --- + +func TestMutator_MixedOperationTypes(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + // Register both metadata and rules mutations to verify they are all applied. + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("mixed", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", cr.Labels["mixed"]) + require.Len(t, cr.Rules, 1) + assert.Equal(t, "pods", cr.Rules[0].Resources[0]) +} + +func TestMutator_MultipleMutations(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"services"}, Verbs: []string{"list"}, + }) + require.NoError(t, m.Apply()) + + assert.Len(t, cr.Rules, 2) + assert.Equal(t, "pods", cr.Rules[0].Resources[0]) + assert.Equal(t, "services", cr.Rules[1].Resources[0]) +} + +// --- Feature boundaries --- + +func TestMutator_MultipleFeatures(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + + // Feature A: add a rule and a label + m.BeginFeature() + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("feature-a", "true") + return nil + }) + + // Feature B: add another rule and a label + m.BeginFeature() + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, Resources: []string{"deployments"}, Verbs: []string{"list"}, + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("feature-b", "true") + return nil + }) + + require.NoError(t, m.Apply()) + + // Both features' edits applied in order + assert.Equal(t, "true", cr.Labels["feature-a"]) + assert.Equal(t, "true", cr.Labels["feature-b"]) + require.Len(t, cr.Rules, 2) + assert.Equal(t, "pods", cr.Rules[0].Resources[0]) + assert.Equal(t, "deployments", cr.Rules[1].Resources[0]) +} + +func TestMutator_FeatureMixedEdits(t *testing.T) { + // Within a single feature, both metadata and rules edits are applied. + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("feature", "complete") + return nil + }) + + require.NoError(t, m.Apply()) + assert.Equal(t, "complete", cr.Labels["feature"]) + require.Len(t, cr.Rules, 1) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + + 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) { + cr := newTestCR(nil) + m := NewMutator(cr) + + 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) { + cr := newTestCR(nil) + m := NewMutator(cr) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, Resources: []string{"deployments"}, Verbs: []string{"list"}, + }) + + assert.Len(t, m.plans[0].rulesEdits, 1, "first plan should have one rules edit") + assert.Len(t, m.plans[1].rulesEdits, 1, "second plan should have one rules edit") +} + +func TestMutator_PanicsWithoutBeginFeature(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + + assert.PanicsWithValue(t, + "clusterrole.Mutator: BeginFeature must be called before registering mutations", + func() { m.EditObjectMetadata(func(*editors.ObjectMetaEditor) error { return nil }) }, + "EditObjectMetadata should panic without BeginFeature", + ) + assert.PanicsWithValue(t, + "clusterrole.Mutator: BeginFeature must be called before registering mutations", + func() { m.EditRules(func(*editors.PolicyRulesEditor) error { return nil }) }, + "EditRules should panic without BeginFeature", + ) + assert.PanicsWithValue(t, + "clusterrole.Mutator: BeginFeature must be called before registering mutations", + func() { m.SetAggregationRule(nil) }, + "SetAggregationRule should panic without BeginFeature", + ) +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + cr := newTestCR(nil) + m := NewMutator(cr) + m.BeginFeature() + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1) +} diff --git a/pkg/primitives/clusterrole/resource.go b/pkg/primitives/clusterrole/resource.go new file mode 100644 index 00000000..566f5d17 --- /dev/null +++ b/pkg/primitives/clusterrole/resource.go @@ -0,0 +1,56 @@ +package clusterrole + +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 ClusterRole 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. +// +// ClusterRole 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. +// +// ClusterRole is cluster-scoped: it has no namespace. +type Resource struct { + base *generic.StaticResource[*rbacv1.ClusterRole, *Mutator] +} + +// Identity returns a unique identifier for the ClusterRole in the format +// "rbac.authorization.k8s.io/v1/ClusterRole/". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes ClusterRole 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 ClusterRole into the desired state. +// +// The mutation process follows this 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 { + return r.base.Mutate(current) +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled ClusterRole. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the ClusterRole. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/clusterrole/resource_test.go b/pkg/primitives/clusterrole/resource_test.go new file mode 100644 index 00000000..a64f1e82 --- /dev/null +++ b/pkg/primitives/clusterrole/resource_test.go @@ -0,0 +1,153 @@ +package clusterrole + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "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 newValidCR() *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cr"}, + Rules: []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}}, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidCR()).Build() + require.NoError(t, err) + assert.Equal(t, "rbac.authorization.k8s.io/v1/ClusterRole/test-cr", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + cr := newValidCR() + res, err := NewBuilder(cr).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*rbacv1.ClusterRole) + require.True(t, ok) + assert.Equal(t, cr.Name, got.Name) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-cr", cr.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidCR() + 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.ClusterRole) + require.Len(t, got.Rules, 1) + assert.Equal(t, []string{"get", "list"}, got.Rules[0].Verbs) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidCR() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-rule", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get"}, + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*rbacv1.ClusterRole) + require.Len(t, got.Rules, 2) + assert.Equal(t, []string{"get", "list"}, got.Rules[0].Verbs) + assert.Equal(t, []string{"deployments"}, got.Rules[1].Resources) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidCR() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}, + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get"}, + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*rbacv1.ClusterRole) + // Base rule + feature-a + feature-b = 3 rules in order. + require.Len(t, got.Rules, 3) + assert.Equal(t, []string{"pods"}, got.Rules[0].Resources) + assert.Equal(t, []string{"secrets"}, got.Rules[1].Resources) + assert.Equal(t, []string{"configmaps"}, got.Rules[2].Resources) +} + +func TestResource_ExtractData(t *testing.T) { + cr := newValidCR() + + var extracted string + res, err := NewBuilder(cr). + WithDataExtractor(func(c rbacv1.ClusterRole) error { + extracted = c.Name + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "test-cr", extracted) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidCR()). + WithDataExtractor(func(_ rbacv1.ClusterRole) 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") +}