diff --git a/docs/primitives.md b/docs/primitives.md index 251816ff..a1875bf8 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -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 @@ -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 diff --git a/docs/primitives/pvc.md b/docs/primitives/pvc.md new file mode 100644 index 00000000..b1c0687c --- /dev/null +++ b/docs/primitives/pvc.md @@ -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 \ | +| `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. diff --git a/examples/pvc-primitive/README.md b/examples/pvc-primitive/README.md new file mode 100644 index 00000000..9f7e123b --- /dev/null +++ b/examples/pvc-primitive/README.md @@ -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. diff --git a/examples/pvc-primitive/app/controller.go b/examples/pvc-primitive/app/controller.go new file mode 100644 index 00000000..9d055e2a --- /dev/null +++ b/examples/pvc-primitive/app/controller.go @@ -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) +} diff --git a/examples/pvc-primitive/app/owner.go b/examples/pvc-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/pvc-primitive/app/owner.go @@ -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 diff --git a/examples/pvc-primitive/features/mutations.go b/examples/pvc-primitive/features/mutations.go new file mode 100644 index 00000000..3bd6b5fc --- /dev/null +++ b/examples/pvc-primitive/features/mutations.go @@ -0,0 +1,54 @@ +// Package features provides sample mutations for the PVC 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/pvc" + "k8s.io/apimachinery/pkg/api/resource" +) + +// VersionLabelMutation sets the app.kubernetes.io/version label on the PVC. +// It is always enabled. +func VersionLabelMutation(version string) pvc.Mutation { + return pvc.Mutation{ + Name: "version-label", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *pvc.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// LargeStorageMutation expands the PVC storage request to 50Gi. +// It is enabled when needsLargeStorage is true. +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("50Gi")) + return nil + }, + } +} + +// StorageAnnotationMutation adds a storage-class hint annotation to the PVC. +// It is always enabled. +func StorageAnnotationMutation(version string, storageClass string) pvc.Mutation { + return pvc.Mutation{ + Name: "storage-annotation", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *pvc.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("storage/class-hint", storageClass) + return nil + }) + return nil + }, + } +} diff --git a/examples/pvc-primitive/main.go b/examples/pvc-primitive/main.go new file mode 100644 index 00000000..ec5ff891 --- /dev/null +++ b/examples/pvc-primitive/main.go @@ -0,0 +1,119 @@ +// Package main is the entry point for the PVC primitive example. +package main + +import ( + "context" + "fmt" + "os" + + ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" + "github.com/sourcehawk/operator-component-framework/examples/pvc-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/pvc-primitive/resources" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client. + scheme := runtime.NewScheme() + if err := app.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := corev1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add core/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&app.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: false, + EnableMetrics: false, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize the controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + NewPVCResource: resources.NewPVCResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate + // how PVC mutations compose across features. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: false, + EnableMetrics: false, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: false, + EnableMetrics: false, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: true, // Triggers large storage mutation + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: true, + Suspended: true, // Suspend the app + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableMetrics, spec.Suspended) + + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/pvc-primitive/resources/pvc.go b/examples/pvc-primitive/resources/pvc.go new file mode 100644 index 00000000..3689f3d0 --- /dev/null +++ b/examples/pvc-primitive/resources/pvc.go @@ -0,0 +1,57 @@ +// Package resources provides resource implementations for the PVC primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/pvc-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/pvc-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/pvc" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewPVCResource constructs a PVC primitive resource with all the features. +func NewPVCResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base PVC object. + base := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-data", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + } + + // 2. Initialize the PVC builder. + builder := pvc.NewBuilder(base) + + // 3. Register mutations in dependency order. + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.StorageAnnotationMutation(owner.Spec.Version, "standard")) + builder.WithMutation(features.LargeStorageMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Extract data from the reconciled PVC. + builder.WithDataExtractor(func(p corev1.PersistentVolumeClaim) error { + fmt.Printf("Reconciled PVC: %s\n", p.Name) + fmt.Printf(" Phase: %s\n", p.Status.Phase) + if storage, ok := p.Spec.Resources.Requests[corev1.ResourceStorage]; ok { + fmt.Printf(" Storage Request: %s\n", storage.String()) + } + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/pvcspec.go b/pkg/mutation/editors/pvcspec.go new file mode 100644 index 00000000..f7907843 --- /dev/null +++ b/pkg/mutation/editors/pvcspec.go @@ -0,0 +1,74 @@ +package editors + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// PVCSpecEditor provides a typed API for mutating a Kubernetes PersistentVolumeClaimSpec. +type PVCSpecEditor struct { + spec *corev1.PersistentVolumeClaimSpec +} + +// NewPVCSpecEditor creates a new PVCSpecEditor for the given PersistentVolumeClaimSpec. +func NewPVCSpecEditor(spec *corev1.PersistentVolumeClaimSpec) *PVCSpecEditor { + return &PVCSpecEditor{spec: spec} +} + +// Raw returns the underlying *corev1.PersistentVolumeClaimSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *PVCSpecEditor) Raw() *corev1.PersistentVolumeClaimSpec { + return e.spec +} + +// SetStorageRequest sets the storage resource request for the PVC. +// +// On existing PVCs, Kubernetes only allows storage expansion (not shrinking), +// provided the StorageClass supports volume expansion. +func (e *PVCSpecEditor) SetStorageRequest(quantity resource.Quantity) { + if e.spec.Resources.Requests == nil { + e.spec.Resources.Requests = corev1.ResourceList{} + } + e.spec.Resources.Requests[corev1.ResourceStorage] = quantity +} + +// SetAccessModes sets the access modes for the PVC. +// +// Access modes are immutable on existing PVCs. This method should be used for +// initial construction, or when reapplying the same value via Server-Side Apply. +// Attempting to change this field on an existing PVC will be rejected by the +// Kubernetes API server. +func (e *PVCSpecEditor) SetAccessModes(modes []corev1.PersistentVolumeAccessMode) { + e.spec.AccessModes = modes +} + +// SetStorageClassName sets the storage class name for the PVC. +// +// The storage class name is immutable on existing PVCs. This method should be +// used for initial construction, or when reapplying the same value via +// Server-Side Apply. Attempting to change this field on an existing PVC will be +// rejected by the Kubernetes API server. +func (e *PVCSpecEditor) SetStorageClassName(name string) { + e.spec.StorageClassName = &name +} + +// SetVolumeMode sets the volume mode (Filesystem or Block) for the PVC. +// +// The volume mode is immutable on existing PVCs. This method should be used +// for initial construction, or when reapplying the same value via Server-Side +// Apply. Attempting to change this field on an existing PVC will be rejected +// by the Kubernetes API server. +func (e *PVCSpecEditor) SetVolumeMode(mode corev1.PersistentVolumeMode) { + e.spec.VolumeMode = &mode +} + +// SetVolumeName binds the PVC to a specific PersistentVolume by name. +// +// The volume name is immutable on existing PVCs. This method should be used +// for initial construction, or when reapplying the same value via Server-Side +// Apply. Attempting to change this field on an existing PVC will be rejected +// by the Kubernetes API server. +func (e *PVCSpecEditor) SetVolumeName(name string) { + e.spec.VolumeName = name +} diff --git a/pkg/mutation/editors/pvcspec_test.go b/pkg/mutation/editors/pvcspec_test.go new file mode 100644 index 00000000..c505fb99 --- /dev/null +++ b/pkg/mutation/editors/pvcspec_test.go @@ -0,0 +1,72 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestPVCSpecEditor_Raw(t *testing.T) { + spec := &corev1.PersistentVolumeClaimSpec{} + editor := NewPVCSpecEditor(spec) + assert.Same(t, spec, editor.Raw()) +} + +func TestPVCSpecEditor_SetStorageRequest(t *testing.T) { + t.Run("nil requests map", func(t *testing.T) { + spec := &corev1.PersistentVolumeClaimSpec{} + editor := NewPVCSpecEditor(spec) + qty := resource.MustParse("10Gi") + editor.SetStorageRequest(qty) + require.NotNil(t, spec.Resources.Requests) + assert.True(t, spec.Resources.Requests[corev1.ResourceStorage].Equal(qty)) + }) + + t.Run("existing requests map", func(t *testing.T) { + spec := &corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + } + editor := NewPVCSpecEditor(spec) + qty := resource.MustParse("20Gi") + editor.SetStorageRequest(qty) + assert.True(t, spec.Resources.Requests[corev1.ResourceStorage].Equal(qty)) + }) +} + +func TestPVCSpecEditor_SetAccessModes(t *testing.T) { + spec := &corev1.PersistentVolumeClaimSpec{} + editor := NewPVCSpecEditor(spec) + modes := []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce, corev1.ReadOnlyMany} + editor.SetAccessModes(modes) + assert.Equal(t, modes, spec.AccessModes) +} + +func TestPVCSpecEditor_SetStorageClassName(t *testing.T) { + spec := &corev1.PersistentVolumeClaimSpec{} + editor := NewPVCSpecEditor(spec) + editor.SetStorageClassName("fast-ssd") + require.NotNil(t, spec.StorageClassName) + assert.Equal(t, "fast-ssd", *spec.StorageClassName) +} + +func TestPVCSpecEditor_SetVolumeMode(t *testing.T) { + spec := &corev1.PersistentVolumeClaimSpec{} + editor := NewPVCSpecEditor(spec) + editor.SetVolumeMode(corev1.PersistentVolumeBlock) + require.NotNil(t, spec.VolumeMode) + assert.Equal(t, corev1.PersistentVolumeBlock, *spec.VolumeMode) +} + +func TestPVCSpecEditor_SetVolumeName(t *testing.T) { + spec := &corev1.PersistentVolumeClaimSpec{} + editor := NewPVCSpecEditor(spec) + editor.SetVolumeName("pv-001") + assert.Equal(t, "pv-001", spec.VolumeName) +} diff --git a/pkg/primitives/pvc/builder.go b/pkg/primitives/pvc/builder.go new file mode 100644 index 00000000..99fdb204 --- /dev/null +++ b/pkg/primitives/pvc/builder.go @@ -0,0 +1,137 @@ +package pvc + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + corev1 "k8s.io/api/core/v1" +) + +// Builder is a configuration helper for creating and customizing a PVC Resource. +// +// It provides a fluent API for registering mutations, status handlers, and data +// extractors. Build() validates the configuration and returns an initialized +// Resource ready for use in a reconciliation loop. +type Builder struct { + base *generic.IntegrationBuilder[*corev1.PersistentVolumeClaim, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided PersistentVolumeClaim object. +// +// The PVC object serves as the desired base state. During reconciliation the Resource +// will make the cluster's state match this base, modified by any registered mutations. +// +// The provided PVC must have both Name and Namespace set, which is validated during +// the Build() call. +func NewBuilder(pvc *corev1.PersistentVolumeClaim) *Builder { + identityFunc := func(p *corev1.PersistentVolumeClaim) string { + return fmt.Sprintf("v1/PersistentVolumeClaim/%s/%s", p.Namespace, p.Name) + } + + base := generic.NewIntegrationBuilder[*corev1.PersistentVolumeClaim, *Mutator]( + pvc, + identityFunc, + NewMutator, + ) + + base. + WithCustomOperationalStatus(DefaultOperationalStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a mutation for the PVC. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomOperationalStatus overrides the default logic for determining if the +// PVC has reached its desired operational state. +// +// The default behavior uses DefaultOperationalStatusHandler, which checks whether +// the PVC is Bound. Use this method if you need additional checks, such as verifying +// specific annotations or conditions. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *corev1.PersistentVolumeClaim) (concepts.OperationalStatusWithReason, error), +) *Builder { + b.base.WithCustomOperationalStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler, which always reports +// Suspended since PVCs have no runtime state to wind down. +func (b *Builder) WithCustomSuspendStatus( + handler func(*corev1.PersistentVolumeClaim) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the PVC should be modified when the +// component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op. +// Override this if you need to add annotations or labels when suspended. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the PVC when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which does not delete +// PVCs during suspension to preserve data. Return true from this handler if you +// want the PVC to be completely removed from the cluster when suspended. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*corev1.PersistentVolumeClaim) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to read values from the PVC after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled PVC. This is useful +// for surfacing the bound volume name, capacity, or other status fields to +// other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(corev1.PersistentVolumeClaim) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(p *corev1.PersistentVolumeClaim) error { + return extractor(*p) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No PVC object was provided. +// - The PVC is missing a Name or Namespace. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/pvc/builder_test.go b/pkg/primitives/pvc/builder_test.go new file mode 100644 index 00000000..34a990c7 --- /dev/null +++ b/pkg/primitives/pvc/builder_test.go @@ -0,0 +1,221 @@ +package pvc + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder(t *testing.T) { + t.Parallel() + + t.Run("Build validation", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pvc *corev1.PersistentVolumeClaim + expectedErr string + }{ + { + name: "nil pvc", + pvc: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid pvc", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.pvc).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "v1/PersistentVolumeClaim/test-ns/test-pvc", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(pvc). + WithMutation(m). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) + }) + + t.Run("WithCustomOperationalStatus", func(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *corev1.PersistentVolumeClaim) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusPending}, nil + } + res, err := NewBuilder(pvc). + WithCustomOperationalStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.OperationalStatusHandler) + status, err := res.base.OperationalStatusHandler(concepts.ConvergingOperationNone, nil) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusPending, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.PersistentVolumeClaim) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(pvc). + WithCustomSuspendStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendStatusHandler) + status, err := res.base.SuspendStatusHandler(nil) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("WithCustomSuspendMutation", func(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(pvc). + WithCustomSuspendMutation(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendMutationHandler) + err = res.base.SuspendMutationHandler(nil) + assert.EqualError(t, err, "suspend error") + }) + + t.Run("WithCustomSuspendDeletionDecision", func(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.PersistentVolumeClaim) bool { + return true + } + res, err := NewBuilder(pvc). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.True(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + p := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + } + called := false + extractor := func(_ corev1.PersistentVolumeClaim) error { + called = true + return nil + } + res, err := NewBuilder(p). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&corev1.PersistentVolumeClaim{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + p := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(p). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/pvc/handlers.go b/pkg/primitives/pvc/handlers.go new file mode 100644 index 00000000..0f20b686 --- /dev/null +++ b/pkg/primitives/pvc/handlers.go @@ -0,0 +1,85 @@ +package pvc + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + corev1 "k8s.io/api/core/v1" +) + +// DefaultOperationalStatusHandler is the default logic for determining if a PVC +// has reached its desired operational state. +// +// It considers a PVC operational when its Status.Phase is Bound: +// - Bound → Operational +// - Pending → OperationPending +// - Lost → OperationFailing +// +// This function is used as the default handler by the Resource if no custom handler +// is registered via Builder.WithCustomOperationalStatus. It can be reused within +// custom handlers to augment the default behavior. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, pvc *corev1.PersistentVolumeClaim, +) (concepts.OperationalStatusWithReason, error) { + switch pvc.Status.Phase { + case corev1.ClaimBound: + reason := "PVC is bound" + if pvc.Spec.VolumeName != "" { + reason = fmt.Sprintf("PVC is bound to volume %s", pvc.Spec.VolumeName) + } + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: reason, + }, nil + case corev1.ClaimLost: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusFailing, + Reason: "PVC has lost its bound volume", + }, nil + default: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "Waiting for PVC to be bound", + }, nil + } +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete +// the PVC when the parent component is suspended. +// +// It always returns false, meaning the PVC is kept in the cluster to preserve its +// underlying storage. Deleting a PVC can lead to permanent data loss if the reclaim +// policy is Delete. +// +// This function is used as the default handler by the Resource if no custom handler +// is registered via Builder.WithCustomSuspendDeletionDecision. +func DefaultDeleteOnSuspendHandler(_ *corev1.PersistentVolumeClaim) bool { + return false +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a PVC when +// the component is suspended. +// +// It is a no-op: PVCs do not have runtime state (like replicas) that needs to be +// wound down. The consuming workload (e.g. a Deployment) is responsible for stopping +// its use of the PVC. +// +// This function is used as the default handler by the Resource if no custom handler +// is registered via Builder.WithCustomSuspendMutation. +func DefaultSuspendMutationHandler(_ *Mutator) error { + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// It always reports Suspended because PVCs themselves have no runtime state to wind +// down. Once Suspend() is called, the PVC is considered immediately suspended. +// +// This function is used as the default handler by the Resource if no custom handler +// is registered via Builder.WithCustomSuspendStatus. +func DefaultSuspensionStatusHandler(_ *corev1.PersistentVolumeClaim) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "PVC does not require suspension actions", + }, nil +} diff --git a/pkg/primitives/pvc/handlers_test.go b/pkg/primitives/pvc/handlers_test.go new file mode 100644 index 00000000..591b3892 --- /dev/null +++ b/pkg/primitives/pvc/handlers_test.go @@ -0,0 +1,99 @@ +package pvc + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func TestDefaultOperationalStatusHandler(t *testing.T) { + tests := []struct { + name string + pvc *corev1.PersistentVolumeClaim + wantStatus concepts.OperationalStatus + wantReason string + }{ + { + name: "bound with volume name", + pvc: &corev1.PersistentVolumeClaim{ + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-001", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "PVC is bound to volume pv-001", + }, + { + name: "bound without volume name", + pvc: &corev1.PersistentVolumeClaim{ + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "PVC is bound", + }, + { + name: "pending", + pvc: &corev1.PersistentVolumeClaim{ + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimPending, + }, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Waiting for PVC to be bound", + }, + { + name: "lost", + pvc: &corev1.PersistentVolumeClaim{ + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimLost, + }, + }, + wantStatus: concepts.OperationalStatusFailing, + wantReason: "PVC has lost its bound volume", + }, + { + name: "empty phase", + pvc: &corev1.PersistentVolumeClaim{}, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Waiting for PVC to be bound", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationCreated, tt.pvc) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + assert.False(t, DefaultDeleteOnSuspendHandler(pvc)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + mutator := NewMutator(pvc) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + require.NoError(t, mutator.Apply()) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + got, err := DefaultSuspensionStatusHandler(pvc) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + assert.Equal(t, "PVC does not require suspension actions", got.Reason) +} diff --git a/pkg/primitives/pvc/mutator.go b/pkg/primitives/pvc/mutator.go new file mode 100644 index 00000000..7495f5b2 --- /dev/null +++ b/pkg/primitives/pvc/mutator.go @@ -0,0 +1,129 @@ +// Package pvc provides a builder and resource for managing Kubernetes PersistentVolumeClaims. +package pvc + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// Mutation defines a mutation that is applied to a pvc Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + specEdits []func(*editors.PVCSpecEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes PersistentVolumeClaim. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the PVC in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + pvc *corev1.PersistentVolumeClaim + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given PersistentVolumeClaim. +// BeginFeature must be called before registering any mutations. +func NewMutator(pvc *corev1.PersistentVolumeClaim) *Mutator { + return &Mutator{ + pvc: pvc, + } +} + +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the PVC's own metadata. +// +// Metadata edits are applied before spec edits within the same feature. +// A nil edit function is ignored. +// +// Panics if BeginFeature has not been called. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + if m.active == nil { + panic("pvc.Mutator: EditObjectMetadata called before BeginFeature") + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditPVCSpec records a mutation for the PVC's spec via a PVCSpecEditor. +// +// The editor provides structured operations (SetStorageRequest, SetAccessModes, etc.) +// as well as Raw() for free-form access. Spec edits are applied after metadata edits +// within the same feature, in registration order. +// +// A nil edit function is ignored. +// +// Panics if BeginFeature has not been called. +func (m *Mutator) EditPVCSpec(edit func(*editors.PVCSpecEditor) error) { + if edit == nil { + return + } + if m.active == nil { + panic("pvc.Mutator: EditPVCSpec called before BeginFeature") + } + m.active.specEdits = append(m.active.specEdits, edit) +} + +// SetStorageRequest records that the PVC's storage request should be set to quantity. +// +// Convenience wrapper over EditPVCSpec. +func (m *Mutator) SetStorageRequest(quantity resource.Quantity) { + m.EditPVCSpec(func(e *editors.PVCSpecEditor) error { + e.SetStorageRequest(quantity) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying PVC. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Spec edits — EditPVCSpec, SetStorageRequest (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the PVC as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.pvc.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Spec edits + if len(plan.specEdits) > 0 { + editor := editors.NewPVCSpecEditor(&m.pvc.Spec) + for _, edit := range plan.specEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/pvc/mutator_test.go b/pkg/primitives/pvc/mutator_test.go new file mode 100644 index 00000000..c55ac57d --- /dev/null +++ b/pkg/primitives/pvc/mutator_test.go @@ -0,0 +1,265 @@ +package pvc + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMutator_EditObjectMetadata(t *testing.T) { + t.Parallel() + + t.Run("adds label", func(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + m := NewMutator(pvc) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", pvc.Labels["app"]) + }) + + t.Run("nil edit ignored", func(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + m.BeginFeature() + m.EditObjectMetadata(nil) + require.NoError(t, m.Apply()) + }) + + t.Run("error propagated", func(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + m.BeginFeature() + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + return errors.New("metadata error") + }) + err := m.Apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "metadata error") + }) +} + +func TestMutator_EditPVCSpec(t *testing.T) { + t.Parallel() + + t.Run("sets storage request", func(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + m.BeginFeature() + m.EditPVCSpec(func(e *editors.PVCSpecEditor) error { + e.SetStorageRequest(resource.MustParse("10Gi")) + return nil + }) + require.NoError(t, m.Apply()) + qty := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + assert.True(t, qty.Equal(resource.MustParse("10Gi"))) + }) + + t.Run("nil edit ignored", func(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + m.BeginFeature() + m.EditPVCSpec(nil) + require.NoError(t, m.Apply()) + }) + + t.Run("error propagated", func(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + m.BeginFeature() + m.EditPVCSpec(func(_ *editors.PVCSpecEditor) error { + return errors.New("spec error") + }) + err := m.Apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "spec error") + }) +} + +func TestMutator_SetStorageRequest(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + m.BeginFeature() + m.SetStorageRequest(resource.MustParse("20Gi")) + require.NoError(t, m.Apply()) + qty := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + assert.True(t, qty.Equal(resource.MustParse("20Gi"))) +} + +func TestMutator_ExecutionOrder(t *testing.T) { + t.Parallel() + + t.Run("metadata before spec within same feature", func(t *testing.T) { + var order []string + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + m := NewMutator(pvc) + m.BeginFeature() + m.EditPVCSpec(func(_ *editors.PVCSpecEditor) error { + order = append(order, "spec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "metadata") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, []string{"metadata", "spec"}, order) + }) + + t.Run("feature ordering", func(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + m := NewMutator(pvc) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("feature", "one") + return nil + }) + + // Simulate second feature + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + // Later feature sees earlier mutation + e.EnsureLabel("feature", "two") + return nil + }) + + require.NoError(t, m.Apply()) + assert.Equal(t, "two", pvc.Labels["feature"]) + }) +} + +func TestMutator_MultiFeature(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + m := NewMutator(pvc) + m.BeginFeature() + + // Feature 1: set label and storage + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + m.EditPVCSpec(func(e *editors.PVCSpecEditor) error { + e.SetStorageRequest(resource.MustParse("5Gi")) + return nil + }) + + // Feature 2: override storage + m.BeginFeature() + m.EditPVCSpec(func(e *editors.PVCSpecEditor) error { + e.SetStorageRequest(resource.MustParse("10Gi")) + return nil + }) + + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", pvc.Labels["app"]) + qty := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + assert.True(t, qty.Equal(resource.MustParse("10Gi"))) +} + +// --- Panic guards --- + +func TestMutator_EditObjectMetadata_PanicsWithoutBeginFeature(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + assert.PanicsWithValue(t, "pvc.Mutator: EditObjectMetadata called before BeginFeature", func() { + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { return nil }) + }) +} + +func TestMutator_EditPVCSpec_PanicsWithoutBeginFeature(t *testing.T) { + t.Parallel() + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + assert.PanicsWithValue(t, "pvc.Mutator: EditPVCSpec called before BeginFeature", func() { + m.EditPVCSpec(func(_ *editors.PVCSpecEditor) error { return nil }) + }) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + + m.BeginFeature() + require.Len(t, m.plans, 1, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[0], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 2) + assert.Equal(t, &m.plans[1], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EditPVCSpec(func(e *editors.PVCSpecEditor) error { + e.SetStorageRequest(resource.MustParse("5Gi")) + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "test") + return nil + }) + + assert.Len(t, m.plans[0].specEdits, 1, "first plan should have one spec edit") + assert.Empty(t, m.plans[0].metadataEdits, "first plan should have no metadata edits") + assert.Len(t, m.plans[1].metadataEdits, 1, "second plan should have one metadata edit") + assert.Empty(t, m.plans[1].specEdits, "second plan should have no spec edits") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + pvc := &corev1.PersistentVolumeClaim{} + m := NewMutator(pvc) + m.BeginFeature() + m.SetStorageRequest(resource.MustParse("1Gi")) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1) +} diff --git a/pkg/primitives/pvc/resource.go b/pkg/primitives/pvc/resource.go new file mode 100644 index 00000000..955d5bb8 --- /dev/null +++ b/pkg/primitives/pvc/resource.go @@ -0,0 +1,94 @@ +package pvc + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes PersistentVolumeClaim +// within a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - concepts.Operational: for tracking whether the PVC is bound and operational. +// - concepts.Suspendable: for controlled suspension (e.g. retaining the PVC while suspending consumers). +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// +// PVC resources follow the Integration lifecycle: they are operationally significant +// (a PVC must be Bound to be useful) and support suspension semantics. +type Resource struct { + base *generic.IntegrationResource[*corev1.PersistentVolumeClaim, *Mutator] +} + +// Identity returns a unique identifier for the PVC in the format +// "v1/PersistentVolumeClaim//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes PersistentVolumeClaim object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes PersistentVolumeClaim into the +// desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// 3. Suspension: if the resource is in a suspending state, the suspension logic is applied. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates whether the PVC has reached its desired operational state. +// +// By default, it uses DefaultOperationalStatusHandler, which checks the PVC's phase +// to determine if it is Bound (operational), Pending, or Lost (failing). +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the PVC should be deleted from the cluster +// when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false to preserve +// the PVC and its underlying storage during suspension. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the PVC. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behaviour uses DefaultSuspendMutationHandler, which is a no-op +// since PVCs do not require modification when suspended — the consuming workload +// is responsible for scaling down. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which always reports +// Suspended since PVCs themselves have no runtime state to wind down. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled PVC. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the PVC. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/pvc/resource_test.go b/pkg/primitives/pvc/resource_test.go new file mode 100644 index 00000000..88275f00 --- /dev/null +++ b/pkg/primitives/pvc/resource_test.go @@ -0,0 +1,178 @@ +package pvc + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newValidPVC() *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidPVC()).Build() + require.NoError(t, err) + assert.Equal(t, "v1/PersistentVolumeClaim/test-ns/test-pvc", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + pvc := newValidPVC() + res, err := NewBuilder(pvc).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*corev1.PersistentVolumeClaim) + require.True(t, ok) + assert.Equal(t, pvc.Name, got.Name) + assert.Equal(t, pvc.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-pvc", pvc.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidPVC() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.PersistentVolumeClaim) + assert.Equal(t, resource.MustParse("10Gi"), got.Spec.Resources.Requests[corev1.ResourceStorage]) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidPVC() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "increase-storage", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetStorageRequest(resource.MustParse("50Gi")) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.PersistentVolumeClaim) + assert.Equal(t, resource.MustParse("50Gi"), got.Spec.Resources.Requests[corev1.ResourceStorage]) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidPVC() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetStorageRequest(resource.MustParse("20Gi")) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetStorageRequest(resource.MustParse("30Gi")) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.PersistentVolumeClaim) + assert.Equal(t, resource.MustParse("30Gi"), got.Spec.Resources.Requests[corev1.ResourceStorage]) +} + +func TestResource_ConvergingStatus(t *testing.T) { + tests := []struct { + name string + phase corev1.PersistentVolumeClaimPhase + expectedStatus concepts.OperationalStatus + }{ + {"Bound", corev1.ClaimBound, concepts.OperationalStatusOperational}, + {"Pending", corev1.ClaimPending, concepts.OperationalStatusPending}, + {"Lost", corev1.ClaimLost, concepts.OperationalStatusFailing}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pvc := newValidPVC() + pvc.Status.Phase = tt.phase + res, err := NewBuilder(pvc).Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, tt.expectedStatus, status.Status) + }) + } +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + res, err := NewBuilder(newValidPVC()).Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) +} + +func TestResource_Suspend_And_SuspensionStatus(t *testing.T) { + res, err := NewBuilder(newValidPVC()).Build() + require.NoError(t, err) + + err = res.Suspend() + require.NoError(t, err) + + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) +} + +func TestResource_ExtractData(t *testing.T) { + pvc := newValidPVC() + + var extracted resource.Quantity + res, err := NewBuilder(pvc). + WithDataExtractor(func(p corev1.PersistentVolumeClaim) error { + extracted = p.Spec.Resources.Requests[corev1.ResourceStorage] + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, resource.MustParse("10Gi"), extracted) +}