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
e3486d6
Add PVSpecEditor for PersistentVolume spec mutations
sourcehawk Mar 22, 2026
b46717d
Add PersistentVolume primitive with Integration lifecycle
sourcehawk Mar 22, 2026
650b118
Add PersistentVolume primitive documentation
sourcehawk Mar 22, 2026
700431b
Add PersistentVolume primitive example
sourcehawk Mar 22, 2026
8f995fe
Fix staticcheck lint: remove redundant embedded field selector
sourcehawk Mar 22, 2026
c117fa2
Address Copilot review comments on PV primitive PR
sourcehawk Mar 22, 2026
597e7de
fix lint
sourcehawk Mar 22, 2026
f06d21c
Add unit tests for DefaultFieldApplicator
sourcehawk Mar 22, 2026
4eb153c
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
46fb62b
docs: address PR review feedback for PV primitive
sourcehawk Mar 22, 2026
195ad33
refactor: delegate PV builder to generic IntegrationBuilder.Build()
sourcehawk Mar 23, 2026
35bc9b1
fix: remove beginFeature from PV mutator constructor
sourcehawk Mar 23, 2026
29fadc1
fix: address PR review feedback for PV primitive
sourcehawk Mar 23, 2026
96f290d
fix: export BeginFeature() on PV mutator to match FeatureMutator inte…
sourcehawk Mar 23, 2026
1a4ad33
Merge remote-tracking branch 'origin/main' into feature/pv-primitive
sourcehawk Mar 24, 2026
045722c
style: format markdown files with prettier
sourcehawk Mar 24, 2026
15c7aaa
Merge remote-tracking branch 'origin/main' into feature/pv-primitive
sourcehawk Mar 24, 2026
b64e1dc
fix: do not initialize empty feature plan in PV mutator constructor
sourcehawk Mar 24, 2026
9707069
Merge remote-tracking branch 'origin/main' into feature/pv-primitive
sourcehawk Mar 25, 2026
34983d4
refactor: remove field applicators and flavors from PV primitive
sourcehawk Mar 25, 2026
8f143cf
fix: remove references to deleted WithFieldApplicationFlavor and Pres…
sourcehawk Mar 25, 2026
32d81f4
fix: format pv.md to pass prettier lint check
sourcehawk Mar 25, 2026
db71071
fix: correct misleading comment about flavor-based annotation preserv…
sourcehawk Mar 25, 2026
175fd91
fix: align operational status names in docs and comments with code
sourcehawk Mar 25, 2026
7c6589c
fix docs
sourcehawk Mar 25, 2026
e8aa859
Merge branch 'main' into feature/pv-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
2 changes: 2 additions & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,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 |
| `PVSpecEditor` | PV spec fields — capacity, access modes, reclaim policy, storage class |
| `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests |
| `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration |
| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object |
Expand Down Expand Up @@ -160,6 +161,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/pv` | Integration | [pv.md](primitives/pv.md) |
| `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) |
| `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) |
| `pkg/primitives/ingress` | Integration | [ingress.md](primitives/ingress.md) |
Expand Down
259 changes: 259 additions & 0 deletions docs/primitives/pv.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
# PersistentVolume Primitive

The `pv` primitive is the framework's built-in integration abstraction for managing Kubernetes `PersistentVolume`
resources. It integrates with the component lifecycle and provides a structured mutation API for managing PV spec fields
and object metadata.

## Capabilities

| Capability | Detail |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Integration lifecycle** | Reports `concepts.OperationalStatusOperational`, `concepts.OperationalStatusPending`, or `concepts.OperationalStatusFailing` based on the PV's phase |
| **Cluster-scoped** | No namespace in the identity or builder — PersistentVolumes are cluster-scoped resources |
| **Mutation pipeline** | Typed editors for PV spec fields and object metadata, with a raw escape hatch for free-form access |
| **Data extraction** | Reads generated or updated values back from the reconciled PersistentVolume after each sync cycle |

## Building a PersistentVolume Primitive

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

base := &corev1.PersistentVolume{
ObjectMeta: metav1.ObjectMeta{
Name: "data-volume",
},
Spec: corev1.PersistentVolumeSpec{
Capacity: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse("100Gi"),
},
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
PersistentVolumeSource: corev1.PersistentVolumeSource{
CSI: &corev1.CSIPersistentVolumeSource{
Driver: "ebs.csi.aws.com",
VolumeHandle: "vol-abc123",
},
},
},
}

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

PersistentVolumes are cluster-scoped. The builder validates that Name is set and that Namespace is empty. Setting a
namespace on the PV object will cause `Build()` to return an error.

## Mutations

Mutations are the primary mechanism for modifying a `PersistentVolume` 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) pv.Mutation {
return pv.Mutation{
Name: "my-feature",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *pv.Mutator) error {
m.SetStorageClassName("fast-ssd")
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 RetainPolicyMutation(version string, retainEnabled bool) pv.Mutation {
return pv.Mutation{
Name: "retain-policy",
Feature: feature.NewResourceFeature(version, nil).When(retainEnabled),
Mutate: func(m *pv.Mutator) error {
m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain)
return nil
},
}
}
```

