Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d366d3c
Add BindingSubjectsEditor for RoleBinding/ClusterRoleBinding subjects
sourcehawk Mar 22, 2026
7765194
Add RoleBinding primitive package
sourcehawk Mar 22, 2026
8611f3f
Add RoleBinding primitive documentation
sourcehawk Mar 22, 2026
a9bff6f
Add RoleBinding primitive example
sourcehawk Mar 22, 2026
802ef5e
Fix DefaultFieldApplicator ResourceVersion check and add tests
sourcehawk Mar 22, 2026
de75160
Clarify Feature field docs to match other primitive conventions
sourcehawk Mar 22, 2026
5442539
Add RoleRef validation in Build() and nil pointer guard in BindingSub…
sourcehawk Mar 22, 2026
c34b773
Preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
e31c6db
add to readme
sourcehawk Mar 22, 2026
dcd7f17
Zero trailing slice elements in RemoveSubject to avoid retaining refe…
sourcehawk Mar 23, 2026
60c22e3
Update rolebinding docs to document server-managed field preservation
sourcehawk Mar 23, 2026
cafef24
Remove beginFeature from primitive mutator constructors
sourcehawk Mar 23, 2026
d6dfad2
Align mutators with exported BeginFeature interface
sourcehawk Mar 23, 2026
601e90d
Merge remote-tracking branch 'origin/main' into feature/rolebinding-p…
sourcehawk Mar 24, 2026
183d2e1
Format rolebinding markdown files with prettier
sourcehawk Mar 24, 2026
e105191
Merge remote-tracking branch 'origin/main' into feature/rolebinding-p…
sourcehawk Mar 24, 2026
1098190
Do not initialize an empty plan on rolebinding mutator construction
sourcehawk Mar 24, 2026
174c95f
Add rolebinding primitive to built-in primitives index
sourcehawk Mar 24, 2026
6eb5170
Add rolebinding to run-examples and add Resource-level tests
sourcehawk Mar 24, 2026
8b70d95
Add BindingSubjectsEditor to Mutation Editors table in docs
sourcehawk Mar 24, 2026
6d776bf
Clarify roleRef preservation semantics in Mutate() docstring
sourcehawk Mar 24, 2026
858062a
Fix markdown table alignment in Mutation Editors section
sourcehawk Mar 24, 2026
2dcaf0f
Remove field applicators and flavors in favor of Server-Side Apply
sourcehawk Mar 25, 2026
8e67874
Merge main into feature/rolebinding-primitive, preferring main
sourcehawk Mar 25, 2026
7447dda
Fix misleading comments about SSA annotation persistence and roleRef …
sourcehawk Mar 25, 2026
44c8a56
fix deployment primitive example
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 @@ -122,6 +122,7 @@ build-examples: ## Build all example binaries.
run-examples: ## Run all examples to verify they execute without error.
go run ./examples/deployment-primitive/.
go run ./examples/configmap-primitive/.
go run ./examples/rolebinding-primitive/.
go run ./examples/custom-resource-implementation/.

##@ E2E Testing
Expand Down
204 changes: 204 additions & 0 deletions docs/primitives/rolebinding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# RoleBinding Primitive

The `rolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes `RoleBinding`
resources. It integrates with the component lifecycle and provides a structured mutation API for managing subjects 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 subjects and object metadata, with a raw escape hatch for free-form access |
| **Immutable roleRef** | `roleRef` must be set on the base object and cannot be changed after creation (requires delete/recreate) |
| **Data extraction** | Reads generated or updated values back from the reconciled RoleBinding after each sync cycle |

## Building a RoleBinding Primitive

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

base := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "app-rolebinding",
Namespace: owner.Namespace,
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: "app-role",
},
Subjects: []rbacv1.Subject{
{Kind: "ServiceAccount", Name: "app-sa", Namespace: owner.Namespace},
},
}

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

`roleRef` must be set on the base object passed to `NewBuilder`. It is immutable after creation in Kubernetes and is not
modifiable via the mutation API.

## Mutations

Mutations are the primary mechanism for modifying a `RoleBinding` 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 AddServiceAccountMutation(version, saName, saNamespace string) rolebinding.Mutation {
return rolebinding.Mutation{
Name: "add-service-account",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *rolebinding.Mutator) error {
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureSubject(rbacv1.Subject{
Kind: "ServiceAccount",
Name: saName,
Namespace: saNamespace,
})
return nil
})
return nil
},
}
}
```

### Boolean-gated mutations

```go
func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutation {
return rolebinding.Mutation{
Name: "monitoring-subject",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *rolebinding.Mutator) error {
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureSubject(rbacv1.Subject{
Kind: "ServiceAccount",
Name: "monitoring-agent",
Namespace: "monitoring",
})
return nil
})
return nil
},
}
}
```

### Version-gated mutations

