Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
877105f
Add StatefulSetSpecEditor tests
sourcehawk Mar 22, 2026
a20aaf4
Implement StatefulSet primitive package
sourcehawk Mar 22, 2026
dff7299
Add StatefulSet primitive documentation
sourcehawk Mar 22, 2026
ccbe198
Add StatefulSet primitive example
sourcehawk Mar 22, 2026
8054491
Fix formatting in statefulset mutator
sourcehawk Mar 22, 2026
f458085
commit statefulsetspec file
sourcehawk Mar 22, 2026
77f2693
Fix Copilot review comments on StatefulSet primitive
sourcehawk Mar 22, 2026
490c612
Fix comment typos in StatefulSet resource docs
sourcehawk Mar 22, 2026
90b402f
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
d6a38e3
Skip VolumeClaimTemplate mutations on existing StatefulSets
sourcehawk Mar 22, 2026
1718512
Fix range variable pointer bug and align docs with implementation
sourcehawk Mar 23, 2026
25c31c6
Fix statefulset mutator constructor to not call beginFeature
sourcehawk Mar 23, 2026
cde2525
Add resource-level tests for StatefulSet primitive
sourcehawk Mar 23, 2026
227f800
Add PreserveStatus call to DefaultFieldApplicator
sourcehawk Mar 23, 2026
5657a88
Export BeginFeature() to satisfy FeatureMutator interface from main
sourcehawk Mar 23, 2026
65cd2c5
Merge remote-tracking branch 'origin/main' into feature/statefulset-p…
sourcehawk Mar 24, 2026
787cdf5
Add ObservedGeneration guard to StatefulSet DefaultConvergingStatusHa…
sourcehawk Mar 24, 2026
e851efd
Use deep-copied VolumeClaimTemplates in DefaultFieldApplicator
sourcehawk Mar 24, 2026
e9b9d12
Add StatefulSet primitive to shared documentation tables
sourcehawk Mar 24, 2026
27f6a63
Fix markdown table alignment in primitives.md
sourcehawk Mar 24, 2026
d7f0248
Address Copilot review: assert Build() errors and fix docstring
sourcehawk Mar 24, 2026
b0bc771
Use no-op Mutate function in WithMutation builder test
sourcehawk Mar 24, 2026
6578643
Add snapshot-based selector matching docs to EditContainers
sourcehawk Mar 24, 2026
ec8729d
Assert Build() errors in flavors_test.go subtests
sourcehawk Mar 24, 2026
1026007
Fix incorrect type name in statefulset example README
sourcehawk Mar 24, 2026
d1876e7
Add statefulset-primitive to run-examples Makefile target
sourcehawk Mar 24, 2026
b57c7e9
Merge remote-tracking branch 'origin/main' into feature/statefulset-p…
sourcehawk Mar 24, 2026
17f93a4
Do not initialize an empty plan on statefulset mutator construction
sourcehawk Mar 24, 2026
b1b97a4
Merge branch 'main' into feature/statefulset-primitive
sourcehawk Mar 25, 2026
aed5593
Remove field applicators and flavors from statefulset primitive
sourcehawk Mar 25, 2026
2370329
Fix gofmt formatting in statefulset resource test
sourcehawk Mar 25, 2026
aa5d005
Fix Prettier formatting in statefulset docs
sourcehawk Mar 25, 2026
0408c40
Fix misleading GoDoc for VolumeClaimTemplate mutator methods
sourcehawk Mar 25, 2026
0283c0d
Merge branch 'main' into feature/statefulset-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/statefulset-primitive/.
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 that 'Shared file modifications are limited to documentation updates', but this PR also changes shared/non-doc files (e.g., Makefile and pkg/mutation/editors/statefulsetspec.go). Either update the PR description/checklist item to reflect the actual scope, or move these non-doc changes into a separate PR if that restriction is important for review/release processes.

Copilot uses AI. Check for mistakes.
go run ./examples/replicaset-primitive/.
go run ./examples/rolebinding-primitive/.
go run ./examples/custom-resource-implementation/.
Comment on lines 122 to 128
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 “Shared file modifications are limited to documentation updates”, but this PR also changes the Makefile (adds the new example to run-examples). Either update the PR description/checklist to reflect this, or move this change out if it’s intentionally out-of-scope.