### Version-gated mutations

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

func LegacyStorageClassMutation(version string) pv.Mutation {
return pv.Mutation{
Name: "legacy-storage-class",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{legacyConstraint},
),
Mutate: func(m *pv.Mutator) error {
m.SetStorageClassName("legacy-hdd")
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 `PersistentVolume` |
| 2 | Spec edits | PV spec fields — storage class, reclaim policy, mount options, etc. |

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

## Editors

### PVSpecEditor

The primary API for modifying PersistentVolume spec fields. Use `m.EditPVSpec` for full control:

```go
m.EditPVSpec(func(e *editors.PVSpecEditor) error {
e.SetCapacity(resource.MustParse("200Gi"))
e.SetAccessModes([]corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany})
e.SetPersistentVolumeReclaimPolicy(corev1.PersistentVolumeReclaimRetain)
return nil
})
```

#### Available methods

| Method | What it sets |
| -------------------------------------- | -------------------------------------- |
| `SetCapacity(resource.Quantity)` | `.spec.capacity[storage]` |
| `SetAccessModes([]AccessMode)` | `.spec.accessModes` |
| `SetPersistentVolumeReclaimPolicy` | `.spec.persistentVolumeReclaimPolicy` |
| `SetStorageClassName(string)` | `.spec.storageClassName` |
| `SetMountOptions([]string)` | `.spec.mountOptions` |
| `SetVolumeMode(PersistentVolumeMode)` | `.spec.volumeMode` |
| `SetNodeAffinity(*VolumeNodeAffinity)` | `.spec.nodeAffinity` |
| `Raw()` | Returns `*corev1.PersistentVolumeSpec` |

#### Raw escape hatch

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

```go
m.EditPVSpec(func(e *editors.PVSpecEditor) error {
e.Raw().PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimDelete
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("storage-tier", "premium")
e.EnsureAnnotation("provisioned-by", "my-operator")
return nil
})
```

## Convenience Methods

The `Mutator` exposes convenience wrappers for the most common PV spec operations:

| Method | Equivalent to |
| --------------------------- | ------------------------------------------------------ |
| `SetStorageClassName(name)` | `EditPVSpec` → `e.SetStorageClassName(name)` |
| `SetReclaimPolicy(policy)` | `EditPVSpec` → `e.SetPersistentVolumeReclaimPolicy(p)` |
| `SetMountOptions(opts)` | `EditPVSpec` → `e.SetMountOptions(opts)` |

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

## Operational Status

The PV primitive uses the Integration lifecycle. The default operational status handler maps PV phases to framework
status:

| PV Phase | Operational Status | Meaning |
| --------- | ---------------------------- | -------------------------------------- |
| Available | OperationalStatusOperational | PV is ready for binding |
| Bound | OperationalStatusOperational | PV is bound to a PersistentVolumeClaim |
| Pending | OperationalStatusPending | PV is waiting to become available |
| Released | OperationalStatusFailing | PV was released, not yet reclaimed |
| Failed | OperationalStatusFailing | PV reclamation has failed |

Override with `WithCustomOperationalStatus` when your PV requires different readiness logic.

## Full Example: Storage-Tier PersistentVolume

```go
func StorageClassMutation(version string) pv.Mutation {
return pv.Mutation{
Name: "storage-class",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *pv.Mutator) error {
m.SetStorageClassName("fast-ssd")
m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain)
return nil
},
}
}

func TierLabelMutation(version, tier string) pv.Mutation {
return pv.Mutation{
Name: "tier-label",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *pv.Mutator) error {
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("storage-tier", tier)
return nil
})
return nil
},
}
}

resource, err := pv.NewBuilder(base).
WithMutation(StorageClassMutation(owner.Spec.Version)).
WithMutation(TierLabelMutation(owner.Spec.Version, "premium")).
Build()
```

## Guidance

**PersistentVolumes are cluster-scoped.** Do not set a namespace on the PV object. The builder rejects namespaced PVs
with a clear error.

**Use the Integration lifecycle for status.** PVs report `OperationalStatusOperational`, `OperationalStatusPending`, or
`OperationalStatusFailing` based on their phase. Override with `WithCustomOperationalStatus` only when phase-based
readiness is insufficient.

**Controller references and garbage collection.** The component reconciliation pipeline attempts to set a controller
reference on created/updated resources. Because `PersistentVolume` is cluster-scoped, its controller owner must also be
cluster-scoped. When the owner is namespace-scoped and the PV is cluster-scoped, the framework detects this mismatch and
**skips setting `ownerReferences`** (logging an informational message) instead of letting the API server reject the
request. As a result, such PVs will **not** be garbage collected automatically when the owning component is deleted. If
you need garbage collection for PVs, either:

- Model the PV as owned by a dedicated **cluster-scoped** controller/component so a valid controller reference can be
set, or
- Accept that PVs managed from a **namespace-scoped** component will not have `ownerReferences` and handle their
lifecycle explicitly (for example, by deleting them in custom logic when appropriate).

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

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

- **Base Construction**: Initializing a cluster-scoped PersistentVolume with storage configuration.
- **Feature Mutations**: Applying feature-gated changes to reclaim policy, mount options, and metadata.
- **Field Flavors**: Preserving annotations managed by external controllers using `PreserveCurrentAnnotations`.
- **Result Inspection**: Printing PV configuration 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, boolean-gated retain policy, and mount options mutations.
- `resources/`: Contains the central `NewPVResource` factory that assembles all features using `pv.Builder`.
- `main.go`: A standalone entry point that demonstrates in-memory mutation across multiple spec variations.

## Running the Example

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

This will:

1. Create an `ExampleApp` owner object.
2. Apply mutations across four spec variations, printing the resulting PV YAML after each cycle.
3. Print operational status examples for each PV phase.
13 changes: 13 additions & 0 deletions examples/pv-primitive/app/controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Package app provides a sample controller using the pv primitive.
//
// PersistentVolumes are cluster-scoped resources. In a real operator, the owner
// would typically be a cluster-scoped CRD (e.g. a ClusterStorageConfig). When a
// component create pipeline is used, it will only set controller references where
// the owner/owned scopes are compatible, and will skip ownerReferences for
// cluster-scoped resources that would otherwise have namespace-scoped owners. In
// those skipped cases, the cluster-scoped resource will not be garbage-collected
// with the namespaced owner.
//
// This example demonstrates the PV primitive's builder, mutation, and status APIs
// without full component reconciliation to keep the focus on the primitive itself.
package app
61 changes: 61 additions & 0 deletions examples/pv-primitive/features/mutations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Package features provides sample mutations for the pv primitive example.
package features

import (
"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"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/pv"
corev1 "k8s.io/api/core/v1"
)

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

// RetainPolicyMutation sets the reclaim policy to Retain when enabled.
// This is gated by a boolean condition.
func RetainPolicyMutation(version string, enabled bool) pv.Mutation {
return pv.Mutation{
Name: "retain-policy",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *pv.Mutator) error {
m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain)
return nil
},
}
}

// MountOptionsMutation adds NFS mount options when enabled.
// This is gated by a boolean condition.
func MountOptionsMutation(version string, enabled bool) pv.Mutation {
return pv.Mutation{
Name: "mount-options",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *pv.Mutator) error {
m.SetMountOptions([]string{"hard", "nfsvers=4.1"})
return nil
},
}
}

// ExampleOperationalStatus demonstrates the default operational status handler
// by returning the status for a given PV phase.
func ExampleOperationalStatus(phase corev1.PersistentVolumePhase) (concepts.OperationalStatusWithReason, error) {
p := &corev1.PersistentVolume{
Status: corev1.PersistentVolumeStatus{Phase: phase},
}
return pv.DefaultOperationalStatusHandler(concepts.ConvergingOperationNone, p)
}
Loading
Loading