diff --git a/.gitignore b/.gitignore index b39f0d58..14cb0a9c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.dll *.so *.dylib +role-primitive # Test binary, built with `go test -c` *.test diff --git a/Makefile b/Makefile index 36728bfe..8acfd884 100644 --- a/Makefile +++ b/Makefile @@ -127,6 +127,7 @@ run-examples: ## Run all examples to verify they execute without error. go run ./examples/replicaset-primitive/. go run ./examples/rolebinding-primitive/. go run ./examples/custom-resource-implementation/. + go run ./examples/role-primitive/. go run ./examples/pdb-primitive/. go run ./examples/daemonset-primitive/. go run ./examples/hpa-primitive/. diff --git a/docs/primitives.md b/docs/primitives.md index 0d66e65b..b92b2016 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -163,6 +163,7 @@ have been applied. This means a single mutation can safely add a container and t | `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | | `pkg/primitives/secret` | Static | [secret.md](primitives/secret.md) | +| `pkg/primitives/role` | Static | [role.md](primitives/role.md) | | `pkg/primitives/pdb` | Static | [pdb.md](primitives/pdb.md) | | `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) | | `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) | diff --git a/docs/primitives/role.md b/docs/primitives/role.md new file mode 100644 index 00000000..280706a5 --- /dev/null +++ b/docs/primitives/role.md @@ -0,0 +1,270 @@ +# Role Primitive + +The `role` primitive is the framework's built-in static abstraction for managing Kubernetes `Role` resources. It +integrates with the component lifecycle and provides a structured mutation API for managing RBAC policy rules 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 `.rules` and object metadata, with a raw escape hatch for free-form access | +| **Data extraction** | Reads generated or updated values back from the reconciled Role after each sync cycle | + +## Building a Role Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/role" + +base := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-role", + Namespace: owner.Namespace, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, +} + +resource, err := role.NewBuilder(base). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `Role` 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 MyFeatureMutation(version string) role.Mutation { + return role.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get"}, + }) + return nil + }) + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by +another, register the dependency first. + +### Boolean-gated mutations + +```go +func SecretAccessMutation(version string, enabled bool) role.Mutation { + return role.Mutation{ + Name: "secret-access", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list"}, + }) + return nil + }) + return nil + }, + } +} +``` + +### Version-gated mutations + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyRoleMutation(version string) role.Mutation { + return role.Mutation{ + Name: "legacy-role", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"extensions"}, + Resources: []string{"ingresses"}, + Verbs: []string{"get", "list"}, + }) + 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 Role | +| 2 | Rules edits | `.rules` — SetRules, AddRule, Raw | + +Within each category, edits are applied in their registration order. Later features observe the Role as modified by all +previous features. + +## Editors + +### PolicyRulesEditor + +The primary API for modifying `.rules`. Use `m.EditRules` for full control: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}}, + }) + return nil +}) +``` + +#### SetRules + +`SetRules` replaces the entire rules slice atomically. Use this when the mutation should define the complete set of +rules, discarding any previously accumulated entries. + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.SetRules(desiredRules) + return nil +}) +``` + +#### AddRule + +`AddRule` appends a single rule to the existing rules slice. Use this when a feature contributes additional permissions +without needing to know about rules from other features. + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "watch"}, + }) + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns a pointer to the underlying `[]rbacv1.PolicyRule` for direct manipulation when none of the structured +methods are sufficient: + +```go +m.EditRules(func(e *editors.PolicyRulesEditor) error { + raw := e.Raw() + // Filter out rules that grant write access + filtered := (*raw)[:0] + for _, r := range *raw { + if !containsVerb(r.Verbs, "create") { + filtered = append(filtered, r) + } + } + *raw = filtered + 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 +}) +``` + +## Full Example: Feature-Composed Permissions + +```go +func BaseRuleMutation(version string) role.Mutation { + return role.Mutation{ + Name: "base-rules", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list", "watch"}}, + }) + return nil + }) + return nil + }, + } +} + +func SecretAccessMutation(version string, enabled bool) role.Mutation { + return role.Mutation{ + Name: "secret-access", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list"}, + }) + return nil + }) + return nil + }, + } +} + +resource, err := role.NewBuilder(base). + WithMutation(BaseRuleMutation(owner.Spec.Version)). + WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableTracing)). + Build() +``` + +When `EnableTracing` is true, the final Role will contain both the base pod rules and the secrets rule. When false, only +the base rules are applied. 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 `AddRule` for composable permissions.** When multiple features need to contribute rules to the same Role, +`AddRule` lets each feature add its permissions independently. Using `SetRules` in multiple features means the last +registration wins — only use that when full replacement is the intended semantics. + +**Register mutations in dependency order.** If mutation B relies on rules set by mutation A, register A first. + +**PolicyRule has no unique key.** There is no upsert or remove-by-key operation. Use `SetRules` to replace atomically, +`AddRule` to accumulate, or `Raw()` for arbitrary manipulation including filtering. diff --git a/examples/role-primitive/README.md b/examples/role-primitive/README.md new file mode 100644 index 00000000..9b6f5473 --- /dev/null +++ b/examples/role-primitive/README.md @@ -0,0 +1,31 @@ +# Role Primitive Example + +This example demonstrates the usage of the `role` primitive within the operator component framework. It shows how to +manage a Kubernetes Role as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a Role with core RBAC permissions. +- **Feature Mutations**: Composing policy rules from independent, feature-gated mutations using `AddRule`. +- **Metadata Mutations**: Setting version labels on the Role via `EditObjectMetadata`. +- **Data Extraction**: Inspecting the reconciled Role's rules after each sync 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 rules, version labelling, and feature-gated secret and metrics access. +- `resources/`: Contains the central `NewRoleResource` factory that assembles all features using `role.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/role-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 RBAC rules after each cycle. +4. Print the resulting status conditions. diff --git a/examples/role-primitive/app/controller.go b/examples/role-primitive/app/controller.go new file mode 100644 index 00000000..64562a31 --- /dev/null +++ b/examples/role-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the role 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 + + // NewRoleResource is a factory function to create the role resource. + // This allows us to inject the resource construction logic. + NewRoleResource 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 role resource for this owner. + roleResource, err := r.NewRoleResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the role. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(roleResource, 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/role-primitive/features/mutations.go b/examples/role-primitive/features/mutations.go new file mode 100644 index 00000000..a69f7bca --- /dev/null +++ b/examples/role-primitive/features/mutations.go @@ -0,0 +1,92 @@ +// Package features provides sample mutations for the role 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/role" + rbacv1 "k8s.io/api/rbac/v1" +) + +// BaseRuleMutation sets the foundational RBAC rules for the application. +// It is always enabled. +func BaseRuleMutation(version string) role.Mutation { + return role.Mutation{ + Name: "base-rules", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.SetRules([]rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"configmaps"}, + Verbs: []string{"get", "list"}, + }, + }) + return nil + }) + return nil + }, + } +} + +// VersionLabelMutation sets the app.kubernetes.io/version label on the Role. +// It is always enabled. +func VersionLabelMutation(version string) role.Mutation { + return role.Mutation{ + Name: "version-label", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *role.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// SecretAccessMutation adds permission to read secrets. +// It is enabled when enableTracing is true (tracing requires reading TLS secrets). +func SecretAccessMutation(version string, enableTracing bool) role.Mutation { + return role.Mutation{ + Name: "secret-access", + Feature: feature.NewResourceFeature(version, nil).When(enableTracing), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + Verbs: []string{"get", "list"}, + }) + return nil + }) + return nil + }, + } +} + +// MetricsAccessMutation adds permission to read services for metrics scraping. +// It is enabled when enableMetrics is true. +func MetricsAccessMutation(version string, enableMetrics bool) role.Mutation { + return role.Mutation{ + Name: "metrics-access", + Feature: feature.NewResourceFeature(version, nil).When(enableMetrics), + Mutate: func(m *role.Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"services", "endpoints"}, + Verbs: []string{"get", "list", "watch"}, + }) + return nil + }) + return nil + }, + } +} diff --git a/examples/role-primitive/main.go b/examples/role-primitive/main.go new file mode 100644 index 00000000..19f55ffb --- /dev/null +++ b/examples/role-primitive/main.go @@ -0,0 +1,115 @@ +// Package main is the entry point for the role 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/role-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/role-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", + 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, + }, + NewRoleResource: resources.NewRoleResource, + } + + // 4. Run reconciliation with multiple spec variations to demonstrate how + // feature-gated mutations compose RBAC rules. + 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 tracing (removes secret access) + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: false, // Disable metrics too + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Tracing=%v, Metrics=%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/role-primitive/resources/role.go b/examples/role-primitive/resources/role.go new file mode 100644 index 00000000..90b7f585 --- /dev/null +++ b/examples/role-primitive/resources/role.go @@ -0,0 +1,54 @@ +// Package resources provides resource implementations for the role primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/role-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/role" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewRoleResource constructs a role primitive resource with all the features. +func NewRoleResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base Role object with core permissions. + base := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-role", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + } + + // 2. Initialize the role builder. + builder := role.NewBuilder(base) + + // 3. Register mutations in dependency order. + // + // BaseRuleMutation sets the foundational permissions. The version label + // mutation always runs to track the app version. Secret access is + // conditionally added based on the tracing flag (as a stand-in for a + // feature that requires reading secrets). + builder.WithMutation(features.BaseRuleMutation(owner.Spec.Version)) + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableTracing)) + builder.WithMutation(features.MetricsAccessMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Extract data from the reconciled Role. + builder.WithDataExtractor(func(r rbacv1.Role) error { + fmt.Printf("Reconciled Role: %s\n", r.Name) + for i, rule := range r.Rules { + fmt.Printf(" Rule %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 index 91ba7bfd..e221157f 100644 --- a/pkg/mutation/editors/policyrules.go +++ b/pkg/mutation/editors/policyrules.go @@ -9,7 +9,7 @@ import ( // 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 +// It exposes structured operations (SetRules, 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 @@ -41,6 +41,11 @@ func (e *PolicyRulesEditor) Raw() *[]rbacv1.PolicyRule { return e.rules } +// SetRules replaces the full rules slice with the provided rules. +func (e *PolicyRulesEditor) SetRules(rules []rbacv1.PolicyRule) { + *e.rules = rules +} + // AddRule appends a PolicyRule to the rules slice. func (e *PolicyRulesEditor) AddRule(rule rbacv1.PolicyRule) { *e.rules = append(*e.rules, rule) diff --git a/pkg/mutation/editors/policyrules_test.go b/pkg/mutation/editors/policyrules_test.go index cab20647..1826a5cf 100644 --- a/pkg/mutation/editors/policyrules_test.go +++ b/pkg/mutation/editors/policyrules_test.go @@ -8,6 +8,42 @@ import ( rbacv1 "k8s.io/api/rbac/v1" ) +// --- SetRules --- + +func TestPolicyRulesEditor_SetRules(t *testing.T) { + var rules []rbacv1.PolicyRule + e := NewPolicyRulesEditor(&rules) + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + }) + assert.Len(t, rules, 1) + assert.Equal(t, []string{"pods"}, rules[0].Resources) +} + +func TestPolicyRulesEditor_SetRules_ReplacesExisting(t *testing.T) { + rules := []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + {APIGroups: []string{""}, Resources: []string{"services"}, Verbs: []string{"list"}}, + } + e := NewPolicyRulesEditor(&rules) + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{"apps"}, Resources: []string{"deployments"}, Verbs: []string{"create"}}, + }) + assert.Len(t, rules, 1) + assert.Equal(t, []string{"deployments"}, rules[0].Resources) +} + +func TestPolicyRulesEditor_SetRules_Empty(t *testing.T) { + rules := []rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + } + e := NewPolicyRulesEditor(&rules) + e.SetRules([]rbacv1.PolicyRule{}) + assert.Empty(t, rules) +} + +// --- AddRule --- + func TestPolicyRulesEditor_AddRule(t *testing.T) { var rules []rbacv1.PolicyRule e := NewPolicyRulesEditor(&rules) @@ -37,6 +73,8 @@ func TestPolicyRulesEditor_AddRule_Appends(t *testing.T) { assert.Equal(t, "secrets", rules[1].Resources[0]) } +// --- RemoveRuleByIndex --- + func TestPolicyRulesEditor_RemoveRuleByIndex(t *testing.T) { rules := []rbacv1.PolicyRule{ {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, @@ -64,6 +102,8 @@ func TestPolicyRulesEditor_RemoveRuleByIndex_OutOfBounds(t *testing.T) { assert.Len(t, rules, 1) } +// --- Clear --- + func TestPolicyRulesEditor_Clear(t *testing.T) { rules := []rbacv1.PolicyRule{ {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, @@ -75,6 +115,8 @@ func TestPolicyRulesEditor_Clear(t *testing.T) { assert.Nil(t, rules) } +// --- Raw --- + func TestPolicyRulesEditor_Raw(t *testing.T) { var rules []rbacv1.PolicyRule e := NewPolicyRulesEditor(&rules) @@ -91,12 +133,6 @@ func TestPolicyRulesEditor_Raw(t *testing.T) { 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) @@ -104,3 +140,11 @@ func TestPolicyRulesEditor_Raw_NilInitialized(t *testing.T) { raw := e.Raw() assert.NotNil(t, *raw) } + +// --- Nil safety --- + +func TestNewPolicyRulesEditor_PanicsOnNilPointer(t *testing.T) { + assert.PanicsWithValue(t, "NewPolicyRulesEditor: rules must be a non-nil pointer", func() { + NewPolicyRulesEditor(nil) + }) +} diff --git a/pkg/primitives/role/builder.go b/pkg/primitives/role/builder.go new file mode 100644 index 00000000..6c71f5af --- /dev/null +++ b/pkg/primitives/role/builder.go @@ -0,0 +1,79 @@ +package role + +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 Role 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.Role, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Role object. +// +// The Role 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 Role must have both Name and Namespace set, which is validated +// during the Build() call. +func NewBuilder(role *rbacv1.Role) *Builder { + identityFunc := func(r *rbacv1.Role) string { + return fmt.Sprintf("rbac.authorization.k8s.io/v1/Role/%s/%s", r.Namespace, r.Name) + } + + return &Builder{ + base: generic.NewStaticBuilder[*rbacv1.Role, *Mutator]( + role, + identityFunc, + NewMutator, + ), + } +} + +// WithMutation registers a mutation for the Role. +// +// 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 Role after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled Role. 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.Role) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(r *rbacv1.Role) error { + return extractor(*r) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No Role object was provided. +// - The Role is missing a Name or Namespace. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/role/builder_test.go b/pkg/primitives/role/builder_test.go new file mode 100644 index 00000000..0f62863f --- /dev/null +++ b/pkg/primitives/role/builder_test.go @@ -0,0 +1,122 @@ +package role + +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 + role *rbacv1.Role + expectedErr string + }{ + { + name: "nil role", + role: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + role: &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + role: &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "test-role"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid role", + role: &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "test-role", Namespace: "test-ns"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.role).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/Role/test-ns/test-role", res.Identity()) + } + }) + } +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "test-role", Namespace: "test-ns"}, + } + res, err := NewBuilder(role). + 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() + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "test-role", Namespace: "test-ns"}, + } + called := false + extractor := func(_ rbacv1.Role) error { + called = true + return nil + } + res, err := NewBuilder(role). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&rbacv1.Role{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "test-role", Namespace: "test-ns"}, + } + res, err := NewBuilder(role). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "test-role", Namespace: "test-ns"}, + } + res, err := NewBuilder(role). + WithDataExtractor(func(_ rbacv1.Role) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&rbacv1.Role{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/role/mutator.go b/pkg/primitives/role/mutator.go new file mode 100644 index 00000000..2a4b17b2 --- /dev/null +++ b/pkg/primitives/role/mutator.go @@ -0,0 +1,123 @@ +package role + +import ( + "fmt" + + "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 role 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 +} + +// Mutator is a high-level helper for modifying a Kubernetes Role. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Role 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 { + role *rbacv1.Role + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Role. +// BeginFeature must be called before registering any mutations. +func NewMutator(role *rbacv1.Role) *Mutator { + return &Mutator{ + role: role, + } +} + +// 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 Role's own metadata. +// +// Metadata edits are applied before rules edits within the same feature. +// A nil edit function is ignored. +// +// Panics if BeginFeature has not been called. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.requireActive("EditObjectMetadata") + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditRules records a mutation for the Role's .rules field via a +// PolicyRulesEditor. +// +// The editor provides SetRules to replace atomically, AddRule to append, and +// 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. +// +// Panics if BeginFeature has not been called. +func (m *Mutator) EditRules(edit func(*editors.PolicyRulesEditor) error) { + if edit == nil { + return + } + m.requireActive("EditRules") + m.active.rulesEdits = append(m.active.rulesEdits, edit) +} + +// requireActive panics with a descriptive message if BeginFeature has not been called. +func (m *Mutator) requireActive(method string) { + if m.active == nil { + panic(fmt.Sprintf("role.Mutator.%s called before BeginFeature", method)) + } +} + +// Apply executes all recorded mutation intents on the underlying Role. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Rules edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the Role 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.role.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.role.Rules) + for _, edit := range plan.rulesEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/role/mutator_test.go b/pkg/primitives/role/mutator_test.go new file mode 100644 index 00000000..e47e93c7 --- /dev/null +++ b/pkg/primitives/role/mutator_test.go @@ -0,0 +1,223 @@ +package role + +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 newTestRole(rules []rbacv1.PolicyRule) *rbacv1.Role { + return &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-role", + Namespace: "default", + }, + Rules: rules, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + role := newTestRole(nil) + m := NewMutator(role) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", role.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + role := newTestRole(nil) + m := NewMutator(role) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EditRules --- + +func TestMutator_EditRules_SetRules(t *testing.T) { + role := newTestRole(nil) + m := NewMutator(role) + m.BeginFeature() + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}}, + }) + return nil + }) + require.NoError(t, m.Apply()) + assert.Len(t, role.Rules, 1) + assert.Equal(t, []string{"pods"}, role.Rules[0].Resources) +} + +func TestMutator_EditRules_AddRule(t *testing.T) { + role := newTestRole([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + }) + m := NewMutator(role) + m.BeginFeature() + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"services"}, Verbs: []string{"list"}, + }) + return nil + }) + require.NoError(t, m.Apply()) + assert.Len(t, role.Rules, 2) + assert.Equal(t, []string{"services"}, role.Rules[1].Resources) +} + +func TestMutator_EditRules_RawAccess(t *testing.T) { + role := newTestRole([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}, + }) + m := NewMutator(role) + m.BeginFeature() + m.EditRules(func(e *editors.PolicyRulesEditor) error { + raw := e.Raw() + *raw = append(*raw, rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, Resources: []string{"deployments"}, Verbs: []string{"create"}, + }) + return nil + }) + require.NoError(t, m.Apply()) + assert.Len(t, role.Rules, 2) +} + +func TestMutator_EditRules_Nil(t *testing.T) { + role := newTestRole(nil) + m := NewMutator(role) + m.BeginFeature() + m.EditRules(nil) + assert.NoError(t, m.Apply()) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before rules edits. + role := newTestRole(nil) + m := NewMutator(role) + m.BeginFeature() + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + return nil + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", role.Labels["order"]) + assert.Len(t, role.Rules, 1) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + role := newTestRole(nil) + m := NewMutator(role) + m.BeginFeature() + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + return nil + }) + m.BeginFeature() + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"services"}, Verbs: []string{"list"}, + }) + return nil + }) + require.NoError(t, m.Apply()) + + assert.Len(t, role.Rules, 2) + assert.Equal(t, []string{"pods"}, role.Rules[0].Resources) + assert.Equal(t, []string{"services"}, role.Rules[1].Resources) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + role := newTestRole(nil) + m := NewMutator(role) + + 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) { + role := newTestRole(nil) + m := NewMutator(role) + + 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) { + role := newTestRole(nil) + m := NewMutator(role) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}, + }) + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"services"}, Verbs: []string{"list"}, + }) + return nil + }) + + // The initial plan should have exactly one rules edit + assert.Len(t, m.plans[0].rulesEdits, 1, "initial plan should have one edit") + // The second plan should also have exactly one rules edit + assert.Len(t, m.plans[1].rulesEdits, 1, "second plan should have one edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + role := newTestRole(nil) + m := NewMutator(role) + 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()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Len(t, role.Rules, 1) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/role/resource.go b/pkg/primitives/role/resource.go new file mode 100644 index 00000000..9a37f2ab --- /dev/null +++ b/pkg/primitives/role/resource.go @@ -0,0 +1,55 @@ +// Package role provides a builder and resource for managing Kubernetes Roles. +package role + +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 Role 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. +// +// Role resources are static: they do not model convergence health, grace periods, +// or suspension. Use a workload or task primitive for resources that require those concepts. +type Resource struct { + base *generic.StaticResource[*rbacv1.Role, *Mutator] +} + +// Identity returns a unique identifier for the Role in the format +// "rbac.authorization.k8s.io/v1/Role//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes Role 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 Role 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 Role. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the Role. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/role/resource_test.go b/pkg/primitives/role/resource_test.go new file mode 100644 index 00000000..794e84d3 --- /dev/null +++ b/pkg/primitives/role/resource_test.go @@ -0,0 +1,172 @@ +package role + +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 newValidRole() *rbacv1.Role { + return &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-role", + Namespace: "test-ns", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list"}, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidRole()).Build() + require.NoError(t, err) + assert.Equal(t, "rbac.authorization.k8s.io/v1/Role/test-ns/test-role", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + role := newValidRole() + res, err := NewBuilder(role).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*rbacv1.Role) + require.True(t, ok) + assert.Equal(t, role.Name, got.Name) + assert.Equal(t, role.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-role", role.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidRole() + 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.Role) + require.Len(t, got.Rules, 1) + assert.Equal(t, []string{"pods"}, got.Rules[0].Resources) + assert.Equal(t, []string{"get", "list"}, got.Rules[0].Verbs) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidRole() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-rule", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{"apps"}, + Resources: []string{"deployments"}, + Verbs: []string{"get"}, + }) + 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.Role) + require.Len(t, got.Rules, 2) + assert.Equal(t, []string{"pods"}, got.Rules[0].Resources) + assert.Equal(t, []string{"deployments"}, got.Rules[1].Resources) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidRole() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.SetRules([]rbacv1.PolicyRule{ + {APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get"}}, + }) + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditRules(func(e *editors.PolicyRulesEditor) error { + e.AddRule(rbacv1.PolicyRule{ + APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"list"}, + }) + 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.Role) + // feature-b appends to the rules set by feature-a. + require.Len(t, got.Rules, 2) + assert.Equal(t, []string{"secrets"}, got.Rules[0].Resources) + assert.Equal(t, []string{"configmaps"}, got.Rules[1].Resources) +} + +func TestResource_ExtractData(t *testing.T) { + role := newValidRole() + + var extracted []rbacv1.PolicyRule + res, err := NewBuilder(role). + WithDataExtractor(func(r rbacv1.Role) error { + extracted = r.Rules + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + require.Len(t, extracted, 1) + assert.Equal(t, []string{"pods"}, extracted[0].Resources) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidRole()). + WithDataExtractor(func(_ rbacv1.Role) 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") +}