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
979a02e
Add PVCSpecEditor for PersistentVolumeClaim spec mutations
sourcehawk Mar 22, 2026
8342c75
Add PVC primitive package with Integration lifecycle
sourcehawk Mar 22, 2026
943fcbf
Add PVC primitive documentation
sourcehawk Mar 22, 2026
3a91a5d
Add PVC primitive example
sourcehawk Mar 22, 2026
d41665c
Add unit tests for DefaultFieldApplicator
sourcehawk Mar 22, 2026
ffb1d90
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
4e7b2bb
add PVC primitive to built-in primitives documentation table
sourcehawk Mar 23, 2026
25906de
fix(pvc): initialize first feature plan inline instead of calling beg…
sourcehawk Mar 23, 2026
2c25d1f
fix(pvc): address PR review - preserve status, add test, fix docs
sourcehawk Mar 23, 2026
dc9f30a
fix(pvc): export BeginFeature to match FeatureMutator interface
sourcehawk Mar 23, 2026
aa16978
Merge remote-tracking branch 'origin/main' into feature/pvc-primitive
sourcehawk Mar 24, 2026
b218c99
style: format markdown files with prettier
sourcehawk Mar 24, 2026
ab9b982
fix(pvc): correct interface and status names in comments and docs
sourcehawk Mar 24, 2026
0a752d3
style: format pvc.md with prettier
sourcehawk Mar 24, 2026
a583539
docs(pvc): fix mutation ordering docs to reflect feature boundary gro…
sourcehawk Mar 24, 2026
7438e02
docs(pvc): use correct operational status names in status table
sourcehawk Mar 24, 2026
f201f48
test(pvc): add resource-level behavioral tests
sourcehawk Mar 24, 2026
1c649fe
Merge remote-tracking branch 'origin/main' into feature/pvc-primitive
sourcehawk Mar 24, 2026
0a6187a
fix(pvc): do not initialize an empty plan on mutator construction
sourcehawk Mar 24, 2026
85c7299
Merge remote-tracking branch 'origin/main' into feature/pvc-primitive
sourcehawk Mar 25, 2026
4bee9e1
refactor(pvc): remove field applicators and flavors for SSA migration
sourcehawk Mar 25, 2026
c6b4d4b
fix(pvc): address Copilot review comments
sourcehawk Mar 25, 2026
73bb703
fix formatting
sourcehawk Mar 25, 2026
9cd3be2
fix(pvc): clarify immutable field comments and add PVCSpecEditor to docs
sourcehawk Mar 25, 2026
e61a070
Merge branch 'main' into feature/pvc-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
18 changes: 8 additions & 10 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource:
| `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 |
| `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests |
| `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
Expand All @@ -143,16 +144,13 @@ 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) |
| `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) |

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.
| 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) |
| `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) |

## Usage Examples

Expand Down
213 changes: 213 additions & 0 deletions docs/primitives/pvc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# PersistentVolumeClaim Primitive

The `pvc` primitive is the framework's built-in integration abstraction for managing Kubernetes `PersistentVolumeClaim`
resources. It integrates with the component lifecycle and provides a structured mutation API for managing storage
requests and object metadata.

## Capabilities

| Capability | Detail |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Operational tracking** | Monitors PVC phase — reports `OperationalStatusOperational` (Bound), `OperationalStatusPending`, or `OperationalStatusFailing` (Lost) |
| **Suspension** | PVCs are immediately suspended (no runtime state to wind down); data is preserved by default |
| **Mutation pipeline** | Typed editors for PVC spec and object metadata, with a raw escape hatch for free-form access |
| **Data extraction** | Reads bound volume name, capacity, or other status fields after each sync cycle |

## Building a PVC Primitive

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

base := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "app-data",
Namespace: owner.Namespace,
},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse("10Gi"),
},
},
},
}

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

## Mutations

Mutations are the primary mechanism for modifying a `PersistentVolumeClaim` 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 MyStorageMutation(version string) pvc.Mutation {
return pvc.Mutation{
Name: "storage-expansion",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *pvc.Mutator) error {
m.SetStorageRequest(resource.MustParse("20Gi"))
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 LargeStorageMutation(version string, needsLargeStorage bool) pvc.Mutation {
return pvc.Mutation{
Name: "large-storage",
Feature: feature.NewResourceFeature(version, nil).When(needsLargeStorage),
Mutate: func(m *pvc.Mutator) error {
m.SetStorageRequest(resource.MustParse("100Gi"))
return nil
},
}
}
```

### Version-gated mutations

