From e60270d9616c742f69f2081c0f5c91c250f52aa0 Mon Sep 17 00:00:00 2001 From: Deepak Kinni Date: Mon, 8 Dec 2025 14:15:02 -0800 Subject: [PATCH] Enhance storageclass validation for linkedclone volume creation Signed-off-by: Deepak Kinni --- pkg/syncer/admissionhandler/validatepvc.go | 134 ++++- .../admissionhandler/validatepvc_test.go | 524 ++++++++++++++++++ 2 files changed, 650 insertions(+), 8 deletions(-) diff --git a/pkg/syncer/admissionhandler/validatepvc.go b/pkg/syncer/admissionhandler/validatepvc.go index 468309e962..63401397d4 100644 --- a/pkg/syncer/admissionhandler/validatepvc.go +++ b/pkg/syncer/admissionhandler/validatepvc.go @@ -319,7 +319,8 @@ func validateGuestPVCOperation(ctx context.Context, req *admissionv1.AdmissionRe // ValidateLinkedCloneRequest validates the various conditions necessary for a valid linkedclone request. // 1. The PVC datasource needs to be of type VolumeSnapshot - // 2. The storageclass associated LinkedClone PVC should be the same the source PVC + // 2. The svStorageClass parameter in the storageclass associated with the LinkedClone PVC + // should match the svStorageClass in the storageclass of the source PVC // 3. The size should be the same as the source PVC // 4. Should not be a second level LinkedClone // 5. VS is not under deletion @@ -462,14 +463,131 @@ func validateGuestPVCOperation(ctx context.Context, req *admissionv1.AdmissionRe }, } } - - // The storageclass associated LinkedClone PVC should be the same the source PVC + // The svStorageClass parameter in the storageclass associated with the LinkedClone PVC + // should be the same as the svStorageClass in the storageclass of the source PVC sourcePVCStorageClassName := sourcePVC.Spec.StorageClassName - same := strings.Compare(*pvc.Spec.StorageClassName, *sourcePVCStorageClassName) - if same != 0 { - errMsg := fmt.Sprintf("StorageClass mismatch, Namespace: %s, LinkedClone StorageClass: "+ - "%s, source PVC StorageClass: %s", sourcePVC.Namespace, *pvc.Spec.StorageClassName, - *sourcePVCStorageClassName) + linkedClonePVCStorageClassName := pvc.Spec.StorageClassName + + // Validate source PVC StorageClass + if sourcePVCStorageClassName == nil { + errMsg := "source PVC does not have a StorageClass specified, " + + "please specify a StorageClass for linked clone creation" + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: errMsg, + }, + } + } + if *sourcePVCStorageClassName == "" { + errMsg := "source PVC has an empty StorageClass name and cannot be validated for linked clone creation" + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: errMsg, + }, + } + } + + // Validate LinkedClone PVC StorageClass + if linkedClonePVCStorageClassName == nil { + errMsg := "LinkedClone PVC does not have a StorageClass specified," + + "please specify a StorageClass for linked clone creation" + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: errMsg, + }, + } + } + if *linkedClonePVCStorageClassName == "" { + errMsg := "LinkedClone PVC has an empty StorageClass name and cannot be validated for linked clone creation" + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: errMsg, + }, + } + } + + // Retrieve the StorageClass objects + sourceStorageClass, err := k8sClient.StorageV1().StorageClasses().Get(ctx, *sourcePVCStorageClassName, + metav1.GetOptions{}) + if err != nil { + errMsg := fmt.Sprintf("error getting source PVC StorageClass %s from api server: %v", + *sourcePVCStorageClassName, err) + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: errMsg, + }, + } + } + + linkedCloneStorageClass, err := k8sClient.StorageV1().StorageClasses().Get(ctx, *linkedClonePVCStorageClassName, + metav1.GetOptions{}) + if err != nil { + errMsg := fmt.Sprintf("error getting LinkedClone PVC StorageClass %s from api server: %v", + *linkedClonePVCStorageClassName, err) + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: errMsg, + }, + } + } + + // Extract svStorageClass from StorageClass parameters (case-insensitive lookup) + var sourceSvStorageClass string + var sourceHasSvStorageClass bool + for param, value := range sourceStorageClass.Parameters { + if strings.ToLower(param) == common.AttributeSupervisorStorageClass { + sourceSvStorageClass = value + sourceHasSvStorageClass = true + break + } + } + + var linkedCloneSvStorageClass string + var linkedCloneHasSvStorageClass bool + for param, value := range linkedCloneStorageClass.Parameters { + if strings.ToLower(param) == common.AttributeSupervisorStorageClass { + linkedCloneSvStorageClass = value + linkedCloneHasSvStorageClass = true + break + } + } + + // Both storage classes must have svStorageClass parameter + if !sourceHasSvStorageClass { + errMsg := fmt.Sprintf("source PVC StorageClass %s does not have %s parameter", + *sourcePVCStorageClassName, common.AttributeSupervisorStorageClass) + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: errMsg, + }, + } + } + + if !linkedCloneHasSvStorageClass { + errMsg := fmt.Sprintf("LinkedClone PVC StorageClass %s does not have %s parameter", + *linkedClonePVCStorageClassName, common.AttributeSupervisorStorageClass) + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: errMsg, + }, + } + } + + // Compare svStorageClass values + if sourceSvStorageClass != linkedCloneSvStorageClass { + errMsg := fmt.Sprintf("StorageClass svStorageClass mismatch, Namespace: %s, "+ + "LinkedClone StorageClass: %s (svStorageClass: %s), "+ + "source PVC StorageClass: %s (svStorageClass: %s)", + sourcePVC.Namespace, *linkedClonePVCStorageClassName, linkedCloneSvStorageClass, + *sourcePVCStorageClassName, sourceSvStorageClass) return &admissionv1.AdmissionResponse{ Allowed: false, Result: &metav1.Status{ diff --git a/pkg/syncer/admissionhandler/validatepvc_test.go b/pkg/syncer/admissionhandler/validatepvc_test.go index a6d4d1d0de..45b8f1d5b9 100644 --- a/pkg/syncer/admissionhandler/validatepvc_test.go +++ b/pkg/syncer/admissionhandler/validatepvc_test.go @@ -15,10 +15,12 @@ import ( "github.com/stretchr/testify/assert" admissionv1 "k8s.io/api/admission/v1" corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" clientset "k8s.io/client-go/kubernetes" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" k8s "sigs.k8s.io/vsphere-csi-driver/v3/pkg/kubernetes" ) @@ -622,3 +624,525 @@ func TestValidatePVC(t *testing.T) { }) } } + +func TestValidateGuestPVCOperation_LinkedClone_StorageClass(t *testing.T) { + // Store the original feature gate value and restore it after the test + originalFeatureGate := featureIsLinkedCloneSupportEnabled + defer func() { + featureIsLinkedCloneSupportEnabled = originalFeatureGate + }() + featureIsLinkedCloneSupportEnabled = true + + const ( + testLinkedCloneNamespace = "test-ns" + testLinkedClonePVCName = "test-lc-pvc" + testSourcePVCName = "test-source-pvc" + testSnapshotName = "test-snapshot" + testStorageClassA = "storage-class-a" + testStorageClassB = "storage-class-b" + testSvStorageClass1 = "wcpglobal-storage-profile" + testSvStorageClass2 = "wcpglobal-storage-profile-2" + testLinkedCloneSnapshotClass = "test-snapshot-class" + ) + + stringPtr := func(s string) *string { + return &s + } + + boolPtr := func(b bool) *bool { + return &b + } + + tests := []struct { + name string + kubeObjs []runtime.Object + snapshotObjs []runtime.Object + pvc *corev1.PersistentVolumeClaim + expectedAllowed bool + expectedMessageContain string + }{ + { + name: "LinkedClone with matching svStorageClass should succeed", + kubeObjs: []runtime.Object{ + // Source PVC StorageClass with svStorageClass1 + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassA, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + common.AttributeSupervisorStorageClass: testSvStorageClass1, + }, + }, + // Source PVC + &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSourcePVCName, + Namespace: testLinkedCloneNamespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassA), + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + }, + }, + }, + snapshotObjs: []runtime.Object{ + // VolumeSnapshot + &snapshotv1.VolumeSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSnapshotName, + Namespace: testLinkedCloneNamespace, + }, + Spec: snapshotv1.VolumeSnapshotSpec{ + Source: snapshotv1.VolumeSnapshotSource{ + PersistentVolumeClaimName: stringPtr(testSourcePVCName), + }, + VolumeSnapshotClassName: stringPtr(testLinkedCloneSnapshotClass), + }, + Status: &snapshotv1.VolumeSnapshotStatus{ + ReadyToUse: boolPtr(true), + }, + }, + }, + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testLinkedClonePVCName, + Namespace: testLinkedCloneNamespace, + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassA), // Same storage class + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + DataSource: &corev1.TypedLocalObjectReference{ + APIGroup: stringPtr("snapshot.storage.k8s.io"), + Kind: "VolumeSnapshot", + Name: testSnapshotName, + }, + }, + }, + expectedAllowed: true, + expectedMessageContain: "", + }, + { + name: "LinkedClone with different StorageClass but same svStorageClass should succeed", + kubeObjs: []runtime.Object{ + // Source PVC StorageClass with svStorageClass1 + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassA, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + common.AttributeSupervisorStorageClass: testSvStorageClass1, + }, + }, + // LinkedClone PVC StorageClass with same svStorageClass1 but different name + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassB, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + common.AttributeSupervisorStorageClass: testSvStorageClass1, // Same supervisor storage class + }, + }, + // Source PVC + &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSourcePVCName, + Namespace: testLinkedCloneNamespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassA), + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + }, + }, + }, + snapshotObjs: []runtime.Object{ + // VolumeSnapshot + &snapshotv1.VolumeSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSnapshotName, + Namespace: testLinkedCloneNamespace, + }, + Spec: snapshotv1.VolumeSnapshotSpec{ + Source: snapshotv1.VolumeSnapshotSource{ + PersistentVolumeClaimName: stringPtr(testSourcePVCName), + }, + VolumeSnapshotClassName: stringPtr(testLinkedCloneSnapshotClass), + }, + Status: &snapshotv1.VolumeSnapshotStatus{ + ReadyToUse: boolPtr(true), + }, + }, + }, + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testLinkedClonePVCName, + Namespace: testLinkedCloneNamespace, + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassB), // Different storage class + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + DataSource: &corev1.TypedLocalObjectReference{ + APIGroup: stringPtr("snapshot.storage.k8s.io"), + Kind: "VolumeSnapshot", + Name: testSnapshotName, + }, + }, + }, + expectedAllowed: true, + expectedMessageContain: "", + }, + { + name: "LinkedClone with mismatched svStorageClass should fail", + kubeObjs: []runtime.Object{ + // Source PVC StorageClass with svStorageClass1 + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassA, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + common.AttributeSupervisorStorageClass: testSvStorageClass1, + }, + }, + // LinkedClone PVC StorageClass with different svStorageClass2 + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassB, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + common.AttributeSupervisorStorageClass: testSvStorageClass2, // Different supervisor storage class + }, + }, + // Source PVC + &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSourcePVCName, + Namespace: testLinkedCloneNamespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassA), + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + }, + }, + }, + snapshotObjs: []runtime.Object{ + // VolumeSnapshot + &snapshotv1.VolumeSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSnapshotName, + Namespace: testLinkedCloneNamespace, + }, + Spec: snapshotv1.VolumeSnapshotSpec{ + Source: snapshotv1.VolumeSnapshotSource{ + PersistentVolumeClaimName: stringPtr(testSourcePVCName), + }, + VolumeSnapshotClassName: stringPtr(testLinkedCloneSnapshotClass), + }, + Status: &snapshotv1.VolumeSnapshotStatus{ + ReadyToUse: boolPtr(true), + }, + }, + }, + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testLinkedClonePVCName, + Namespace: testLinkedCloneNamespace, + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassB), // Different storage class with different policy + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + DataSource: &corev1.TypedLocalObjectReference{ + APIGroup: stringPtr("snapshot.storage.k8s.io"), + Kind: "VolumeSnapshot", + Name: testSnapshotName, + }, + }, + }, + expectedAllowed: false, + expectedMessageContain: "svStorageClass mismatch", + }, + { + name: "LinkedClone with missing svStorageClass in source StorageClass should fail", + kubeObjs: []runtime.Object{ + // Source PVC StorageClass without svStorageClass + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassA, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + // Missing common.AttributeSupervisorStorageClass + }, + }, + // LinkedClone PVC StorageClass with svStorageClass + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassB, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + common.AttributeSupervisorStorageClass: testSvStorageClass1, + }, + }, + // Source PVC + &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSourcePVCName, + Namespace: testLinkedCloneNamespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassA), + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + }, + }, + }, + snapshotObjs: []runtime.Object{ + // VolumeSnapshot + &snapshotv1.VolumeSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSnapshotName, + Namespace: testLinkedCloneNamespace, + }, + Spec: snapshotv1.VolumeSnapshotSpec{ + Source: snapshotv1.VolumeSnapshotSource{ + PersistentVolumeClaimName: stringPtr(testSourcePVCName), + }, + VolumeSnapshotClassName: stringPtr(testLinkedCloneSnapshotClass), + }, + Status: &snapshotv1.VolumeSnapshotStatus{ + ReadyToUse: boolPtr(true), + }, + }, + }, + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testLinkedClonePVCName, + Namespace: testLinkedCloneNamespace, + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassB), + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + DataSource: &corev1.TypedLocalObjectReference{ + APIGroup: stringPtr("snapshot.storage.k8s.io"), + Kind: "VolumeSnapshot", + Name: testSnapshotName, + }, + }, + }, + expectedAllowed: false, + expectedMessageContain: "does not have svstorageclass parameter", + }, + { + name: "LinkedClone with missing svStorageClass in LinkedClone StorageClass should fail", + kubeObjs: []runtime.Object{ + // Source PVC StorageClass with svStorageClass + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassA, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + common.AttributeSupervisorStorageClass: testSvStorageClass1, + }, + }, + // LinkedClone PVC StorageClass without svStorageClass + &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testStorageClassB, + }, + Provisioner: "csi.vsphere.vmware.com", + Parameters: map[string]string{ + // Missing common.AttributeSupervisorStorageClass + }, + }, + // Source PVC + &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSourcePVCName, + Namespace: testLinkedCloneNamespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassA), + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + }, + }, + }, + snapshotObjs: []runtime.Object{ + // VolumeSnapshot + &snapshotv1.VolumeSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: testSnapshotName, + Namespace: testLinkedCloneNamespace, + }, + Spec: snapshotv1.VolumeSnapshotSpec{ + Source: snapshotv1.VolumeSnapshotSource{ + PersistentVolumeClaimName: stringPtr(testSourcePVCName), + }, + VolumeSnapshotClassName: stringPtr(testLinkedCloneSnapshotClass), + }, + Status: &snapshotv1.VolumeSnapshotStatus{ + ReadyToUse: boolPtr(true), + }, + }, + }, + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: testLinkedClonePVCName, + Namespace: testLinkedCloneNamespace, + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: stringPtr(testStorageClassB), + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("5Gi"), + }, + }, + DataSource: &corev1.TypedLocalObjectReference{ + APIGroup: stringPtr("snapshot.storage.k8s.io"), + Kind: "VolumeSnapshot", + Name: testSnapshotName, + }, + }, + }, + expectedAllowed: false, + expectedMessageContain: "does not have svstorageclass parameter", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Create fake clients + kubeClient := fake.NewSimpleClientset(test.kubeObjs...) + snapshotClient := snapshotclientfake.NewSimpleClientset(test.snapshotObjs...) + + // Patch k8s client functions + patches := gomonkey.ApplyFunc( + k8s.NewClient, func(ctx context.Context) (clientset.Interface, error) { + return kubeClient, nil + }) + defer patches.Reset() + + patches = gomonkey.ApplyFunc( + k8s.NewSnapshotterClient, func(ctx context.Context) (snapshotterClientSet.Interface, error) { + return snapshotClient, nil + }) + defer patches.Reset() + + // Marshal the PVC to raw JSON + pvcBytes, err := json.Marshal(test.pvc) + assert.NoError(t, err) + + // Create admission request + admissionReq := &admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{ + Kind: "PersistentVolumeClaim", + }, + Operation: admissionv1.Create, + Namespace: testLinkedCloneNamespace, + Name: testLinkedClonePVCName, + Object: runtime.RawExtension{ + Raw: pvcBytes, + }, + } + + // Call the validation function + ctx := context.Background() + response := validateGuestPVCOperation(ctx, admissionReq) + + // Verify the response + assert.Equal(t, test.expectedAllowed, response.Allowed, + "Expected allowed=%v but got allowed=%v. Message: %v", + test.expectedAllowed, response.Allowed, response.Result) + + if !test.expectedAllowed && test.expectedMessageContain != "" { + assert.Contains(t, response.Result.Message, test.expectedMessageContain, + "Expected error message to contain '%s' but got: %s", + test.expectedMessageContain, response.Result.Message) + } + }) + } +}