```go
var legacyConstraint = mustSemverConstraint("< 2.0.0")

func LegacySubjectMutation(version string) rolebinding.Mutation {
return rolebinding.Mutation{
Name: "legacy-subject",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{legacyConstraint},
),
Mutate: func(m *rolebinding.Mutator) error {
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureSubject(rbacv1.Subject{
Kind: "User",
Name: "legacy-admin",
})
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 RoleBinding |
| 2 | Subject edits | `.subjects` entries via BindingSubjectsEditor |

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

## Editors

### BindingSubjectsEditor

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

```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureSubject(rbacv1.Subject{
Kind: "ServiceAccount",
Name: "my-sa",
Namespace: "default",
})
e.RemoveSubject("ServiceAccount", "old-sa", "default")
return nil
})
```

#### EnsureSubject

`EnsureSubject` upserts a subject by the combination of `Kind`, `Name`, and `Namespace`. If a matching subject already
exists, it is replaced; otherwise the new subject is appended.

#### RemoveSubject

`RemoveSubject` removes a subject identified by kind, name, and namespace. It is a no-op if no matching subject exists.

#### Raw

`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` slice for free-form access when the structured methods
are insufficient:

```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
raw := e.Raw()
*raw = append(*raw, rbacv1.Subject{
Kind: "Group",
Name: "developers",
})
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("operator.example.io/version", version)
return nil
})
```

## Guidance

**Set `roleRef` on the base object, not via mutations.** Kubernetes makes `roleRef` immutable after creation. To change
a `roleRef`, delete and recreate the RoleBinding.

**`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 `EnsureSubject` for idempotent subject management.** `EnsureSubject` upserts by Kind+Name+Namespace, making it
safe to call on every reconciliation without creating duplicates.

**Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first.
2 changes: 0 additions & 2 deletions examples/configmap-primitive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ to manage a Kubernetes ConfigMap as a component of a larger application, utilisi
- **Base Construction**: Initializing a ConfigMap with basic metadata.
- **Feature Mutations**: Composing YAML configuration from independent, feature-gated mutations using `MergeYAML`.
- **Metadata Mutations**: Setting version labels on the ConfigMap via `EditObjectMetadata`.
- **Field Flavors**: Preserving `.data` entries managed by external controllers using `PreserveExternalEntries`.
- **Data Extraction**: Harvesting ConfigMap entries after each reconcile cycle.

## Directory Structure
Expand All @@ -15,7 +14,6 @@ to manage a Kubernetes ConfigMap as a component of a larger application, utilisi
`examples/shared/app`.
- `features/`: Contains modular feature definitions:
- `mutations.go`: base config, version labelling, and feature-gated tracing and metrics sections.
- `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries.
- `resources/`: Contains the central `NewConfigMapResource` factory that assembles all features using
`configmap.Builder`.
- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client.
Expand Down
3 changes: 0 additions & 3 deletions examples/deployment-primitive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ to manage a Kubernetes Deployment as a component of a larger application, utiliz
- **Base Construction**: Initializing a Deployment with basic metadata and spec.
- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the
`Mutator`.
- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual
edits).
- **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health
assessment during rollouts (`GraceStatus`).
- **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations.
Expand All @@ -18,7 +16,6 @@ to manage a Kubernetes Deployment as a component of a larger application, utiliz
- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework.
- `features/`: Contains modular feature definitions:
- `mutations.go`: sidecar injection, env vars, and version-based image updates.
- `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields.
- `status.go`: implementation of custom handlers for convergence, grace, and suspension.
- `resources/`: Contains the central `NewDeploymentResource` factory that assembles all features using the
`deployment.Builder`.
Expand Down
16 changes: 5 additions & 11 deletions examples/deployment-primitive/features/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package features

import (
"fmt"
"time"

"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"github.com/sourcehawk/operator-component-framework/pkg/mutation/editors"
Expand Down Expand Up @@ -53,17 +52,12 @@ func CustomSuspendMutation() func(*deployment.Mutator) error {
return err
}

// Additionally, record when the deployment was first suspended.
// Only set if absent so the timestamp is stable across reconcile cycles.
// This works because PreserveCurrentAnnotations (registered as a flavor)
// restores live-cluster annotations before mutations run — so on the second
// and subsequent reconciles while suspended the annotation is already present
// and is left unchanged.
// Additionally, mark the deployment as suspended via an annotation.
// Note: mutators operate on a freshly-built desired object each reconcile,
// not the live server state. Stateful comparisons (e.g., "only set this if
// it doesn't already exist") won't work here since the object is always new.
m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error {
raw := meta.Raw()
if _, exists := raw.Annotations["example.io/suspended-at"]; !exists {
meta.EnsureAnnotation("example.io/suspended-at", time.Now().UTC().Format(time.RFC3339))
}
meta.EnsureAnnotation("example.io/suspended", "true")
return nil
})

Expand Down
32 changes: 32 additions & 0 deletions examples/rolebinding-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# RoleBinding Primitive Example

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

- **Base Construction**: Initializing a RoleBinding with an immutable `roleRef` and basic metadata.
- **Feature Mutations**: Composing subjects from independent, feature-gated mutations using `EditSubjects`.
- **Metadata Mutations**: Setting version labels on the RoleBinding via `EditObjectMetadata`.
- **Data Extraction**: Inspecting subjects and roleRef 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`: base subject binding, version labelling, and feature-gated monitoring subject.
- `resources/`: Contains the central `NewRoleBindingResource` factory that assembles all features using
`rolebinding.Builder`.
- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client.

## Running the Example

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

This will:

1. Initialize a fake Kubernetes client.
2. Create an `ExampleApp` owner object.
3. Reconcile through three spec variations, printing the subjects after each cycle.
4. Print the resulting status conditions.
54 changes: 54 additions & 0 deletions examples/rolebinding-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 rolebinding 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

// NewRoleBindingResource is a factory function to create the rolebinding resource.
// This allows us to inject the resource construction logic.
NewRoleBindingResource 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 rolebinding resource for this owner.
rbResource, err := r.NewRoleBindingResource(owner)
if err != nil {
return err
}

// 2. Build the component that manages the rolebinding.
comp, err := component.NewComponentBuilder().
WithName("example-app").
WithConditionType("AppReady").
WithResource(rbResource, 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