Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6204138
Add PolicyRulesEditor for Role and ClusterRole primitives
sourcehawk Mar 22, 2026
1b55ff9
Add Role primitive package
sourcehawk Mar 22, 2026
42ff3ea
Add Role primitive documentation
sourcehawk Mar 22, 2026
49a5c22
Add Role primitive example
sourcehawk Mar 22, 2026
87d7705
Add resource-level tests for Role primitive
sourcehawk Mar 22, 2026
373c92c
Add Role primitive to built-in primitives index
sourcehawk Mar 22, 2026
33c2e67
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
eb1bcf2
address PR review: nil-guard PolicyRulesEditor and fix docs
sourcehawk Mar 23, 2026
5b1e402
fix: role mutator constructor should not call beginFeature
sourcehawk Mar 23, 2026
e7a6ce9
fix: export BeginFeature to satisfy FeatureMutator interface
sourcehawk Mar 23, 2026
0768240
Merge remote-tracking branch 'origin/main' into feature/role-primitive
sourcehawk Mar 24, 2026
8717a99
style: apply markdown formatting to role primitive docs
sourcehawk Mar 24, 2026
3991c1b
Merge remote-tracking branch 'origin/main' into feature/role-primitive
sourcehawk Mar 24, 2026
3e31e00
fix: do not initialize empty plan on role mutator construction
sourcehawk Mar 24, 2026
a280ace
Merge remote-tracking branch 'origin/main' into feature/role-primitive
sourcehawk Mar 25, 2026
88b350a
refactor: remove field applicators and flavors from role primitive
sourcehawk Mar 25, 2026
5ebcb23
docs: address PR review — add PolicyRulesEditor to editors table and …
sourcehawk Mar 25, 2026
8104f94
chore: add role-primitive binary to .gitignore
sourcehawk Mar 25, 2026
2126fc8
fix: add requireActive guard to role Mutator edit methods
sourcehawk Mar 25, 2026
a932e18
Merge origin/main into feature/role-primitive
sourcehawk Mar 25, 2026
543f2c5
Merge branch 'main' into feature/role-primitive
sourcehawk Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*.dll
*.so
*.dylib
role-primitive

# Test binary, built with `go test -c`
*.test
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
Comment on lines 127 to +130
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description checklist states "Does not modify shared files", but this change updates shared repository files (e.g. Makefile, docs, and shared mutation editors). Please update the PR description/checklist to reflect the actual scope so reviewers have an accurate contract for what the PR changes.

Copilot uses AI. Check for mistakes.
go run ./examples/pdb-primitive/.
go run ./examples/daemonset-primitive/.
go run ./examples/hpa-primitive/.
Expand Down
1 change: 1 addition & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
270 changes: 270 additions & 0 deletions docs/primitives/role.md
Original file line number Diff line number Diff line change
@@ -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 |

Comment on lines +9 to +14
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Capabilities table is malformed markdown: the header and rows start with || (double pipe), which creates an extra empty column and renders inconsistently. Please use the single-pipe table format used in the other primitive docs (e.g. | Capability | Detail |).

Copilot uses AI. Check for mistakes.
## 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{
Comment on lines +134 to +142
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PolicyRulesEditor is introduced as a new mutation editor, but the central editors list in docs/primitives.md ("Mutation Editors" table) does not include it. Please add a row for PolicyRulesEditor there so the global documentation stays complete and discoverable.

Copilot uses AI. Check for mistakes.
{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.
31 changes: 31 additions & 0 deletions examples/role-primitive/README.md
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repo’s make run-examples target runs a hard-coded list of examples, and it currently won’t execute the new role-primitive example. To keep the example suite complete, add go run ./examples/role-primitive/. to the run-examples target in the Makefile.

Suggested change
go run examples/role-primitive/main.go
go run ./examples/role-primitive/.

Copilot uses AI. Check for mistakes.
```

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.
54 changes: 54 additions & 0 deletions examples/role-primitive/app/controller.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading