diff --git a/osac-operator/AGENTS.md b/osac-operator/AGENTS.md index 3d190f041..3c5ab398d 100644 --- a/osac-operator/AGENTS.md +++ b/osac-operator/AGENTS.md @@ -16,6 +16,7 @@ OSAC operator is a Kubernetes operator that reconciles infrastructure resources - **ExternalIP** — external IP allocated from ExternalIPPool - **ExternalIPAttachment** — attachment of ExternalIP to ComputeInstance - **NATGateway** — outbound SNAT for a VirtualNetwork +- **Volume** (`vol`) — block storage on vendor arrays via CSI ## Critical Rules diff --git a/osac-operator/README.md b/osac-operator/README.md index 78eff1a55..b714b6020 100644 --- a/osac-operator/README.md +++ b/osac-operator/README.md @@ -21,6 +21,8 @@ custom resources and reconciles them to their desired state: - **Subnet** (`subnet`) — represents a subnet within a VirtualNetwork. - **SecurityGroup** (`sg`) — defines network security (firewall) rules with ingress/egress rules, protocols, port ranges, and CIDR blocks. +- **Volume** (`vol`) — provisions block storage on vendor arrays via the + VendorProvisioner interface (vendor CSI controllers). ## Configuration diff --git a/osac-operator/api/v1alpha1/volume_names.go b/osac-operator/api/v1alpha1/volume_names.go deleted file mode 100644 index 9b773388d..000000000 --- a/osac-operator/api/v1alpha1/volume_names.go +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2026. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1alpha1 - -const ( - // VolumeNamespace is the default namespace where Volume CRs are created. - VolumeNamespace = "osac-volume" - - // VolumeLabelName is the label key for the volume name. - VolumeLabelName = "osac.openshift.io/volume" - - // VolumeLabelUUID is the label key for the fulfillment-service volume ID, - // used by the feedback controller to map Volume CRs back to inventory records. - VolumeLabelUUID = "osac.openshift.io/volume-uuid" - - // VolumeFinalizer is the finalizer managed by the Volume resource controller. - VolumeFinalizer = "osac.openshift.io/volume-finalizer" - - // VolumeFeedbackFinalizer is the finalizer managed by the Volume feedback controller. - VolumeFeedbackFinalizer = "osac.openshift.io/volume-feedback" - - // VolumeCleanupFinalizer is the finalizer added to ClusterOrder when volumes - // are created, blocking cluster deletion until volumes are processed. - VolumeCleanupFinalizer = "osac.openshift.io/volume-cleanup" -) diff --git a/osac-operator/api/v1alpha1/volume_types.go b/osac-operator/api/v1alpha1/volume_types.go index f9008937d..4fb6a7799 100644 --- a/osac-operator/api/v1alpha1/volume_types.go +++ b/osac-operator/api/v1alpha1/volume_types.go @@ -56,6 +56,10 @@ const ( ) // VolumeProtocol defines valid storage protocols for volumes. +// When adding a value here, also add it to the kubebuilder Enum below, to the +// crdProtocolToProto switch in internal/controller/volume_feedback_controller.go, +// and to allVolumeProtocols in that controller's test (which fails if the switch +// is left incomplete). // +kubebuilder:validation:Enum=Block;NFS type VolumeProtocol string diff --git a/osac-operator/charts/operator/templates/clusterrole.yaml b/osac-operator/charts/operator/templates/clusterrole.yaml index b960a3b54..fdb8200a6 100644 --- a/osac-operator/charts/operator/templates/clusterrole.yaml +++ b/osac-operator/charts/operator/templates/clusterrole.yaml @@ -92,6 +92,7 @@ rules: - subnets - tenants - virtualnetworks + - volumes verbs: - create - delete @@ -114,6 +115,7 @@ rules: - subnets/finalizers - tenants/finalizers - virtualnetworks/finalizers + - volumes/finalizers verbs: - update - apiGroups: @@ -130,6 +132,7 @@ rules: - subnets/status - tenants/status - virtualnetworks/status + - volumes/status verbs: - get - patch diff --git a/osac-operator/charts/operator/templates/deployment.yaml b/osac-operator/charts/operator/templates/deployment.yaml index b8bb06cf1..d06665901 100644 --- a/osac-operator/charts/operator/templates/deployment.yaml +++ b/osac-operator/charts/operator/templates/deployment.yaml @@ -94,6 +94,10 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace + - name: OSAC_VOLUME_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace - name: OSAC_ENABLE_CLUSTER_CONTROLLER value: {{ .Values.controllers.clusterOrder | quote }} - name: OSAC_ENABLE_COMPUTE_INSTANCE_CONTROLLER @@ -106,6 +110,8 @@ spec: value: {{ .Values.controllers.bareMetalInstance | quote }} - name: OSAC_ENABLE_STORAGE_CONTROLLER value: {{ .Values.controllers.storage | quote }} + - name: OSAC_ENABLE_VOLUME_CONTROLLER + value: {{ .Values.controllers.volume | quote }} securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/osac-operator/charts/operator/templates/hub-access-clusterrole.yaml b/osac-operator/charts/operator/templates/hub-access-clusterrole.yaml index e8242f73c..16841b2e8 100644 --- a/osac-operator/charts/operator/templates/hub-access-clusterrole.yaml +++ b/osac-operator/charts/operator/templates/hub-access-clusterrole.yaml @@ -22,6 +22,7 @@ rules: - subnets - tenants - virtualnetworks + - volumes verbs: - create - delete @@ -44,6 +45,7 @@ rules: - subnets/status - tenants/status - virtualnetworks/status + - volumes/status verbs: - get - apiGroups: diff --git a/osac-operator/charts/operator/values.yaml b/osac-operator/charts/operator/values.yaml index a119e6321..cf6281cf1 100644 --- a/osac-operator/charts/operator/values.yaml +++ b/osac-operator/charts/operator/values.yaml @@ -31,6 +31,10 @@ controllers: networking: true bareMetalInstance: true storage: true + # Disabled until OSAC-4138 wires the vendor CSI provisioner; enabling it now + # would only leave Volumes stuck in CREATING (nil provisioner). Flip to true + # as part of OSAC-4138. + volume: false configSecret: name: "osac-config" diff --git a/osac-operator/cmd/main.go b/osac-operator/cmd/main.go index 6bcbad1b0..b264ccba0 100644 --- a/osac-operator/cmd/main.go +++ b/osac-operator/cmd/main.go @@ -83,6 +83,7 @@ const ( envClusterOrderNamespace = "OSAC_CLUSTER_ORDER_NAMESPACE" envAgentNamespace = "OSAC_AGENT_NAMESPACE" envBareMetalInstanceNamespace = "OSAC_BARE_METAL_INSTANCE_NAMESPACE" + envVolumeNamespace = "OSAC_VOLUME_NAMESPACE" // AAP configuration envAAPURL = "OSAC_AAP_URL" @@ -118,6 +119,7 @@ const ( // Controller enable flags (defaults when flag is not set) envEnableTenantController = "OSAC_ENABLE_TENANT_CONTROLLER" envEnableStorageController = "OSAC_ENABLE_STORAGE_CONTROLLER" + envEnableVolumeController = "OSAC_ENABLE_VOLUME_CONTROLLER" envEnableComputeInstanceController = "OSAC_ENABLE_COMPUTE_INSTANCE_CONTROLLER" envEnableClusterController = "OSAC_ENABLE_CLUSTER_CONTROLLER" envEnableNetworkingController = "OSAC_ENABLE_NETWORKING_CONTROLLER" @@ -136,6 +138,7 @@ const ( type controllerFlags struct { Tenant bool Storage bool + Volume bool ComputeInstance bool Cluster bool Networking bool @@ -151,7 +154,10 @@ func registerControllerFlags() *controllerFlags { "Enable the tenant controller.") flag.BoolVar(&flags.Storage, "enable-storage-controller", helpers.GetEnvWithDefault(envEnableStorageController, false), - "Enable the storage controller.") + "Enable the storage controller (tenant StorageClass management, ClusterOrder storage provisioning).") + flag.BoolVar(&flags.Volume, "enable-volume-controller", + helpers.GetEnvWithDefault(envEnableVolumeController, false), + "Enable the volume controller (block volume provisioning via vendor CSI).") flag.BoolVar(&flags.ComputeInstance, "enable-compute-instance-controller", helpers.GetEnvWithDefault(envEnableComputeInstanceController, false), "Enable the compute-instance controller.") @@ -168,15 +174,21 @@ func registerControllerFlags() *controllerFlags { } // enableAllIfNoneSet enables all controllers if none are explicitly enabled. +// +// The Volume controller is intentionally excluded: its VendorProvisioner is a +// nil stub until OSAC-4138 wires the real vendor CSI client, so enabling it +// would only leave Volumes stuck in Progressing/CREATING with no path to +// success. It stays opt-in (--enable-volume-controller) until then. OSAC-4138 +// adds it back here and flips controllers.volume to true in the Helm values. func (f *controllerFlags) enableAllIfNoneSet() { - if !f.Tenant && !f.Storage && !f.ComputeInstance && !f.Cluster && !f.Networking && !f.BareMetalInstance { + if !f.Tenant && !f.Storage && !f.Volume && !f.ComputeInstance && !f.Cluster && !f.Networking && !f.BareMetalInstance { f.Tenant = true f.Storage = true f.ComputeInstance = true f.Cluster = true f.Networking = true f.BareMetalInstance = true - setupLog.Info("no controller flags set, enabling all controllers") + setupLog.Info("no controller flags set, enabling all controllers except volume (no vendor provisioner configured)") } } @@ -473,6 +485,80 @@ func setupStorageController(mgr mcmanager.Manager, grpcConn *grpc.ClientConn, ma return nil } +// setupControllers registers all enabled controllers with the manager. +func setupControllers( + mgr mcmanager.Manager, grpcConn *grpc.ClientConn, + flags *controllerFlags, maxJobHistory int, +) error { + if flags.Cluster { + if err := setupClusterControllers(mgr, grpcConn, maxJobHistory); err != nil { + return fmt.Errorf("cluster controllers: %w", err) + } + } + if flags.ComputeInstance { + if err := setupComputeInstanceControllers(mgr, grpcConn, maxJobHistory); err != nil { + return fmt.Errorf("computeinstance controllers: %w", err) + } + } + if flags.Tenant { + if err := setupTenantController(mgr); err != nil { + return fmt.Errorf("tenant controller: %w", err) + } + } + if flags.Storage { + if err := setupStorageController(mgr, grpcConn, maxJobHistory); err != nil { + return fmt.Errorf("storage controller: %w", err) + } + } + if flags.Volume { + if err := setupVolumeControllers(mgr, grpcConn); err != nil { + return fmt.Errorf("volume controllers: %w", err) + } + } + if flags.Networking { + if err := setupNetworkingControllers(mgr, grpcConn, maxJobHistory); err != nil { + return fmt.Errorf("networking controllers: %w", err) + } + } + if flags.BareMetalInstance { + if err := setupBareMetalInstanceControllers(mgr, grpcConn); err != nil { + return fmt.Errorf("baremetalinstance controllers: %w", err) + } + } + return nil +} + +// setupVolumeControllers registers the Volume resource controller and, when +// grpcConn is set, the Volume feedback controller. The Volume controller uses +// a VendorProvisioner interface instead of AAP; for now no real vendor is +// configured (nil provisioner), so the controller sets Progressing and waits +// for the vendor CSI integration in a follow-up PR. +func setupVolumeControllers(mgr mcmanager.Manager, grpcConn *grpc.ClientConn) error { + localMgr := mgr.GetLocalManager() + volumeNamespace := os.Getenv(envVolumeNamespace) + + if grpcConn != nil { + if err := controller.NewVolumeFeedbackReconciler( + localMgr.GetClient(), + grpcConn, + volumeNamespace, + ).SetupWithManager(mgr); err != nil { + return fmt.Errorf("volume feedback controller: %w", err) + } + } + + // VendorProvisioner is nil until the real vendor CSI client is wired. + // The controller will set phase to Progressing and skip provisioning. + if err := controller.NewVolumeReconciler( + mgr, + volumeNamespace, + nil, + ).SetupWithManager(mgr); err != nil { + return fmt.Errorf("volume controller: %w", err) + } + return nil +} + // setupNetworkingControllers registers all networking controllers along with their // feedback controllers when grpcConn is set. func setupNetworkingControllers( @@ -976,41 +1062,9 @@ func main() { }) setupLog.Info("job history configuration", "maxJobs", maxJobHistory) - if ctrlFlags.Cluster { - if err := setupClusterControllers(mgr, grpcConn, maxJobHistory); err != nil { - setupLog.Error(err, "unable to setup cluster controllers") - os.Exit(1) - } - } - if ctrlFlags.ComputeInstance { - if err := setupComputeInstanceControllers(mgr, grpcConn, maxJobHistory); err != nil { - setupLog.Error(err, "unable to setup computeinstance controllers") - os.Exit(1) - } - } - if ctrlFlags.Tenant { - if err := setupTenantController(mgr); err != nil { - setupLog.Error(err, "unable to setup tenant controller") - os.Exit(1) - } - } - if ctrlFlags.Storage { - if err := setupStorageController(mgr, grpcConn, maxJobHistory); err != nil { - setupLog.Error(err, "unable to setup storage controller") - os.Exit(1) - } - } - if ctrlFlags.Networking { - if err := setupNetworkingControllers(mgr, grpcConn, maxJobHistory); err != nil { - setupLog.Error(err, "unable to setup networking controllers") - os.Exit(1) - } - } - if ctrlFlags.BareMetalInstance { - if err := setupBareMetalInstanceControllers(mgr, grpcConn); err != nil { - setupLog.Error(err, "unable to setup baremetalinstance controllers") - os.Exit(1) - } + if err := setupControllers(mgr, grpcConn, ctrlFlags, maxJobHistory); err != nil { + setupLog.Error(err, "unable to setup controllers") + os.Exit(1) } // +kubebuilder:scaffold:builder diff --git a/osac-operator/config/rbac/role.yaml b/osac-operator/config/rbac/role.yaml index ab7b0cd9b..c73b8ef6a 100644 --- a/osac-operator/config/rbac/role.yaml +++ b/osac-operator/config/rbac/role.yaml @@ -119,6 +119,7 @@ rules: - subnets/finalizers - tenants/finalizers - virtualnetworks/finalizers + - volumes/finalizers verbs: - update - apiGroups: @@ -134,6 +135,7 @@ rules: - subnets - tenants - virtualnetworks + - volumes verbs: - create - delete @@ -155,6 +157,7 @@ rules: - subnets/status - tenants/status - virtualnetworks/status + - volumes/status verbs: - get - patch diff --git a/osac-operator/internal/controller/volume_controller.go b/osac-operator/internal/controller/volume_controller.go new file mode 100644 index 000000000..f39572a69 --- /dev/null +++ b/osac-operator/internal/controller/volume_controller.go @@ -0,0 +1,320 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + controllerutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + "github.com/osac-project/osac/osac-operator/api/v1alpha1" +) + +// osacVolumeFinalizer is the finalizer the Volume resource controller adds so +// it can deprovision the backend volume before the CR is deleted. It is owned +// by this controller only, so it lives here rather than in volume_names.go +// (which holds identifiers shared with the feedback controller). +const osacVolumeFinalizer = "osac.openshift.io/volume-finalizer" + +// VendorProvisioner abstracts vendor storage array operations. Unlike other +// OSAC resources that provision through AAP (RunProvisioningLifecycle), volumes +// are provisioned by calling the vendor CSI controller directly. This interface +// decouples the controller from the vendor implementation, allowing a mock for +// testing and development while the real vendor CSI client is wired in PR #3. +type VendorProvisioner interface { + CreateVolume(ctx context.Context, req VendorCreateVolumeRequest) (VendorCreateVolumeResponse, error) + DeleteVolume(ctx context.Context, req VendorDeleteVolumeRequest) error +} + +// VendorCreateVolumeRequest carries the parameters the vendor needs to +// provision a volume. Backend is resolved by the fulfillment-service tier +// resolution (OSAC-3277) before the Volume CR is created; the operator +// passes it through to the vendor without re-resolving. +type VendorCreateVolumeRequest struct { + Name string + Backend string + SizeGiB int64 + AccessMode v1alpha1.VolumeAccessMode +} + +// VendorCreateVolumeResponse carries the vendor-assigned identifiers that the +// feedback controller syncs back to the fulfillment-service inventory. +type VendorCreateVolumeResponse struct { + VendorVolumeID string + Backend string + Protocol string +} + +// VendorDeleteVolumeRequest identifies the vendor volume to deprovision. +type VendorDeleteVolumeRequest struct { + VendorVolumeID string + Backend string +} + +// VolumeReconciler reconciles Volume CRs created by the fulfillment-service +// reconciler. It calls the VendorProvisioner to create volumes on the backend +// storage array and updates the CR status with vendor-assigned identifiers. +// The feedback controller then syncs that status back to fulfillment-service. +// +// Unlike networking and compute controllers that use AAP for provisioning, +// this controller calls the vendor CSI directly because storage provisioning +// is a synchronous gRPC call, not an asynchronous job. +type VolumeReconciler struct { + client.Client + Scheme *runtime.Scheme + mgr mcmanager.Manager + VolumeNamespace string + VendorProvisioner VendorProvisioner +} + +// NewVolumeReconciler creates a new reconciler for Volume resources. The +// volumeNamespace controls which namespace the controller watches; in +// production this is set via OSAC_VOLUME_NAMESPACE (same as the Helm release +// namespace), defaulting to "osac-volume" for local development. +func NewVolumeReconciler( + mgr mcmanager.Manager, + volumeNamespace string, + vendorProvisioner VendorProvisioner, +) *VolumeReconciler { + if mgr == nil { + panic("mgr must not be nil") + } + if volumeNamespace == "" { + volumeNamespace = defaultVolumeNamespace + } + return &VolumeReconciler{ + Client: mgr.GetLocalManager().GetClient(), + Scheme: mgr.GetLocalManager().GetScheme(), + mgr: mgr, + VolumeNamespace: volumeNamespace, + VendorProvisioner: vendorProvisioner, + } +} + +// +kubebuilder:rbac:groups=osac.openshift.io,resources=volumes,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=osac.openshift.io,resources=volumes/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=osac.openshift.io,resources=volumes/finalizers,verbs=update + +// Reconcile is part of the main Kubernetes reconciliation loop. It drives +// Volume CRs through the provisioning lifecycle: Progressing -> Ready (on +// success) or Failed (on vendor error), and handles deletion by calling +// the vendor to deprovision before removing the finalizer. +func (r *VolumeReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + log := ctrllog.FromContext(ctx) + + vol := &v1alpha1.Volume{} + if err := r.Get(ctx, req.NamespacedName, vol); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + log.Info("start reconcile") + + oldstatus := vol.Status.DeepCopy() + + var res ctrl.Result + var err error + if vol.ObjectMeta.DeletionTimestamp.IsZero() { + res, err = r.handleUpdate(ctx, vol) + } else { + res, err = r.handleDelete(ctx, vol) + } + + if !equality.Semantic.DeepEqual(vol.Status, *oldstatus) { + log.Info("status requires update") + if updateErr := r.Status().Update(ctx, vol); updateErr != nil { + // On the delete path the object may already be gone once its last + // finalizer was removed; tolerate NotFound and preserve any + // reconcile error alongside a genuine status-update failure. + return res, errors.Join(err, client.IgnoreNotFound(updateErr)) + } + } + + log.Info("end reconcile") + return res, err +} + +// handleUpdate runs on every non-deleted reconcile. It ensures the finalizer +// is present, sets the initial phase to Progressing, and delegates to +// handleProvisioning if the volume has not yet reached Ready. +func (r *VolumeReconciler) handleUpdate(ctx context.Context, vol *v1alpha1.Volume) (ctrl.Result, error) { + log := ctrllog.FromContext(ctx) + + if controllerutil.AddFinalizer(vol, osacVolumeFinalizer) { + if err := r.Update(ctx, vol); err != nil { + return ctrl.Result{}, err + } + } + + if vol.Status.Phase == "" { + vol.Status.Phase = v1alpha1.VolumePhaseProgressing + } + + if r.VendorProvisioner == nil { + // Temporary: VendorProvisioner is nil until the real vendor CSI client + // is wired in the CSI driver integration PR. Remove this guard once a + // concrete implementation is always passed to NewVolumeReconciler. + log.Info("no vendor provisioner configured, skipping provisioning") + return ctrl.Result{}, nil + } + + // Already provisioned; nothing to do until spec changes (future: resize). + if vol.Status.Phase == v1alpha1.VolumePhaseReady { + return ctrl.Result{}, nil + } + + // Failed is terminal: vendor provisioning is not auto-retried, to avoid + // spamming the vendor API with a persistent configuration error. Recovery + // requires recreating the Volume (the fulfillment-service reconciler creates + // a fresh CR), which starts over from an empty phase. There is no in-place + // reset to Progressing here. + if vol.Status.Phase == v1alpha1.VolumePhaseFailed { + return ctrl.Result{}, nil + } + + return r.handleProvisioning(ctx, vol) +} + +// handleProvisioning calls the vendor CSI to create the volume on the backend +// array. On success it transitions the phase to Ready and sets the +// VendorProvisioned condition. On failure it transitions to Failed and returns +// nil (no retry) so the error is visible in the condition; the feedback +// controller will sync this state to the fulfillment-service. +func (r *VolumeReconciler) handleProvisioning(ctx context.Context, vol *v1alpha1.Volume) (ctrl.Result, error) { + log := ctrllog.FromContext(ctx) + + resp, err := r.VendorProvisioner.CreateVolume(ctx, VendorCreateVolumeRequest{ + Name: vol.Name, + Backend: vol.Status.Backend, + SizeGiB: vol.Spec.SizeGiB, + AccessMode: vol.Spec.AccessMode, + }) + if err != nil { + log.Error(err, "vendor provisioning failed") + vol.Status.Phase = v1alpha1.VolumePhaseFailed + setVendorProvisionedCondition(&vol.Status.Conditions, metav1.ConditionFalse, "ProvisioningFailed", err.Error()) + return ctrl.Result{}, nil + } + + vol.Status.VendorVolumeID = resp.VendorVolumeID + vol.Status.Backend = resp.Backend + vol.Status.Protocol = v1alpha1.VolumeProtocol(resp.Protocol) + vol.Status.Phase = v1alpha1.VolumePhaseReady + setVendorProvisionedCondition(&vol.Status.Conditions, metav1.ConditionTrue, "Provisioned", "Volume provisioned on vendor storage array") + + log.Info("vendor provisioning succeeded", + "vendorVolumeID", resp.VendorVolumeID, + "backend", resp.Backend, + "protocol", resp.Protocol, + ) + + return ctrl.Result{}, nil +} + +// handleDelete runs when the Volume CR has a deletion timestamp. It calls the +// vendor to deprovision the volume from the backend array, then removes the +// resource controller's finalizer. If deprovisioning fails the error is +// returned so the reconciler retries on the next cycle. +func (r *VolumeReconciler) handleDelete(ctx context.Context, vol *v1alpha1.Volume) (ctrl.Result, error) { + log := ctrllog.FromContext(ctx) + log.Info("deleting volume") + + vol.Status.Phase = v1alpha1.VolumePhaseDeleting + + if !controllerutil.ContainsFinalizer(vol, osacVolumeFinalizer) { + return ctrl.Result{}, nil + } + + // A provisioned volume (VendorVolumeID set) must be deprovisioned on the + // vendor array before the finalizer is removed. If no provisioner is + // configured we refuse to remove the finalizer; otherwise the backend + // volume would leak silently. The reconcile requeues until a provisioner + // is available. Volumes that failed before vendor provisioning have no + // VendorVolumeID, so they fall through to finalizer removal. + if vol.Status.VendorVolumeID != "" { + if r.VendorProvisioner == nil { + return ctrl.Result{}, fmt.Errorf( + "volume %q has vendorVolumeID %q but no vendor provisioner is configured; "+ + "refusing to remove finalizer to avoid leaking the backend volume", + vol.Name, vol.Status.VendorVolumeID) + } + err := r.VendorProvisioner.DeleteVolume(ctx, VendorDeleteVolumeRequest{ + VendorVolumeID: vol.Status.VendorVolumeID, + Backend: vol.Status.Backend, + }) + if err != nil { + log.Error(err, "vendor deprovisioning failed") + return ctrl.Result{}, err + } + log.Info("vendor deprovisioning succeeded", "vendorVolumeID", vol.Status.VendorVolumeID) + } + + if controllerutil.RemoveFinalizer(vol, osacVolumeFinalizer) { + if err := r.Update(ctx, vol); err != nil { + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil +} + +// VolumeNamespacePredicate filters events to only those in the configured +// volume namespace, preventing the controller from reacting to Volume CRs +// in other namespaces. +func VolumeNamespacePredicate(namespace string) predicate.Predicate { + return predicate.NewPredicateFuncs( + func(obj client.Object) bool { + return obj.GetNamespace() == namespace + }, + ) +} + +// SetupWithManager registers the Volume controller with the manager. It +// watches Volume CRs in the configured namespace on the local (hub) cluster. +func (r *VolumeReconciler) SetupWithManager(mgr mcmanager.Manager) error { + return mcbuilder.ControllerManagedBy(mgr). + For(&v1alpha1.Volume{}, + mcbuilder.WithPredicates(VolumeNamespacePredicate(r.VolumeNamespace)), + mcbuilder.WithEngageWithLocalCluster(true), + mcbuilder.WithEngageWithProviderClusters(false)). + Complete(r) +} + +// setVendorProvisionedCondition upserts the VendorProvisioned condition. +// Uses apimeta.SetStatusCondition which preserves LastTransitionTime when +// the status hasn't changed, but always updates Reason and Message so +// repeated failures reflect the latest error. +func setVendorProvisionedCondition(conditions *[]metav1.Condition, status metav1.ConditionStatus, reason, message string) { + apimeta.SetStatusCondition(conditions, metav1.Condition{ + Type: string(v1alpha1.VolumeConditionVendorProvisioned), + Status: status, + Reason: reason, + Message: message, + }) +} diff --git a/osac-operator/internal/controller/volume_controller_test.go b/osac-operator/internal/controller/volume_controller_test.go new file mode 100644 index 000000000..c77c6e6b0 --- /dev/null +++ b/osac-operator/internal/controller/volume_controller_test.go @@ -0,0 +1,354 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + osacv1alpha1 "github.com/osac-project/osac/osac-operator/api/v1alpha1" +) + +var _ = Describe("VolumeReconciler", func() { + var ( + reconciler *VolumeReconciler + mockProv *MockVendorProvisioner + testCtx context.Context + vol *osacv1alpha1.Volume + ) + + BeforeEach(func() { + testCtx = context.TODO() + mockProv = NewMockVendorProvisioner() + reconciler = &VolumeReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + mgr: testMcManager, + VolumeNamespace: "default", + VendorProvisioner: mockProv, + } + + vol = &osacv1alpha1.Volume{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vol", + Namespace: "default", + }, + Spec: osacv1alpha1.VolumeSpec{ + StorageTier: "gold", + SizeGiB: 100, + AccessMode: osacv1alpha1.VolumeAccessModeReadWriteOnce, + }, + } + }) + + AfterEach(func() { + volKey := types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace} + existingVol := &osacv1alpha1.Volume{} + if err := k8sClient.Get(testCtx, volKey, existingVol); err == nil { + existingVol.Finalizers = nil + _ = k8sClient.Update(testCtx, existingVol) + _ = k8sClient.Delete(testCtx, existingVol) + } + }) + + It("should add finalizer on first reconcile", func() { + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + updated := &osacv1alpha1.Volume{} + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, updated)).To(Succeed()) + Expect(updated.Finalizers).To(ContainElement(osacVolumeFinalizer)) + }) + + It("should reach Ready on first reconcile when the mock provisioner succeeds", func() { + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + updated := &osacv1alpha1.Volume{} + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, updated)).To(Succeed()) + // Phase should be Ready because the mock provisioner succeeds immediately + Expect(updated.Status.Phase).To(Equal(osacv1alpha1.VolumePhaseReady)) + }) + + It("should provision volume and set status fields on success", func() { + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + // A single reconcile adds the finalizer and provisions to Ready: + // handleUpdate adds the finalizer, then falls through to + // handleProvisioning in the same pass. + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + // A second reconcile is idempotent (phase is already Ready). + _, err = reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + updated := &osacv1alpha1.Volume{} + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, updated)).To(Succeed()) + + Expect(updated.Status.Phase).To(Equal(osacv1alpha1.VolumePhaseReady)) + Expect(updated.Status.VendorVolumeID).To(HavePrefix("mock-")) + Expect(updated.Status.Backend).To(Equal("mock-backend")) + Expect(updated.Status.Protocol).To(Equal(osacv1alpha1.VolumeProtocolBlock)) + Expect(mockProv.CreateCallCount()).To(BeNumerically(">=", 1)) + + cond := apimeta.FindStatusCondition(updated.Status.Conditions, string(osacv1alpha1.VolumeConditionVendorProvisioned)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal("Provisioned")) + }) + + It("should set phase to Failed when vendor provisioning fails", func() { + mockProv.CreateErr = fmt.Errorf("vendor array unreachable") + + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + // A single reconcile adds the finalizer and attempts provisioning, + // which fails and transitions the phase to Failed (no error returned; + // the failure is recorded in the condition). + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + updated := &osacv1alpha1.Volume{} + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, updated)).To(Succeed()) + + Expect(updated.Status.Phase).To(Equal(osacv1alpha1.VolumePhaseFailed)) + + cond := apimeta.FindStatusCondition(updated.Status.Conditions, string(osacv1alpha1.VolumeConditionVendorProvisioned)) + Expect(cond).ToNot(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal("ProvisioningFailed")) + Expect(cond.Message).To(ContainSubstring("vendor array unreachable")) + }) + + It("does not auto-retry provisioning once Failed (terminal phase)", func() { + mockProv.CreateErr = fmt.Errorf("vendor array unreachable") + + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + // First reconcile provisions, fails, and lands in Failed. + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + updated := &osacv1alpha1.Volume{} + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, updated)).To(Succeed()) + Expect(updated.Status.Phase).To(Equal(osacv1alpha1.VolumePhaseFailed)) + countAfterFailure := mockProv.CreateCallCount() + + // Clear the vendor error: a later reconcile must NOT retry provisioning, + // because Failed is terminal (recovery requires recreating the Volume). + mockProv.CreateErr = nil + _, err = reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, updated)).To(Succeed()) + Expect(updated.Status.Phase).To(Equal(osacv1alpha1.VolumePhaseFailed)) + Expect(mockProv.CreateCallCount()).To(Equal(countAfterFailure)) + }) + + It("should not re-provision when already Ready", func() { + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + // Reconcile until Ready + for range 3 { + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + } + + countBefore := mockProv.CreateCallCount() + + // One more reconcile should be a no-op + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mockProv.CreateCallCount()).To(Equal(countBefore)) + }) + + It("should handle deletion with vendor deprovisioning", func() { + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + // Reconcile to Ready + for range 3 { + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + } + + // Delete + Expect(k8sClient.Delete(testCtx, vol)).To(Succeed()) + + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mockProv.DeleteCallCount()).To(BeNumerically(">=", 1)) + + // Volume should be gone after finalizer removal + deleted := &osacv1alpha1.Volume{} + err = k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, deleted) + Expect(errors.IsNotFound(err)).To(BeTrue()) + }) + + It("should return error and keep finalizer when vendor deprovisioning fails", func() { + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + // Reconcile to Ready + for range 3 { + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + } + + // Inject vendor delete failure + mockProv.DeleteErr = fmt.Errorf("storage array unavailable") + + Expect(k8sClient.Delete(testCtx, vol)).To(Succeed()) + + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).To(HaveOccurred()) + + // Finalizer must still be present — the volume was not deprovisioned + still := &osacv1alpha1.Volume{} + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, still)).To(Succeed()) + Expect(still.Finalizers).To(ContainElement(osacVolumeFinalizer)) + // Phase must be Deleting so the feedback controller syncs VOLUME_STATE_DELETING + Expect(still.Status.Phase).To(Equal(osacv1alpha1.VolumePhaseDeleting)) + }) + + It("should keep finalizer when provisioned but no VendorProvisioner is configured", func() { + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + // Reconcile to Ready so the volume has a VendorVolumeID. + for range 3 { + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + } + + // Simulate a misconfigured restart: provisioner is gone but the volume + // was already provisioned on the array. + reconciler.VendorProvisioner = nil + + Expect(k8sClient.Delete(testCtx, vol)).To(Succeed()) + + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + // Must error and retain the finalizer so the backend volume is not leaked. + Expect(err).To(HaveOccurred()) + + still := &osacv1alpha1.Volume{} + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, still)).To(Succeed()) + Expect(still.Finalizers).To(ContainElement(osacVolumeFinalizer)) + }) + + It("should return not-found gracefully when volume is already deleted", func() { + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: "nonexistent", Namespace: "default"}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should skip provisioning when no VendorProvisioner is configured", func() { + reconciler.VendorProvisioner = nil + + Expect(k8sClient.Create(testCtx, vol)).To(Succeed()) + + // Reconcile adds finalizer + sets Progressing. The nil-provisioner path + // is expected to succeed without error. + for range 2 { + _, err := reconciler.Reconcile(testCtx, mcreconcile.Request{ + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + } + + updated := &osacv1alpha1.Volume{} + Expect(k8sClient.Get(testCtx, types.NamespacedName{Name: vol.Name, Namespace: vol.Namespace}, updated)).To(Succeed()) + Expect(updated.Status.Phase).To(Equal(osacv1alpha1.VolumePhaseProgressing)) + Expect(updated.Status.VendorVolumeID).To(BeEmpty()) + }) +}) diff --git a/osac-operator/internal/controller/volume_feedback_controller.go b/osac-operator/internal/controller/volume_feedback_controller.go new file mode 100644 index 000000000..9d5ef5a79 --- /dev/null +++ b/osac-operator/internal/controller/volume_feedback_controller.go @@ -0,0 +1,194 @@ +/* +Copyright (c) 2026 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package controller + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/grpc" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + clnt "sigs.k8s.io/controller-runtime/pkg/client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + + "github.com/osac-project/osac/osac-operator/api/v1alpha1" + privatev1 "github.com/osac-project/osac/osac-operator/internal/api/osac/private/v1" + "github.com/osac-project/osac/osac-operator/internal/controller/feedback" +) + +// VolumeFeedbackReconciler syncs Volume CR status from the hub cluster back +// to the fulfillment-service via the private Volumes gRPC API. It maps CRD +// phases to proto states and copies vendor-assigned fields (vendorVolumeID, +// backend, protocol) so the fulfillment-service inventory stays current. +type VolumeFeedbackReconciler struct { + bridge *feedback.Bridge[*v1alpha1.Volume, *privatev1.Volume] + volumeNamespace string +} + +// NewVolumeFeedbackReconciler creates a feedback reconciler that syncs Volume +// CR status to the fulfillment-service. The volumeNamespace controls which +// namespace this controller watches, matching the resource controller's scope. +func NewVolumeFeedbackReconciler(hubClient clnt.Client, grpcConn *grpc.ClientConn, volumeNamespace string) *VolumeFeedbackReconciler { + if volumeNamespace == "" { + volumeNamespace = defaultVolumeNamespace + } + volClient := privatev1.NewVolumesClient(grpcConn) + r := &VolumeFeedbackReconciler{volumeNamespace: volumeNamespace} + r.bridge = &feedback.Bridge[*v1alpha1.Volume, *privatev1.Volume]{ + Client: hubClient, + Finalizer: osacVolumeFeedbackFinalizer, + IDLabel: osacVolumeIDLabel, + Kind: "Volume", + IDKey: "volumeID", + NewObject: func() *v1alpha1.Volume { return &v1alpha1.Volume{} }, + Fetch: func(ctx context.Context, id string) (*privatev1.Volume, error) { + response, err := volClient.Get(ctx, privatev1.VolumesGetRequest_builder{Id: id}.Build()) + if err != nil { + return nil, err + } + vol := response.GetObject() + if vol == nil { + return nil, errors.New("volume response contained nil object") + } + if !vol.HasSpec() { + vol.SetSpec(&privatev1.VolumeSpec{}) + } + if !vol.HasStatus() { + vol.SetStatus(&privatev1.VolumeStatus{}) + } + return vol, nil + }, + Save: func(ctx context.Context, remote *privatev1.Volume) error { + _, err := volClient.Update(ctx, privatev1.VolumesUpdateRequest_builder{ + Object: remote, + }.Build()) + return err + }, + Signal: func(ctx context.Context, id string) error { + _, err := volClient.Signal(ctx, privatev1.VolumesSignalRequest_builder{ + Id: id, + }.Build()) + return err + }, + SyncUpdate: syncVolumeUpdate, + SyncDelete: syncVolumeDelete, + } + return r +} + +// SetupWithManager registers the feedback controller with the manager. It +// watches Volume CRs in the configured namespace on the local (hub) cluster. +func (r *VolumeFeedbackReconciler) SetupWithManager(mgr mcmanager.Manager) error { + localMgr := mgr.GetLocalManager() + if localMgr == nil { + return fmt.Errorf("local manager is nil") + } + + return ctrl.NewControllerManagedBy(localMgr). + Named("volume-feedback"). + For(&v1alpha1.Volume{}, builder.WithPredicates(VolumeNamespacePredicate(r.volumeNamespace))). + Complete(r) +} + +// Reconcile delegates to the shared feedback Bridge which handles the +// finalizer lifecycle, clone-compare-save, and last-finalizer Signal. +func (r *VolumeFeedbackReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + return r.bridge.Reconcile(ctx, request) +} + +// syncVolumeUpdate maps Volume CR status to the fulfillment-service proto on +// the non-delete path. It syncs the phase, vendor-assigned identifiers, and +// the PVC/PV references that the operator populates after provisioning. +func syncVolumeUpdate(ctx context.Context, obj *v1alpha1.Volume, remote *privatev1.Volume) error { + syncVolumePhase(ctx, obj, remote) + syncVolumeVendorFields(ctx, obj, remote) + return nil +} + +// syncVolumeDelete maps Volume CR status during deletion. Failed volumes +// report FAILED; all other deletion states report DELETING. +func syncVolumeDelete(_ context.Context, obj *v1alpha1.Volume, remote *privatev1.Volume) error { + if obj.Status.Phase == v1alpha1.VolumePhaseFailed { + remote.GetStatus().SetState(privatev1.VolumeState_VOLUME_STATE_FAILED) + return nil + } + remote.GetStatus().SetState(privatev1.VolumeState_VOLUME_STATE_DELETING) + return nil +} + +// syncVolumePhase converts the CRD phase to the proto state enum. +// +// CRD Phase -> Proto State +// Progressing -> CREATING (volume is being provisioned on vendor array) +// Ready -> AVAILABLE (vendor provisioned, ready for use) +// Failed -> FAILED (vendor provisioning failed) +// Deleting -> DELETING (volume is being deprovisioned) +func syncVolumePhase(ctx context.Context, obj *v1alpha1.Volume, remote *privatev1.Volume) { + switch obj.Status.Phase { + case v1alpha1.VolumePhaseProgressing: + remote.GetStatus().SetState(privatev1.VolumeState_VOLUME_STATE_CREATING) + case v1alpha1.VolumePhaseReady: + remote.GetStatus().SetState(privatev1.VolumeState_VOLUME_STATE_AVAILABLE) + case v1alpha1.VolumePhaseFailed: + remote.GetStatus().SetState(privatev1.VolumeState_VOLUME_STATE_FAILED) + case v1alpha1.VolumePhaseDeleting: + remote.GetStatus().SetState(privatev1.VolumeState_VOLUME_STATE_DELETING) + default: + log := ctrllog.FromContext(ctx) + log.Info("Unknown phase, will ignore it", "phase", obj.Status.Phase) + } +} + +// syncVolumeVendorFields copies the vendor-assigned identifiers from the CR +// status to the proto status so the fulfillment-service inventory reflects +// the actual storage array state. +func syncVolumeVendorFields(ctx context.Context, obj *v1alpha1.Volume, remote *privatev1.Volume) { + if obj.Status.VendorVolumeID != "" { + remote.GetStatus().SetVendorVolumeId(obj.Status.VendorVolumeID) + } + if obj.Status.Backend != "" { + remote.GetStatus().SetBackend(obj.Status.Backend) + } + if obj.Status.Protocol != "" { + // Only sync a protocol the switch recognizes. An unrecognized CRD value + // maps to UNSPECIFIED; writing that would silently overwrite a valid + // protocol the fulfillment-service already recorded (losing inventory + // data is worse than skipping the field until the switch learns it). A + // value reaching here that the switch doesn't know means the CRD enum + // was extended without updating crdProtocolToProto, so log it. + if protocol := crdProtocolToProto(obj.Status.Protocol); protocol != privatev1.StorageProtocol_STORAGE_PROTOCOL_UNSPECIFIED { + remote.GetStatus().SetProtocol(protocol) + } else { + log := ctrllog.FromContext(ctx) + log.Info("Unknown volume protocol, not syncing to fulfillment-service", "protocol", obj.Status.Protocol) + } + } +} + +// crdProtocolToProto converts the CRD VolumeProtocol typed string (e.g. +// "Block", "NFS") to the proto StorageProtocol enum. A direct map lookup +// would fail because the proto keys are "STORAGE_PROTOCOL_BLOCK", not "Block". +func crdProtocolToProto(protocol v1alpha1.VolumeProtocol) privatev1.StorageProtocol { + switch protocol { + case v1alpha1.VolumeProtocolBlock: + return privatev1.StorageProtocol_STORAGE_PROTOCOL_BLOCK + case v1alpha1.VolumeProtocolNFS: + return privatev1.StorageProtocol_STORAGE_PROTOCOL_NFS + default: + return privatev1.StorageProtocol_STORAGE_PROTOCOL_UNSPECIFIED + } +} diff --git a/osac-operator/internal/controller/volume_feedback_controller_test.go b/osac-operator/internal/controller/volume_feedback_controller_test.go new file mode 100644 index 000000000..ce0f2bb25 --- /dev/null +++ b/osac-operator/internal/controller/volume_feedback_controller_test.go @@ -0,0 +1,541 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "net" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + grpcstatus "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/osac-project/osac/osac-operator/api/v1alpha1" + privatev1 "github.com/osac-project/osac/osac-operator/internal/api/osac/private/v1" +) + +var _ = Describe("VolumeFeedbackController", func() { + const ( + volName = "test-vol" + volNamespace = "test-namespace" + volID = "vol-123" + ) + + var ( + ctx context.Context + fakeK8s client.Client + mockServer *mockVolumesServer + reconciler *VolumeFeedbackReconciler + grpcServer *grpc.Server + listener *bufconn.Listener + grpcConn *grpc.ClientConn + ) + + BeforeEach(func() { + ctx = context.Background() + + scheme := runtime.NewScheme() + Expect(v1alpha1.AddToScheme(scheme)).To(Succeed()) + fakeK8s = fake.NewClientBuilder().WithScheme(scheme).Build() + + mockServer = &mockVolumesServer{ + volumes: make(map[string]*privatev1.Volume), + updates: make([]*privatev1.Volume, 0), + signals: make([]string, 0), + } + listener = bufconn.Listen(1024 * 1024) + grpcServer = grpc.NewServer() + privatev1.RegisterVolumesServer(grpcServer, mockServer) + + go func() { + _ = grpcServer.Serve(listener) + }() + + var err error + grpcConn, err = grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + Expect(err).NotTo(HaveOccurred()) + + reconciler = NewVolumeFeedbackReconciler(fakeK8s, grpcConn, volNamespace) + }) + + AfterEach(func() { + if grpcConn != nil { + _ = grpcConn.Close() + } + if grpcServer != nil { + grpcServer.Stop() + } + if listener != nil { + _ = listener.Close() + } + }) + + Context("phase-to-state mapping", func() { + It("should sync Phase=Ready to state=AVAILABLE", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_CREATING)) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseReady, nil) + cr.Status.VendorVolumeID = "vast-001" + cr.Status.Backend = "vast-backend" + cr.Status.Protocol = v1alpha1.VolumeProtocolBlock + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(HaveLen(1)) + updated := mockServer.updates[0] + Expect(updated.GetStatus().GetState()).To(Equal(privatev1.VolumeState_VOLUME_STATE_AVAILABLE)) + Expect(updated.GetStatus().GetVendorVolumeId()).To(Equal("vast-001")) + Expect(updated.GetStatus().GetBackend()).To(Equal("vast-backend")) + + // Signal should not be called on non-delete reconciles + Expect(mockServer.signals).To(BeEmpty()) + + updatedCR := &v1alpha1.Volume{} + Expect(fakeK8s.Get(ctx, types.NamespacedName{Name: volName, Namespace: volNamespace}, updatedCR)).To(Succeed()) + Expect(controllerutil.ContainsFinalizer(updatedCR, osacVolumeFeedbackFinalizer)).To(BeTrue()) + }) + + It("should sync Phase=Progressing to state=CREATING", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_AVAILABLE)) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseProgressing, nil) + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(HaveLen(1)) + Expect(mockServer.updates[0].GetStatus().GetState()).To(Equal(privatev1.VolumeState_VOLUME_STATE_CREATING)) + Expect(mockServer.signals).To(BeEmpty()) + }) + + It("should sync Phase=Failed to state=FAILED", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_CREATING)) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseFailed, nil) + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(HaveLen(1)) + Expect(mockServer.updates[0].GetStatus().GetState()).To(Equal(privatev1.VolumeState_VOLUME_STATE_FAILED)) + Expect(mockServer.signals).To(BeEmpty()) + }) + }) + + Context("vendor field syncing", func() { + It("should sync vendorVolumeID, backend, and protocol to remote", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_CREATING)) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseReady, nil) + cr.Status.VendorVolumeID = "netapp-vol-42" + cr.Status.Backend = "netapp-cluster-1" + cr.Status.Protocol = v1alpha1.VolumeProtocolNFS + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(HaveLen(1)) + updated := mockServer.updates[0] + Expect(updated.GetStatus().GetVendorVolumeId()).To(Equal("netapp-vol-42")) + Expect(updated.GetStatus().GetBackend()).To(Equal("netapp-cluster-1")) + Expect(updated.GetStatus().GetProtocol()).To(Equal(privatev1.StorageProtocol_STORAGE_PROTOCOL_NFS)) + }) + + It("should map Block protocol correctly", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_CREATING)) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseReady, nil) + cr.Status.VendorVolumeID = "vast-001" + cr.Status.Backend = "vast-backend" + cr.Status.Protocol = v1alpha1.VolumeProtocolBlock + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(HaveLen(1)) + Expect(mockServer.updates[0].GetStatus().GetProtocol()).To(Equal(privatev1.StorageProtocol_STORAGE_PROTOCOL_BLOCK)) + }) + + It("should not overwrite remote fields when CR fields are empty", func() { + remote := newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_CREATING) + remote.GetStatus().SetVendorVolumeId("existing-id") + remote.GetStatus().SetBackend("existing-backend") + mockServer.addVolume(remote) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseProgressing, nil) + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + // Progressing maps to CREATING, which the remote already reports, and the CR + // carries no vendor fields. Nothing changes, so no Update RPC is sent. + Expect(mockServer.updates).To(BeEmpty()) + }) + }) + + Context("label and identity handling", func() { + It("should skip CRs without volume-uuid label", func() { + cr := &v1alpha1.Volume{ + ObjectMeta: metav1.ObjectMeta{ + Name: volName, + Namespace: volNamespace, + Labels: map[string]string{}, + }, + Spec: v1alpha1.VolumeSpec{ + StorageTier: "gold", + SizeGiB: 100, + AccessMode: v1alpha1.VolumeAccessModeReadWriteOnce, + }, + Status: v1alpha1.VolumeStatus{ + Phase: v1alpha1.VolumePhaseReady, + }, + } + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(mockServer.updates).To(BeEmpty()) + }) + }) + + Context("deletion handling", func() { + It("should sync Phase=Deleting to state=DELETING during deletion", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_AVAILABLE)) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseDeleting, + []string{osacVolumeFeedbackFinalizer, osacVolumeFinalizer}) + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + Expect(fakeK8s.Delete(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(HaveLen(1)) + Expect(mockServer.updates[0].GetStatus().GetState()).To(Equal(privatev1.VolumeState_VOLUME_STATE_DELETING)) + + // Signal should NOT be called when other finalizers remain + Expect(mockServer.signals).To(BeEmpty()) + + // Feedback finalizer should remain (other finalizers still present) + updatedCR := &v1alpha1.Volume{} + Expect(fakeK8s.Get(ctx, types.NamespacedName{Name: volName, Namespace: volNamespace}, updatedCR)).To(Succeed()) + Expect(controllerutil.ContainsFinalizer(updatedCR, osacVolumeFeedbackFinalizer)).To(BeTrue()) + }) + + It("should sync Phase=Failed to state=FAILED during deletion", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_AVAILABLE)) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseFailed, + []string{osacVolumeFeedbackFinalizer, osacVolumeFinalizer}) + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + Expect(fakeK8s.Delete(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(HaveLen(1)) + Expect(mockServer.updates[0].GetStatus().GetState()).To(Equal(privatev1.VolumeState_VOLUME_STATE_FAILED)) + }) + + It("should remove finalizer and signal when feedback finalizer is the last one", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_AVAILABLE)) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseDeleting, + []string{osacVolumeFeedbackFinalizer}) + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + Expect(fakeK8s.Delete(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(HaveLen(1)) + Expect(mockServer.updates[0].GetStatus().GetState()).To(Equal(privatev1.VolumeState_VOLUME_STATE_DELETING)) + Expect(mockServer.signals).To(HaveLen(1)) + Expect(mockServer.signals[0]).To(Equal(volID)) + + // CR should be gone (last finalizer removed) + updatedCR := &v1alpha1.Volume{} + err = fakeK8s.Get(ctx, types.NamespacedName{Name: volName, Namespace: volNamespace}, updatedCR) + Expect(err).To(HaveOccurred()) + }) + + It("should still remove finalizer when signal fails", func() { + mockServer.addVolume(newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_AVAILABLE)) + mockServer.signalErr = fmt.Errorf("signal unavailable") + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseDeleting, + []string{osacVolumeFeedbackFinalizer}) + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + Expect(fakeK8s.Delete(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + // CR should still be gone (finalizer removed despite signal failure) + updatedCR := &v1alpha1.Volume{} + err = fakeK8s.Get(ctx, types.NamespacedName{Name: volName, Namespace: volNamespace}, updatedCR) + Expect(err).To(HaveOccurred()) + }) + + It("should remove feedback finalizer when remote record is NotFound during deletion", func() { + // Don't add volume to mock server (simulates archived record) + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseDeleting, + []string{osacVolumeFeedbackFinalizer}) + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + Expect(fakeK8s.Delete(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(mockServer.updates).To(BeEmpty()) + Expect(mockServer.signals).To(BeEmpty()) + + // CR should be gone + updatedCR := &v1alpha1.Volume{} + err = fakeK8s.Get(ctx, types.NamespacedName{Name: volName, Namespace: volNamespace}, updatedCR) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("idempotency", func() { + It("should not call Update when remote state already matches", func() { + remote := newRemoteVolume(volID, privatev1.VolumeState_VOLUME_STATE_AVAILABLE) + remote.GetStatus().SetVendorVolumeId("vast-001") + remote.GetStatus().SetBackend("vast-backend") + mockServer.addVolume(remote) + + cr := newVolumeFeedbackCR(volName, volNamespace, volID, v1alpha1.VolumePhaseReady, nil) + cr.Status.VendorVolumeID = "vast-001" + cr.Status.Backend = "vast-backend" + // Pre-seed the feedback finalizer so the reconciler doesn't add it (which triggers an update) + cr.Finalizers = []string{osacVolumeFeedbackFinalizer} + Expect(fakeK8s.Create(ctx, cr)).To(Succeed()) + + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: volName, Namespace: volNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) + + // No Update RPC called since remote already matches + Expect(mockServer.updates).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("crdProtocolToProto", func() { + // allVolumeProtocols must list every v1alpha1.VolumeProtocol enum value. + // Keep it in sync with the CRD enum in api/v1alpha1/volume_types.go and the + // switch in crdProtocolToProto. The coverage spec below fails if any listed + // protocol maps to UNSPECIFIED, i.e. a value was added to the enum without a + // matching case in the switch. + allVolumeProtocols := []v1alpha1.VolumeProtocol{ + v1alpha1.VolumeProtocolBlock, + v1alpha1.VolumeProtocolNFS, + } + + DescribeTable("maps known CRD protocols to their proto enum", + func(in v1alpha1.VolumeProtocol, expected privatev1.StorageProtocol) { + Expect(crdProtocolToProto(in)).To(Equal(expected)) + }, + Entry("Block", v1alpha1.VolumeProtocolBlock, privatev1.StorageProtocol_STORAGE_PROTOCOL_BLOCK), + Entry("NFS", v1alpha1.VolumeProtocolNFS, privatev1.StorageProtocol_STORAGE_PROTOCOL_NFS), + Entry("unrecognized value", v1alpha1.VolumeProtocol("iSCSI"), privatev1.StorageProtocol_STORAGE_PROTOCOL_UNSPECIFIED), + Entry("empty value", v1alpha1.VolumeProtocol(""), privatev1.StorageProtocol_STORAGE_PROTOCOL_UNSPECIFIED), + ) + + It("maps every VolumeProtocol enum value to a defined proto protocol", func() { + for _, protocol := range allVolumeProtocols { + Expect(crdProtocolToProto(protocol)).ToNot( + Equal(privatev1.StorageProtocol_STORAGE_PROTOCOL_UNSPECIFIED), + "VolumeProtocol %q maps to UNSPECIFIED; add a case to crdProtocolToProto", protocol, + ) + } + }) +}) + +var _ = Describe("syncVolumeVendorFields", func() { + It("does not overwrite an existing remote protocol with an unrecognized value", func() { + remote := newRemoteVolume("vol-1", privatev1.VolumeState_VOLUME_STATE_AVAILABLE) + remote.GetStatus().SetProtocol(privatev1.StorageProtocol_STORAGE_PROTOCOL_BLOCK) + + obj := &v1alpha1.Volume{} + obj.Status.Protocol = v1alpha1.VolumeProtocol("iSCSI") // not known to the switch + + syncVolumeVendorFields(context.Background(), obj, remote) + + // The previously recorded protocol must be preserved, not clobbered. + Expect(remote.GetStatus().GetProtocol()).To(Equal(privatev1.StorageProtocol_STORAGE_PROTOCOL_BLOCK)) + }) + + It("syncs a recognized protocol to the remote", func() { + remote := newRemoteVolume("vol-2", privatev1.VolumeState_VOLUME_STATE_AVAILABLE) + + obj := &v1alpha1.Volume{} + obj.Status.Protocol = v1alpha1.VolumeProtocolNFS + + syncVolumeVendorFields(context.Background(), obj, remote) + + Expect(remote.GetStatus().GetProtocol()).To(Equal(privatev1.StorageProtocol_STORAGE_PROTOCOL_NFS)) + }) +}) + +// Helper to create a Volume CR for feedback controller tests. +func newVolumeFeedbackCR(name, namespace, id string, phase v1alpha1.VolumePhaseType, finalizers []string) *v1alpha1.Volume { + cr := &v1alpha1.Volume{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + osacVolumeIDLabel: id, + }, + }, + Spec: v1alpha1.VolumeSpec{ + StorageTier: "gold", + SizeGiB: 100, + AccessMode: v1alpha1.VolumeAccessModeReadWriteOnce, + }, + Status: v1alpha1.VolumeStatus{ + Phase: phase, + }, + } + if len(finalizers) > 0 { + cr.Finalizers = finalizers + } + return cr +} + +// Helper to create a remote Volume proto for the mock server. +func newRemoteVolume(id string, state privatev1.VolumeState) *privatev1.Volume { + return privatev1.Volume_builder{ + Id: id, + Metadata: privatev1.Metadata_builder{ + Name: "test-vol", + }.Build(), + Spec: privatev1.VolumeSpec_builder{ + StorageTier: "gold", + SizeGib: 100, + AccessMode: privatev1.VolumeAccessMode_VOLUME_ACCESS_MODE_READ_WRITE_ONCE, + }.Build(), + Status: privatev1.VolumeStatus_builder{ + State: state, + }.Build(), + }.Build() +} + +// mockVolumesServer implements privatev1.VolumesServer for testing. +type mockVolumesServer struct { + privatev1.UnimplementedVolumesServer + mu sync.Mutex + volumes map[string]*privatev1.Volume + updates []*privatev1.Volume + signals []string + signalErr error +} + +func (m *mockVolumesServer) addVolume(vol *privatev1.Volume) { + m.mu.Lock() + defer m.mu.Unlock() + m.volumes[vol.GetId()] = vol +} + +func (m *mockVolumesServer) Get(_ context.Context, req *privatev1.VolumesGetRequest) (*privatev1.VolumesGetResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + + vol, ok := m.volumes[req.GetId()] + if !ok { + return nil, grpcstatus.Errorf(codes.NotFound, "object with identifier '%s' not found", req.GetId()) + } + + return privatev1.VolumesGetResponse_builder{ + Object: vol, + }.Build(), nil +} + +func (m *mockVolumesServer) Update(_ context.Context, req *privatev1.VolumesUpdateRequest) (*privatev1.VolumesUpdateResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + + vol := req.GetObject() + m.volumes[vol.GetId()] = vol + m.updates = append(m.updates, vol) + + return privatev1.VolumesUpdateResponse_builder{ + Object: vol, + }.Build(), nil +} + +func (m *mockVolumesServer) Signal(_ context.Context, req *privatev1.VolumesSignalRequest) (*privatev1.VolumesSignalResponse, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.signals = append(m.signals, req.GetId()) + + if m.signalErr != nil { + return nil, m.signalErr + } + return &privatev1.VolumesSignalResponse{}, nil +} diff --git a/osac-operator/internal/controller/volume_mock_provisioner_test.go b/osac-operator/internal/controller/volume_mock_provisioner_test.go new file mode 100644 index 000000000..01f6745cc --- /dev/null +++ b/osac-operator/internal/controller/volume_mock_provisioner_test.go @@ -0,0 +1,75 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "sync/atomic" +) + +// MockVendorProvisioner is a test-only VendorProvisioner that succeeds +// immediately with deterministic IDs. It tracks call counts for test +// assertions. Set CreateErr or DeleteErr to simulate vendor failures. +type MockVendorProvisioner struct { + createCount atomic.Int64 + deleteCount atomic.Int64 + + // CreateErr, when non-nil, is returned by CreateVolume instead of + // succeeding. Allows tests to simulate vendor failures. + CreateErr error + + // DeleteErr, when non-nil, is returned by DeleteVolume instead of + // succeeding. + DeleteErr error +} + +// NewMockVendorProvisioner creates a mock provisioner that succeeds by default. +func NewMockVendorProvisioner() *MockVendorProvisioner { + return &MockVendorProvisioner{} +} + +// CreateVolume returns a deterministic vendor volume ID composed of +// "mock-" plus a monotonic counter. Backend and protocol are fixed +// strings suitable for test assertions. +func (m *MockVendorProvisioner) CreateVolume(_ context.Context, req VendorCreateVolumeRequest) (VendorCreateVolumeResponse, error) { + n := m.createCount.Add(1) + if m.CreateErr != nil { + return VendorCreateVolumeResponse{}, m.CreateErr + } + return VendorCreateVolumeResponse{ + VendorVolumeID: fmt.Sprintf("mock-%d", n), + Backend: "mock-backend", + Protocol: "Block", + }, nil +} + +// DeleteVolume records the call and returns DeleteErr (nil by default). +func (m *MockVendorProvisioner) DeleteVolume(_ context.Context, _ VendorDeleteVolumeRequest) error { + m.deleteCount.Add(1) + return m.DeleteErr +} + +// CreateCallCount returns the number of times CreateVolume was called. +func (m *MockVendorProvisioner) CreateCallCount() int64 { + return m.createCount.Load() +} + +// DeleteCallCount returns the number of times DeleteVolume was called. +func (m *MockVendorProvisioner) DeleteCallCount() int64 { + return m.deleteCount.Load() +} diff --git a/osac-operator/internal/controller/volume_names.go b/osac-operator/internal/controller/volume_names.go new file mode 100644 index 000000000..cb98371b7 --- /dev/null +++ b/osac-operator/internal/controller/volume_names.go @@ -0,0 +1,30 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" +) + +const ( + defaultVolumeNamespace = "osac-volume" +) + +var ( + osacVolumeIDLabel string = fmt.Sprintf("%s/volume-uuid", osacPrefix) + osacVolumeFeedbackFinalizer string = fmt.Sprintf("%s/volume-feedback", osacPrefix) +)