Repository: kubernetes-sigs/gcp-compute-persistent-disk-csi-driver
Pull request #1975: Relax volumeContentSource restriction for ROX multi-zone dynamic volume creation
Author: login: pwschuurman
Labels: name: kind/feature
description: Categorizes issue or PR as related to a new feature.
name: size/L
description: Denotes a PR that changes 100-499 lines, ignoring generated files.
name: cncf-cla: yes
description: Indicates the PR's author has signed the CNCF CLA.
name: lgtm
description: "Looks good to me", indicates that a PR is ready to be merged.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: release-note
description: Denotes a PR that will be considered when it comes time to generate release notes.
Created: 2025-03-05T23:00:43Z | Merged: 2025-04-02T16:38:47Z

This enables empty ROX multi-zone PVCs to be created, to allow for static hydration of disks

<!--  Thanks for sending a pull request!  Here are some tips for you:

1. If this is your first time, please read our contributor guidelines: https://git.k8s.io/community/contributors/guide#your-first-contribution and developer guide https://git.k8s.io/community/contributors/devel/development.md#development-guide
2. Follow the instructions for writing a release note: https://git.k8s.io/community/contributors/guide/release-notes.md
3. If the PR is unfinished, see how to mark it: https://git.k8s.io/community/contributors/guide/pull-requests.md#marking-unfinished-pull-requests
-->

**What type of PR is this?**
> Uncomment only one ` /kind <>` line, hit enter to put that in a new line, and remove leading whitespaces from that line:
>
> /kind api-change
> /kind bug
> /kind cleanup
> /kind design
> /kind documentation
> /kind failing-test
/kind feature
> /kind flake

**What this PR does / why we need it**: Currently ROX multi-zone volumes can only be created from a volume source (eg: snapshot/disk image). This limits the flexibility of a user that wants to create a ROX multi-zone volume which is empty, and then write the same data individually to each created disk. This allows all disks to share the same name (as they are part of a "multi-zone" volume), but have data populated without needing to pre-create a snapshot/disk image (as copying data directly to each individual disk in parallel can be faster than creating and restoring from a snapshot).

**Which issue(s) this PR fixes**:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
-->
Fixes #

**Special notes for your reviewer**:

**Does this PR introduce a user-facing change?**:
<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->
```release-note
Relax multi-zone volume ROX restriction when creating a volume without a volumeContentSource.
```