```go
var v2Constraint = mustSemverConstraint(">= 2.0.0")

func V2StorageMutation(version string) pvc.Mutation {
return pvc.Mutation{
Name: "v2-storage",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{v2Constraint},
),
Mutate: func(m *pvc.Mutator) error {
m.SetStorageRequest(resource.MustParse("50Gi"))
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 `PersistentVolumeClaim` |
| 2 | Spec edits | PVC spec — storage requests, access modes, etc. |

Within each category, edits are applied in their registration order. The PVC primitive groups mutations by feature
boundary: for each applicable feature (after evaluating version constraints and any `When()` conditions), all of its
planned edits are applied in order, and later features and mutations observe the fully-applied state from earlier ones.

## Editors

### PVCSpecEditor

The primary API for modifying PVC spec fields. Use `m.EditPVCSpec` for full control:

```go
m.EditPVCSpec(func(e *editors.PVCSpecEditor) error {
e.SetStorageRequest(resource.MustParse("20Gi"))
return nil
})
```

Available methods:

| Method | What it does |
| --------------------- | ------------------------------------------------------- |
| `SetStorageRequest` | Sets `spec.resources.requests[storage]` |
| `SetAccessModes` | Sets `spec.accessModes` (immutable after creation) |
| `SetStorageClassName` | Sets `spec.storageClassName` (immutable after creation) |
| `SetVolumeMode` | Sets `spec.volumeMode` (immutable after creation) |
| `SetVolumeName` | Sets `spec.volumeName` (immutable after creation) |
| `Raw` | Returns `*corev1.PersistentVolumeClaimSpec` |

#### Raw Escape Hatch

`Raw()` returns the underlying `*corev1.PersistentVolumeClaimSpec` for free-form editing when none of the structured
methods are sufficient:

```go
m.EditPVCSpec(func(e *editors.PVCSpecEditor) error {
raw := e.Raw()
raw.Selector = &metav1.LabelSelector{
MatchLabels: map[string]string{"type": "fast"},
}
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("storage/class-hint", "fast-ssd")
return nil
})
```

## Convenience Methods

The `Mutator` exposes a convenience wrapper for the most common PVC operation:

| Method | Equivalent to |
| ----------------------------- | ----------------------------------------------- |
| `SetStorageRequest(quantity)` | `EditPVCSpec` → `e.SetStorageRequest(quantity)` |

Use this for simple, single-operation mutations. Use `EditPVCSpec` when you need multiple operations or raw access in a
single edit block.

## Status Handlers

### Operational Status

The default handler (`DefaultOperationalStatusHandler`) maps PVC phase to operational status:

| PVC Phase | Status | Reason |
| --------- | ------------------------------ | ------------------------------- |
| `Bound` | `OperationalStatusOperational` | PVC is bound to volume \<name\> |
| `Pending` | `OperationalStatusPending` | Waiting for PVC to be bound |
| `Lost` | `OperationalStatusFailing` | PVC has lost its bound volume |

Override with `WithCustomOperationalStatus` for additional checks (e.g. verifying specific annotations or volume
attributes).

### Suspension

PVCs have no runtime state to wind down, so:

- `DefaultSuspendMutationHandler` is a no-op.
- `DefaultSuspensionStatusHandler` always reports `Suspended`.
- `DefaultDeleteOnSuspendHandler` returns `false` to preserve data.

Override these handlers if you need custom suspension behavior, such as adding annotations when suspended or deleting
PVCs that use ephemeral storage.

## Guidance

**Register mutations for storage expansion carefully.** Kubernetes only allows expanding PVC storage (not shrinking).
Ensure your mutations respect this constraint. The `SetStorageRequest` method does not enforce this — the API server
will reject invalid requests.

**Prefer `WithCustomSuspendDeletionDecision` over deleting PVCs manually.** If you need PVCs to be cleaned up during
suspension, register a deletion decision handler rather than deleting them in a mutation.
31 changes: 31 additions & 0 deletions examples/pvc-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# PVC Primitive Example

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

- **Base Construction**: Initializing a PVC with access modes, storage request, and metadata.
- **Feature Mutations**: Applying conditional storage expansion and metadata updates using the `Mutator`.
- **Suspension**: PVCs are immediately suspended with data preserved by default.
- **Data Extraction**: Harvesting PVC status 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, storage annotation, and conditional large-storage expansion.
- `resources/`: Contains the central `NewPVCResource` factory that assembles all features using `pvc.Builder`.
- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client.

## Running the Example

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

This will:

1. Initialize a fake Kubernetes client.
2. Create an `ExampleApp` owner object.
3. Reconcile through four spec variations, demonstrating version upgrades, storage expansion, and suspension.
4. Print the resulting status conditions.
54 changes: 54 additions & 0 deletions examples/pvc-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 PVC primitive.
package app

import (
"context"

"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

// NewPVCResource is a factory function to create the PVC resource.
// This allows us to inject the resource construction logic.
NewPVCResource func(*ExampleApp) (component.Resource, error)
}

// Reconcile performs the reconciliation for a single ExampleApp.
func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error {
// 1. Build the PVC resource for this owner.
pvcResource, err := r.NewPVCResource(owner)
if err != nil {
return err
}

// 2. Build the component that manages the PVC.
comp, err := component.NewComponentBuilder().
WithName("example-app").
WithConditionType("AppReady").
WithResource(pvcResource, component.ResourceOptions{}).
Suspend(owner.Spec.Suspended).
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)
}
20 changes: 20 additions & 0 deletions examples/pvc-primitive/app/owner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app

import (
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
)

// ExampleApp re-exports the shared CRD type so callers in this package need no import alias.
type ExampleApp = sharedapp.ExampleApp

// ExampleAppSpec re-exports the shared spec type.
type ExampleAppSpec = sharedapp.ExampleAppSpec

// ExampleAppStatus re-exports the shared status type.
type ExampleAppStatus = sharedapp.ExampleAppStatus

// ExampleAppList re-exports the shared list type.
type ExampleAppList = sharedapp.ExampleAppList

// AddToScheme registers the ExampleApp types with the given scheme.
var AddToScheme = sharedapp.AddToScheme
Loading
Loading