From e73bf9905da70801bb3a702fb53cf0c07170f089 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Tue, 13 Jan 2026 13:18:13 +0100 Subject: [PATCH] [linstor] Refactor node-level RWX validation Signed-off-by: Andrei Kvapil (cherry picked from commit 3c4f0cd952e91ec54ddb26aaae2f550ebd93a320) --- .../patches/001-rwx-validation.diff | 386 ++++++++++++------ 1 file changed, 253 insertions(+), 133 deletions(-) diff --git a/packages/system/linstor/images/linstor-csi/patches/001-rwx-validation.diff b/packages/system/linstor/images/linstor-csi/patches/001-rwx-validation.diff index 9c26f565..7da0f137 100644 --- a/packages/system/linstor/images/linstor-csi/patches/001-rwx-validation.diff +++ b/packages/system/linstor/images/linstor-csi/patches/001-rwx-validation.diff @@ -1,100 +1,202 @@ +diff --git a/cmd/linstor-csi/linstor-csi.go b/cmd/linstor-csi/linstor-csi.go +index 143f6cee..bd28e06e 100644 +--- a/cmd/linstor-csi/linstor-csi.go ++++ b/cmd/linstor-csi/linstor-csi.go +@@ -41,22 +41,23 @@ import ( + + func main() { + var ( +- lsEndpoint = flag.String("linstor-endpoint", "", "Controller API endpoint for LINSTOR") +- lsSkipTLSVerification = flag.Bool("linstor-skip-tls-verification", false, "If true, do not verify tls") +- csiEndpoint = flag.String("csi-endpoint", "unix:///var/lib/kubelet/plugins/linstor.csi.linbit.com/csi.sock", "CSI endpoint") +- node = flag.String("node", "", "Node ID to pass to node service") +- logLevel = flag.String("log-level", "info", "Enable debug log output. Choose from: panic, fatal, error, warn, info, debug") +- rps = flag.Float64("linstor-api-requests-per-second", 0, "Maximum allowed number of LINSTOR API requests per second. Default: Unlimited") +- burst = flag.Int("linstor-api-burst", 1, "Maximum number of API requests allowed before being limited by requests-per-second. Default: 1 (no bursting)") +- bearerTokenFile = flag.String("bearer-token", "", "Read the bearer token from the given file and use it for authentication.") +- propNs = flag.String("property-namespace", linstor.NamespcAuxiliary, "Limit the reported topology keys to properties from the given namespace.") +- labelBySP = flag.Bool("label-by-storage-pool", true, "Set to false to disable labeling of nodes based on their configured storage pools.") +- nodeCacheTimeout = flag.Duration("node-cache-timeout", 1*time.Minute, "Duration for which the results of node and storage pool related API responses should be cached.") +- resourceCacheTimeout = flag.Duration("resource-cache-timeout", 30*time.Second, "Duration for which the results of resource related API responses should be cached.") +- resyncAfter = flag.Duration("resync-after", 5*time.Minute, "Duration after which reconciliations (such as for VolumeSnapshotClasses) should be rerun. Set to 0 to disable.") +- enableRWX = flag.Bool("enable-rwx", false, "Enable RWX support via NFS (requires running in Kubernetes).") +- namespace = flag.String("nfs-service-namespace", "", "The namespace the NFS service is running in.") +- reactorConfigMapName = flag.String("nfs-reactor-config-map-name", "linstor-csi-nfs-reactor-config", "Name of the config map used to store promoter configuration") ++ lsEndpoint = flag.String("linstor-endpoint", "", "Controller API endpoint for LINSTOR") ++ lsSkipTLSVerification = flag.Bool("linstor-skip-tls-verification", false, "If true, do not verify tls") ++ csiEndpoint = flag.String("csi-endpoint", "unix:///var/lib/kubelet/plugins/linstor.csi.linbit.com/csi.sock", "CSI endpoint") ++ node = flag.String("node", "", "Node ID to pass to node service") ++ logLevel = flag.String("log-level", "info", "Enable debug log output. Choose from: panic, fatal, error, warn, info, debug") ++ rps = flag.Float64("linstor-api-requests-per-second", 0, "Maximum allowed number of LINSTOR API requests per second. Default: Unlimited") ++ burst = flag.Int("linstor-api-burst", 1, "Maximum number of API requests allowed before being limited by requests-per-second. Default: 1 (no bursting)") ++ bearerTokenFile = flag.String("bearer-token", "", "Read the bearer token from the given file and use it for authentication.") ++ propNs = flag.String("property-namespace", linstor.NamespcAuxiliary, "Limit the reported topology keys to properties from the given namespace.") ++ labelBySP = flag.Bool("label-by-storage-pool", true, "Set to false to disable labeling of nodes based on their configured storage pools.") ++ nodeCacheTimeout = flag.Duration("node-cache-timeout", 1*time.Minute, "Duration for which the results of node and storage pool related API responses should be cached.") ++ resourceCacheTimeout = flag.Duration("resource-cache-timeout", 30*time.Second, "Duration for which the results of resource related API responses should be cached.") ++ resyncAfter = flag.Duration("resync-after", 5*time.Minute, "Duration after which reconciliations (such as for VolumeSnapshotClasses) should be rerun. Set to 0 to disable.") ++ enableRWX = flag.Bool("enable-rwx", false, "Enable RWX support via NFS (requires running in Kubernetes).") ++ namespace = flag.String("nfs-service-namespace", "", "The namespace the NFS service is running in.") ++ reactorConfigMapName = flag.String("nfs-reactor-config-map-name", "linstor-csi-nfs-reactor-config", "Name of the config map used to store promoter configuration") ++ disableRWXBlockValidation = flag.Bool("disable-rwx-block-validation", false, "Disable KubeVirt VM ownership validation for RWX block volumes.") + ) + + flag.Var(&volume.DefaultRemoteAccessPolicy, "default-remote-access-policy", "") +@@ -169,6 +170,10 @@ func main() { + opts = append(opts, driver.ConfigureRWX(*namespace, *reactorConfigMapName)) + } + ++ if *disableRWXBlockValidation { ++ opts = append(opts, driver.DisableRWXBlockValidation()) ++ } ++ + drv, err := driver.NewDriver(opts...) + if err != nil { + log.Fatal(err) diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go -index bea69a8..69e71a6 100644 +index bea69a8b..a39674b6 100644 --- a/pkg/driver/driver.go +++ b/pkg/driver/driver.go -@@ -707,6 +707,219 @@ func (d Driver) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequest) - return &csi.DeleteVolumeResponse{}, nil +@@ -83,6 +83,8 @@ type Driver struct { + topologyPrefix string + // resyncAfter is the interval after which reconciliations should be retried + resyncAfter time.Duration ++ // disableRWXBlockValidation disables KubeVirt VM ownership validation for RWX block volumes ++ disableRWXBlockValidation bool + + // Embed for forward compatibility. + csi.UnimplementedIdentityServer +@@ -300,6 +302,17 @@ func ResyncAfter(resyncAfter time.Duration) func(*Driver) error { + } } ++// DisableRWXBlockValidation disables the KubeVirt VM ownership validation for RWX block volumes. ++// When disabled, the driver will not check if multiple pods using the same RWX block volume ++// belong to the same VM. This may be needed in environments where the validation causes issues ++// or when using RWX block volumes outside of KubeVirt. ++func DisableRWXBlockValidation() func(*Driver) error { ++ return func(d *Driver) error { ++ d.disableRWXBlockValidation = true ++ return nil ++ } ++} ++ + // GetPluginInfo https://github.com/container-storage-interface/spec/blob/v1.9.0/spec.md#getplugininfo + func (d Driver) GetPluginInfo(ctx context.Context, req *csi.GetPluginInfoRequest) (*csi.GetPluginInfoResponse, error) { + return &csi.GetPluginInfoResponse{ +@@ -751,6 +764,14 @@ func (d Driver) ControllerPublishVolume(ctx context.Context, req *csi.Controller + // ReadWriteMany block volume + rwxBlock := req.VolumeCapability.AccessMode.GetMode() == csi.VolumeCapability_AccessMode_MULTI_NODE_MULTI_WRITER && req.VolumeCapability.GetBlock() != nil + ++ // Validate RWX block attachment to prevent misuse of allow-two-primaries ++ if rwxBlock && !d.disableRWXBlockValidation { ++ if _, err := utils.ValidateRWXBlockAttachment(ctx, d.kubeClient, d.log, req.GetVolumeId()); err != nil { ++ return nil, status.Errorf(codes.FailedPrecondition, ++ "ControllerPublishVolume failed for %s: %v", req.GetVolumeId(), err) ++ } ++ } ++ + devPath, err := d.Assignments.Attach(ctx, req.GetVolumeId(), req.GetNodeId(), rwxBlock) + if err != nil { + return nil, status.Errorf(codes.Internal, +diff --git a/pkg/utils/rwx_validation.go b/pkg/utils/rwx_validation.go +new file mode 100644 +index 00000000..9fe82768 +--- /dev/null ++++ b/pkg/utils/rwx_validation.go +@@ -0,0 +1,263 @@ ++/* ++CSI Driver for Linstor ++Copyright © 2018 LINBIT USA, LLC ++ ++This program is free software; you can redistribute it and/or modify ++it under the terms of the GNU General Public License as published by ++the Free Software Foundation; either version 2 of the License, or ++(at your option) any later version. ++ ++This program is distributed in the hope that it will be useful, ++but WITHOUT ANY WARRANTY; without even the implied warranty of ++MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++GNU General Public License for more details. ++ ++You should have received a copy of the GNU General Public License ++along with this program; if not, see . ++*/ ++ ++package utils ++ ++import ( ++ "context" ++ "fmt" ++ ++ "github.com/sirupsen/logrus" ++ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ++ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ++ "k8s.io/apimachinery/pkg/runtime/schema" ++ "k8s.io/client-go/dynamic" ++) ++ +// KubeVirtVMLabel is the label that KubeVirt adds to pods to identify the VM they belong to. +const KubeVirtVMLabel = "vm.kubevirt.io/name" + +// KubeVirtHotplugDiskLabel is the label that KubeVirt adds to hotplug disk pods. +const KubeVirtHotplugDiskLabel = "kubevirt.io" + -+// podGVR is the GroupVersionResource for pods. -+var podGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} ++// PodGVR is the GroupVersionResource for pods. ++var PodGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + -+// pvGVR is the GroupVersionResource for persistent volumes. -+var pvGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumes"} ++// PVGVR is the GroupVersionResource for persistent volumes. ++var PVGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumes"} + -+// getVMNameFromPod extracts the VM name from a pod, handling both regular virt-launcher pods -+// and hotplug disk pods (which reference the virt-launcher pod via ownerReferences). -+func (d Driver) getVMNameFromPod(ctx context.Context, pod *unstructured.Unstructured) (string, error) { -+ labels := pod.GetLabels() -+ if labels == nil { -+ return "", nil -+ } -+ -+ // Direct case: pod has vm.kubevirt.io/name label (virt-launcher pod) -+ if vmName, ok := labels[KubeVirtVMLabel]; ok && vmName != "" { -+ return vmName, nil -+ } -+ -+ // Hotplug disk case: pod has kubevirt.io: hotplug-disk label -+ // Follow ownerReferences to find the virt-launcher pod -+ if hotplugValue, ok := labels[KubeVirtHotplugDiskLabel]; ok && hotplugValue == "hotplug-disk" { -+ ownerRefs := pod.GetOwnerReferences() -+ for _, owner := range ownerRefs { -+ if owner.Kind != "Pod" || owner.Controller == nil || !*owner.Controller { -+ continue -+ } -+ -+ // Get the owner pod (virt-launcher) -+ ownerPod, err := d.kubeClient.Resource(podGVR).Namespace(pod.GetNamespace()).Get(ctx, owner.Name, metav1.GetOptions{}) -+ if err != nil { -+ return "", fmt.Errorf("failed to get owner pod %s: %w", owner.Name, err) -+ } -+ -+ // Extract VM name from owner pod -+ ownerLabels := ownerPod.GetLabels() -+ if ownerLabels != nil { -+ if vmName, ok := ownerLabels[KubeVirtVMLabel]; ok && vmName != "" { -+ d.log.WithFields(logrus.Fields{ -+ "hotplugPod": pod.GetName(), -+ "virtLauncher": owner.Name, -+ "vmName": vmName, -+ }).Debug("resolved VM name from hotplug disk pod via owner reference") -+ -+ return vmName, nil -+ } -+ } -+ -+ return "", fmt.Errorf("owner pod %s does not have %s label", owner.Name, KubeVirtVMLabel) -+ } -+ -+ return "", fmt.Errorf("hotplug disk pod %s has no controller owner reference", pod.GetName()) -+ } -+ -+ return "", nil -+} -+ -+// validateRWXBlockAttachment checks that RWX block volumes are only used by pods belonging to the same VM. ++// ValidateRWXBlockAttachment checks that RWX block volumes are only used by pods belonging to the same VM. +// This prevents misuse of allow-two-primaries while still permitting live migration. +// Returns the VM name if validation passes, or an error if: +// - Multiple pods from different VMs are trying to use the same volume +// - A pod without the KubeVirt VM label is trying to use a volume already attached elsewhere (strict mode) +// Returns empty string for VM name when no pods are using the volume or validation is skipped. -+func (d Driver) validateRWXBlockAttachment(ctx context.Context, volumeID string) (string, error) { -+ d.log.WithField("volumeID", volumeID).Info("validateRWXBlockAttachment called") ++func ValidateRWXBlockAttachment(ctx context.Context, kubeClient dynamic.Interface, log *logrus.Entry, volumeID string) (string, error) { ++ log.WithField("volumeID", volumeID).Info("validateRWXBlockAttachment called") + -+ if d.kubeClient == nil { ++ if kubeClient == nil { + // Not running in Kubernetes, skip validation -+ d.log.Warn("validateRWXBlockAttachment: kubeClient is nil, skipping validation") ++ log.Warn("validateRWXBlockAttachment: kubeClient is nil, skipping validation") + return "", nil + } + -+ // Get PV to find PVC reference (volumeID == PV name in CSI) -+ pv, err := d.kubeClient.Resource(pvGVR).Get(ctx, volumeID, metav1.GetOptions{}) ++ // Get PV to find PVC reference ++ pv, err := kubeClient.Resource(PVGVR).Get(ctx, volumeID, metav1.GetOptions{}) + if err != nil { -+ d.log.WithError(err).Warn("cannot validate RWX attachment: failed to get PV") ++ log.WithError(err).Warn("cannot validate RWX attachment: failed to get PV") ++ return "", nil ++ } ++ ++ // Verify that PV's volumeHandle matches the volumeID ++ volumeHandle, found, err := unstructured.NestedString(pv.Object, "spec", "csi", "volumeHandle") ++ if err != nil { ++ log.WithError(err).Warnf("cannot validate RWX attachment: failed to read volumeHandle for PV %s", volumeID) ++ ++ return "", nil ++ } ++ ++ if !found { ++ log.Warnf("cannot validate RWX attachment: volumeHandle not found for PV %s", volumeID) ++ ++ return "", nil ++ } ++ ++ if volumeHandle != volumeID { ++ log.WithFields(logrus.Fields{ ++ "volumeID": volumeID, ++ "volumeHandle": volumeHandle, ++ }).Warn("cannot validate RWX attachment: PV volumeHandle does not match volumeID") ++ + return "", nil + } + + // Extract claimRef from PV + claimRef, found, _ := unstructured.NestedMap(pv.Object, "spec", "claimRef") + if !found { -+ d.log.Warn("cannot validate RWX attachment: PV has no claimRef") ++ log.Warn("cannot validate RWX attachment: PV has no claimRef") + return "", nil + } + @@ -102,12 +204,12 @@ index bea69a8..69e71a6 100644 + pvcNamespace, _, _ := unstructured.NestedString(claimRef, "namespace") + + if pvcNamespace == "" || pvcName == "" { -+ d.log.Warn("cannot validate RWX attachment: PVC name or namespace is empty in claimRef") ++ log.Warn("cannot validate RWX attachment: PVC name or namespace is empty in claimRef") + return "", nil + } + + // List all pods in the namespace -+ podList, err := d.kubeClient.Resource(podGVR).Namespace(pvcNamespace).List(ctx, metav1.ListOptions{}) ++ podList, err := kubeClient.Resource(PodGVR).Namespace(pvcNamespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list pods in namespace %s: %w", pvcNamespace, err) + } @@ -139,28 +241,25 @@ index bea69a8..69e71a6 100644 + continue + } + -+ pvc, found, _ := unstructured.NestedMap(volMap, "persistentVolumeClaim") -+ if !found { ++ claimName, found, _ := unstructured.NestedString(volMap, "persistentVolumeClaim", "claimName") ++ if !found || claimName != pvcName { + continue + } + -+ claimName, _, _ := unstructured.NestedString(pvc, "claimName") -+ if claimName == pvcName { -+ // Extract VM name, handling both regular and hotplug disk pods -+ vmName, err := d.getVMNameFromPod(ctx, &item) -+ if err != nil { -+ d.log.WithError(err).WithField("pod", item.GetName()).Warn("failed to get VM name from pod") -+ // Continue with empty vmName - will be caught by strict mode check -+ vmName = "" -+ } -+ -+ podsUsingPVC = append(podsUsingPVC, podInfo{ -+ name: item.GetName(), -+ vmName: vmName, -+ }) -+ -+ break ++ // Extract VM name, handling both regular and hotplug disk pods ++ vmName, err := GetVMNameFromPod(ctx, kubeClient, log, &item) ++ if err != nil { ++ log.WithError(err).WithField("pod", item.GetName()).Warn("failed to get VM name from pod") ++ // Continue with empty vmName - will be caught by strict mode check ++ vmName = "" + } ++ ++ podsUsingPVC = append(podsUsingPVC, podInfo{ ++ name: item.GetName(), ++ vmName: vmName, ++ }) ++ ++ break + } + } + @@ -168,7 +267,7 @@ index bea69a8..69e71a6 100644 + if len(podsUsingPVC) <= 1 { + // Return VM name if there's exactly one pod + if len(podsUsingPVC) == 1 { -+ d.log.WithFields(logrus.Fields{ ++ log.WithFields(logrus.Fields{ + "volumeID": volumeID, + "vmName": podsUsingPVC[0].vmName, + "podCount": 1, @@ -179,7 +278,7 @@ index bea69a8..69e71a6 100644 + return podsUsingPVC[0].vmName, nil + } + -+ d.log.WithFields(logrus.Fields{ ++ log.WithFields(logrus.Fields{ + "volumeID": volumeID, + "pvcNamespace": pvcNamespace, + "pvcName": pvcName, @@ -209,7 +308,7 @@ index bea69a8..69e71a6 100644 + } + } + -+ d.log.WithFields(logrus.Fields{ ++ log.WithFields(logrus.Fields{ + "pvcNamespace": pvcNamespace, + "pvcName": pvcName, + "vmName": vmName, @@ -219,30 +318,62 @@ index bea69a8..69e71a6 100644 + return vmName, nil +} + - // ControllerPublishVolume https://github.com/container-storage-interface/spec/blob/v1.9.0/spec.md#controllerpublishvolume - func (d Driver) ControllerPublishVolume(ctx context.Context, req *csi.ControllerPublishVolumeRequest) (*csi.ControllerPublishVolumeResponse, error) { - if req.GetVolumeId() == "" { -@@ -751,6 +964,14 @@ func (d Driver) ControllerPublishVolume(ctx context.Context, req *csi.Controller - // ReadWriteMany block volume - rwxBlock := req.VolumeCapability.AccessMode.GetMode() == csi.VolumeCapability_AccessMode_MULTI_NODE_MULTI_WRITER && req.VolumeCapability.GetBlock() != nil - -+ // Validate RWX block attachment to prevent misuse of allow-two-primaries -+ if rwxBlock { -+ if _, err := d.validateRWXBlockAttachment(ctx, req.GetVolumeId()); err != nil { -+ return nil, status.Errorf(codes.FailedPrecondition, -+ "ControllerPublishVolume failed for %s: %v", req.GetVolumeId(), err) -+ } ++// GetVMNameFromPod extracts the VM name from a pod, handling both regular virt-launcher pods ++// and hotplug disk pods (which reference the virt-launcher pod via ownerReferences). ++func GetVMNameFromPod(ctx context.Context, kubeClient dynamic.Interface, log *logrus.Entry, pod *unstructured.Unstructured) (string, error) { ++ labels := pod.GetLabels() ++ if labels == nil { ++ return "", nil + } + - devPath, err := d.Assignments.Attach(ctx, req.GetVolumeId(), req.GetNodeId(), rwxBlock) - if err != nil { - return nil, status.Errorf(codes.Internal, -diff --git a/pkg/driver/rwx_validation_test.go b/pkg/driver/rwx_validation_test.go ++ // Direct case: pod has vm.kubevirt.io/name label (virt-launcher pod) ++ if vmName, ok := labels[KubeVirtVMLabel]; ok && vmName != "" { ++ return vmName, nil ++ } ++ ++ // Hotplug disk case: pod has kubevirt.io: hotplug-disk label ++ // Follow ownerReferences to find the virt-launcher pod ++ if hotplugValue, ok := labels[KubeVirtHotplugDiskLabel]; ok && hotplugValue == "hotplug-disk" { ++ ownerRefs := pod.GetOwnerReferences() ++ for _, owner := range ownerRefs { ++ if owner.Kind != "Pod" || owner.Controller == nil || !*owner.Controller { ++ continue ++ } ++ ++ // Get the owner pod (virt-launcher) ++ ownerPod, err := kubeClient.Resource(PodGVR).Namespace(pod.GetNamespace()).Get(ctx, owner.Name, metav1.GetOptions{}) ++ if err != nil { ++ return "", fmt.Errorf("failed to get owner pod %s: %w", owner.Name, err) ++ } ++ ++ // Extract VM name from owner pod ++ ownerLabels := ownerPod.GetLabels() ++ if ownerLabels != nil { ++ if vmName, ok := ownerLabels[KubeVirtVMLabel]; ok && vmName != "" { ++ log.WithFields(logrus.Fields{ ++ "hotplugPod": pod.GetName(), ++ "virtLauncher": owner.Name, ++ "vmName": vmName, ++ }).Debug("resolved VM name from hotplug disk pod via owner reference") ++ ++ return vmName, nil ++ } ++ } ++ ++ return "", fmt.Errorf("owner pod %s does not have %s label", owner.Name, KubeVirtVMLabel) ++ } ++ ++ return "", fmt.Errorf("hotplug disk pod %s has no controller owner reference", pod.GetName()) ++ } ++ ++ return "", nil ++} +diff --git a/pkg/utils/rwx_validation_test.go b/pkg/utils/rwx_validation_test.go new file mode 100644 -index 0000000..92c1046 +index 00000000..d75690f9 --- /dev/null -+++ b/pkg/driver/rwx_validation_test.go -@@ -0,0 +1,353 @@ ++++ b/pkg/utils/rwx_validation_test.go +@@ -0,0 +1,342 @@ +/* +CSI Driver for Linstor +Copyright © 2018 LINBIT USA, LLC @@ -261,7 +392,7 @@ index 0000000..92c1046 +along with this program; if not, see . +*/ + -+package driver ++package utils + +import ( + "context" @@ -424,22 +555,17 @@ index 0000000..92c1046 + } + + gvrToListKind := map[schema.GroupVersionResource]string{ -+ podGVR: "PodList", -+ pvGVR: "PersistentVolumeList", ++ PodGVR: "PodList", ++ PVGVR: "PersistentVolumeList", + } + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, gvrToListKind, objects...) + -+ // Create driver with fake client ++ // Create logger + logger := logrus.NewEntry(logrus.New()) + logger.Logger.SetLevel(logrus.DebugLevel) + -+ driver := &Driver{ -+ kubeClient: client, -+ log: logger, -+ } -+ + // Run validation -+ vmName, err := driver.validateRWXBlockAttachment(context.Background(), "test-volume-id") ++ vmName, err := ValidateRWXBlockAttachment(context.Background(), client, logger, "test-volume-id") + + if tc.expectError { + assert.Error(t, err) @@ -461,12 +587,8 @@ index 0000000..92c1046 +func TestValidateRWXBlockAttachmentNoKubeClient(t *testing.T) { + // When not running in Kubernetes (no client), validation should be skipped + logger := logrus.NewEntry(logrus.New()) -+ driver := &Driver{ -+ kubeClient: nil, -+ log: logger, -+ } + -+ vmName, err := driver.validateRWXBlockAttachment(context.Background(), "test-volume-id") ++ vmName, err := ValidateRWXBlockAttachment(context.Background(), nil, logger, "test-volume-id") + assert.NoError(t, err) + assert.Empty(t, vmName) +} @@ -476,20 +598,15 @@ index 0000000..92c1046 + scheme := runtime.NewScheme() + + gvrToListKind := map[schema.GroupVersionResource]string{ -+ podGVR: "PodList", -+ pvGVR: "PersistentVolumeList", ++ PodGVR: "PodList", ++ PVGVR: "PersistentVolumeList", + } + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, gvrToListKind) + + logger := logrus.NewEntry(logrus.New()) + logger.Logger.SetLevel(logrus.DebugLevel) + -+ driver := &Driver{ -+ kubeClient: client, -+ log: logger, -+ } -+ -+ vmName, err := driver.validateRWXBlockAttachment(context.Background(), "non-existent-pv") ++ vmName, err := ValidateRWXBlockAttachment(context.Background(), client, logger, "non-existent-pv") + assert.NoError(t, err) + assert.Empty(t, vmName) +} @@ -538,6 +655,9 @@ index 0000000..92c1046 + "name": pvcName, + "namespace": pvcNamespace, + }, ++ "csi": map[string]interface{}{ ++ "volumeHandle": name, ++ }, + }, + }, + }