Copilot uses AI. Check for mistakes.
Expand Down
2 changes: 2 additions & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource:
| `ContainerEditor` | Environment variables, arguments, resource limits, ports |
| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context |
| `DeploymentSpecEditor` | Replicas, update strategy, label selectors |
| `StatefulSetSpecEditor` | Replicas, service name, pod management policy, update strategy |
| `ReplicaSetSpecEditor` | Replicas, min ready seconds |
| `DaemonSetSpecEditor` | Update strategy, min ready seconds, revision history limit |
| `PodDisruptionBudgetSpecEditor` | MinAvailable, MaxUnavailable, selector, eviction policy |
Expand Down Expand Up @@ -151,6 +152,7 @@ have been applied. This means a single mutation can safely add a container and t
| Primitive | Category | Documentation |
| ----------------------------------- | ----------- | --------------------------------------------------------- |
| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) |
| `pkg/primitives/statefulset` | Workload | [statefulset.md](primitives/statefulset.md) |
| `pkg/primitives/replicaset` | Workload | [replicaset.md](primitives/replicaset.md) |
| `pkg/primitives/daemonset` | Workload | [daemonset.md](primitives/daemonset.md) |
| `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) |
Expand Down
320 changes: 320 additions & 0 deletions docs/primitives/statefulset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
# StatefulSet Primitive

The `statefulset` primitive is the framework's built-in workload abstraction for managing Kubernetes `StatefulSet`
resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers,
pod specs, metadata, and volume claim templates.
Comment thread
sourcehawk marked this conversation as resolved.

Comment thread
sourcehawk marked this conversation as resolved.
## Capabilities

| Capability | Detail |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling`; grace handler can mark Down/Degraded |
| **Rollout health** | Surfaces stalled or failing rollouts by transitioning the resource to `Degraded` or `Down` (no grace-period timing) |
| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` |
| **Mutation pipeline** | Typed editors for metadata, statefulset spec, pod spec, containers, and volume claim templates |
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 capability table (and other tables in this new doc) use || prefixes, which typically renders as an extra empty column in Markdown. Switching table rows to start with a single | will avoid the empty-column artifact and improve rendering consistency across Markdown viewers.

Copilot uses AI. Check for mistakes.

## Building a StatefulSet Primitive

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

base := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "db",
Namespace: owner.Namespace,
},
Spec: appsv1.StatefulSetSpec{
ServiceName: "db-headless",
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "db"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "db"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "db", Image: "postgres:15"},
},
},
},
},
}

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

## Mutations

Mutations are the primary mechanism for modifying a `StatefulSet` 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) statefulset.Mutation {
return statefulset.Mutation{
Name: "my-feature",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *statefulset.Mutator) error {
// record edits here
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

Use `When(bool)` to gate a mutation on a runtime condition:

```go
func TracingMutation(version string, enabled bool) statefulset.Mutation {
return statefulset.Mutation{
Name: "tracing",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *statefulset.Mutator) error {
m.EnsureInitContainer(corev1.Container{
Name: "init-config",
Image: "config-init:latest",
})
return nil
},
}
}
```

### Version-gated mutations

Pass a `[]feature.VersionConstraint` to gate on a semver range:

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

func LegacyStorageMutation(version string) statefulset.Mutation {
return statefulset.Mutation{
Name: "legacy-storage",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{legacyConstraint},
),
Mutate: func(m *statefulset.Mutator) error {
Comment thread
sourcehawk marked this conversation as resolved.
m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "STORAGE_BACKEND", Value: "legacy"})
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 grouped into categories and applied in a fixed sequence regardless of the
order they are recorded. This ensures structural consistency across mutations.

| Step | Category | What it affects |
| ---- | -------------------------------- | ----------------------------------------------------------------------- |
| 1 | StatefulSet metadata edits | Labels and annotations on the `StatefulSet` object |
| 2 | StatefulSetSpec edits | Replicas, service name, update strategy, etc. |
| 3 | Pod template metadata edits | Labels and annotations on the pod template |
| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context |
| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` |
| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) |
| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` |
| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) |
| 9 | Volume claim template operations | Adding or removing entries from `spec.volumeClaimTemplates` |

Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
This means a single mutation can add a container and then configure it without selector resolution issues.

## Editors

### StatefulSetSpecEditor

Controls statefulset-level settings via `m.EditStatefulSetSpec`.

Available methods: `SetReplicas`, `SetServiceName`, `SetPodManagementPolicy`, `SetUpdateStrategy`,
`SetRevisionHistoryLimit`, `SetMinReadySeconds`, `SetPersistentVolumeClaimRetentionPolicy`, `Raw`.

```go
m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error {
e.SetReplicas(3)
e.SetServiceName("db-headless")
e.SetPodManagementPolicy(appsv1.ParallelPodManagement)
return nil
})
```

For fields not covered by the typed API, use `Raw()`:

```go
m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error {
e.Raw().UpdateStrategy = appsv1.StatefulSetUpdateStrategy{
Type: appsv1.OnDeleteStatefulSetStrategyType,
}
return nil
})
```

### PodSpecEditor

Manages pod-level configuration via `m.EditPodSpec`.

Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`,
`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`,
`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`.

