Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b50c0ad
Add BindingSubjectsEditor for RoleBinding/ClusterRoleBinding subjects
sourcehawk Mar 22, 2026
6141584
Add ClusterRoleBinding primitive package
sourcehawk Mar 22, 2026
8828947
Add ClusterRoleBinding primitive documentation
sourcehawk Mar 22, 2026
daee41d
Add ClusterRoleBinding primitive example
sourcehawk Mar 22, 2026
c20bed0
format
sourcehawk Mar 22, 2026
28ddf45
address copilot comments
sourcehawk Mar 22, 2026
c27ce01
Address Copilot review: namespace validation and StaticResource type
sourcehawk Mar 22, 2026
eebdf9d
Preserve ResourceVersion in DefaultFieldApplicator on updates
sourcehawk Mar 22, 2026
a527376
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
a9ce977
Refactor builder to use generic.NewStaticBuilder and address review c…
sourcehawk Mar 23, 2026
82dd2d8
Update ClusterRoleBinding docs to document server-managed field prese…
sourcehawk Mar 23, 2026
1a69ad8
Fix ClusterRoleBinding mutator to not call beginFeature in constructor
sourcehawk Mar 23, 2026
189eeab
Add ClusterRoleBinding to primitives index and run-examples target
sourcehawk Mar 23, 2026
d142266
Export BeginFeature() to match updated FeatureMutator interface
sourcehawk Mar 23, 2026
0d026c2
Merge remote-tracking branch 'origin/main' into feature/clusterrolebi…
sourcehawk Mar 24, 2026
2f6e17b
Format markdown files with prettier
sourcehawk Mar 24, 2026
4cbc0c0
Merge remote-tracking branch 'origin/main' into feature/clusterrolebi…
sourcehawk Mar 24, 2026
a015957
Do not initialize an empty plan on ClusterRoleBinding mutator constru…
sourcehawk Mar 24, 2026
660628d
Fix BindingSubjectsEditor docs to match actual API
sourcehawk Mar 24, 2026
afcf01a
fix lint
sourcehawk Mar 24, 2026
a22b721
Merge remote-tracking branch 'origin/main' into feature/clusterrolebi…
sourcehawk Mar 25, 2026
214d7f9
Remove field applicators and flavors from clusterrolebinding primitive
sourcehawk Mar 25, 2026
a1cb596
Fix example to remove references to deleted field applicator API
sourcehawk Mar 25, 2026
9e856e7
Remove stale field flavors reference from clusterrolebinding example …
sourcehawk Mar 25, 2026
80a9c00
Address PR review: safe mutator API, side-effect-free Raw(), test imp…
sourcehawk Mar 25, 2026
bb60841
Merge origin/main into feature/clusterrolebinding-primitive
sourcehawk Mar 25, 2026
888e1f3
resolve
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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ run-examples: ## Run all examples to verify they execute without error.
go run ./examples/configmap-primitive/.
go run ./examples/rolebinding-primitive/.
go run ./examples/custom-resource-implementation/.
go run ./examples/clusterrolebinding-primitive/.

##@ E2E Testing

Expand Down
32 changes: 17 additions & 15 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,15 @@ 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 |
| `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, 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 |
| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — ensure, remove, raw |
| `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 @@ -142,14 +143,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) |
| `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.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) |
| `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.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
The `clusterrole` and `clusterrolebinding` primitives are exercised by their respective examples. Because they require
cluster-scoped RBAC and may need elevated permissions, they are intentionally not included in the default
`make run-examples` target used for CI/local smoke runs.

## Usage Examples
Expand Down
197 changes: 197 additions & 0 deletions docs/primitives/clusterrolebinding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# ClusterRoleBinding Primitive

The `clusterrolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes
`ClusterRoleBinding` resources. It integrates with the component lifecycle and provides a structured mutation API for
managing `.subjects` entries and object metadata.

## Capabilities

| Capability | Detail |
| --------------------- | ------------------------------------------------------------------------------------------------------------ |
| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state |
| **Cluster-scoped** | Cluster-scoped resource — Build() validates Name and requires metadata.namespace to be empty (errors if set) |
| **Mutation pipeline** | Typed editors for `.subjects` entries and object metadata, with a raw escape hatch for free-form access |
| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRoleBinding after each sync cycle |

## Building a ClusterRoleBinding Primitive

```go
import "github.com/sourcehawk/operator-component-framework/pkg/primitives/clusterrolebinding"

base := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "app-cluster-admin",
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "ClusterRole",
Name: "cluster-admin",
},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: "app-sa",
Namespace: "default",
},
},
}

