diff --git a/docs/primitives.md b/docs/primitives.md index 105ebe21..7e6e156e 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -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 | @@ -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) | diff --git a/docs/primitives/pv.md b/docs/primitives/pv.md new file mode 100644 index 00000000..2b2984b4 --- /dev/null +++ b/docs/primitives/pv.md @@ -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. diff --git a/examples/pv-primitive/README.md b/examples/pv-primitive/README.md new file mode 100644 index 00000000..7e0d589e --- /dev/null +++ b/examples/pv-primitive/README.md @@ -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. diff --git a/examples/pv-primitive/app/controller.go b/examples/pv-primitive/app/controller.go new file mode 100644 index 00000000..e24403c9 --- /dev/null +++ b/examples/pv-primitive/app/controller.go @@ -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 diff --git a/examples/pv-primitive/features/mutations.go b/examples/pv-primitive/features/mutations.go new file mode 100644 index 00000000..7bf07584 --- /dev/null +++ b/examples/pv-primitive/features/mutations.go @@ -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) +} diff --git a/examples/pv-primitive/main.go b/examples/pv-primitive/main.go new file mode 100644 index 00000000..f1099971 --- /dev/null +++ b/examples/pv-primitive/main.go @@ -0,0 +1,121 @@ +// Package main is the entry point for the pv primitive example. +package main + +import ( + "fmt" + "os" + + "github.com/sourcehawk/operator-component-framework/examples/pv-primitive/features" + "github.com/sourcehawk/operator-component-framework/examples/pv-primitive/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +func main() { + // 1. Create an example Owner object. + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{ + Version: "1.0.0", + EnableTracing: false, + EnableMetrics: false, + }, + } + owner.Name = "my-storage-app" + owner.Namespace = "default" + + // 2. Run through multiple spec variations to demonstrate how mutations compose. + // EnableTracing gates the retain-policy mutation; EnableMetrics gates mount options. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.0.0", + EnableTracing: false, + EnableMetrics: false, + }, + { + Version: "2.0.0", // Version upgrade + EnableTracing: false, + EnableMetrics: false, + }, + { + Version: "2.0.0", + EnableTracing: true, // Enable retain policy + EnableMetrics: false, + }, + { + Version: "2.0.0", + EnableTracing: true, + EnableMetrics: true, // Enable mount options + }, + } + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, RetainPolicy=%v, MountOpts=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics) + + owner.Spec = spec + + // Build the PV resource for this spec. + pvResource, err := resources.NewPVResource(owner) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to build PV resource: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Identity: %s\n", pvResource.Identity()) + + // Simulate a current PV from the cluster (what CreateOrUpdate would provide). + current := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-data", + ResourceVersion: "12345", // non-empty to simulate existing object + Annotations: map[string]string{ + "external-controller/managed": "true", // preserved: mutations only touch fields they explicitly target + }, + }, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("50Gi"), + }, + PersistentVolumeSource: corev1.PersistentVolumeSource{ + HostPath: &corev1.HostPathVolumeSource{Path: "/mnt/old-data"}, + }, + }, + } + + // Apply mutations to the current object. + if err := pvResource.Mutate(current); err != nil { + fmt.Fprintf(os.Stderr, "mutation failed: %v\n", err) + os.Exit(1) + } + + // Print the result. + y, err := yaml.Marshal(current) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to marshal PV: %v\n", err) + os.Exit(1) + } + fmt.Printf("Resulting PV:\n%s", string(y)) + + // Show that immutable fields were preserved from current. + var preservedPath string + if hp := current.Spec.HostPath; hp != nil { + preservedPath = hp.Path + } + fmt.Printf("Preserved volume source path: %s (original: /mnt/old-data)\n", preservedPath) + } + + // 3. Demonstrate the operational status handler. + fmt.Println("\n--- Operational Status Examples ---") + for _, phase := range []corev1.PersistentVolumePhase{ + corev1.VolumeAvailable, corev1.VolumeBound, corev1.VolumePending, + corev1.VolumeReleased, corev1.VolumeFailed, + } { + status, _ := features.ExampleOperationalStatus(phase) + fmt.Printf("Phase %-10s → Status: %s, Reason: %s\n", phase, status.Status, status.Reason) + } + + fmt.Println("\nExample completed successfully!") +} diff --git a/examples/pv-primitive/resources/pv.go b/examples/pv-primitive/resources/pv.go new file mode 100644 index 00000000..e30bb41d --- /dev/null +++ b/examples/pv-primitive/resources/pv.go @@ -0,0 +1,50 @@ +// Package resources provides resource implementations for the pv primitive example. +package resources + +import ( + "github.com/sourcehawk/operator-component-framework/examples/pv-primitive/features" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/pv" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewPVResource constructs a PersistentVolume primitive resource with all the features. +func NewPVResource(owner *sharedapp.ExampleApp) (*pv.Resource, error) { + // 1. Create the base PersistentVolume object. + // PVs are cluster-scoped — no namespace is set. + base := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-data", + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("100Gi"), + }, + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + PersistentVolumeSource: corev1.PersistentVolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/mnt/data", + }, + }, + StorageClassName: "standard", + }, + } + + // 2. Initialize the PV builder. + builder := pv.NewBuilder(base) + + // 3. Register mutations in dependency order. + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.RetainPolicyMutation(owner.Spec.Version, owner.Spec.EnableTracing)) + builder.WithMutation(features.MountOptionsMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/pvspec.go b/pkg/mutation/editors/pvspec.go new file mode 100644 index 00000000..e0e76890 --- /dev/null +++ b/pkg/mutation/editors/pvspec.go @@ -0,0 +1,64 @@ +package editors + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// PVSpecEditor provides a typed API for mutating a Kubernetes PersistentVolumeSpec. +// +// It exposes structured operations for the most common PV spec fields. Use Raw() +// for free-form access when none of the structured methods are sufficient. +type PVSpecEditor struct { + spec *corev1.PersistentVolumeSpec +} + +// NewPVSpecEditor creates a new PVSpecEditor for the given PersistentVolumeSpec. +func NewPVSpecEditor(spec *corev1.PersistentVolumeSpec) *PVSpecEditor { + return &PVSpecEditor{spec: spec} +} + +// Raw returns the underlying *corev1.PersistentVolumeSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *PVSpecEditor) Raw() *corev1.PersistentVolumeSpec { + return e.spec +} + +// SetCapacity sets the storage capacity of the PersistentVolume. +func (e *PVSpecEditor) SetCapacity(storage resource.Quantity) { + if e.spec.Capacity == nil { + e.spec.Capacity = make(corev1.ResourceList) + } + e.spec.Capacity[corev1.ResourceStorage] = storage +} + +// SetAccessModes replaces the access modes of the PersistentVolume. +func (e *PVSpecEditor) SetAccessModes(modes []corev1.PersistentVolumeAccessMode) { + e.spec.AccessModes = modes +} + +// SetPersistentVolumeReclaimPolicy sets the reclaim policy for the PersistentVolume. +func (e *PVSpecEditor) SetPersistentVolumeReclaimPolicy(policy corev1.PersistentVolumeReclaimPolicy) { + e.spec.PersistentVolumeReclaimPolicy = policy +} + +// SetStorageClassName sets the storage class name for the PersistentVolume. +func (e *PVSpecEditor) SetStorageClassName(name string) { + e.spec.StorageClassName = name +} + +// SetMountOptions replaces the mount options for the PersistentVolume. +func (e *PVSpecEditor) SetMountOptions(options []string) { + e.spec.MountOptions = options +} + +// SetVolumeMode sets the volume mode for the PersistentVolume. +func (e *PVSpecEditor) SetVolumeMode(mode corev1.PersistentVolumeMode) { + e.spec.VolumeMode = &mode +} + +// SetNodeAffinity sets the node affinity for the PersistentVolume. +func (e *PVSpecEditor) SetNodeAffinity(affinity *corev1.VolumeNodeAffinity) { + e.spec.NodeAffinity = affinity +} diff --git a/pkg/mutation/editors/pvspec_test.go b/pkg/mutation/editors/pvspec_test.go new file mode 100644 index 00000000..fb6b51c7 --- /dev/null +++ b/pkg/mutation/editors/pvspec_test.go @@ -0,0 +1,105 @@ +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 TestPVSpecEditor_Raw(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{} + editor := NewPVSpecEditor(spec) + assert.Same(t, spec, editor.Raw()) +} + +func TestPVSpecEditor_SetCapacity(t *testing.T) { + t.Run("sets storage on nil capacity", func(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{} + editor := NewPVSpecEditor(spec) + qty := resource.MustParse("10Gi") + editor.SetCapacity(qty) + + require.NotNil(t, spec.Capacity) + assert.True(t, spec.Capacity[corev1.ResourceStorage].Equal(qty)) + }) + + t.Run("overwrites existing capacity", func(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + } + editor := NewPVSpecEditor(spec) + qty := resource.MustParse("20Gi") + editor.SetCapacity(qty) + + assert.True(t, spec.Capacity[corev1.ResourceStorage].Equal(qty)) + }) +} + +func TestPVSpecEditor_SetAccessModes(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{} + editor := NewPVSpecEditor(spec) + modes := []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + corev1.ReadOnlyMany, + } + editor.SetAccessModes(modes) + assert.Equal(t, modes, spec.AccessModes) +} + +func TestPVSpecEditor_SetPersistentVolumeReclaimPolicy(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{} + editor := NewPVSpecEditor(spec) + editor.SetPersistentVolumeReclaimPolicy(corev1.PersistentVolumeReclaimRetain) + assert.Equal(t, corev1.PersistentVolumeReclaimRetain, spec.PersistentVolumeReclaimPolicy) +} + +func TestPVSpecEditor_SetStorageClassName(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{} + editor := NewPVSpecEditor(spec) + editor.SetStorageClassName("fast-ssd") + assert.Equal(t, "fast-ssd", spec.StorageClassName) +} + +func TestPVSpecEditor_SetMountOptions(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{} + editor := NewPVSpecEditor(spec) + opts := []string{"hard", "nfsvers=4.1"} + editor.SetMountOptions(opts) + assert.Equal(t, opts, spec.MountOptions) +} + +func TestPVSpecEditor_SetVolumeMode(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{} + editor := NewPVSpecEditor(spec) + mode := corev1.PersistentVolumeBlock + editor.SetVolumeMode(mode) + require.NotNil(t, spec.VolumeMode) + assert.Equal(t, mode, *spec.VolumeMode) +} + +func TestPVSpecEditor_SetNodeAffinity(t *testing.T) { + spec := &corev1.PersistentVolumeSpec{} + editor := NewPVSpecEditor(spec) + affinity := &corev1.VolumeNodeAffinity{ + Required: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + { + MatchExpressions: []corev1.NodeSelectorRequirement{ + { + Key: "topology.kubernetes.io/zone", + Operator: corev1.NodeSelectorOpIn, + Values: []string{"us-east-1a"}, + }, + }, + }, + }, + }, + } + editor.SetNodeAffinity(affinity) + assert.Equal(t, affinity, spec.NodeAffinity) +} diff --git a/pkg/primitives/pv/builder.go b/pkg/primitives/pv/builder.go new file mode 100644 index 00000000..c22e7d8e --- /dev/null +++ b/pkg/primitives/pv/builder.go @@ -0,0 +1,100 @@ +// Package pv provides a builder and resource for managing Kubernetes PersistentVolumes. +package pv + +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 PersistentVolume Resource. +// +// It provides a fluent API for registering mutations, operational 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.PersistentVolume, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided PersistentVolume object. +// +// The PersistentVolume 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. +// +// PersistentVolumes are cluster-scoped; the provided object must have a Name set +// but must not have a Namespace. This is validated during the Build() call. +func NewBuilder(pv *corev1.PersistentVolume) *Builder { + identityFunc := func(p *corev1.PersistentVolume) string { + return fmt.Sprintf("v1/PersistentVolume/%s", p.Name) + } + + base := generic.NewIntegrationBuilder[*corev1.PersistentVolume, *Mutator]( + pv, + identityFunc, + NewMutator, + ) + base.MarkClusterScoped() + base.WithCustomOperationalStatus(DefaultOperationalStatusHandler) + + return &Builder{base: base} +} + +// WithMutation registers a mutation for the PersistentVolume. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation, +// after the baseline field applicator and any registered flavors have run. +// 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 +// PersistentVolume is operationally ready. +// +// The default behavior uses DefaultOperationalStatusHandler, which considers a PV +// operational when its phase is Available or Bound. Use this method if your PV +// requires more complex readiness checks. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *corev1.PersistentVolume) (concepts.OperationalStatusWithReason, error), +) *Builder { + b.base.WithCustomOperationalStatus(handler) + return b +} + +// WithDataExtractor registers a function to read values from the PersistentVolume +// after it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled PersistentVolume. This is +// useful for surfacing generated or updated fields to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(corev1.PersistentVolume) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(pv *corev1.PersistentVolume) error { + return extractor(*pv) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No PersistentVolume object was provided. +// - The PersistentVolume is missing a Name. +// - The PersistentVolume has a Namespace set (PVs are cluster-scoped). +// - Identity function or mutator factory is nil. +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/pv/builder_test.go b/pkg/primitives/pv/builder_test.go new file mode 100644 index 00000000..01cec72a --- /dev/null +++ b/pkg/primitives/pv/builder_test.go @@ -0,0 +1,141 @@ +package pv + +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" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder_Build_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pv *corev1.PersistentVolume + expectedErr string + }{ + { + name: "nil persistent volume", + pv: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + pv: &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "namespace set on cluster-scoped resource", + pv: &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv", Namespace: "should-not-be-set"}, + }, + expectedErr: "cluster-scoped object must not have a namespace", + }, + { + name: "valid persistent volume", + pv: &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.pv).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/PersistentVolume/test-pv", res.Identity()) + } + }) + } +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv"}, + } + res, err := NewBuilder(pv). + WithMutation(Mutation{Name: "test-mutation"}). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) +} + +func TestBuilder_WithCustomOperationalStatus(t *testing.T) { + t.Parallel() + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv"}, + } + handler := func(_ concepts.ConvergingOperation, _ *corev1.PersistentVolume) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational}, nil + } + res, err := NewBuilder(pv). + 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.OperationalStatusOperational, status.Status) +} + +func TestBuilder_WithDataExtractor(t *testing.T) { + t.Parallel() + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv"}, + } + called := false + extractor := func(_ corev1.PersistentVolume) error { + called = true + return nil + } + res, err := NewBuilder(pv). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&corev1.PersistentVolume{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv"}, + } + res, err := NewBuilder(pv). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv"}, + } + res, err := NewBuilder(pv). + WithDataExtractor(func(_ corev1.PersistentVolume) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&corev1.PersistentVolume{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/pv/handlers.go b/pkg/primitives/pv/handlers.go new file mode 100644 index 00000000..a078a2cc --- /dev/null +++ b/pkg/primitives/pv/handlers.go @@ -0,0 +1,60 @@ +package pv + +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 PersistentVolume +// is operationally ready. +// +// It considers a PV operational when its Status.Phase is Available or Bound. +// A PV in the Pending phase is reported as concepts.OperationalStatusPending. A PV in the +// Released or Failed phase is reported as concepts.OperationalStatusFailing. +// +// 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, pv *corev1.PersistentVolume, +) (concepts.OperationalStatusWithReason, error) { + switch pv.Status.Phase { + case corev1.VolumeAvailable: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "PersistentVolume is available", + }, nil + + case corev1.VolumeBound: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "PersistentVolume is bound to a claim", + }, nil + + case corev1.VolumePending: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "PersistentVolume is pending", + }, nil + + case corev1.VolumeReleased: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusFailing, + Reason: "PersistentVolume has been released and is not yet reclaimed", + }, nil + + case corev1.VolumeFailed: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusFailing, + Reason: "PersistentVolume reclamation has failed", + }, nil + + default: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: fmt.Sprintf("PersistentVolume phase is %q", pv.Status.Phase), + }, nil + } +} diff --git a/pkg/primitives/pv/handlers_test.go b/pkg/primitives/pv/handlers_test.go new file mode 100644 index 00000000..bd5252a3 --- /dev/null +++ b/pkg/primitives/pv/handlers_test.go @@ -0,0 +1,76 @@ +package pv + +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 + phase corev1.PersistentVolumePhase + wantStatus concepts.OperationalStatus + wantReason string + }{ + { + name: "available", + phase: corev1.VolumeAvailable, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "PersistentVolume is available", + }, + { + name: "bound", + phase: corev1.VolumeBound, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "PersistentVolume is bound to a claim", + }, + { + name: "pending", + phase: corev1.VolumePending, + wantStatus: concepts.OperationalStatusPending, + wantReason: "PersistentVolume is pending", + }, + { + name: "released", + phase: corev1.VolumeReleased, + wantStatus: concepts.OperationalStatusFailing, + wantReason: "PersistentVolume has been released and is not yet reclaimed", + }, + { + name: "failed", + phase: corev1.VolumeFailed, + wantStatus: concepts.OperationalStatusFailing, + wantReason: "PersistentVolume reclamation has failed", + }, + { + name: "unknown phase", + phase: corev1.PersistentVolumePhase("Unknown"), + wantStatus: concepts.OperationalStatusPending, + wantReason: `PersistentVolume phase is "Unknown"`, + }, + { + name: "empty phase", + phase: "", + wantStatus: concepts.OperationalStatusPending, + wantReason: `PersistentVolume phase is ""`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pv := &corev1.PersistentVolume{ + Status: corev1.PersistentVolumeStatus{ + Phase: tt.phase, + }, + } + got, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationNone, pv) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} diff --git a/pkg/primitives/pv/mutator.go b/pkg/primitives/pv/mutator.go new file mode 100644 index 00000000..1962fb01 --- /dev/null +++ b/pkg/primitives/pv/mutator.go @@ -0,0 +1,139 @@ +package pv + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a pv 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.PVSpecEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes PersistentVolume. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the PersistentVolume 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 { + pv *corev1.PersistentVolume + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given PersistentVolume. +// BeginFeature must be called before registering any mutations. +func NewMutator(pv *corev1.PersistentVolume) *Mutator { + return &Mutator{ + pv: pv, + } +} + +// 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 PersistentVolume's own metadata. +// +// Metadata edits are applied before spec edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditPVSpec records a mutation for the PersistentVolume's spec via a PVSpecEditor. +// +// The editor provides structured operations (SetCapacity, SetAccessModes, +// SetPersistentVolumeReclaimPolicy, 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. +func (m *Mutator) EditPVSpec(edit func(*editors.PVSpecEditor) error) { + if edit == nil { + return + } + m.active.specEdits = append(m.active.specEdits, edit) +} + +// SetStorageClassName records that the PV's storage class should be set to the given name. +// +// Convenience wrapper over EditPVSpec. +func (m *Mutator) SetStorageClassName(name string) { + m.EditPVSpec(func(e *editors.PVSpecEditor) error { + e.SetStorageClassName(name) + return nil + }) +} + +// SetReclaimPolicy records that the PV's reclaim policy should be set to the given value. +// +// Convenience wrapper over EditPVSpec. +func (m *Mutator) SetReclaimPolicy(policy corev1.PersistentVolumeReclaimPolicy) { + m.EditPVSpec(func(e *editors.PVSpecEditor) error { + e.SetPersistentVolumeReclaimPolicy(policy) + return nil + }) +} + +// SetMountOptions records that the PV's mount options should be set to the given values. +// +// Convenience wrapper over EditPVSpec. +func (m *Mutator) SetMountOptions(options []string) { + m.EditPVSpec(func(e *editors.PVSpecEditor) error { + e.SetMountOptions(options) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying PersistentVolume. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Spec edits — EditPVSpec, SetStorageClassName, SetReclaimPolicy, SetMountOptions +// (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the PersistentVolume 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.pv.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Spec edits + if len(plan.specEdits) > 0 { + editor := editors.NewPVSpecEditor(&m.pv.Spec) + for _, edit := range plan.specEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/pv/mutator_test.go b/pkg/primitives/pv/mutator_test.go new file mode 100644 index 00000000..e408fada --- /dev/null +++ b/pkg/primitives/pv/mutator_test.go @@ -0,0 +1,220 @@ +package pv + +import ( + "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 newTestPV() *corev1.PersistentVolume { + return &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pv", + }, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", pv.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EditPVSpec --- + +func TestMutator_EditPVSpec(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.EditPVSpec(func(e *editors.PVSpecEditor) error { + e.SetStorageClassName("fast-ssd") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "fast-ssd", pv.Spec.StorageClassName) +} + +func TestMutator_EditPVSpec_Nil(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.EditPVSpec(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditPVSpec_RawAccess(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.EditPVSpec(func(e *editors.PVSpecEditor) error { + e.Raw().PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimRetain + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, corev1.PersistentVolumeReclaimRetain, pv.Spec.PersistentVolumeReclaimPolicy) +} + +// --- Convenience methods --- + +func TestMutator_SetStorageClassName(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.SetStorageClassName("premium") + require.NoError(t, m.Apply()) + assert.Equal(t, "premium", pv.Spec.StorageClassName) +} + +func TestMutator_SetReclaimPolicy(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.SetReclaimPolicy(corev1.PersistentVolumeReclaimDelete) + require.NoError(t, m.Apply()) + assert.Equal(t, corev1.PersistentVolumeReclaimDelete, pv.Spec.PersistentVolumeReclaimPolicy) +} + +func TestMutator_SetMountOptions(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + opts := []string{"hard", "nfsvers=4.1"} + m.SetMountOptions(opts) + require.NoError(t, m.Apply()) + assert.Equal(t, opts, pv.Spec.MountOptions) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before spec edits. + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.SetStorageClassName("standard") + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", pv.Labels["order"]) + assert.Equal(t, "standard", pv.Spec.StorageClassName) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.SetStorageClassName("feature1-class") + m.BeginFeature() + m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain) + require.NoError(t, m.Apply()) + + assert.Equal(t, "feature1-class", pv.Spec.StorageClassName) + assert.Equal(t, corev1.PersistentVolumeReclaimRetain, pv.Spec.PersistentVolumeReclaimPolicy) +} + +func TestMutator_LaterFeatureObservesPrior(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.SetStorageClassName("first") + m.BeginFeature() + // Second feature should see the storage class set by the first. + m.EditPVSpec(func(e *editors.PVSpecEditor) error { + if e.Raw().StorageClassName == "first" { + e.SetStorageClassName("first-seen") + } + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "first-seen", pv.Spec.StorageClassName) +} + +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + + 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) { + pv := newTestPV() + m := NewMutator(pv) + + 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) { + pv := newTestPV() + m := NewMutator(pv) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.SetStorageClassName("class0") + + // Start a new feature and record a different mutation + m.BeginFeature() + m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain) + + // The initial plan should have exactly one spec edit + assert.Len(t, m.plans[0].specEdits, 1, "initial plan should have one edit") + // The second plan should also have exactly one spec edit + assert.Len(t, m.plans[1].specEdits, 1, "second plan should have one edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + pv := newTestPV() + m := NewMutator(pv) + m.BeginFeature() + m.SetStorageClassName("standard") + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, "standard", pv.Spec.StorageClassName) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/pv/resource.go b/pkg/primitives/pv/resource.go new file mode 100644 index 00000000..0a760f96 --- /dev/null +++ b/pkg/primitives/pv/resource.go @@ -0,0 +1,66 @@ +package pv + +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 PersistentVolume +// within a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - component.Operational: for tracking whether the PV is operationally ready. +// - component.DataExtractable: for exporting values after successful reconciliation. +// +// PersistentVolume resources use the Integration lifecycle: they report an +// OperationalStatus rather than an Alive/Grace status, and do not support suspension. +type Resource struct { + base *generic.IntegrationResource[*corev1.PersistentVolume, *Mutator] +} + +// Identity returns a unique identifier for the PersistentVolume in the format +// "v1/PersistentVolume/". +// +// PersistentVolumes are cluster-scoped and do not include a namespace in their identity. +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes PersistentVolume 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 PersistentVolume 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. +// +// 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 if the PersistentVolume is operationally ready. +// +// By default, it uses DefaultOperationalStatusHandler, which considers a PV +// operational when its phase is Available or Bound. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled PersistentVolume. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the PV. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/pv/resource_test.go b/pkg/primitives/pv/resource_test.go new file mode 100644 index 00000000..c26fcb70 --- /dev/null +++ b/pkg/primitives/pv/resource_test.go @@ -0,0 +1,162 @@ +package pv + +import ( + "errors" + "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 newValidPV() *corev1.PersistentVolume { + return &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pv", + }, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + PersistentVolumeSource: corev1.PersistentVolumeSource{ + HostPath: &corev1.HostPathVolumeSource{Path: "/data"}, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidPV()).Build() + require.NoError(t, err) + assert.Equal(t, "v1/PersistentVolume/test-pv", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + pv := newValidPV() + res, err := NewBuilder(pv).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*corev1.PersistentVolume) + require.True(t, ok) + assert.Equal(t, pv.Name, got.Name) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-pv", pv.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidPV() + 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.PersistentVolume) + expectedCapacity := resource.MustParse("10Gi") + actualCapacity := got.Spec.Capacity[corev1.ResourceStorage] + assert.True(t, expectedCapacity.Equal(actualCapacity)) + assert.Equal(t, "/data", got.Spec.HostPath.Path) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidPV() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "set-storage-class", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetStorageClassName("fast-ssd") + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.PersistentVolume) + assert.Equal(t, "fast-ssd", got.Spec.StorageClassName) + assert.Equal(t, "/data", got.Spec.HostPath.Path) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidPV() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetStorageClassName("a") + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetStorageClassName("b") + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*corev1.PersistentVolume) + assert.Equal(t, "b", got.Spec.StorageClassName) +} + +func TestResource_ConvergingStatus(t *testing.T) { + desired := newValidPV() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationNone) + require.NoError(t, err) + // Default handler on a PV with no phase set returns OperationPending. + assert.Equal(t, concepts.OperationalStatusPending, status.Status) +} + +func TestResource_ExtractData(t *testing.T) { + pv := newValidPV() + var extracted string + res, err := NewBuilder(pv). + WithDataExtractor(func(p corev1.PersistentVolume) error { + extracted = p.Spec.HostPath.Path + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "/data", extracted) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidPV()). + WithDataExtractor(func(_ corev1.PersistentVolume) error { + return errors.New("extract error") + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.Error(t, err) + assert.Contains(t, err.Error(), "extract error") +}