Skip to content

PersistentVolume Primitive

The pv primitive wraps a Kubernetes PersistentVolume and integrates with the component lifecycle as an Integration and Graceful resource, providing a structured mutation API for managing PV spec fields and object metadata.

Capabilities

Capability Detail
Operational Maps PV phase to Operational, OperationPending, or OperationFailing
Graceful Available/Bound are Healthy; Pending is Degraded; Released/Failed are Down
Cluster-scoped No namespace in the identity or builder. PersistentVolumes are cluster-scoped resources
DataExtractable Reads generated or updated values back from the reconciled PersistentVolume after each sync cycle
Mutation pipeline Typed editors for PV spec fields and object metadata, with a Raw() escape hatch for free-form access

See Lifecycle Interfaces for the full set of status values each interface reports. For cluster-scoped handling and owner-reference behavior, see Cluster-Scoped Primitives.

Building a PersistentVolume Primitive

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 causes Build() to return an error.

Mutations

Register mutations with WithMutation. The mutation system, boolean-gated mutations, and version-gated mutations are explained in The Mutation System, Boolean-Gated Mutations, and Version-Gated Mutations.

A kind-specific example using the SetStorageClassName convenience method:

func RetainPolicyMutation(version string, retainEnabled bool) pv.Mutation {
    return pv.Mutation{
        Name:    "retain-policy",
        Feature: feature.NewVersionGate(version, nil).When(retainEnabled),
        Mutate: func(m *pv.Mutator) error {
            m.SetReclaimPolicy(corev1.PersistentVolumeReclaimRetain)
            return nil
        },
    }
}

Internal Mutation Ordering

Within a single mutation, edits are applied in a fixed category order regardless of recording order:

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 run in registration order. Later features observe the PersistentVolume as modified by all earlier ones.

Relevant Editors

See Mutation Editors for the general editor model.

PVSpecEditor

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

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:

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.

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) EditPVSpece.SetStorageClassName(name)
SetReclaimPolicy(policy) EditPVSpece.SetPersistentVolumeReclaimPolicy(p)
SetMountOptions(opts) EditPVSpece.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 implements concepts.Operational. The default handler maps PV phase to operational status:

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

Override with WithCustomOperationalStatus when your PV requires different readiness logic.

Grace Status

The default grace status handler maps the PV phase to a grace status after the grace period expires:

PV Phase Status Meaning
Available Healthy PV is ready for binding
Bound Healthy PV is bound to a PersistentVolumeClaim
Pending Degraded PV is waiting to become available
Released Down PV was released, not yet reclaimed
Failed Down PV reclamation has failed

Override with WithCustomGraceStatus:

pv.NewBuilder(base).
    WithCustomGraceStatus(func(p *corev1.PersistentVolume) (concepts.GraceStatusWithReason, error) {
        status, err := pv.DefaultGraceStatusHandler(p)
        if err != nil {
            return status, err
        }
        // Add custom logic
        return status, nil
    })

Full Example

func StorageClassMutation(version string) pv.Mutation {
    return pv.Mutation{
        Name:    "storage-class",
        Feature: feature.NewVersionGate(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.NewVersionGate(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.

Understand the garbage collection constraint. 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, the framework detects the mismatch and skips setting ownerReferences instead of letting the API server reject the request. Such PVs will not be garbage collected automatically when the owning component is deleted. Either model the PV under a dedicated cluster-scoped component to allow a valid controller reference, or accept that PVs managed from a namespace-scoped component require explicit lifecycle handling.

Use string status values in conditions. The operational status values that appear in conditions are the runtime strings "Operational", "OperationPending", and "OperationFailing", not the Go constant identifiers.

Register mutations in dependency order. If mutation B relies on a field set by mutation A, register A first.