resource, err := clusterrolebinding.NewBuilder(base).
WithMutation(MySubjectMutation(owner.Spec.Version)).
Build()
```

## Mutations

Mutations are the primary mechanism for modifying a `ClusterRoleBinding` 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 MySubjectMutation(version string) clusterrolebinding.Mutation {
return clusterrolebinding.Mutation{
Name: "my-subjects",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *clusterrolebinding.Mutator) error {
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureServiceAccount("my-sa", "default")
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 ConditionalSubjectMutation(version string, addExtraSubject bool) clusterrolebinding.Mutation {
return clusterrolebinding.Mutation{
Name: "conditional-subject",
Feature: feature.NewResourceFeature(version, nil).When(addExtraSubject),
Mutate: func(m *clusterrolebinding.Mutator) error {
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureServiceAccount("extra-sa", "monitoring")
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 `ClusterRoleBinding` |
| 2 | Subject edits | `.subjects` entries — Add, Remove, EnsureServiceAccount |

Within each category, edits are applied in their registration order. Later features observe the ClusterRoleBinding as
modified by all previous features.

## Editors

### BindingSubjectsEditor

The primary API for modifying `.subjects` entries. Use `m.EditSubjects` for full control:

```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureServiceAccount("my-sa", "default")
e.RemoveSubject("User", "old-user", "")
return nil
})
```

#### EnsureSubject

Upserts a subject in the subjects list. A subject is identified by the combination of Kind, Name, and Namespace. If a
matching subject already exists it is replaced; otherwise the new subject is appended:

```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureSubject(rbacv1.Subject{
Kind: "Group",
Name: "developers",
APIGroup: "rbac.authorization.k8s.io",
})
return nil
})
```

#### EnsureServiceAccount

Convenience method that ensures a `ServiceAccount` subject with the given name and namespace exists:

```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureServiceAccount("app-sa", "production")
return nil
})
```

#### RemoveSubject and RemoveServiceAccount

`RemoveSubject` removes a subject matching the given kind, name, and namespace. `RemoveServiceAccount` is a convenience
wrapper for removing `ServiceAccount` subjects:

```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.RemoveSubject("User", "old-user", "")
e.RemoveServiceAccount("deprecated-sa", "default")
return nil
})
```

#### Raw Escape Hatch

`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` for free-form editing:

```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
raw := e.Raw()
for i := range *raw {
if (*raw)[i].Kind == "ServiceAccount" {
(*raw)[i].Namespace = "updated-namespace"
}
}
return nil
})
```

### ObjectMetaEditor

Modifies labels and annotations via `m.EditObjectMetadata`.

Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.

```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/managed-by", "my-operator")
e.EnsureAnnotation("description", "cluster-wide admin binding")
return nil
})
```

## 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.

**Cluster-scoped resources have no namespace.** Unlike namespaced primitives, ClusterRoleBinding does not require or
validate a namespace. The identity format is `rbac.authorization.k8s.io/v1/ClusterRoleBinding/<name>`.

**Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first.
33 changes: 33 additions & 0 deletions examples/clusterrolebinding-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ClusterRoleBinding Primitive Example

This example demonstrates the usage of the `clusterrolebinding` primitive within the operator component framework. It
shows how to manage a Kubernetes ClusterRoleBinding as a component of a larger application, utilising features like:

- **Base Construction**: Initializing a ClusterRoleBinding with a roleRef and base subjects.
- **Feature Mutations**: Adding subjects conditionally via feature-gated mutations using `EditSubjects`.
- **Metadata Mutations**: Setting version labels on the ClusterRoleBinding via `EditObjectMetadata`.
- **Data Extraction**: Inspecting ClusterRoleBinding state 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`: version labelling and feature-gated monitoring subject addition.
- `resources/`: Contains the central `NewClusterRoleBindingResource` factory that assembles all features using
`clusterrolebinding.Builder`.
- `main.go`: A standalone entry point that demonstrates building and mutating a ClusterRoleBinding through multiple spec
variations.

## Running the Example

```bash
go run examples/clusterrolebinding-primitive/main.go
```

This will:

1. Create an in-memory `ExampleApp` owner object.
2. For each of three spec variations, build a fresh resource and apply mutations to a simulated current
ClusterRoleBinding.
3. Print the reconciled ClusterRoleBinding state (labels, roleRef, subjects) after each mutation cycle.
65 changes: 65 additions & 0 deletions examples/clusterrolebinding-primitive/app/controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Package app provides a sample controller using the clusterrolebinding primitive.
//
// Note: ClusterRoleBinding is cluster-scoped. When the owner is namespace-scoped,
// the component framework automatically skips setting a controller owner reference
// (since Kubernetes does not allow cross-scope owner references) and logs an info
// message. This means the ClusterRoleBinding will not be garbage-collected when
// the owner is deleted — operators should implement their own cleanup logic if
// needed. In production, using a cluster-scoped CRD as the owner avoids this
// limitation entirely.
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.
//
// When the owner is namespace-scoped, the framework skips the controller owner
// reference for cluster-scoped resources. Use a cluster-scoped owner CRD in
// production if automatic garbage collection is required.
type ExampleController struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
Metrics component.Recorder

// NewClusterRoleBindingResource is a factory function to create the clusterrolebinding resource.
NewClusterRoleBindingResource 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 clusterrolebinding resource for this owner.
crbResource, err := r.NewClusterRoleBindingResource(owner)
if err != nil {
return err
}

// 2. Build the component that manages the clusterrolebinding.
comp, err := component.NewComponentBuilder().
WithName("example-app").
WithConditionType("AppReady").
WithResource(crbResource, 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)
}
40 changes: 40 additions & 0 deletions examples/clusterrolebinding-primitive/features/mutations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Package features provides sample mutations for the clusterrolebinding 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/clusterrolebinding"
)

// VersionLabelMutation sets the app.kubernetes.io/version label on the
// ClusterRoleBinding. It is always enabled.
func VersionLabelMutation(version string) clusterrolebinding.Mutation {
return clusterrolebinding.Mutation{
Name: "version-label",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *clusterrolebinding.Mutator) error {
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
return nil
})
return nil
},
}
}

// MonitoringSubjectMutation adds a monitoring service account as a subject
// when metrics are enabled.
func MonitoringSubjectMutation(version string, enableMetrics bool) clusterrolebinding.Mutation {
return clusterrolebinding.Mutation{
Name: "monitoring-subject",
Feature: feature.NewResourceFeature(version, nil).When(enableMetrics),
Mutate: func(m *clusterrolebinding.Mutator) error {
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureServiceAccount("monitoring-agent", "monitoring")
return nil
})
return nil
},
}
}
Loading
Loading