```go
m.EditPodSpec(func(e *editors.PodSpecEditor) error {
e.SetServiceAccountName("db-sa")
e.EnsureVolume(corev1.Volume{
Name: "config",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: "db-config"},
},
},
})
return nil
})
```

### ContainerEditor

Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
[selector](../primitives.md#container-selectors).

Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.

```go
m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "PGDATA", Value: "/var/lib/postgresql/data"})
e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("2Gi"))
return nil
})
```

### ObjectMetaEditor

Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `StatefulSet` object itself, or
`m.EditPodTemplateMetadata` to target the pod template.

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

```go
// On the StatefulSet itself
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
return nil
})

// On the pod template
m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureAnnotation("prometheus.io/scrape", "true")
return nil
})
```

## Volume Claim Templates

The mutator provides `EnsureVolumeClaimTemplate` and `RemoveVolumeClaimTemplate` for managing persistent storage:

```go
m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{Name: "data"},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse("10Gi"),
},
},
},
})
```

**Important:** `spec.volumeClaimTemplates` is immutable after creation in Kubernetes. These mutation methods are
primarily useful for constructing the initial desired state or when recreating a StatefulSet.

## Convenience Methods

The `Mutator` also exposes convenience wrappers:

| Method | Equivalent to |
| ----------------------------- | ------------------------------------------------------------- |
| `EnsureReplicas(n)` | `EditStatefulSetSpec` → `SetReplicas(n)` |
| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` |
| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` |
| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` |

## Full Example: Database StatefulSet with Storage

```go
func DatabaseMutation(version string) statefulset.Mutation {
return statefulset.Mutation{
Name: "database-storage",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *statefulset.Mutator) error {
// Configure the StatefulSet spec
m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error {
e.SetReplicas(3)
e.SetPodManagementPolicy(appsv1.OrderedReadyPodManagement)
return nil
})

// Add a volume claim template for persistent data
m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{Name: "data"},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse("50Gi"),
},
},
},
})

// Mount the volume in the database container
m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error {
e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{
Name: "data",
MountPath: "/var/lib/postgresql/data",
})
return nil
})

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.

**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
The internal ordering within each mutation handles intra-mutation dependencies automatically.

**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
the same mutation resolve correctly and reconciliation remains idempotent.

**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation.

**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if sidecar containers are present.
37 changes: 37 additions & 0 deletions examples/statefulset-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# StatefulSet Primitive Example

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

- **Base Construction**: Initializing a StatefulSet with basic metadata, spec, and volume claim templates.
- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the
`Mutator`.
- **Custom Status Handlers**: Overriding the default logic for determining readiness (via `ConvergingStatus` and the
`WithCustomConvergeStatus` builder hook) and health assessment during rollouts (`GraceStatus`).
- **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations.
- **Data Extraction**: Harvesting information from the reconciled resource.

## Directory Structure

- `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.
- `handlers.go`: custom status and suspension handlers.
- `resources/`: Contains the central `NewStatefulSetResource` factory that assembles all features using the
`statefulset.Builder`.
- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client.

## Running the Example

You can run this example directly using `go run`:

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

Comment on lines +24 to +31
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

This PR adds a new example, but the repository’s make run-examples target runs an explicit list of go run ./examples/<name>/. commands (Makefile:114-119) and currently won’t exercise statefulset-primitive. Consider updating the Makefile so the new example is covered by the standard example run target.

Copilot uses AI. Check for mistakes.
This will:

1. Initialize a fake Kubernetes client.
2. Create an `ExampleApp` owner object.
3. Reconcile the `ExampleApp` components.
4. Print the resulting status conditions.
Loading
Loading