From 962393c40b053f7dfa1940f141162e56ee5b1ae5 Mon Sep 17 00:00:00 2001 From: Surya Gupta Date: Mon, 28 Sep 2026 11:20:50 +0530 Subject: [PATCH 1/3] [CCS-5] Volume Resize Fix for IOPS and Size (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description On Resize Volume, the form did not show the volume’s current size and IOPS. It also used the first disk offering in the list, not the offering already attached to the volume. So the dialog often opened empty or with the wrong IOPS fields, and a resize could send the wrong values. Root cause Three UI issues in fetchData(): Size unit — the API stores size in bytes. The form label is GB. Size was never copied onto the form, so the GB field stayed empty. Wrong offering — the code always used this.offerings[0] (first offering returned by listDiskOfferings), not this.resource.diskofferingid (the volume’s offering). Custom size / custom IOPS flags could be wrong. IOPS not filled — even when min/max IOPS existed on the volume, they were not copied into form.miniops / form.maxiops. Steps to reproduce Create a volume with a custom IOPS disk offering (for example min 2000, max 5000) and a known size (for example 20 GB). Open Storage → Volumes → Resize. Check the Size (GB) field and the Min/Max IOPS fields. ### Types of changes - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] New feature (non-breaking change which adds functionality) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Enhancement (improves an existing feature and functionality) - [ ] Cleanup (Code refactoring and cleanup, that may add test cases) - [ ] Build/CI - [ ] Test (unit or integration test code) ### Feature/Enhancement Scale or Bug Severity #### Feature/Enhancement Scale - [ ] Major - [ ] Minor #### Bug Severity - [ ] BLOCKER - [ ] Critical - [ ] Major - [ ] Minor - [ ] Trivial ### Screenshots (if appropriate): Before Fix: image After Fix: image ### How Has This Been Tested? Tested with both Fix and Custom IOPS: 1- Resize Shows IOPS value for custom IOPS not for fix, along with size 2- Exact 20 GB volume → shows as 20 float 20.1 -> shows on UI as 20 #### How did you try to break this feature and the system with this change? --------- Co-authored-by: Gupta, Surya --- ui/src/views/storage/ResizeVolume.vue | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/ui/src/views/storage/ResizeVolume.vue b/ui/src/views/storage/ResizeVolume.vue index 5f9efd6ed506..2b6d4cb17901 100644 --- a/ui/src/views/storage/ResizeVolume.vue +++ b/ui/src/views/storage/ResizeVolume.vue @@ -83,8 +83,6 @@ export default { }, data () { return { - offerings: [], - customDiskOffering: false, loading: false, customDiskOfferingIops: false } @@ -105,14 +103,23 @@ export default { }, fetchData () { this.loading = true + if (this.resource.size != null) { + this.form.size = this.resource.size / (1024 * 1024 * 1024) + } + if (!this.resource.diskofferingid) { + this.loading = false + return + } getAPI('listDiskOfferings', { - zoneid: this.resource.zoneid, - listall: true + id: this.resource.diskofferingid, + state: 'all' }).then(json => { - this.offerings = json.listdiskofferingsresponse.diskoffering || [] - this.form.diskofferingid = this.offerings[0].id || '' - this.customDiskOffering = this.offerings[0].iscustomized || false - this.customDiskOfferingIops = this.offerings[0].iscustomizediops || false + const currentOffering = (json.listdiskofferingsresponse.diskoffering || [])[0] + this.customDiskOfferingIops = currentOffering?.iscustomizediops || false + if (this.customDiskOfferingIops) { + this.form.miniops = this.resource.miniops + this.form.maxiops = this.resource.maxiops + } }).finally(() => { this.loading = false }) From c2c30e87b93d574aa2aa5d40b90d802e715a8c12 Mon Sep 17 00:00:00 2001 From: Surya Gupta Date: Thu, 29 Oct 2026 18:28:35 +0530 Subject: [PATCH 2/3] Resize Upstream PR Review Comment (#124) Root Volume created with custom IOPS and not overridden by the custom disk offering should also show the min and max IOPS on form - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] New feature (non-breaking change which adds functionality) - [x] Bug fix (non-breaking change which fixes an issue) - [ ] Enhancement (improves an existing feature and functionality) - [ ] Cleanup (Code refactoring and cleanup, that may add test cases) - [ ] Build/CI - [ ] Test (unit or integration test code) - [x] Major - [ ] Minor - [ ] BLOCKER - [ ] Critical - [x] Major - [ ] Minor - [ ] Trivial image image image 1- Created a Fix IOPS Offering of compute/disk and use to create VM/Volume, resize should not show min max IOPS - success 2- Created a Custom IOPS Offering of compute/disk and use to create VM/Volume, resize should show min max IOPS - success change? --------- Co-authored-by: Gupta, Surya --- .../api/response/VolumeResponse.java | 16 ++ .../api/query/dao/VolumeJoinDaoImpl.java | 6 + .../api/query/dao/VolumeJoinDaoImplTest.java | 173 +++++++++++++++++- ui/src/views/storage/ResizeVolume.vue | 23 +-- 4 files changed, 198 insertions(+), 20 deletions(-) diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java index 031d9315cd08..8cde09af1212 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java @@ -117,6 +117,10 @@ public class VolumeResponse extends BaseResponseWithTagInformation implements Co @Param(description = "Max IOPS of the disk volume") private Long maxIops; + @SerializedName("iscustomizediops") + @Param(description = "True if the volume's disk offering uses custom IOPS, false otherwise") + private Boolean customizedIops; + @SerializedName(ApiConstants.CREATED) @Param(description = "The date the disk volume was created") private Date created; @@ -410,6 +414,10 @@ public void setMaxIops(Long maxIops) { this.maxIops = maxIops; } + public void setCustomizedIops(Boolean customizedIops) { + this.customizedIops = customizedIops; + } + public void setCreated(Date created) { this.created = created; } @@ -712,6 +720,14 @@ public Long getMaxIops() { return maxIops; } + public Boolean isCustomizedIops() { + return customizedIops; + } + + public Boolean getCustomizedIops() { + return customizedIops; + } + public Date getCreated() { return created; } diff --git a/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java index 4a462a61daed..675738cbedc3 100644 --- a/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java @@ -227,6 +227,12 @@ public VolumeResponse newVolumeResponse(ResponseView view, VolumeJoinVO volume) volResponse.setDiskOfferingName(volume.getDiskOfferingName()); volResponse.setDiskOfferingDisplayText(volume.getDiskOfferingDisplayText()); } + DiskOffering diskOffering = computeOnlyDiskOffering != null + ? computeOnlyDiskOffering + : ApiDBUtils.findDiskOfferingById(volume.getDiskOfferingId()); + if (diskOffering != null) { + volResponse.setCustomizedIops(diskOffering.isCustomizedIops()); + } if (view == ResponseView.Full) { volResponse.setStorageType(volume.isUseLocalStorage() ? ServiceOffering.StorageType.local.toString() : ServiceOffering.StorageType.shared.toString()); diff --git a/server/src/test/java/com/cloud/api/query/dao/VolumeJoinDaoImplTest.java b/server/src/test/java/com/cloud/api/query/dao/VolumeJoinDaoImplTest.java index e4e0b69de7d8..a6bf851798df 100755 --- a/server/src/test/java/com/cloud/api/query/dao/VolumeJoinDaoImplTest.java +++ b/server/src/test/java/com/cloud/api/query/dao/VolumeJoinDaoImplTest.java @@ -16,32 +16,70 @@ // under the License. package com.cloud.api.query.dao; -import com.cloud.api.query.vo.VolumeJoinVO; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.function.Consumer; + +import org.apache.cloudstack.annotation.dao.AnnotationDao; +import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.response.VolumeResponse; import org.junit.Assert; +import org.apache.cloudstack.context.CallContext; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mockito; +import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.MockitoJUnitRunner; +import com.cloud.api.ApiDBUtils; +import com.cloud.api.ApiResponseHelper; +import com.cloud.api.query.vo.VolumeJoinVO; +import com.cloud.service.ServiceOfferingVO; +import com.cloud.storage.DiskOfferingVO; +import com.cloud.storage.Volume; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; + @RunWith(MockitoJUnitRunner.class) public class VolumeJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseTest { + private static final long DISK_OFFERING_ID = 10L; + @InjectMocks private VolumeJoinDaoImpl _volumeJoinDaoImpl; + @Mock + private AnnotationDao annotationDao; + + @Mock + private AccountManager _accountMgr; + private VolumeJoinVO volume = new VolumeJoinVO(); private VolumeResponse volumeResponse = new VolumeResponse(); @Before public void setup() { - prepareSetup(); + lenient().when(annotationDao.hasAnnotations(anyString(), anyString(), anyBoolean())).thenReturn(false); + lenient().when(_accountMgr.isRootAdmin(eq(1L))).thenReturn(true); } @Test public void testUpdateVolumeTagInfo(){ + prepareSetup(); testUpdateTagInformation(_volumeJoinDaoImpl, volume, volumeResponse); } @@ -62,4 +100,135 @@ public void testSetThrottleRatesMapsReadAndWriteDistinctly() { Assert.assertEquals(Long.valueOf(400L), response.getIopsWriteRate()); } + @Test + public void testNewVolumeResponseSetsCustomizedIopsForComputeOfferingRoot() { + VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering(); + DiskOfferingVO computeOnlyOffering = mock(DiskOfferingVO.class); + when(computeOnlyOffering.isCustomizedIops()).thenReturn(true); + ServiceOfferingVO serviceOffering = mock(ServiceOfferingVO.class); + when(serviceOffering.getId()).thenReturn(5L); + when(serviceOffering.getName()).thenReturn("FullCustom_Compute"); + when(serviceOffering.getDisplayText()).thenReturn("FullCustom_Compute"); + + VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> { + apiDBUtils.when(() -> ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID)) + .thenReturn(computeOnlyOffering); + apiDBUtils.when(() -> ApiDBUtils.findServiceOfferingByComputeOnlyDiskOffering(DISK_OFFERING_ID, false)) + .thenReturn(serviceOffering); + }); + + assertTrue(response.isCustomizedIops()); + assertEquals("5", response.getServiceOfferingId()); + assertEquals("FullCustom_Compute", response.getServiceOfferingName()); + assertNull(response.getDiskOfferingId()); + } + + @Test + public void testNewVolumeResponseSetsCustomizedIopsForDiskOffering() { + VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering(); + when(volumeJoin.getDiskOfferingUuid()).thenReturn("disk-offering-uuid"); + when(volumeJoin.getDiskOfferingName()).thenReturn("NFS_Disk_Custom75"); + when(volumeJoin.getDiskOfferingDisplayText()).thenReturn("NFS_Disk_Custom75"); + DiskOfferingVO diskOffering = mock(DiskOfferingVO.class); + when(diskOffering.isCustomizedIops()).thenReturn(true); + + VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> { + apiDBUtils.when(() -> ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID)).thenReturn(null); + apiDBUtils.when(() -> ApiDBUtils.findDiskOfferingById(DISK_OFFERING_ID)).thenReturn(diskOffering); + }); + + assertTrue(response.isCustomizedIops()); + assertEquals("disk-offering-uuid", response.getDiskOfferingId()); + assertNull(response.getServiceOfferingId()); + } + + @Test + public void testNewVolumeResponseSetsCustomizedIopsFalseWhenOfferingIsFixed() { + VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering(); + DiskOfferingVO diskOffering = mock(DiskOfferingVO.class); + when(diskOffering.isCustomizedIops()).thenReturn(false); + + VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> { + apiDBUtils.when(() -> ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID)).thenReturn(null); + apiDBUtils.when(() -> ApiDBUtils.findDiskOfferingById(DISK_OFFERING_ID)).thenReturn(diskOffering); + }); + + assertFalse(response.isCustomizedIops()); + } + + @Test + public void testNewVolumeResponseSetsCustomizedIopsFalseForFixedComputeOffering() { + VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering(); + DiskOfferingVO computeOnlyOffering = mock(DiskOfferingVO.class); + when(computeOnlyOffering.isCustomizedIops()).thenReturn(false); + ServiceOfferingVO serviceOffering = mock(ServiceOfferingVO.class); + when(serviceOffering.getId()).thenReturn(5L); + when(serviceOffering.getName()).thenReturn("Fixed_Compute"); + when(serviceOffering.getDisplayText()).thenReturn("Fixed_Compute"); + + VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> { + apiDBUtils.when(() -> ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID)) + .thenReturn(computeOnlyOffering); + apiDBUtils.when(() -> ApiDBUtils.findServiceOfferingByComputeOnlyDiskOffering(DISK_OFFERING_ID, false)) + .thenReturn(serviceOffering); + }); + + assertFalse(response.isCustomizedIops()); + assertEquals("5", response.getServiceOfferingId()); + assertEquals("Fixed_Compute", response.getServiceOfferingName()); + assertNull(response.getDiskOfferingId()); + } + + @Test + public void testNewVolumeResponseLeavesCustomizedIopsNullWhenOfferingValueIsNull() { + VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering(); + when(volumeJoin.getDiskOfferingUuid()).thenReturn("disk-offering-uuid"); + DiskOfferingVO diskOffering = mock(DiskOfferingVO.class); + when(diskOffering.isCustomizedIops()).thenReturn(null); + + VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> { + apiDBUtils.when(() -> ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID)).thenReturn(null); + apiDBUtils.when(() -> ApiDBUtils.findDiskOfferingById(DISK_OFFERING_ID)).thenReturn(diskOffering); + }); + + assertNull(response.isCustomizedIops()); + assertNull(response.getCustomizedIops()); + } + + private VolumeJoinVO mockVolumeWithDiskOffering() { + VolumeJoinVO volumeJoin = mock(VolumeJoinVO.class); + when(volumeJoin.getUuid()).thenReturn("volume-uuid"); + when(volumeJoin.getName()).thenReturn("ROOT-67"); + when(volumeJoin.getDiskOfferingId()).thenReturn(DISK_OFFERING_ID); + when(volumeJoin.getVmId()).thenReturn(0L); + when(volumeJoin.getState()).thenReturn(Volume.State.Ready); + when(volumeJoin.getVolumeType()).thenReturn(Volume.Type.ROOT); + when(volumeJoin.getTemplateId()).thenReturn(0L); + when(volumeJoin.getTagId()).thenReturn(0L); + when(volumeJoin.getMinIops()).thenReturn(0L); + when(volumeJoin.getMaxIops()).thenReturn(1000L); + when(volumeJoin.getDeleteProtection()).thenReturn(false); + when(volumeJoin.isDisplayVolume()).thenReturn(true); + when(volumeJoin.getKmsWrappedKeyId()).thenReturn(null); + when(volumeJoin.getJobId()).thenReturn(null); + return volumeJoin; + } + + private VolumeResponse newVolumeResponse(VolumeJoinVO volumeJoin, Consumer> apiStubs) { + Account callingAccount = mock(Account.class); + when(callingAccount.getId()).thenReturn(1L); + CallContext callContext = mock(CallContext.class); + when(callContext.getCallingAccount()).thenReturn(callingAccount); + + try (MockedStatic apiDBUtils = mockStatic(ApiDBUtils.class); + MockedStatic callContextMocked = mockStatic(CallContext.class); + MockedStatic apiResponseHelperMocked = mockStatic(ApiResponseHelper.class)) { + apiStubs.accept(apiDBUtils); + callContextMocked.when(CallContext::current).thenReturn(callContext); + apiResponseHelperMocked.when(() -> ApiResponseHelper.populateOwner(any(), any())) + .thenAnswer(invocation -> null); + return _volumeJoinDaoImpl.newVolumeResponse(ResponseView.Restricted, volumeJoin); + } + } + } diff --git a/ui/src/views/storage/ResizeVolume.vue b/ui/src/views/storage/ResizeVolume.vue index 2b6d4cb17901..4b60c032cd8b 100644 --- a/ui/src/views/storage/ResizeVolume.vue +++ b/ui/src/views/storage/ResizeVolume.vue @@ -65,7 +65,7 @@