Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3fc108f
Add PolicyRulesEditor for RBAC rule mutations
sourcehawk Mar 22, 2026
163c5d4
Add ClusterRole primitive package
sourcehawk Mar 22, 2026
0b5ce0d
Add ClusterRole primitive documentation
sourcehawk Mar 22, 2026
e57a631
Add ClusterRole primitive example
sourcehawk Mar 22, 2026
455871c
address copilot comments
sourcehawk Mar 22, 2026
7c43752
Address Copilot review comments on ClusterRole primitive
sourcehawk Mar 22, 2026
a0d4f8d
Guard against nil rules pointer in NewPolicyRulesEditor
sourcehawk Mar 22, 2026
488a4ca
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
85d940c
Address Copilot review: remove duplicate test, document ownership lim…
sourcehawk Mar 23, 2026
3acf33c
Address Copilot review: per-feature mutator, ownership docs, Preserve…
sourcehawk Mar 23, 2026
d60ed11
Update ClusterRole docs to describe preserved fields in DefaultFieldA…
sourcehawk Mar 23, 2026
ff378c1
fix: clusterrole mutator constructor should not call beginFeature
sourcehawk Mar 23, 2026
a604af1
fix: address Copilot review comments on ClusterRole primitive
sourcehawk Mar 23, 2026
69f9bd3
fix: export BeginFeature() to satisfy FeatureMutator interface
sourcehawk Mar 23, 2026
f51c934
style: format markdown files with prettier
sourcehawk Mar 24, 2026
717ddca
docs: clarify PolicyRule comparison uses order-insensitive set semantics
sourcehawk Mar 24, 2026
cf4218a
refactor: wrap generic.StaticBuilder in clusterrole.Builder
sourcehawk Mar 24, 2026
8d8bdfc
fix: address PR review — nil Feature for unconditional mutations, upd…
sourcehawk Mar 24, 2026
ddc6e74
fix: address Copilot review — broaden PolicyRulesEditor docs, remove …
sourcehawk Mar 24, 2026
84f5260
style: format primitives.md with prettier
sourcehawk Mar 24, 2026
3145f52
fix: address Copilot review — valid mutation in builder test, add res…
sourcehawk Mar 24, 2026
9962187
fix: clarify PolicyRule comparison uses order-insensitive multiset, n…
sourcehawk Mar 24, 2026
1b5aa4d
fix: do not initialize an empty plan on clusterrole mutator construction
sourcehawk Mar 24, 2026
a82b772
refactor: remove field applicators and flavors from clusterrole primi…
sourcehawk Mar 25, 2026
d502741
fix: address copilot review comments on clusterrole primitive PR
sourcehawk Mar 25, 2026
2ff185d
fix: panic on nil pointer in NewPolicyRulesEditor instead of silently…
sourcehawk Mar 25, 2026
48bcb6b
fix: rename misleading test to accurately describe its assertion
sourcehawk Mar 25, 2026
31e8bd2
fix: add explicit panic when BeginFeature not called before mutations
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
28 changes: 17 additions & 11 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) |

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.

make run-examples currently runs a hard-coded list of examples and does not include the new clusterrole-primitive example (verified in Makefile:122-126). To keep CI/local smoke runs covering all examples, add go run ./examples/clusterrole-primitive/. to the run-examples target (or update docs/README to clarify it’s intentionally excluded).

Suggested change
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.

Copilot uses AI. Check for mistakes.
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

Expand Down
292 changes: 292 additions & 0 deletions docs/primitives/clusterrole.md
Original file line number Diff line number Diff line change
@@ -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/<name>` |
| **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.
32 changes: 32 additions & 0 deletions examples/clusterrole-primitive/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading