Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f42fe26
Add serviceaccount primitive package
sourcehawk Mar 22, 2026
36fa790
Add serviceaccount primitive documentation
sourcehawk Mar 22, 2026
dececa1
Add serviceaccount primitive example
sourcehawk Mar 22, 2026
64d398f
Fix gofmt formatting in serviceaccount example
sourcehawk Mar 22, 2026
8c2c6db
Address Copilot review comments on serviceaccount example
sourcehawk Mar 22, 2026
32098f4
Preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
95ed3b3
fix duplicate calls to beginFeature in serviceaccount mutator constru…
sourcehawk Mar 23, 2026
af10313
export BeginFeature and use nil Feature for unconditional mutations
sourcehawk Mar 23, 2026
9328bb2
fix lint error and update serviceaccount docs after rebase
sourcehawk Mar 23, 2026
8f5f14d
Merge remote-tracking branch 'origin/main' into feature/serviceaccoun…
sourcehawk Mar 24, 2026
236bfba
format serviceaccount markdown files with prettier
sourcehawk Mar 24, 2026
ea07303
add serviceaccount to built-in primitives table in docs
sourcehawk Mar 24, 2026
5925f27
format docs/primitives.md with prettier to fix lint
sourcehawk Mar 24, 2026
c8ec914
address copilot review: preserve SA secrets, snapshot automount bool
sourcehawk Mar 24, 2026
4d18ac9
address copilot review: document .secrets ownership, expand test cove…
sourcehawk Mar 24, 2026
228fc59
fix doc comment: ServiceAccount has no Spec, .Secrets is top-level
sourcehawk Mar 24, 2026
b23afdc
Merge remote-tracking branch 'origin/main' into feature/serviceaccoun…
sourcehawk Mar 24, 2026
0c05584
do not initialize an empty plan on serviceaccount mutator construction
sourcehawk Mar 24, 2026
c14f026
Merge remote-tracking branch 'origin/main' into feature/serviceaccoun…
sourcehawk Mar 25, 2026
cd27004
remove field applicators and flavors from serviceaccount primitive
sourcehawk Mar 25, 2026
2ee1b8e
remove references to deleted field applicators in serviceaccount example
sourcehawk Mar 25, 2026
ecb6761
address copilot review: remove stale flavor references, add SA exampl…
sourcehawk Mar 25, 2026
1f3469b
add ensureActive guard to serviceaccount mutator methods
sourcehawk Mar 25, 2026
87eceaa
replace panic with lazy BeginFeature in serviceaccount ensureActive g…
sourcehawk Mar 25, 2026
20e3236
Merge branch 'main' into feature/serviceaccount-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 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/serviceaccount-primitive/.
go run ./examples/secret-primitive/.
go run ./examples/statefulset-primitive/.
go run ./examples/replicaset-primitive/.
Expand Down
1 change: 1 addition & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ have been applied. This means a single mutation can safely add a container and t
| `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) |
| `pkg/primitives/serviceaccount` | Static | [serviceaccount.md](primitives/serviceaccount.md) |
| `pkg/primitives/service` | Integration | [service.md](primitives/service.md) |
| `pkg/primitives/pv` | Integration | [pv.md](primitives/pv.md) |
| `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) |
Expand Down
194 changes: 194 additions & 0 deletions docs/primitives/serviceaccount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# ServiceAccount Primitive

The `serviceaccount` primitive is the framework's built-in static abstraction for managing Kubernetes `ServiceAccount`
resources. It integrates with the component lifecycle and provides a structured mutation API for managing image pull
secrets, the automount token flag, and object metadata.

## Capabilities

| Capability | Detail |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state |
| **Mutation pipeline** | Direct mutator methods for `.imagePullSecrets` and `.automountServiceAccountToken`, plus metadata editors |
| **Data extraction** | Reads generated or updated values back from the reconciled ServiceAccount after each sync cycle |

## Building a ServiceAccount Primitive

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

base := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "app-sa",
Namespace: owner.Namespace,
},
}

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

## Mutations

Mutations are the primary mechanism for modifying a `ServiceAccount` beyond its baseline. Each mutation is a named
function that receives a `*Mutator` and records edit intent through direct methods.

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) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "my-feature",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *serviceaccount.Mutator) error {
m.EnsureImagePullSecret("my-registry")
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 PrivateRegistryMutation(version string, usePrivateRegistry bool) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "private-registry",
Feature: feature.NewResourceFeature(version, nil).When(usePrivateRegistry),
Mutate: func(m *serviceaccount.Mutator) error {
m.EnsureImagePullSecret("private-registry-creds")
return nil
},
}
}
```

### Version-gated mutations

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

func LegacyTokenMutation(version string) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "legacy-token",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{legacyConstraint},
),
Mutate: func(m *serviceaccount.Mutator) error {
v := true
m.SetAutomountServiceAccountToken(&v)
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 `ServiceAccount` |
| 2 | Image pull secret edits | `.imagePullSecrets` — EnsureImagePullSecret, RemoveImagePullSecret |
| 3 | Automount edits | `.automountServiceAccountToken` — SetAutomountServiceAccountToken |

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

## Mutator Methods

### EnsureImagePullSecret

Adds a named image pull secret to `.imagePullSecrets` if not already present. Idempotent — calling it with an
already-present name is a no-op.

```go
m.EnsureImagePullSecret("my-registry-creds")
```

### RemoveImagePullSecret

Removes a named image pull secret from `.imagePullSecrets`. It is a no-op if the secret is not present.

```go
m.RemoveImagePullSecret("old-registry-creds")
```

### SetAutomountServiceAccountToken

Sets `.automountServiceAccountToken` to the provided value. Pass `nil` to unset the field.

```go
v := false
m.SetAutomountServiceAccountToken(&v)
```

### EditObjectMetadata

Modifies labels and annotations via `editors.ObjectMetaEditor`.

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 ServiceAccount

```go
func BaseImagePullSecretMutation(version string) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "base-pull-secret",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *serviceaccount.Mutator) error {
m.EnsureImagePullSecret("default-registry")
return nil
},
}
}

func DisableAutomountMutation(version string, disableAutomount bool) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "disable-automount",
Feature: feature.NewResourceFeature(version, nil).When(disableAutomount),
Mutate: func(m *serviceaccount.Mutator) error {
v := false
m.SetAutomountServiceAccountToken(&v)
return nil
},
}
}

resource, err := serviceaccount.NewBuilder(base).
WithMutation(BaseImagePullSecretMutation(owner.Spec.Version)).
WithMutation(DisableAutomountMutation(owner.Spec.Version, owner.Spec.DisableAutomount)).
Build()
```

When `DisableAutomount` is true, `.automountServiceAccountToken` is set to `false`. When the condition is not met, the
field is left at its baseline value. 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 `EnsureImagePullSecret` for idempotent secret registration.** Multiple features can independently ensure their
required pull secrets without conflicting with each other.

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

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

- **Base Construction**: Initializing a ServiceAccount with basic metadata.
- **Feature Mutations**: Composing image pull secrets and automount settings from independent, feature-gated mutations.
- **Metadata Mutations**: Setting version labels on the ServiceAccount via `EditObjectMetadata`.
- **Data Extraction**: Harvesting ServiceAccount fields 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, image pull secrets, private registry, and automount control.
- `resources/`: Contains the central `NewServiceAccountResource` factory that assembles all features using
`serviceaccount.Builder`.
- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client.

## Running the Example

```bash
go run examples/serviceaccount-primitive/main.go
```
Comment on lines +21 to +25
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 set of examples and does not include examples/serviceaccount-primitive (verified in Makefile). If the intent is to keep run-examples validating all examples, this new example should be added there so it is exercised in the same way as the existing deployment/configmap examples.

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 ServiceAccount's image pull secrets and automount settings after
each cycle.
4. Print the resulting status conditions.
54 changes: 54 additions & 0 deletions examples/serviceaccount-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 serviceaccount 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

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

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

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

// ImagePullSecretMutation ensures the default registry pull secret is attached
// to the ServiceAccount. It is always enabled.
func ImagePullSecretMutation(_ string) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "image-pull-secret",
Feature: nil,
Mutate: func(m *serviceaccount.Mutator) error {
m.EnsureImagePullSecret("default-registry-creds")
return nil
},
}
}

// PrivateRegistryMutation adds a private registry pull secret to the ServiceAccount.
// It is enabled when usePrivateRegistry is true.
func PrivateRegistryMutation(version string, usePrivateRegistry bool) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "private-registry",
Feature: feature.NewResourceFeature(version, nil).When(usePrivateRegistry),
Mutate: func(m *serviceaccount.Mutator) error {
m.EnsureImagePullSecret("private-registry-creds")
return nil
},
}
}

// DisableAutomountMutation disables automatic mounting of the service account token.
// It is enabled when disableAutomount is true.
func DisableAutomountMutation(version string, disableAutomount bool) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "disable-automount",
Feature: feature.NewResourceFeature(version, nil).When(disableAutomount),
Mutate: func(m *serviceaccount.Mutator) error {
v := false
m.SetAutomountServiceAccountToken(&v)
return nil
},
}
}
Loading
Loading