Diff:
diff --git a/pkg/common/parameters.go b/pkg/common/parameters.go
index 18cca76fb..cd973b319 100644
--- a/pkg/common/parameters.go
+++ b/pkg/common/parameters.go
@@ -80,6 +80,7 @@ const (
 	DiskTypeHdHA = "hyperdisk-balanced-high-availability"
 	DiskTypeHdT  = "hyperdisk-throughput"
 	DiskTypeHdE  = "hyperdisk-extreme"
+	DiskTypeHdML = "hyperdisk-ml"
 )
 
 type DataCacheParameters struct {
diff --git a/pkg/gce-pd-csi-driver/controller.go b/pkg/gce-pd-csi-driver/controller.go
index fcb1ddb60..dcb0e3c45 100644
--- a/pkg/gce-pd-csi-driver/controller.go
+++ b/pkg/gce-pd-csi-driver/controller.go
@@ -232,7 +232,7 @@ var (
 		"nextPageToken",
 	}
 	listDisksFieldsWithUsers      = append(listDisksFieldsWithoutUsers, "items/users")
-	disksWithModifiableAccessMode = []string{"hyperdisk-ml"}
+	disksWithModifiableAccessMode = []string{common.DiskTypeHdML}
 	disksWithUnsettableAccessMode = map[string]bool{
 		common.DiskTypeHdE: true,
 		common.DiskTypeHdT: true,
@@ -374,7 +374,8 @@ func (gceCS *GCEControllerServer) createVolumeInternal(ctx context.Context, req
 
 	// Validate VolumeContentSource is set when access mode is read only
 	readonly, _ := getReadOnlyFromCapabilities(volumeCapabilities)
-	if readonly && req.GetVolumeContentSource() == nil {
+
+	if readonly && req.GetVolumeContentSource() == nil && params.DiskType != common.DiskTypeHdML {
 		return nil, status.Error(codes.InvalidArgument, "VolumeContentSource must be provided when AccessMode is set to read only")
 	}
 
@@ -485,12 +486,19 @@ func (gceCS *GCEControllerServer) createMultiZoneDisk(ctx context.Context, req *
 	}
 	defer gceCS.volumeLocks.Release(volumeID)
 
+	// If creating an empty disk (content source nil), always create RWO disks (when supported)
+	// This allows disks to be created as underlying RWO disks, so they can be hydrated.
+	accessMode := common.GCEReadWriteOnceAccessMode
+	if req.GetVolumeContentSource() != nil {
+		accessMode = common.GCEReadOnlyManyAccessMode
+	}
+
 	createDiskErrs := []error{}
 	createdDisks := make([]*gce.CloudDisk, 0, len(zones))
 	for _, zone := range zones {
 		volKey := meta.ZonalKey(req.GetName(), zone)
 		klog.V(4).Infof("Creating single zone disk for zone %q and volume: %v", zone, volKey)
-		disk, err := gceCS.createSingleDisk(ctx, req, params, volKey, []string{zone})
+		disk, err := gceCS.createSingleDisk(ctx, req, params, volKey, []string{zone}, accessMode)
 		if err != nil {
 			createDiskErrs = append(createDiskErrs, err)
 			continue
@@ -593,11 +601,23 @@ func (gceCS *GCEControllerServer) createSingleDeviceDisk(ctx context.Context, re
 	if err != nil {
 		return nil, common.LoggedError("Failed to convert volume key to volume ID: ", err)
 	}
+	accessMode, err := getAccessMode(req, params)
+	if err != nil {
+		return nil, common.LoggedError("Failed to get access mode: ", err)
+	}
+
+	// If creating an empty disk (content source nil), always create RWO disks (when supported)
+	// This allows disks to be created as underlying RWO disks, so they can be hydrated.
+	readonly, _ := getReadOnlyFromCapabilities(req.GetVolumeCapabilities())
+	if readonly && req.GetVolumeContentSource() == nil && params.DiskType == common.DiskTypeHdML {
+		accessMode = common.GCEReadWriteOnceAccessMode
+	}
+
 	if acquired := gceCS.volumeLocks.TryAcquire(volumeID); !acquired {
 		return nil, status.Errorf(codes.Aborted, common.VolumeOperationAlreadyExistsFmt, volumeID)
 	}
 	defer gceCS.volumeLocks.Release(volumeID)
-	disk, err := gceCS.createSingleDisk(ctx, req, params, volKey, zones)
+	disk, err := gceCS.createSingleDisk(ctx, req, params, volKey, zones, accessMode)
 
 	if err != nil {
 		return nil, common.LoggedError("CreateVolume failed: %v", err)
@@ -606,30 +626,36 @@ func (gceCS *GCEControllerServer) createSingleDeviceDisk(ctx context.Context, re
 	return generateCreateVolumeResponseWithVolumeId(disk, zones, params, dataCacheParams, enableDataCache, volumeID), err
 }
 
-func (gceCS *GCEControllerServer) createSingleDisk(ctx context.Context, req *csi.CreateVolumeRequest, params common.DiskParameters, volKey *meta.Key, zones []string) (*gce.CloudDisk, error) {
-	capacityRange := req.GetCapacityRange()
-	capBytes, _ := getRequestCapacity(capacityRange)
+func getAccessMode(req *csi.CreateVolumeRequest, params common.DiskParameters) (string, error) {
 	readonly, _ := getReadOnlyFromCapabilities(req.GetVolumeCapabilities())
-	accessMode := ""
-	multiWriter := false
 	if common.IsHyperdisk(params.DiskType) {
 		if am, err := getHyperdiskAccessModeFromCapabilities(req.GetVolumeCapabilities()); err != nil {
-			return nil, err
+			return "", err
 		} else if disksWithUnsettableAccessMode[params.DiskType] {
 			// Disallow multi-attach for HdT and HdE. These checks were done in `createVolumeInternal`,
 			// but repeating them here future-proves us from possible refactors.
 			if am != common.GCEReadWriteOnceAccessMode {
-				return nil, status.Errorf(codes.Internal, "")
+				return "", status.Errorf(codes.Internal, "")
 			}
 		} else {
-			accessMode = am
+			return am, nil
 		}
-	} else {
-		multiWriter, _ = getMultiWriterFromCapabilities(req.GetVolumeCapabilities())
 	}
 
 	if readonly && slices.Contains(disksWithModifiableAccessMode, params.DiskType) {
-		accessMode = common.GCEReadOnlyManyAccessMode
+		return common.GCEReadOnlyManyAccessMode, nil
+	}
+
+	return "", nil
+}
+
+func (gceCS *GCEControllerServer) createSingleDisk(ctx context.Context, req *csi.CreateVolumeRequest, params common.DiskParameters, volKey *meta.Key, zones []string, accessMode string) (*gce.CloudDisk, error) {
+	capacityRange := req.GetCapacityRange()
+	capBytes, _ := getRequestCapacity(capacityRange)
+
+	multiWriter := false
+	if !common.IsHyperdisk(params.DiskType) {
+		multiWriter, _ = getMultiWriterFromCapabilities(req.GetVolumeCapabilities())
 	}
 
 	// Validate if disk already exists
diff --git a/pkg/gce-pd-csi-driver/controller_test.go b/pkg/gce-pd-csi-driver/controller_test.go
index 30a508ae8..f55d33529 100644
--- a/pkg/gce-pd-csi-driver/controller_test.go
+++ b/pkg/gce-pd-csi-driver/controller_test.go
@@ -1298,6 +1298,54 @@ func TestCreateVolumeArguments(t *testing.T) {
 			},
 			expErrCode: codes.InvalidArgument,
 		},
+		{
+			name: "err empty HdB ROX single-zone disk no content source",
+			req: &csi.CreateVolumeRequest{
+				Name:          name,
+				CapacityRange: stdCapRange,
+				VolumeCapabilities: []*csi.VolumeCapability{
+					{
+						AccessType: &csi.VolumeCapability_Mount{
+							Mount: &csi.VolumeCapability_MountVolume{},
+						},
+						AccessMode: &csi.VolumeCapability_AccessMode{
+							Mode: csi.VolumeCapability_AccessMode_MULTI_NODE_READER_ONLY,
+						},
+					},
+				},
+				Parameters: map[string]string{
+					common.ParameterKeyType: "hyperdisk-balanced",
+				},
+			},
+			expErrCode: codes.InvalidArgument,
+		},
+		{
+			name: "success empty HdML ROX single-zone disk no content source",
+			req: &csi.CreateVolumeRequest{
+				Name:          name,
+				CapacityRange: stdCapRange,
+				VolumeCapabilities: []*csi.VolumeCapability{
+					{
+						AccessType: &csi.VolumeCapability_Mount{
+							Mount: &csi.VolumeCapability_MountVolume{},
+						},
+						AccessMode: &csi.VolumeCapability_AccessMode{
+							Mode: csi.VolumeCapability_AccessMode_MULTI_NODE_READER_ONLY,
+						},
+					},
+				},
+				Parameters: map[string]string{
+					common.ParameterKeyType: "hyperdisk-ml",
+				},
+			},
+			expVol: &csi.Volume{
+				CapacityBytes:      common.GbToBytes(20),
+				VolumeId:           testVolumeID,
+				VolumeContext:      nil,
+				AccessibleTopology: stdTopology,
+			},
+			expErrCode: codes.OK,
+		},
 	}
 
 	// Run test cases
@@ -1318,6 +1366,7 @@ func TestCreateVolumeArguments(t *testing.T) {
 			}
 			continue
 		}
+		t.Logf("ErroCode: %v", err)
 		if tc.expErrCode != codes.OK {
 			t.Fatalf("Expected error: %v, got no error", tc.expErrCode)
 		}
@@ -1504,7 +1553,7 @@ func TestMultiZoneVolumeCreation(t *testing.T) {
 			expZones: []string{"us-central1-a", "us-central1-b", "us-central1-c"},
 		},
 		{
-			name: "err single ROX multi-zone no topology",
+			name: "success empty HdML ROX multi-zone disk no content source",
 			req: &csi.CreateVolumeRequest{
 				Name:          "test-name",
 				CapacityRange: stdCapRange,
@@ -1522,18 +1571,26 @@ func TestMultiZoneVolumeCreation(t *testing.T) {
 					common.ParameterKeyType:                        "hyperdisk-ml",
 					common.ParameterKeyEnableMultiZoneProvisioning: "true",
 				},
-				VolumeContentSource: &csi.VolumeContentSource{
-					Type: &csi.VolumeContentSource_Snapshot{
-						Snapshot: &csi.VolumeContentSource_SnapshotSource{
-							SnapshotId: testSnapshotID,
+				AccessibilityRequirements: &csi.TopologyRequirement{
+					Requisite: []*csi.Topology{
+						{
+							Segments: map[string]string{common.TopologyKeyZone: "us-central1-a"},
+						},
+					},
+					Preferred: []*csi.Topology{
+						{
+							Segments: map[string]string{common.TopologyKeyZone: "us-central1-a"},
+						},
+						{
+							Segments: map[string]string{common.TopologyKeyZone: "us-central1-b"},
 						},
 					},
 				},
 			},
-			expErrCode: codes.InvalidArgument,
+			expZones: []string{"us-central1-a", "us-central1-b"},
 		},
 		{
-			name: "err rwo access mode",
+			name: "error empty HdB ROX multi-zone disk no content source",
 			req: &csi.CreateVolumeRequest{
 				Name:          "test-name",
 				CapacityRange: stdCapRange,
@@ -1543,21 +1600,14 @@ func TestMultiZoneVolumeCreation(t *testing.T) {
 							Mount: &csi.VolumeCapability_MountVolume{},
 						},
 						AccessMode: &csi.VolumeCapability_AccessMode{
-							Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER,
+							Mode: csi.VolumeCapability_AccessMode_MULTI_NODE_READER_ONLY,
 						},
 					},
 				},
 				Parameters: map[string]string{
-					common.ParameterKeyType:                        "hyperdisk-ml",
+					common.ParameterKeyType:                        "hyperdisk-balanced",
 					common.ParameterKeyEnableMultiZoneProvisioning: "true",
 				},
-				VolumeContentSource: &csi.VolumeContentSource{
-					Type: &csi.VolumeContentSource_Snapshot{
-						Snapshot: &csi.VolumeContentSource_SnapshotSource{
-							SnapshotId: testSnapshotID,
-						},
-					},
-				},
 				AccessibilityRequirements: &csi.TopologyRequirement{
 					Requisite: []*csi.Topology{
 						{
@@ -1568,13 +1618,16 @@ func TestMultiZoneVolumeCreation(t *testing.T) {
 						{
 							Segments: map[string]string{common.TopologyKeyZone: "us-central1-a"},
 						},
+						{
+							Segments: map[string]string{common.TopologyKeyZone: "us-central1-b"},
+						},
 					},
 				},
 			},
 			expErrCode: codes.InvalidArgument,
 		},
 		{
-			name: "err no content source",
+			name: "err single ROX multi-zone no topology",
 			req: &csi.CreateVolumeRequest{
 				Name:          "test-name",
 				CapacityRange: stdCapRange,
@@ -1592,6 +1645,42 @@ func TestMultiZoneVolumeCreation(t *testing.T) {
 					common.ParameterKeyType:                        "hyperdisk-ml",
 					common.ParameterKeyEnableMultiZoneProvisioning: "true",
 				},
+				VolumeContentSource: &csi.VolumeContentSource{
+					Type: &csi.VolumeContentSource_Snapshot{
+						Snapshot: &csi.VolumeContentSource_SnapshotSource{
+							SnapshotId: testSnapshotID,
+						},
+					},
+				},
+			},
+			expErrCode: codes.InvalidArgument,
+		},
+		{
+			name: "err rwo access mode",
+			req: &csi.CreateVolumeRequest{
+				Name:          "test-name",
+				CapacityRange: stdCapRange,
+				VolumeCapabilities: []*csi.VolumeCapability{
+					{
+						AccessType: &csi.VolumeCapability_Mount{
+							Mount: &csi.VolumeCapability_MountVolume{},
+						},
+						AccessMode: &csi.VolumeCapability_AccessMode{
+							Mode: csi.VolumeCapability_AccessMode_SINGLE_NODE_WRITER,
+						},
+					},
+				},
+				Parameters: map[string]string{
+					common.ParameterKeyType:                        "hyperdisk-ml",
+					common.ParameterKeyEnableMultiZoneProvisioning: "true",
+				},
+				VolumeContentSource: &csi.VolumeContentSource{
+					Type: &csi.VolumeContentSource_Snapshot{
+						Snapshot: &csi.VolumeContentSource_SnapshotSource{
+							SnapshotId: testSnapshotID,
+						},
+					},
+				},
 				AccessibilityRequirements: &csi.TopologyRequirement{
 					Requisite: []*csi.Topology{
 						{
diff --git a/test/e2e/tests/multi_zone_e2e_test.go b/test/e2e/tests/multi_zone_e2e_test.go
index 4a20b756d..c9b7fa6bf 100644
--- a/test/e2e/tests/multi_zone_e2e_test.go
+++ b/test/e2e/tests/multi_zone_e2e_test.go
@@ -95,14 +95,12 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 	It("Should attach ROX 'multi-zone' PV instances to two separate VMs", func() {
 		checkSkipMultiZoneTests()
 
-		// Create new driver and client
-
-		Expect(testContexts).NotTo(BeEmpty())
+		Expect(hyperdiskTestContexts).NotTo(BeEmpty())
 
 		zoneToContext := map[string]*remote.TestContext{}
 		zones := []string{}
 
-		for _, tc := range testContexts {
+		for _, tc := range hyperdiskTestContexts {
 			_, z, _ := tc.Instance.GetIdentity()
 			// Zone hasn't been seen before
 			if _, ok := zoneToContext[z]; !ok {
@@ -189,14 +187,12 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 	It("Should create RWO 'multi-zone' PV instances from a previously created disk", func() {
 		checkSkipMultiZoneTests()
 
-		// Create new driver and client
-
-		Expect(testContexts).NotTo(BeEmpty())
+		Expect(hyperdiskTestContexts).NotTo(BeEmpty())
 
 		zoneToContext := map[string]*remote.TestContext{}
 		zones := []string{}
 
-		for _, tc := range testContexts {
+		for _, tc := range hyperdiskTestContexts {
 			_, z, _ := tc.Instance.GetIdentity()
 			// Zone hasn't been seen before
 			if _, ok := zoneToContext[z]; !ok {
@@ -289,12 +285,13 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 
 	It("Should create ROX 'multi-zone' PV from existing snapshot", func() {
 		checkSkipMultiZoneTests()
-		Expect(testContexts).NotTo(BeEmpty())
+
+		Expect(hyperdiskTestContexts).NotTo(BeEmpty())
 
 		zoneToContext := map[string]*remote.TestContext{}
 		zones := []string{}
 
-		for _, tc := range testContexts {
+		for _, tc := range hyperdiskTestContexts {
 			_, z, _ := tc.Instance.GetIdentity()
 			// Zone hasn't been seen before
 			if _, ok := zoneToContext[z]; !ok {
@@ -317,7 +314,7 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 		tc0 := zoneToContext[zones[0]]
 		tc1 := zoneToContext[zones[1]]
 
-		snapshotVolName, snapshotVolID := createAndValidateUniqueZonalDisk(controllerClient, p, zones[0], standardDiskType)
+		snapshotVolName, snapshotVolID := createAndValidateUniqueZonalDisk(controllerClient, p, zones[0], ssdDiskType)
 
 		underSpecifiedID := common.GenerateUnderspecifiedVolumeID(snapshotVolName, true /* isZonal */)
 
@@ -436,12 +433,13 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 
 	It("Should create ROX 'multi-zone' PV from existing snapshot with no topology", func() {
 		checkSkipMultiZoneTests()
-		Expect(testContexts).NotTo(BeEmpty())
+
+		Expect(hyperdiskTestContexts).NotTo(BeEmpty())
 
 		zoneToContext := map[string]*remote.TestContext{}
 		zones := []string{}
 
-		for _, tc := range testContexts {
+		for _, tc := range hyperdiskTestContexts {
 			_, z, _ := tc.Instance.GetIdentity()
 			// Zone hasn't been seen before
 			if _, ok := zoneToContext[z]; !ok {
@@ -464,7 +462,7 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 		tc0 := zoneToContext[zones[0]]
 		tc1 := zoneToContext[zones[1]]
 
-		snapshotVolName, snapshotVolID := createAndValidateUniqueZonalDisk(controllerClient, p, zones[0], standardDiskType)
+		snapshotVolName, snapshotVolID := createAndValidateUniqueZonalDisk(controllerClient, p, zones[0], ssdDiskType)
 
 		underSpecifiedID := common.GenerateUnderspecifiedVolumeID(snapshotVolName, true /* isZonal */)
 
@@ -574,12 +572,13 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 
 	It("Should create ROX 'multi-zone' PV from existing disk image", func() {
 		checkSkipMultiZoneTests()
-		Expect(testContexts).NotTo(BeEmpty())
+
+		Expect(hyperdiskTestContexts).NotTo(BeEmpty())
 
 		zoneToContext := map[string]*remote.TestContext{}
 		zones := []string{}
 
-		for _, tc := range testContexts {
+		for _, tc := range hyperdiskTestContexts {
 			_, z, _ := tc.Instance.GetIdentity()
 			// Zone hasn't been seen before
 			if _, ok := zoneToContext[z]; !ok {
@@ -602,7 +601,7 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 		tc0 := zoneToContext[zones[0]]
 		tc1 := zoneToContext[zones[1]]
 
-		snapshotVolName, snapshotVolID := createAndValidateUniqueZonalDisk(controllerClient, p, zones[0], standardDiskType)
+		snapshotVolName, snapshotVolID := createAndValidateUniqueZonalDisk(controllerClient, p, zones[0], ssdDiskType)
 
 		underSpecifiedID := common.GenerateUnderspecifiedVolumeID(snapshotVolName, true /* isZonal */)
 
@@ -717,13 +716,14 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 
 	It("Should create RWO 'multi-zone' PV that has empty disks", func() {
 		checkSkipMultiZoneTests()
+
 		// Create new driver and client
-		Expect(testContexts).NotTo(BeEmpty())
+		Expect(hyperdiskTestContexts).NotTo(BeEmpty())
 
 		zoneToContext := map[string]*remote.TestContext{}
 		zones := []string{}
 
-		for _, tc := range testContexts {
+		for _, tc := range hyperdiskTestContexts {
 			_, z, _ := tc.Instance.GetIdentity()
 			// Zone hasn't been seen before
 			if _, ok := zoneToContext[z]; !ok {
@@ -831,6 +831,207 @@ var _ = Describe("GCE PD CSI Driver Multi-Zone", func() {
 
 	})
 
+	It("Should create ROX 'multi-zone' PV that has empty disks in RWO mode", func() {
+		checkSkipMultiZoneTests()
+
+		Expect(hyperdiskTestContexts).NotTo(BeEmpty())
+
+		zoneToContext := map[string]*remote.TestContext{}
+		zones := []string{}
+
+		for _, tc := range hyperdiskTestContexts {
+			_, z, _ := tc.Instance.GetIdentity()
+			// Zone hasn't been seen before
+			if _, ok := zoneToContext[z]; !ok {
+				zoneToContext[z] = tc
+				zones = append(zones, z)
+			}
+			if len(zoneToContext) == 2 {
+				break
+			}
+		}
+
+		Expect(len(zoneToContext)).To(Equal(2), "Must have instances in 2 zones")
+
+		controllerContext := zoneToContext[zones[0]]
+		controllerClient := controllerContext.Client
+		controllerInstance := controllerContext.Instance
+
+		p, _, _ := controllerInstance.GetIdentity()
+
+		// Attach disk to instance in the first zone.
+		tc0 := zoneToContext[zones[0]]
+		tc1 := zoneToContext[zones[1]]
+
+		// Create Disk
+		volName := testNamePrefix + string(uuid.NewUUID())
+		_, err := controllerClient.CreateVolumeWithCaps(volName, map[string]string{
+			common.ParameterKeyEnableMultiZoneProvisioning: "true",
+			common.ParameterKeyType:                        "hyperdisk-ml",
+		}, defaultHdmlSizeGb,
+			&csi.TopologyRequirement{
+				Requisite: []*csi.Topology{
+					{
+						Segments: map[string]string{common.TopologyKeyZone: zones[0]},
+					},
+					{
+						Segments: map[string]string{common.TopologyKeyZone: zones[1]},
+					},
+				},
+			},
+			[]*csi.VolumeCapability{
+				{
+					AccessType: &csi.VolumeCapability_Mount{
+						Mount: &csi.VolumeCapability_MountVolume{},
+					},
+					AccessMode: &csi.VolumeCapability_AccessMode{
+						Mode: csi.VolumeCapability_AccessMode_MULTI_NODE_READER_ONLY,
+					},
+				},
+			},
+			nil)
+		Expect(err).To(BeNil(), "CreateVolume failed with error: %v", err)
+
+		volID := fmt.Sprintf("projects/%s/zones/multi-zone/disks/%s", p, volName)
+		defer func() {
+			// Delete Disk
+			err := controllerClient.DeleteVolume(volID)
+			Expect(err).To(BeNil(), "DeleteVolume failed")
+
+			// Validate Disk Deleted
+			_, err = computeService.Disks.Get(p, zones[0], volName).Do()
+			Expect(gce.IsGCEError(err, "notFound")).To(BeTrue(), "Expected disk to not be found. Err: %v", err)
+			_, err = computeService.Disks.Get(p, zones[1], volName).Do()
+			Expect(gce.IsGCEError(err, "notFound")).To(BeTrue(), "Expected disk to not be found. Err: %v", err)
+		}()
+
+		disk1, err := computeService.Disks.Get(p, zones[0], volName).Do()
+		Expect(err).To(BeNil(), "Failed to get disk %v/%v", zones[0], volName)
+		disk2, err := computeService.Disks.Get(p, zones[1], volName).Do()
+		Expect(err).To(BeNil(), "Failed to get disk %v/%v", zones[1], volName)
+
+		// Validate disks have multi-zone labels
+		Expect(disk1.Labels[common.MultiZoneLabel]).To(Equal("true"))
+		Expect(disk2.Labels[common.MultiZoneLabel]).To(Equal("true"))
+
+		// Validate disks are RWO
+		Expect(disk1.AccessMode).To(Equal("READ_WRITE_SINGLE"))
+		Expect(disk2.AccessMode).To(Equal("READ_WRITE_SINGLE"))
+
+		// Validate underlying disks can be used
+		volID0 := fmt.Sprintf("projects/%s/zones/%s/disks/%s", p, zones[0], volName)
+		volID1 := fmt.Sprintf("projects/%s/zones/%s/disks/%s", p, zones[1], volName)
+
+		err = testAttachWriteReadDetach(volID0, volName, tc0.Instance, tc0.Client, false /* readonly */, false /* detachAndReattach */, false /* setupDataCache */)
+		Expect(err).To(BeNil(), "Failed to attach/write/read/detach on vol1")
+
+		err = testAttachWriteReadDetach(volID1, volName, tc1.Instance, tc1.Client, false /* readonly */, false /* detachAndReattach */, false /* setupDataCache */)
+		Expect(err).To(BeNil(), "Failed to attach/write/read/detach on vol2")
+
+		// Validate disks can be used in multi-zone mode on both nodes
+		volIDMultiZone := fmt.Sprintf("projects/%s/zones/multi-zone/disks/%s", p, volName)
+		err = testAttachWriteReadDetach(volIDMultiZone, volName, tc0.Instance, tc0.Client, true /* readonly */, false /* detachAndReattach */, false /* setupDataCache */)
+		Expect(err).To(BeNil(), "Failed to attach/read/detach on vol1")
+
+		err = testAttachWriteReadDetach(volIDMultiZone, volName, tc1.Instance, tc1.Client, true /* readonly */, false /* detachAndReattach */, false /* setupDataCache */)
+		Expect(err).To(BeNil(), "Failed to attach/read/detach on vol2")
+
+		// Validate disks are ROX now
+		disk1, err = computeService.Disks.Get(p, zones[0], volName).Do()
+		Expect(err).To(BeNil(), "Failed to get disk %v/%v", zones[0], volName)
+		disk2, err = computeService.Disks.Get(p, zones[1], volName).Do()
+		Expect(err).To(BeNil(), "Failed to get disk %v/%v", zones[1], volName)
+
+		Expect(disk1.AccessMode).To(Equal("READ_ONLY_MANY"))
+		Expect(disk2.AccessMode).To(Equal("READ_ONLY_MANY"))
+	})
+
+	It("Should create ROX 'single-zone' PV that has empty disks in RWO mode", func() {
+		Expect(hyperdiskTestContexts).NotTo(BeEmpty())
+
+		zoneToContext := map[string]*remote.TestContext{}
+		zones := []string{}
+
+		for _, tc := range hyperdiskTestContexts {
+			_, z, _ := tc.Instance.GetIdentity()
+			// Zone hasn't been seen before
+			if _, ok := zoneToContext[z]; !ok {
+				zoneToContext[z] = tc
+				zones = append(zones, z)
+			}
+			if len(zoneToContext) == 2 {
+				break
+			}
+		}
+
+		controllerContext := zoneToContext[zones[0]]
+		controllerClient := controllerContext.Client
+		controllerInstance := controllerContext.Instance
+
+		p, _, _ := controllerInstance.GetIdentity()
+
+		// Attach disk to instance in the first zone.
+		tc0 := zoneToContext[zones[0]]
+
+		// Create Disk
+		volName := testNamePrefix + string(uuid.NewUUID())
+		_, err := controllerClient.CreateVolumeWithCaps(volName, map[string]string{
+			common.ParameterKeyType: "hyperdisk-ml",
+		}, defaultHdmlSizeGb,
+			&csi.TopologyRequirement{
+				Requisite: []*csi.Topology{
+					{
+						Segments: map[string]string{common.TopologyKeyZone: zones[0]},
+					},
+				},
+			},
+			[]*csi.VolumeCapability{
+				{
+					AccessType: &csi.VolumeCapability_Mount{
+						Mount: &csi.VolumeCapability_MountVolume{},
+					},
+					AccessMode: &csi.VolumeCapability_AccessMode{
+						Mode: csi.VolumeCapability_AccessMode_MULTI_NODE_READER_ONLY,
+					},
+				},
+			},
+			nil)
+		Expect(err).To(BeNil(), "CreateVolume failed with error: %v", err)
+
+		volID := fmt.Sprintf("projects/%s/zones/%s/disks/%s", p, zones[0], volName)
+		defer func() {
+			// Delete Disk
+			err := controllerClient.DeleteVolume(volID)
+			Expect(err).To(BeNil(), "DeleteVolume failed")
+
+			// Validate Disk Deleted
+			_, err = computeService.Disks.Get(p, zones[0], volName).Do()
+			Expect(gce.IsGCEError(err, "notFound")).To(BeTrue(), "Expected disk to not be found. Err: %v", err)
+		}()
+
+		disk1, err := computeService.Disks.Get(p, zones[0], volName).Do()
+		Expect(err).To(BeNil(), "Failed to get disk %v/%v", zones[0], volName)
+
+		// Validate disks are RWO
+		Expect(disk1.AccessMode).To(Equal("READ_WRITE_SINGLE"))
+
+		// Validate underlying disks can be used
+		volID1 := fmt.Sprintf("projects/%s/zones/%s/disks/%s", p, zones[0], volName)
+
+		err = testAttachWriteReadDetach(volID1, volName, tc0.Instance, tc0.Client, false /* readonly */, false /* detachAndReattach */, false /* setupDataCache */)
+		Expect(err).To(BeNil(), "Failed to attach/write/read/detach on vol1")
+
+		// Validate disks can be used in single-zone mode on both nodes
+		err = testAttachWriteReadDetach(volID1, volName, tc0.Instance, tc0.Client, true /* readonly */, false /* detachAndReattach */, false /* setupDataCache */)
+		Expect(err).To(BeNil(), "Failed to attach/read/detach on vol1")
+
+		// Validate disk is ROX now
+		disk1, err = computeService.Disks.Get(p, zones[0], volName).Do()
+		Expect(err).To(BeNil(), "Failed to get disk %v/%v", zones[0], volName)
+
+		Expect(disk1.AccessMode).To(Equal("READ_ONLY_MANY"))
+	})
+
 	It("Should successfully run through entire lifecycle of an RePD volume on instances in 2 zones", func() {
 		// Create new driver and client
 


Reviews:
user: login: dannawang0221
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T00:28:31Z
user: login: pwschuurman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T15:51:16Z

Review comments:
diff_hunk: @@ -485,12 +486,19 @@ func (gceCS *GCEControllerServer) createMultiZoneDisk(ctx context.Context, req *
 	}
 	defer gceCS.volumeLocks.Release(volumeID)
 
+	// If creating an empty disk (content source nil), always create RWO disks (when supported)
+	// This allows disks to be created as underlying RWO disks, so they can be hydrated.
path: pkg/gce-pd-csi-driver/controller.go
user: login: dannawang0221
body: This means in volume populator we need to update the disk accessMode?
created_at: 2025-04-02T00:28:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -485,12 +486,19 @@ func (gceCS *GCEControllerServer) createMultiZoneDisk(ctx context.Context, req *
 	}
 	defer gceCS.volumeLocks.Release(volumeID)
 
+	// If creating an empty disk (content source nil), always create RWO disks (when supported)
+	// This allows disks to be created as underlying RWO disks, so they can be hydrated.
path: pkg/gce-pd-csi-driver/controller.go
user: login: pwschuurman
body: No, accessMode is updated when the disk is attached (in controller publish), based on the accessMode of the PV that is using it: https://github.com/kubernetes-sigs/gcp-compute-persistent-disk-csi-driver/blob/193aa66666ffba45fee3e3d60ac40e7b9453987f/pkg/gce-pd-csi-driver/controller.go#L1167

So this allows for:
1) An empty RWX PV/PVC to be created with underlying disks in RWO mode
2) A static RWO PV/PVC to be created, with the volumeHandle from (1) to hydrate the disk in RWO mode
3) A consumer to use the RWX PV/PVC from (1) after data has been hydrated
created_at: 2025-04-02T15:51:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: k8s-ci-robot
created_at: 2025-03-05T23:00:49Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/gcp-compute-persistent-disk-csi-driver/pull/1975#" title="Author self-approved">pwschuurman</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fgcp-compute-persistent-disk-csi-driver).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/gcp-compute-persistent-disk-csi-driver/blob/master/OWNERS)~~ [pwschuurman]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: pwschuurman
created_at: 2025-03-05T23:18:26Z
body: /retest
author_association: CONTRIBUTOR
user: login: pwschuurman
created_at: 2025-03-06T04:56:23Z
body: /retest
author_association: CONTRIBUTOR
user: login: pwschuurman
created_at: 2025-03-06T19:59:24Z
body: /retest
author_association: CONTRIBUTOR
user: login: pwschuurman
created_at: 2025-03-21T19:27:40Z
body: /retest
author_association: CONTRIBUTOR
user: login: pwschuurman
created_at: 2025-04-01T03:39:42Z
body: /test pull-gcp-compute-persistent-disk-csi-driver-e2e
author_association: CONTRIBUTOR
user: login: pwschuurman
created_at: 2025-04-01T23:45:02Z
body: /test pull-gcp-compute-persistent-disk-csi-driver-e2e
author_association: CONTRIBUTOR
user: login: pwschuurman
created_at: 2025-04-01T23:56:49Z
body: /assign @dannawang0221 
author_association: CONTRIBUTOR
user: login: dannawang0221
created_at: 2025-04-02T16:36:03Z
body: lgtm
author_association: CONTRIBUTOR
user: login: dannawang0221
created_at: 2025-04-02T16:37:45Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: pwschuurman
created_at: 2025-04-03T03:15:01Z
body: /cherrypick release-1.17
author_association: CONTRIBUTOR
user: login: k8s-infra-cherrypick-robot
created_at: 2025-04-03T03:15:39Z
body: @pwschuurman: new pull request created: #2036

<details>

In response to [this](https://github.com/kubernetes-sigs/gcp-compute-persistent-disk-csi-driver/pull/1975#issuecomment-2774340640):

>/cherrypick release-1.17


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
author_association: NONE