diff --git a/api/src/main/java/com/cloud/vm/VmDetailConstants.java b/api/src/main/java/com/cloud/vm/VmDetailConstants.java index 877df55c6d67..67e5555f1d75 100644 --- a/api/src/main/java/com/cloud/vm/VmDetailConstants.java +++ b/api/src/main/java/com/cloud/vm/VmDetailConstants.java @@ -67,6 +67,10 @@ public interface VmDetailConstants { String CPU_SPEED = "cpuSpeed"; String MEMORY = "memory"; + // VM deployment with custom root disk offering params + String MIN_IOPS = "minIops"; + String MAX_IOPS = "maxIops"; + // Misc details for internal usage (not to be set/changed by user or admin) String CPU_OVER_COMMIT_RATIO = "cpuOvercommitRatio"; String MEMORY_OVER_COMMIT_RATIO = "memoryOvercommitRatio"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java index 4b0a6ba00b28..90498b9a8cb8 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java @@ -22,6 +22,7 @@ import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiArgValidator; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; @@ -53,7 +54,8 @@ public class UpdateStoragePoolCmd extends BaseCmd { @Parameter(name = ApiConstants.TAGS, type = CommandType.LIST, collectionType = CommandType.STRING, description = "Comma-separated list of tags for the storage pool") private List tags; - @Parameter(name = ApiConstants.CAPACITY_IOPS, type = CommandType.LONG, required = false, description = "IOPS CloudStack can provision from this storage pool") + @Parameter(name = ApiConstants.CAPACITY_IOPS, type = CommandType.LONG, required = false, description = "IOPS CloudStack can provision from this storage pool", + validations = {ApiArgValidator.PositiveNumber}) private Long capacityIops; @Parameter(name = ApiConstants.CAPACITY_BYTES, type = CommandType.LONG, required = false, description = "Bytes CloudStack can provision from this storage pool") diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java index 9f6d02cc1234..9a06e7285e4d 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java @@ -61,14 +61,12 @@ import com.cloud.vm.NicProfile; import com.cloud.vm.VMInstanceVO; import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; import com.cloud.vm.VmDiskInfo; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDetailsDao; import com.cloud.vm.dao.VMInstanceDao; -import static org.apache.cloudstack.api.ApiConstants.MAX_IOPS; -import static org.apache.cloudstack.api.ApiConstants.MIN_IOPS; - @Component public class CloudOrchestrator implements OrchestrationService { @@ -205,8 +203,8 @@ public VirtualMachineEntity createVirtualMachine(String id, String owner, String Map userVmDetails = _vmInstanceDetailsDao.listDetailsKeyPairs(vm.getId()); if (userVmDetails != null) { - String minIops = userVmDetails.get(MIN_IOPS); - String maxIops = userVmDetails.get(MAX_IOPS); + String minIops = userVmDetails.get(VmDetailConstants.MIN_IOPS); + String maxIops = userVmDetails.get(VmDetailConstants.MAX_IOPS); rootDiskOfferingInfo.setMinIops(minIops != null && minIops.trim().length() > 0 ? Long.parseLong(minIops) : null); rootDiskOfferingInfo.setMaxIops(maxIops != null && maxIops.trim().length() > 0 ? Long.parseLong(maxIops) : null); diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java new file mode 100644 index 000000000000..fd7deac04548 --- /dev/null +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.engine.orchestration; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.offering.DiskOfferingInfo; +import com.cloud.service.ServiceOfferingVO; +import com.cloud.service.dao.ServiceOfferingDao; +import com.cloud.storage.DiskOfferingVO; +import com.cloud.storage.VMTemplateVO; +import com.cloud.storage.dao.DiskOfferingDao; +import com.cloud.storage.dao.VMTemplateDao; +import com.cloud.utils.component.ComponentContext; +import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.VMInstanceDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; +import org.apache.cloudstack.engine.cloud.entity.api.VirtualMachineEntityImpl; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class CloudOrchestratorTest { + + private static final long VM_ID = 1L; + private static final long SERVICE_OFFERING_ID = 2L; + private static final long ROOT_DISK_OFFERING_ID = 3L; + private static final String TEMPLATE_ID = "4"; + + @InjectMocks + private CloudOrchestrator cloudOrchestrator = new CloudOrchestrator(); + + @Mock + private VirtualMachineManager _itMgr; + @Mock + private VMTemplateDao _templateDao; + @Mock + private VMInstanceDao _vmDao; + @Mock + private VMInstanceDetailsDao _vmInstanceDetailsDao; + @Mock + private ServiceOfferingDao _serviceOfferingDao; + @Mock + private DiskOfferingDao _diskOfferingDao; + + @Test + public void createVirtualMachineSetsCustomIopsFromVmDetails() throws Exception { + VMInstanceVO vm = Mockito.mock(VMInstanceVO.class); + ServiceOfferingVO serviceOffering = Mockito.mock(ServiceOfferingVO.class); + DiskOfferingVO rootDiskOffering = Mockito.mock(DiskOfferingVO.class); + VMTemplateVO template = Mockito.mock(VMTemplateVO.class); + VirtualMachineEntityImpl vmEntity = Mockito.mock(VirtualMachineEntityImpl.class); + + Mockito.when(_vmDao.findByUuid("vm-uuid")).thenReturn(vm); + Mockito.when(vm.getId()).thenReturn(VM_ID); + Mockito.when(vm.getServiceOfferingId()).thenReturn(SERVICE_OFFERING_ID); + Mockito.when(vm.getInstanceName()).thenReturn("i-1-1-VM"); + Mockito.when(_serviceOfferingDao.findById(VM_ID, SERVICE_OFFERING_ID)).thenReturn(serviceOffering); + Mockito.when(_diskOfferingDao.findById(ROOT_DISK_OFFERING_ID)).thenReturn(rootDiskOffering); + Mockito.when(rootDiskOffering.isCustomizedIops()).thenReturn(true); + Mockito.when(_templateDao.findById(Long.valueOf(TEMPLATE_ID))).thenReturn(template); + + Map details = new HashMap<>(); + details.put(VmDetailConstants.MIN_IOPS, "100"); + details.put(VmDetailConstants.MAX_IOPS, "1000"); + Mockito.when(_vmInstanceDetailsDao.listDetailsKeyPairs(VM_ID)).thenReturn(details); + + try (MockedStatic componentContext = Mockito.mockStatic(ComponentContext.class)) { + componentContext.when(() -> ComponentContext.inject(VirtualMachineEntityImpl.class)).thenReturn(vmEntity); + + cloudOrchestrator.createVirtualMachine("vm-uuid", "owner", TEMPLATE_ID, "host", "display", HypervisorType.KVM.name(), + 1, 1000, 1024, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null, + null, null, null, null, ROOT_DISK_OFFERING_ID, null, null, null, null); + } + + ArgumentCaptor rootDiskOfferingInfo = ArgumentCaptor.forClass(DiskOfferingInfo.class); + Mockito.verify(_itMgr).allocate(Mockito.eq("i-1-1-VM"), Mockito.eq(template), Mockito.eq(serviceOffering), rootDiskOfferingInfo.capture(), + Mockito.anyList(), Mockito.anyList(), Mockito.any(LinkedHashMap.class), Mockito.isNull(), Mockito.eq(HypervisorType.KVM), + Mockito.isNull(), Mockito.isNull(), Mockito.isNull(), Mockito.isNull()); + + Assert.assertEquals(Long.valueOf(100), rootDiskOfferingInfo.getValue().getMinIops()); + Assert.assertEquals(Long.valueOf(1000), rootDiskOfferingInfo.getValue().getMaxIops()); + } +} diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java index ed942b438e16..f123995d3213 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java @@ -27,6 +27,7 @@ import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.storage.ResizeVolumePayload; import com.cloud.storage.Storage; import com.cloud.storage.StoragePool; import com.cloud.storage.Volume; @@ -68,6 +69,7 @@ import org.apache.cloudstack.storage.feign.model.Lun; import org.apache.cloudstack.storage.feign.model.LunSpace; import org.apache.cloudstack.storage.feign.model.Svm; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.SANStrategy; @@ -89,6 +91,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * Primary datastore driver for NetApp ONTAP storage systems. @@ -213,7 +216,7 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet errMsg = e.getMessage(); logger.error("createAsync: Failed for dataObject name [{}]: {}", dataObject.getName(), errMsg); createCmdResult = new CreateCmdResult(null, new Answer(null, false, errMsg)); - createCmdResult.setResult(e.toString()); + createCmdResult.setResult(errMsg); } finally { if (createCmdResult != null && createCmdResult.isSuccess()) { logger.info("createAsync: Operation completed successfully for {}", dataObject.getType()); @@ -226,8 +229,85 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet * Creates a volume on the ONTAP backend. */ private CloudStackVolume createCloudStackVolume(StoragePoolVO storagePool, VolumeInfo volumeObject, Map details) { + verifySufficientIopsForStoragePool(storagePool, volumeObject.getMinIops(), volumeObject.getId()); StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); - return storageStrategy.createCloudStackVolume(createVolumeRequest(storagePool, details, volumeObject)); + VolumeQosPolicy qosPolicy = null; + Volume.Type volumeType = volumeObject.getVolumeType(); + if (volumeType == Volume.Type.DATADISK || volumeType == Volume.Type.ROOT) { + qosPolicy = createQosPolicyIfNeeded(storageStrategy, details, + volumeObject.getMinIops(), volumeObject.getMaxIops(), storagePool.getId()); + } + CloudStackVolume request = createCloudStackVolumeRequestByProtocol( + storagePool, details, volumeObject, qosPolicy); + try { + CloudStackVolume created = storageStrategy.createCloudStackVolume(request); + persistQosPolicyDetails(volumeObject.getId(), qosPolicy); + return created; + } catch (RuntimeException e) { + if (qosPolicy != null) { + storageStrategy.deleteVolumeQosPolicy(qosPolicy.getUuid()); + } + throw e; + } + } + + private VolumeQosPolicy createQosPolicyIfNeeded(StorageStrategy storageStrategy, Map details, + Long minIops, Long maxIops, Long poolId) { + if (!validateIops(storageStrategy, details, poolId, minIops, maxIops)) { + return null; + } + String policyName = getQosPolicyName(details.get(OntapStorageConstants.SVM_NAME), minIops, maxIops); + return storageStrategy.createVolumeQosPolicy(policyName, minIops, maxIops); + } + + /** + * Empty/zero IOPS means no policy. Min greater than max is rejected. + * Min IOPS is AFF-only; older pools without {@code isAFF} are probed once and persisted. + */ + private boolean validateIops(StorageStrategy storageStrategy, Map details, + Long poolId, Long minIops, Long maxIops) { + long min = minIops == null ? 0 : minIops; + long max = maxIops == null ? 0 : maxIops; + if (min <= 0 && max <= 0) { + return false; + } + if (min > 0 && max > 0 && min > max) { + throw new CloudRuntimeException("Minimum IOPS cannot be greater than maximum IOPS"); + } + if (min > 0) { + String isAff = details.get(OntapStorageConstants.IS_AFF); + if (StringUtils.isBlank(isAff)) { + isAff = Boolean.toString(storageStrategy.isAff()); + details.put(OntapStorageConstants.IS_AFF, isAff); + storagePoolDetailsDao.addDetail(poolId, OntapStorageConstants.IS_AFF, isAff, false); + } + if (!Boolean.parseBoolean(isAff)) { + throw new CloudRuntimeException( + "Minimum IOPS is not supported on FAS/non-AFF ONTAP platforms; only maximum IOPS is supported"); + } + } + return true; + } + + /** + * Builds a reusable SVM-scoped QoS policy name: cs_{min}_to_{max}_iops_{svmName}. + * Dots in the SVM name are replaced with underscores (ONTAP QoS names cannot contain '.'). + */ + private String getQosPolicyName(String svmName, Long minIops, Long maxIops) { + String sanitizedSvmName = svmName == null ? "" : svmName.replace(".", OntapStorageConstants.UNDERSCORE); + long min = minIops != null && minIops > 0 ? minIops : 0; + long max = maxIops != null && maxIops > 0 ? maxIops : 0; + return OntapStorageConstants.QOS_POLICY_NAME_PREFIX + min + OntapStorageConstants.UNDERSCORE + + OntapStorageConstants.QOS_POLICY_NAME_TO + max + OntapStorageConstants.UNDERSCORE + + OntapStorageConstants.QOS_POLICY_NAME_IOPS + sanitizedSvmName; + } + + private void persistQosPolicyDetails(long volumeId, VolumeQosPolicy qosPolicy) { + volumeDetailsDao.removeDetail(volumeId, OntapStorageConstants.QOS_POLICY_UUID); + if (qosPolicy == null) { + return; + } + volumeDetailsDao.addDetail(volumeId, OntapStorageConstants.QOS_POLICY_UUID, qosPolicy.getUuid(), false); } /** @@ -289,28 +369,46 @@ private CloudStackVolume cloneCloudStackVolumeFromTemplate(StoragePoolVO storage StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); boolean iscsi = isIscsi(details); + verifySufficientIopsForStoragePool(storagePool, volumeInfo.getMinIops(), volumeInfo.getId()); + VolumeQosPolicy qosPolicy = null; + Volume.Type volumeType = volumeInfo.getVolumeType(); + if (volumeType == Volume.Type.DATADISK || volumeType == Volume.Type.ROOT) { + qosPolicy = createQosPolicyIfNeeded(storageStrategy, details, + volumeInfo.getMinIops(), volumeInfo.getMaxIops(), storagePool.getId()); + } + CloudStackVolume request = iscsi - ? createCloneLunRequest(storagePool, details, volumeInfo, templatePoolRef, templateId) + ? createCloneLunRequest(storagePool, details, volumeInfo, templatePoolRef, templateId, qosPolicy) : createCloneFileRequest(storagePool, volumeInfo, templatePoolRef, templateId); - CloudStackVolume cloned = storageStrategy.cloneCloudStackVolume(request); - // SAN cloneCloudStackVolume validates the Feign response (LUN name + uuid) before returning - if (cloned == null) { - throw new CloudRuntimeException("ONTAP returned nothing when cloning template [" + templateId - + "] for volume [" + volumeInfo.getId() + "]"); - } + try { + CloudStackVolume cloned = storageStrategy.cloneCloudStackVolume(request); + if (cloned == null) { + throw new CloudRuntimeException("ONTAP returned nothing when cloning template [" + templateId + + "] for volume [" + volumeInfo.getId() + "]"); + } - logger.info("cloneCloudStackVolumeFromTemplate: Cloned template [{}] for volume [{}] on pool [{}]", - templateId, volumeInfo.getId(), storagePool.getId()); + logger.info("cloneCloudStackVolumeFromTemplate: Cloned template [{}] for volume [{}] on pool [{}]", + templateId, volumeInfo.getId(), storagePool.getId()); - long requestedSize = getDataObjectSizeIncludingHypervisorSnapshotReserve(volumeInfo, storagePool); - if (requestedSize > templatePoolRef.getTemplateSize()) { - logger.info("cloneCloudStackVolumeFromTemplate: Growing clone of template [{}] from {} to {} bytes for volume [{}]", - templateId, templatePoolRef.getTemplateSize(), requestedSize, volumeInfo.getId()); - storageStrategy.resizeCloudStackVolume(cloned, requestedSize); - } + long requestedSize = getDataObjectSizeIncludingHypervisorSnapshotReserve(volumeInfo, storagePool); + if (requestedSize > templatePoolRef.getTemplateSize()) { + logger.info("cloneCloudStackVolumeFromTemplate: Growing clone of template [{}] from {} to {} bytes for volume [{}]", + templateId, templatePoolRef.getTemplateSize(), requestedSize, volumeInfo.getId()); + storageStrategy.resizeCloudStackVolume(cloned, requestedSize); + } - return cloned; + if (!iscsi && qosPolicy != null) { + attachQosPolicy(storageStrategy, storagePool, details, volumeInfo, qosPolicy); + } + persistQosPolicyDetails(volumeInfo.getId(), qosPolicy); + return cloned; + } catch (Exception e) { + if (qosPolicy != null) { + storageStrategy.deleteVolumeQosPolicy(qosPolicy.getUuid()); + } + throw e; + } } /** @@ -440,6 +538,28 @@ private void deleteNfsTemplateCache(Map details, TemplateInfo te filePath, templateInfo.getId()); } + /** + * Rejects the request when the minimum IOPS being asked for would push the pool past its + * configured IOPS capacity. Pools without an IOPS capacity enforce no ceiling. + * + * Used IOPS include volumes still in {@link Volume.State#Creating} so a second overlapping + * create sees the first reservation. The volume being checked is omitted so its own min IOPS + * are not counted twice (CloudStack has already moved it to Creating before this runs). + */ + private void verifySufficientIopsForStoragePool(StoragePoolVO storagePool, Long requestedMinIops, long excludeVolumeId) { + Long capacityIops = storagePool.getCapacityIops(); + if (capacityIops == null || requestedMinIops == null || requestedMinIops <= 0) { + return; + } + + long requestedTotalIops = getAllocatedMinIops(storagePool, excludeVolumeId) + requestedMinIops; + if (requestedTotalIops > capacityIops) { + throw new CloudRuntimeException(String.format( + "Insufficient IOPS capacity on storage pool %s: requested total of %d IOPS exceeds the pool IOPS capacity of %d", + storagePool.getName(), requestedTotalIops, capacityIops)); + } + } + /** * Deletes a volume or snapshot from the ONTAP storage system. * @@ -470,8 +590,14 @@ public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallbac StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); logger.info("createCloudStackVolumeForTypeVolume: Connection to Ontap SVM [{}] successful, preparing CloudStackVolumeRequest", details.get(OntapStorageConstants.SVM_NAME)); VolumeInfo volumeInfo = (VolumeInfo) data; + VolumeDetailVO qosPolicyDetail = volumeDetailsDao.findDetail( + volumeInfo.getId(), OntapStorageConstants.QOS_POLICY_UUID); CloudStackVolume cloudStackVolumeRequest = createDeleteCloudStackVolumeRequest(storagePool, details, volumeInfo); storageStrategy.deleteCloudStackVolume(cloudStackVolumeRequest); + if (qosPolicyDetail != null) { + volumeDetailsDao.removeDetail(volumeInfo.getId(), OntapStorageConstants.QOS_POLICY_UUID); + storageStrategy.deleteVolumeQosPolicy(qosPolicyDetail.getValue()); + } logger.info("deleteAsync: Volume deleted: " + volumeInfo.getId()); commandResult.setResult(null); commandResult.setSuccess(true); @@ -596,7 +722,95 @@ public boolean canCopy(DataObject srcData, DataObject destData) { } @Override - public void resize(DataObject data, AsyncCompletionCallback callback) {} + public void resize(DataObject data, AsyncCompletionCallback callback) { + String errMsg = null; + String path = null; + try { + if (!(data instanceof VolumeInfo)) { + throw new CloudRuntimeException("Invalid DataObjectType (" + + (data != null ? data.getType() : null) + ") passed to resize"); + } + VolumeInfo volumeInfo = (VolumeInfo) data; + path = volumeInfo.getPath(); + applyVolumeQos(volumeInfo); + } catch (Exception e) { + errMsg = e.getMessage(); + logger.error("Failed to update IOPS for volume [{}]: {}", data != null ? data.getId() : null, errMsg, e); + } + + CreateCmdResult result = new CreateCmdResult(path, new Answer(null, errMsg == null, errMsg)); + result.setResult(errMsg); + callback.complete(result); + } + + private void applyVolumeQos(VolumeInfo volumeInfo) { + ResizeVolumePayload payload = (ResizeVolumePayload) volumeInfo.getpayload(); + if (payload == null) { + throw new CloudRuntimeException("Missing resize payload for volume " + volumeInfo.getId()); + } + VolumeVO volume = volumeDao.findById(volumeInfo.getId()); + if (volume == null || volume.getPoolId() == null) { + throw new CloudRuntimeException("Unable to resolve volume or storage pool for IOPS update"); + } + StoragePoolVO storagePool = storagePoolDao.findById(volume.getPoolId()); + if (storagePool == null) { + throw new CloudRuntimeException("Storage pool not found for volume " + volume.getId()); + } + + verifySufficientIopsForStoragePool(storagePool, payload.newMinIops, volume.getId()); + + Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId()); + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); + VolumeDetailVO qosDetail = volumeDetailsDao.findDetail(volume.getId(), OntapStorageConstants.QOS_POLICY_UUID); + String previousUuid = qosDetail != null ? qosDetail.getValue() : null; + VolumeQosPolicy qosPolicy = createQosPolicyIfNeeded(storageStrategy, details, + payload.newMinIops, payload.newMaxIops, volume.getPoolId()); + + if (qosPolicy != null && Objects.equals(previousUuid, qosPolicy.getUuid())) { + return; + } + if (qosPolicy != null) { + try { + attachQosPolicy(storageStrategy, storagePool, details, volumeInfo, qosPolicy); + persistQosPolicyDetails(volume.getId(), qosPolicy); + storageStrategy.deleteVolumeQosPolicy(previousUuid); + } catch (RuntimeException e) { + storageStrategy.deleteVolumeQosPolicy(qosPolicy.getUuid()); + throw e; + } + return; + } + if (previousUuid != null) { + detachQosPolicy(storageStrategy, storagePool, details, volumeInfo); + persistQosPolicyDetails(volume.getId(), null); + storageStrategy.deleteVolumeQosPolicy(previousUuid); + } + } + + private void attachQosPolicy(StorageStrategy storageStrategy, StoragePoolVO storagePool, + Map details, VolumeInfo volumeInfo, + VolumeQosPolicy qosPolicy) { + CloudStackVolume request = createCloudStackVolumeRequestByProtocol( + storagePool, details, volumeInfo, qosPolicy); + if (isIscsi(details)) { + VolumeDetailVO lunUuid = volumeDetailsDao.findDetail(volumeInfo.getId(), OntapStorageConstants.LUN_DOT_UUID); + if (lunUuid == null || lunUuid.getValue() == null) { + throw new CloudRuntimeException("LUN UUID is missing for volume " + volumeInfo.getId()); + } + if (request.getLun() == null) { + throw new CloudRuntimeException("Missing LUN on QoS update request for volume " + volumeInfo.getId()); + } + request.getLun().setUuid(lunUuid.getValue()); + } + storageStrategy.updateCloudStackVolume(request); + } + + private void detachQosPolicy(StorageStrategy storageStrategy, StoragePoolVO storagePool, + Map details, VolumeInfo volumeInfo) { + VolumeQosPolicy noPolicy = new VolumeQosPolicy(); + noPolicy.setName(OntapStorageConstants.QOS_POLICY_NONE); + attachQosPolicy(storageStrategy, storagePool, details, volumeInfo, noPolicy); + } @Override public ChapInfo getChapInfo(DataObject dataObject) { @@ -1023,9 +1237,31 @@ public long getUsedBytes(StoragePool storagePool) { return 0; } + /** + * Returns min IOPS reserved on the pool, including volumes still being created. + * Destroyed and expunged volumes are omitted by the DAO query. + */ @Override public long getUsedIops(StoragePool storagePool) { - return 0; + return getAllocatedMinIops(storagePool, null); + } + + private long getAllocatedMinIops(StoragePool storagePool, Long excludeVolumeId) { + long usedIops = 0; + + List volumes = volumeDao.findNonDestroyedVolumesByPoolId(storagePool.getId(), null); + if (volumes != null) { + for (VolumeVO volume : volumes) { + if (excludeVolumeId != null && excludeVolumeId.equals(volume.getId())) { + continue; + } + if (volume.getMinIops() != null) { + usedIops += volume.getMinIops(); + } + } + } + + return usedIops; } /** @@ -1367,34 +1603,56 @@ private boolean isIscsi(Map details) { } /** - * Builds the request that creates a blank volume (LUN for iSCSI, qcow2 file for NFS). + * Builds the request that creates or updates a volume (LUN for iSCSI, qcow2 file for NFS), + * attaching a QoS policy reference when one is provided. */ - private CloudStackVolume createVolumeRequest(StoragePoolVO storagePool, Map details, DataObject volumeObject) { - CloudStackVolume request = new CloudStackVolume(); + private CloudStackVolume createCloudStackVolumeRequestByProtocol(StoragePoolVO storagePool, Map details, + DataObject volumeObject, VolumeQosPolicy qosPolicy) { + VolumeQosPolicy qosPolicyReference = null; + if (qosPolicy != null) { + qosPolicyReference = new VolumeQosPolicy(); + qosPolicyReference.setName(qosPolicy.getName()); + qosPolicyReference.setUuid(qosPolicy.getUuid()); + } + String protocol = details.get(OntapStorageConstants.PROTOCOL); - if (ProtocolType.NFS3.name().equalsIgnoreCase(protocol)) { - request.setDatastoreId(String.valueOf(storagePool.getId())); - request.setVolumeInfo(volumeObject); - } else if (ProtocolType.ISCSI.name().equalsIgnoreCase(protocol)) { - Lun lunRequest = new Lun(); - Svm svm = new Svm(); - svm.setName(details.get(OntapStorageConstants.SVM_NAME)); - String lunName = volumeObject.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE); - if (!OntapStorageUtils.isValidName(lunName)) { - throw new InvalidParameterValueException("Invalid dataObject name [" + lunName - + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long."); - } - lunRequest.setSvm(svm); - lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), lunName)); - lunRequest.setOsType(Lun.OsTypeEnum.valueOf(OntapStorageUtils.getOSTypeFromHypervisor(storagePool.getHypervisor().name()))); - LunSpace lunSpace = new LunSpace(); - lunSpace.setSize(volumeObject.getSize()); - lunRequest.setSpace(lunSpace); - request.setLun(lunRequest); - } else { - throw new CloudRuntimeException("Unsupported protocol " + protocol); + ProtocolType protocolType = ProtocolType.valueOf(protocol); + switch (protocolType) { + case NFS3: + CloudStackVolume nfsRequest = new CloudStackVolume(); + nfsRequest.setDatastoreId(String.valueOf(storagePool.getId())); + nfsRequest.setFlexVolumeUuid(details.get(OntapStorageConstants.VOLUME_UUID)); + nfsRequest.setVolumeInfo(volumeObject); + if (qosPolicyReference != null) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicyReference); + nfsRequest.setFile(fileInfo); + } + return nfsRequest; + case ISCSI: + Svm svm = new Svm(); + svm.setName(details.get(OntapStorageConstants.SVM_NAME)); + CloudStackVolume iscsiRequest = new CloudStackVolume(); + Lun lunRequest = new Lun(); + lunRequest.setSvm(svm); + + LunSpace lunSpace = new LunSpace(); + lunSpace.setSize(volumeObject.getSize()); + lunRequest.setSpace(lunSpace); + String lunName = volumeObject.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE); + if (!OntapStorageUtils.isValidName(lunName)) { + throw new InvalidParameterValueException("Invalid dataObject name [" + lunName + + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long."); + } + lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), lunName)); + lunRequest.setOsType(Lun.OsTypeEnum.valueOf( + OntapStorageUtils.getOSTypeFromHypervisor(storagePool.getHypervisor().name()))); + lunRequest.setQosPolicy(qosPolicyReference); + iscsiRequest.setLun(lunRequest); + return iscsiRequest; + default: + throw new CloudRuntimeException("Unsupported protocol " + protocol); } - return request; } /** @@ -1417,7 +1675,7 @@ private String getTemplateLunName(StoragePoolVO storagePool, long templateId) { */ private CloudStackVolume createCloneLunRequest(StoragePoolVO storagePool, Map details, VolumeInfo volumeObject, VMTemplateStoragePoolVO templatePoolRef, - long templateId) { + long templateId, VolumeQosPolicy qosPolicy) { String sourceLunUuid = templatePoolRef.getLocalDownloadPath(); if (sourceLunUuid == null || sourceLunUuid.isEmpty()) { throw new CloudRuntimeException("Template [" + templateId + "] has no cached LUN on pool [" @@ -1443,6 +1701,12 @@ private CloudStackVolume createCloneLunRequest(StoragePoolVO storagePool, Map getPolicies(@Param("authHeader") String authHeader, + @QueryMap Map queryParams); + + @RequestLine("GET /api/storage/qos/policies/{uuid}") + @Headers({"Authorization: {authHeader}"}) + VolumeQosPolicy getPolicy(@Param("authHeader") String authHeader, @Param("uuid") String uuid, + @QueryMap Map queryParams); + + @RequestLine("DELETE /api/storage/qos/policies/{uuid}") + @Headers({"Authorization: {authHeader}"}) + JobResponse deletePolicy(@Param("authHeader") String authHeader, @Param("uuid") String uuid); +} diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java index d365468cee10..8a60a9a3e2bb 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java @@ -52,11 +52,11 @@ public interface SANFeignClient { @RequestLine("PATCH /api/storage/luns/{uuid}") @Headers({"Authorization: {authHeader}", "Content-Type: application/json"}) - void updateLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Lun lun); + JobResponse updateLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Lun lun); @RequestLine("DELETE /api/storage/luns/{uuid}") @Headers({"Authorization: {authHeader}"}) - void deleteLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, @QueryMap Map queryMap); + JobResponse deleteLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, @QueryMap Map queryMap); // iGroup Operation APIs @RequestLine("POST /api/protocols/san/igroups?return_records={returnRecords}") diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java index a5dd24a3a286..71bf4c980bd4 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java @@ -49,6 +49,8 @@ public class FileInfo { private Boolean overwriteEnabled = null; @JsonProperty("path") private String path = null; + @JsonProperty("qos_policy") + private VolumeQosPolicy qosPolicy = null; @JsonProperty("size") private Long size = null; @JsonProperty("target") @@ -178,6 +180,15 @@ public String getPath() { public void setPath(String path) { this.path = path; } + + public VolumeQosPolicy getQosPolicy() { + return qosPolicy; + } + + public void setQosPolicy(VolumeQosPolicy qosPolicy) { + this.qosPolicy = qosPolicy; + } + public Long getSize() { return size; } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java index 922751c9a77a..0b2e09dbec45 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java @@ -83,6 +83,9 @@ public static PropertyClassEnum fromValue(String value) { @JsonProperty("name") private String name = null; + @JsonProperty("qos_policy") + private VolumeQosPolicy qosPolicy = null; + @JsonProperty("clone") private Clone clone = null; @@ -202,6 +205,14 @@ public void setName(String name) { this.name = name; } + public VolumeQosPolicy getQosPolicy() { + return qosPolicy; + } + + public void setQosPolicy(VolumeQosPolicy qosPolicy) { + this.qosPolicy = qosPolicy; + } + public Lun osType(OsTypeEnum osType) { this.osType = osType; return this; diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java index 7a9a4307ab1a..2e6ce1b6fa3c 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java @@ -26,43 +26,23 @@ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class VolumeQosPolicy { - @JsonProperty("max_throughput_iops") - private Integer maxThroughputIops = null; - - @JsonProperty("max_throughput_mbps") - private Integer maxThroughputMbps = null; - - @JsonProperty("min_throughput_iops") - private Integer minThroughputIops = null; - + @JsonProperty("fixed") + private Fixed fixed; @JsonProperty("name") private String name = null; - @JsonProperty("uuid") private String uuid = null; + @JsonProperty("svm") + private Svm svm; + @JsonProperty("object_count") + private Integer objectCount; - public Integer getMaxThroughputIops() { - return maxThroughputIops; - } - - public void setMaxThroughputIops(Integer maxThroughputIops) { - this.maxThroughputIops = maxThroughputIops; - } - - public Integer getMaxThroughputMbps() { - return maxThroughputMbps; + public Fixed getFixed() { + return fixed; } - public void setMaxThroughputMbps(Integer maxThroughputMbps) { - this.maxThroughputMbps = maxThroughputMbps; - } - - public Integer getMinThroughputIops() { - return minThroughputIops; - } - - public void setMinThroughputIops(Integer minThroughputIops) { - this.minThroughputIops = minThroughputIops; + public void setFixed(Fixed fixed) { + this.fixed = fixed; } public String getName() { @@ -80,4 +60,57 @@ public String getUuid() { public void setUuid(String uuid) { this.uuid = uuid; } + + public Svm getSvm() { + return svm; + } + + public void setSvm(Svm svm) { + this.svm = svm; + } + + public Integer getObjectCount() { + return objectCount; + } + + public void setObjectCount(Integer objectCount) { + this.objectCount = objectCount; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Fixed { + @JsonProperty("capacity_shared") + private Boolean capacityShared; + + @JsonProperty("min_throughput_iops") + private Long minThroughputIops; + + @JsonProperty("max_throughput_iops") + private Long maxThroughputIops; + + public Boolean getCapacityShared() { + return capacityShared; + } + + public void setCapacityShared(Boolean capacityShared) { + this.capacityShared = capacityShared; + } + + public Long getMinThroughputIops() { + return minThroughputIops; + } + + public void setMinThroughputIops(Long minThroughputIops) { + this.minThroughputIops = minThroughputIops; + } + + public Long getMaxThroughputIops() { + return maxThroughputIops; + } + + public void setMaxThroughputIops(Long maxThroughputIops) { + this.maxThroughputIops = maxThroughputIops; + } + } } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java index f6fff96f9ca9..55fb7c868c49 100755 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java @@ -57,6 +57,7 @@ import com.cloud.agent.api.StoragePoolInfo; import com.cloud.alert.AlertManager; +import com.cloud.capacity.CapacityManager; import com.cloud.dc.ClusterVO; import com.cloud.dc.dao.ClusterDao; import com.cloud.exception.InvalidParameterValueException; @@ -81,6 +82,7 @@ public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycl @Inject private PrimaryDataStoreDao storagePoolDao; @Inject private StoragePoolDetailsDao storagePoolDetailsDao; @Inject private AlertManager _alertMgr; + @Inject private CapacityManager _capacityMgr; private static final Logger logger = LogManager.getLogger(OntapPrimaryDatastoreLifecycle.class); private static final long ONTAP_MIN_VOLUME_SIZE_IN_BYTES = 20971520L; @@ -101,6 +103,7 @@ public DataStore initialize(Map dsInfos) { String storagePoolName = (String) dsInfos.get("name"); String providerName = (String) dsInfos.get("providerName"); Long capacityBytes = (Long) dsInfos.get("capacityBytes"); + Long capacityIops = (Long) dsInfos.get("capacityIops"); boolean managed = (boolean) dsInfos.get("managed"); String tags = (String) dsInfos.get("tags"); Boolean isTagARule = (Boolean) dsInfos.get("isTagARule"); @@ -113,7 +116,7 @@ public DataStore initialize(Map dsInfos) { @SuppressWarnings("unchecked") Map details = (Map) dsInfos.get("details"); - validateInitializeInputs(capacityBytes, podId, clusterId, zoneId, storagePoolName, providerName, managed, details); + validateInitializeInputs(capacityBytes, capacityIops, podId, clusterId, zoneId, storagePoolName, providerName, managed, details); PrimaryDataStoreParameters parameters = new PrimaryDataStoreParameters(); if (clusterId != null) { @@ -140,6 +143,7 @@ public DataStore initialize(Map dsInfos) { StorageStrategy storageStrategy = StorageProviderFactory.getStrategy(ontapStorage); boolean isValid = storageStrategy.connect(); if (isValid) { + details.put(OntapStorageConstants.IS_AFF, Boolean.toString(storageStrategy.isAff())); if (storageStrategy.getResolvedSvmUuid() != null && !storageStrategy.getResolvedSvmUuid().isEmpty()) { details.put(OntapStorageConstants.SVM_UUID, storageStrategy.getResolvedSvmUuid()); } @@ -207,12 +211,13 @@ public DataStore initialize(Map dsInfos) { parameters.setProviderName(providerName); parameters.setManaged(managed); parameters.setCapacityBytes(capacityBytes); + parameters.setCapacityIops(capacityIops); parameters.setUsedBytes(0); return _dataStoreHelper.createPrimaryDataStore(parameters); } - private void validateInitializeInputs(Long capacityBytes, Long podId, Long clusterId, Long zoneId, + private void validateInitializeInputs(Long capacityBytes, Long capacityIops, Long podId, Long clusterId, Long zoneId, String storagePoolName, String providerName, boolean managed, Map details) { if (capacityBytes == null || capacityBytes <= 0) { @@ -223,6 +228,10 @@ private void validateInitializeInputs(Long capacityBytes, Long podId, Long clust throw new InvalidParameterValueException("Storage pool capacity " + capacityBytes + " bytes is below the ONTAP minimum volume size of " + ONTAP_MIN_VOLUME_SIZE_IN_BYTES + " bytes (20 MB)"); } + // IOPS capacity is optional; when left blank no pool-level IOPS ceiling is enforced. + if (capacityIops != null && capacityIops <= 0) { + throw new InvalidParameterValueException("Storage pool IOPS capacity must be greater than 0"); + } // Validate scope if (podId == null ^ clusterId == null) { @@ -551,6 +560,11 @@ public boolean migrateToObjectStore(DataStore store) { @Override public void updateStoragePool(StoragePool storagePool, Map details) { + String newCapacityIopsStr = details.get(PrimaryDataStoreLifeCycle.CAPACITY_IOPS); + if (newCapacityIopsStr != null) { + validateUpdatedCapacityIops(storagePool, newCapacityIopsStr); + } + String newCapacityStr = details.get(PrimaryDataStoreLifeCycle.CAPACITY_BYTES); if (newCapacityStr == null) { logger.debug("No capacity change requested for pool: {}, skipping FlexVolume resize", storagePool.getName()); @@ -581,6 +595,29 @@ public void updateStoragePool(StoragePool storagePool, Map detai } } + private void validateUpdatedCapacityIops(StoragePool storagePool, String newCapacityIopsStr) { + long newCapacityIops; + try { + newCapacityIops = Long.parseLong(newCapacityIopsStr); + } catch (NumberFormatException e) { + throw new InvalidParameterValueException("Invalid storage pool IOPS capacity: " + newCapacityIopsStr); + } + if (newCapacityIops <= 0) { + throw new InvalidParameterValueException("Storage pool IOPS capacity must be greater than 0"); + } + + StoragePoolVO storagePoolVO = storagePoolDao.findById(storagePool.getId()); + if (storagePoolVO == null) { + throw new InvalidParameterValueException("Storage pool not found for id: " + storagePool.getId()); + } + long allocatedIops = _capacityMgr.getUsedIops(storagePoolVO); + if (newCapacityIops < allocatedIops) { + throw new InvalidParameterValueException(String.format( + "Cannot set IOPS capacity of storage pool %s to %d IOPS because %d IOPS are already allocated", + storagePool.getName(), newCapacityIops, allocatedIops)); + } + } + @Override public void enableStoragePool(DataStore store) { _dataStoreHelper.enable(store); diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/StorageStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/StorageStrategy.java index e482301967f1..5d763459b20b 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/StorageStrategy.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/StorageStrategy.java @@ -25,12 +25,14 @@ import java.util.Map; import java.util.Objects; +import com.cloud.utils.StringUtils; import org.apache.cloudstack.storage.feign.FeignClientFactory; import org.apache.cloudstack.storage.feign.client.AggregateFeignClient; import org.apache.cloudstack.storage.feign.client.ClusterFeignClient; import org.apache.cloudstack.storage.feign.client.JobFeignClient; import org.apache.cloudstack.storage.feign.client.NASFeignClient; import org.apache.cloudstack.storage.feign.client.NetworkFeignClient; +import org.apache.cloudstack.storage.feign.client.QosFeignClient; import org.apache.cloudstack.storage.feign.client.SANFeignClient; import org.apache.cloudstack.storage.feign.client.SnapshotFeignClient; import org.apache.cloudstack.storage.feign.client.EmsFeignClient; @@ -48,6 +50,7 @@ import org.apache.cloudstack.storage.feign.model.Svm; import org.apache.cloudstack.storage.feign.model.Version; import org.apache.cloudstack.storage.feign.model.Volume; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; @@ -81,6 +84,7 @@ public abstract class StorageStrategy { protected SvmFeignClient svmFeignClient; protected JobFeignClient jobFeignClient; protected NetworkFeignClient networkFeignClient; + protected QosFeignClient qosFeignClient; protected SANFeignClient sanFeignClient; protected NASFeignClient nasFeignClient; protected SnapshotFeignClient snapshotFeignClient; @@ -114,6 +118,7 @@ public StorageStrategy(OntapStorage ontapStorage) { this.svmFeignClient = feignClientFactory.createClient(SvmFeignClient.class, baseURL); this.jobFeignClient = feignClientFactory.createClient(JobFeignClient.class, baseURL); this.networkFeignClient = feignClientFactory.createClient(NetworkFeignClient.class, baseURL); + this.qosFeignClient = feignClientFactory.createClient(QosFeignClient.class, baseURL); this.sanFeignClient = feignClientFactory.createClient(SANFeignClient.class, baseURL); this.nasFeignClient = feignClientFactory.createClient(NASFeignClient.class, baseURL); this.snapshotFeignClient = feignClientFactory.createClient(SnapshotFeignClient.class, baseURL); @@ -229,6 +234,26 @@ private static String rollupPlatformType(LinkedHashSet platformTypes) { return OntapStorageConstants.ASUP_PLATFORM_TYPE_COMPOSITE; } + /** + * True when every cluster node reports {@code is_all_flash_optimized} (AFF, including C-series). + * Any FAS node makes this false. Used for min-throughput QoS support. + */ + public boolean isAff() { + Map query = new HashMap<>(); + query.put(OntapStorageConstants.FIELDS, OntapStorageConstants.CLUSTER_NODE_ASUP_FIELDS); + OntapResponse response = clusterFeignClient.getClusterNodes(getAuthHeader(), query); + if (response == null || response.getRecords() == null || response.getRecords().isEmpty()) { + throw new CloudRuntimeException( + "Unable to determine whether the ONTAP cluster is AFF or FAS"); + } + for (ClusterNode node : response.getRecords()) { + if (node == null || Boolean.FALSE.equals(node.getAllFlashOptimized())) { + return false; + } + } + return true; + } + /** * Pushes a single ASUP (AutoSupport) EMS application-log message to the ONTAP cluster. * @@ -800,7 +825,7 @@ abstract public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, * @param cloudstackVolume the CloudStack volume to update * @return the updated CloudStackVolume object */ - abstract CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume); + public abstract CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume); /** * Method encapsulates the behavior based on the opted protocol in subclasses. @@ -957,6 +982,108 @@ public String getAuthHeader() { return OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); } + public VolumeQosPolicy createVolumeQosPolicy(String policyName, Long minIops, Long maxIops) { + VolumeQosPolicy policy = buildVolumeQosPolicy(policyName, minIops, maxIops); + Svm svm = new Svm(); + svm.setName(storage.getSvmName()); + policy.setSvm(svm); + try { + JobResponse response = qosFeignClient.createPolicy(getAuthHeader(), policy); + pollJobIfPresent(response, "create QoS policy [" + policyName + "]"); + } catch (FeignException e) { + if (e.status() != 409) { + throw new CloudRuntimeException("Failed to create ONTAP QoS policy [" + policyName + "]: " + + e.getMessage(), e); + } + logger.info("QoS policy [{}] already exists; using the existing policy", policyName); + } + + VolumeQosPolicy createdPolicy = getVolumeQosPolicy(policyName); + return createdPolicy; + } + + public void deleteVolumeQosPolicy(String policyUuid) { + if (policyUuid == null || policyUuid.isEmpty()) { + return; + } + VolumeQosPolicy policy = getVolumeQosPolicyByUuid(policyUuid); + if (policy.getObjectCount() != null && policy.getObjectCount() > 0) { + logger.info("QoS policy [{}] still has object_count={}; skipping delete", + policyUuid, policy.getObjectCount()); + return; + } + try { + JobResponse response = qosFeignClient.deletePolicy(getAuthHeader(), policyUuid); + pollJobIfPresent(response, "delete QoS policy [" + policyUuid + "]"); + } catch (Exception e) { + if ((e instanceof FeignException && ((FeignException) e).status() == 409) + || OntapStorageUtils.isOntapObjectNotFoundError(e)) { + logger.info("QoS policy [{}] was not deleted on ONTAP (already absent or conflict): {}", + policyUuid, e.getMessage()); + return; + } + throw new CloudRuntimeException("Failed to delete ONTAP QoS policy [" + policyUuid + "]: " + + e.getMessage(), e); + } + } + + private VolumeQosPolicy getVolumeQosPolicyByUuid(String policyUuid) { + Map queryParams = new HashMap<>(); + queryParams.put(OntapStorageConstants.FIELDS, OntapStorageConstants.QOS_POLICY_OBJECT_COUNT_FIELDS); + try { + VolumeQosPolicy policy = qosFeignClient.getPolicy(getAuthHeader(), policyUuid, queryParams); + if (policy == null || StringUtils.isEmpty(policy.getUuid())) { + throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyUuid + + "]: empty response"); + } + return policy; + } catch (FeignException e) { + if (OntapStorageUtils.isOntapObjectNotFoundError(e)) { + return null; + } + throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyUuid + "]: " + + e.getMessage(), e); + } + } + + private VolumeQosPolicy getVolumeQosPolicy(String policyName) { + Map queryParams = new HashMap<>(); + queryParams.put(OntapStorageConstants.NAME, policyName); + queryParams.put(OntapStorageConstants.SVM_DOT_NAME, storage.getSvmName()); + try { + OntapResponse response = qosFeignClient.getPolicies(getAuthHeader(), queryParams); + if (response == null || response.getRecords() == null || response.getRecords().size() <= 0) { + throw new CloudRuntimeException("Unable to get ONTAP QoS policy [" + policyName + "] after creation"); + } + VolumeQosPolicy policy = response.getRecords().get(0); + if (policy == null || StringUtils.isEmpty(policy.getUuid())) { + throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyName + + "]: empty response"); + } + return policy; + } catch (FeignException e) { + throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyName + "]: " + + e.getMessage(), e); + } + } + + private VolumeQosPolicy buildVolumeQosPolicy(String policyName, Long minIops, Long maxIops) { + VolumeQosPolicy.Fixed fixed = new VolumeQosPolicy.Fixed(); + fixed.setCapacityShared(false); + // ONTAP rejects a policy whose throughput limit is zero, so only unlimited-side values are omitted. + if (minIops != null && minIops > 0) { + fixed.setMinThroughputIops(minIops); + } + if (maxIops != null && maxIops > 0) { + fixed.setMaxThroughputIops(maxIops); + } + + VolumeQosPolicy policy = new VolumeQosPolicy(); + policy.setName(policyName); + policy.setFixed(fixed); + return policy; + } + /** * Polls an ONTAP async job for successful completion. * diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java index 4a9f45f7301e..f0ed59bdbb98 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java @@ -95,8 +95,28 @@ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume logger.error("createCloudStackVolume: " + errMsg); throw new CloudRuntimeException(errMsg); } + if (cloudstackVolume.getFile() != null && cloudstackVolume.getFile().getQosPolicy() != null) { + try { + updateCloudStackVolume(cloudstackVolume); + } catch (RuntimeException qosError) { + logger.error("createCloudStackVolume: QoS attach failed; deleting leftover NFS volume file", qosError); + try { + Answer cleanup = deleteVolumeOnKVMHost(cloudstackVolume.getVolumeInfo()); + if (cleanup == null || !cleanup.getResult()) { + logger.error("createCloudStackVolume: leftover NFS file may remain after QoS attach failure: {}", + cleanup != null ? cleanup.getDetails() : "null answer"); + } + } catch (Exception cleanupError) { + logger.error("createCloudStackVolume: failed to delete leftover NFS volume file after QoS attach failure", + cleanupError); + } + throw qosError; + } + } return cloudstackVolume; - }catch (Exception e) { + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { logger.error("createCloudStackVolume: error occured " + e); throw new CloudRuntimeException(e); } @@ -115,8 +135,23 @@ public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, TemplateI } @Override - CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { - return null; + public CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { + if (cloudstackVolume == null || cloudstackVolume.getVolumeInfo() == null + || cloudstackVolume.getFlexVolumeUuid() == null || cloudstackVolume.getFile() == null) { + throw new CloudRuntimeException("Invalid NFS volume QoS update request"); + } + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(cloudstackVolume.getFile().getQosPolicy()); + String filePath = cloudstackVolume.getVolumeInfo().getUuid(); + try { + nasFeignClient.updateFile(getAuthHeader(), cloudstackVolume.getFlexVolumeUuid(), filePath, fileInfo); + } catch (FeignException e) { + throw new CloudRuntimeException("Failed to apply QoS policy to NFS volume file: " + e.getMessage(), e); + } + logger.info("Applied QoS policy [{}] to NFS volume file [{}]", + cloudstackVolume.getFile().getQosPolicy() != null + ? cloudstackVolume.getFile().getQosPolicy().getName() : null, filePath); + return cloudstackVolume; } @Override diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java index b9e32b081e4d..cca1982cdaf4 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java @@ -90,12 +90,12 @@ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume } catch (FeignException e) { logger.error("FeignException occurred while creating LUN: {}, Status: {}, Exception: {}", cloudstackVolume.getLun().getName(), e.status(), e.getMessage()); - throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage()); + throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage(), e); } catch (CloudRuntimeException e) { throw e; } catch (Exception e) { logger.error("Exception occurred while creating LUN: {}, Exception: {}", cloudstackVolume.getLun().getName(), e.getMessage()); - throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage()); + throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage(), e); } } @@ -183,8 +183,25 @@ private void bestEffortDeleteTemplateCacheLun(String svmName, String lunName, St } @Override - CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { - return null; + public CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { + if (cloudstackVolume == null || cloudstackVolume.getLun() == null + || cloudstackVolume.getLun().getUuid() == null) { + throw new CloudRuntimeException("Invalid iSCSI volume QoS update request"); + } + Lun lunUpdate = new Lun(); + lunUpdate.setQosPolicy(cloudstackVolume.getLun().getQosPolicy()); + try { + JobResponse response = sanFeignClient.updateLun( + getAuthHeader(), cloudstackVolume.getLun().getUuid(), lunUpdate); + pollJobIfPresent(response, "update QoS policy on LUN [" + cloudstackVolume.getLun().getUuid() + "]"); + } catch (FeignException e) { + throw new CloudRuntimeException("Failed to apply QoS policy to LUN: " + e.getMessage(), e); + } + logger.info("Applied QoS policy [{}] to LUN [{}]", + cloudstackVolume.getLun().getQosPolicy() != null + ? cloudstackVolume.getLun().getQosPolicy().getName() : null, + cloudstackVolume.getLun().getUuid()); + return cloudstackVolume; } @Override @@ -198,7 +215,8 @@ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) { String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); Map queryParams = Map.of("allow_delete_while_mapped", "true"); try { - sanFeignClient.deleteLun(authHeader, cloudstackVolume.getLun().getUuid(), queryParams); + JobResponse response = sanFeignClient.deleteLun(authHeader, cloudstackVolume.getLun().getUuid(), queryParams); + pollJobIfPresent(response, "delete Lun [" + cloudstackVolume.getLun().getName() + "]"); } catch (FeignException feignEx) { if (feignEx.status() == 404) { logger.warn("deleteCloudStackVolume: Lun {} does not exist (status 404), skipping deletion", cloudstackVolume.getLun().getName()); @@ -298,9 +316,7 @@ public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeI sanFeignClient.updateLun(authHeader, lunUuid, patch); logger.debug("resizeCloudStackVolume: Lun {} resized to {} bytes", lunUuid, sizeInBytes); } catch (FeignException e) { - logger.error("FeignException occurred while resizing LUN: {}, Status: {}, Exception: {}", - lunUuid, e.status(), e.getMessage()); - throw new CloudRuntimeException("Failed to resize Lun: " + e.getMessage()); + throw new CloudRuntimeException("Failed to resize Lun: " + e.getMessage(), e); } catch (Exception e) { logger.error("Exception occurred while resizing LUN: {}, Exception: {}", lunUuid, e.getMessage()); throw new CloudRuntimeException("Failed to resize Lun: " + e.getMessage()); diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java index 4ac49c95dfa1..6f68dd6c4604 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java @@ -102,6 +102,14 @@ public class OntapStorageConstants { public static final String LUN_DOT_NAME = "lun.name"; public static final String IQN = "iqn"; public static final String LUN_DOT_UUID = "lun.uuid"; + public static final String QOS_POLICY_UUID = "qosPolicyUuid"; + public static final String IS_AFF = "isAFF"; + public static final String QOS_POLICY_NONE = "none"; + public static final String QOS_POLICY_NAME_PREFIX = "cs_"; + public static final String QOS_POLICY_NAME_TO = "to_"; + public static final String QOS_POLICY_NAME_IOPS = "iops_"; + public static final String UUID = "uuid"; + public static final String QOS_POLICY_OBJECT_COUNT_FIELDS = "uuid,name,object_count"; public static final String LOGICAL_UNIT_NUMBER = "logical_unit_number"; public static final String IGROUP_DOT_NAME = "igroup.name"; public static final String IGROUP_DOT_UUID = "igroup.uuid"; diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java index db1806c8473e..9b6f09585358 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java @@ -22,9 +22,11 @@ import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.ResizeVolumePayload; import com.cloud.storage.ScopeType; import com.cloud.storage.Storage; import com.cloud.storage.VMTemplateStoragePoolVO; +import com.cloud.storage.Volume; import com.cloud.storage.VolumeVO; import com.cloud.storage.VolumeDetailVO; import com.cloud.storage.dao.VMTemplatePoolDao; @@ -42,8 +44,11 @@ import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.feign.model.FileInfo; import org.apache.cloudstack.storage.feign.model.Igroup; import org.apache.cloudstack.storage.feign.model.Lun; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; +import org.apache.cloudstack.storage.service.StorageStrategy; import org.apache.cloudstack.storage.service.UnifiedNASStrategy; import org.apache.cloudstack.storage.service.UnifiedSANStrategy; import org.apache.cloudstack.storage.service.model.AccessGroup; @@ -61,6 +66,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.HashMap; +import java.util.List; import java.util.Map; import static com.cloud.agent.api.to.DataObjectType.TEMPLATE; @@ -76,6 +82,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -147,6 +154,11 @@ void setUp() { storagePoolDetails = new HashMap<>(); storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.ISCSI.name()); storagePoolDetails.put(OntapStorageConstants.SVM_NAME, "svm1"); + storagePoolDetails.put(OntapStorageConstants.IS_AFF, "true"); + lenient().when(volumeInfo.getName()).thenReturn("test-volume"); + lenient().when(volumeInfo.getSize()).thenReturn(1073741824L); + lenient().when(storagePool.getName()).thenReturn("vol1"); + lenient().when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); } @Test @@ -190,8 +202,8 @@ void testCreateAsync_VolumeWithISCSI_Success() { when(volumeInfo.getName()).thenReturn("test-volume"); when(storagePoolDao.findById(1L)).thenReturn(storagePool); - when(storagePool.getId()).thenReturn(1L); - when(storagePool.getName()).thenReturn("vol1"); + lenient().when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getName()).thenReturn("vol1"); when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); @@ -273,7 +285,7 @@ void testCreateAsync_VolumeWithNFS_Success() { @Test void testCreateAsync_UnsupportedHypervisor_FailsWithError() { - // Use NFS so createVolumeRequest does not fail earlier in getOSTypeFromHypervisor; + // Use NFS so LUN OS-type resolution does not fail earlier in getOSTypeFromHypervisor; // the failure under test is image-format resolution for non-KVM hypervisors. storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); @@ -352,6 +364,7 @@ void testDeleteAsync_ISCSIVolume_Success() { when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail); when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(lunUuidDetail); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(null); try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) @@ -924,6 +937,7 @@ void testCreateAsync_VolumeClonedFromTemplate_ClonesWithoutGrowing() { assertEquals("template-lun-uuid", requestCaptor.getValue().getLun().getClone().getSource().getUuid()); verify(sanStrategy, never()).createCloudStackVolume(any()); verify(sanStrategy, never()).resizeCloudStackVolume(any(), anyLong()); + verify(sanStrategy, never()).updateCloudStackVolume(any()); verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.LUN_DOT_UUID), eq("cloned-lun-uuid"), eq(false)); } } @@ -964,6 +978,7 @@ private void stubVolumeCloneFromTemplate(long templateSize, long volumeSize) { lenient().when(storagePool.getName()).thenReturn("vol1"); lenient().when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); lenient().when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); when(volumeDao.findById(100L)).thenReturn(volumeVO); @@ -1122,6 +1137,74 @@ void testCreateAsync_VolumeClonedFromTemplateNFS_ClonesFile() { assertEquals("template-uuid", requestCaptor.getValue().getFile().getPath()); assertEquals("volume-uuid", requestCaptor.getValue().getDestinationPath()); verify(nasStrategy, never()).resizeCloudStackVolume(any(), anyLong()); + verify(nasStrategy, never()).updateCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplate_RootWithIops_SetsQosOnLunCreate() { + stubVolumeCloneFromTemplate(5368709120L, 5368709120L); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.ROOT); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + VolumeQosPolicy qosPolicy = qosPolicy("qos-root-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloned, qosPolicy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(sanStrategy).cloneCloudStackVolume(argThat(request -> + request.getLun() != null && request.getLun().getQosPolicy() != null + && "qos-root-uuid".equals(request.getLun().getQosPolicy().getUuid()))); + verify(sanStrategy, never()).updateCloudStackVolume(any()); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-root-uuid"), eq(false)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplateNFS_RootWithIops_AttachesQosToFile() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flex-uuid"); + stubVolumeCloneFromTemplate(5368709120L, 5368709120L); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(volumeInfo.getUuid()).thenReturn("volume-uuid"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.ROOT); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(templatePoolRef.getInstallPath()).thenReturn("template-uuid"); + + CloudStackVolume cloned = new CloudStackVolume(); + VolumeQosPolicy qosPolicy = qosPolicy("qos-nfs-root-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, nasStrategy, cloned, qosPolicy); + when(nasStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + when(nasStrategy.updateCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(nasStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(nasStrategy).updateCloudStackVolume(argThat(request -> + request.getFile() != null && request.getFile().getQosPolicy() != null + && "qos-nfs-root-uuid".equals(request.getFile().getQosPolicy().getUuid()))); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-nfs-root-uuid"), eq(false)); } } @@ -1226,6 +1309,658 @@ void testRevokeAccess_Template_UnmapsCacheLun() { } } + @Test + void testGetUsedIops_SumsMinIopsIncludingCreatingVolumes() { + VolumeVO readyVolume = mock(VolumeVO.class); + VolumeVO creatingVolume = mock(VolumeVO.class); + VolumeVO volumeWithoutMinIops = mock(VolumeVO.class); + + when(storagePool.getId()).thenReturn(1L); + when(readyVolume.getMinIops()).thenReturn(700L); + when(creatingVolume.getMinIops()).thenReturn(300L); + when(volumeWithoutMinIops.getMinIops()).thenReturn(null); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)) + .thenReturn(List.of(readyVolume, creatingVolume, volumeWithoutMinIops)); + + assertEquals(1000L, driver.getUsedIops(storagePool)); + } + + @Test + void testResize_IopsUpdate_AttachesQosPolicy() { + long currentSize = 4L * 1024 * 1024 * 1024; + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 0L, 5000L, null, false, null, null, true)); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + when(volumeVO.getPoolId()).thenReturn(1L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(null); + VolumeDetailVO lunUuidDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(lunUuidDetail); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_0_to_5000_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + when(sanStrategy.updateCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.resize(volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).updateCloudStackVolume(argThat(request -> + request.getLun() != null && "lun-uuid-123".equals(request.getLun().getUuid()))); + verify(volumeDetailsDao).addDetail(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); + } + } + + @Test + void testResize_IopsUpdate_NfsAttachesQosPolicy() { + long currentSize = 4L * 1024 * 1024 * 1024; + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flex-uuid"); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 100L, 200L, null, false, null, null, true)); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + when(volumeVO.getPoolId()).thenReturn(1L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(null); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = nfsCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, nasStrategy, cloudStackVolume, qosPolicy); + when(nasStrategy.updateCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.resize(volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(nasStrategy).updateCloudStackVolume(any()); + verify(volumeDetailsDao).addDetail(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); + } + } + + @Test + void testResize_SameQosPolicy_SkipsAttach() { + long currentSize = 4L * 1024 * 1024 * 1024; + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 0L, 3333L, null, false, null, null, true)); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + when(volumeVO.getPoolId()).thenReturn(1L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + VolumeDetailVO qosDetail = new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(qosDetail); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_0_to_3333_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.resize(volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).updateCloudStackVolume(any()); + } + } + + @Test + void testResize_ClearIops_DetachesQosPolicy() { + long currentSize = 4L * 1024 * 1024 * 1024; + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 0L, 0L, null, false, null, null, true)); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + when(volumeVO.getPoolId()).thenReturn(1L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + VolumeDetailVO qosDetail = new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(qosDetail); + VolumeDetailVO lunUuidDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(lunUuidDetail); + + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.updateCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.resize(volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).updateCloudStackVolume(argThat(request -> + request.getLun() != null && request.getLun().getQosPolicy() != null + && OntapStorageConstants.QOS_POLICY_NONE.equals(request.getLun().getQosPolicy().getName()))); + verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + @Test + void testResize_MinIopsBeyondPoolCapacity_Fails() { + long currentSize = 4L * 1024 * 1024 * 1024; + VolumeVO otherVolume = mock(VolumeVO.class); + when(otherVolume.getId()).thenReturn(50L); + when(otherVolume.getMinIops()).thenReturn(800L); + + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 300L, 1000L, null, false, null, null, true)); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + when(volumeVO.getPoolId()).thenReturn(1L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("ontap-pool"); + when(storagePool.getCapacityIops()).thenReturn(1000L); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)).thenReturn(List.of(otherVolume, volumeVO)); + + driver.resize(volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains( + "requested total of 1100 IOPS exceeds the pool IOPS capacity of 1000")); + verify(sanStrategy, never()).updateCloudStackVolume(any()); + } + + @Test + void testCreateAsync_MinIopsBeyondPoolCapacity_FailsWithCapacityDetails() { + VolumeVO allocatedVolume = mock(VolumeVO.class); + when(allocatedVolume.getId()).thenReturn(50L); + when(allocatedVolume.getMinIops()).thenReturn(800L); + + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(volumeInfo.getMinIops()).thenReturn(300L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("ontap-pool"); + when(storagePool.getCapacityIops()).thenReturn(1000L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)).thenReturn(List.of(allocatedVolume)); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains( + "storage pool ontap-pool: requested total of 1100 IOPS exceeds the pool IOPS capacity of 1000")); + verify(sanStrategy, never()).createCloudStackVolume(any()); + } + + @Test + void testCreateAsync_OtherCreatingVolumeCountsAgainstCapacity() { + VolumeVO readyVolume = mock(VolumeVO.class); + when(readyVolume.getId()).thenReturn(50L); + when(readyVolume.getMinIops()).thenReturn(500L); + + VolumeVO otherCreatingVolume = mock(VolumeVO.class); + when(otherCreatingVolume.getId()).thenReturn(60L); + when(otherCreatingVolume.getMinIops()).thenReturn(300L); + + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(volumeInfo.getMinIops()).thenReturn(300L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("ontap-pool"); + when(storagePool.getCapacityIops()).thenReturn(1000L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)) + .thenReturn(List.of(readyVolume, otherCreatingVolume)); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains( + "storage pool ontap-pool: requested total of 1100 IOPS exceeds the pool IOPS capacity of 1000")); + verify(sanStrategy, never()).createCloudStackVolume(any()); + } + + @Test + void testCreateAsync_DoesNotDoubleCountSelfWhenAlreadyCreating() { + VolumeVO readyVolume = mock(VolumeVO.class); + when(readyVolume.getId()).thenReturn(50L); + when(readyVolume.getMinIops()).thenReturn(500L); + + VolumeVO selfCreatingVolume = mock(VolumeVO.class); + when(selfCreatingVolume.getId()).thenReturn(100L); + + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(volumeInfo.getMinIops()).thenReturn(300L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + when(storagePool.getCapacityIops()).thenReturn(1000L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)) + .thenReturn(List.of(readyVolume, selfCreatingVolume)); + + Lun mockLun = new Lun(); + mockLun.setName("/vol/vol1/lun1"); + mockLun.setUuid("lun-uuid-123"); + CloudStackVolume cloudStackVolume = new CloudStackVolume(); + cloudStackVolume.setLun(mockLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.createCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_DataDiskFixedIops_CreatesAndPersistsQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(sanStrategy).createCloudStackVolume(argThat(request -> + request.getLun() != null && request.getLun().getQosPolicy() != null + && "qos-uuid".equals(request.getLun().getQosPolicy().getUuid()))); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-uuid"), eq(false)); + verify(sanStrategy, never()).isAff(); + } + } + + @Test + void testCreateAsync_FasPoolWithMinIops_FailsWithoutCallingOntap() { + stubIscsiVolumeCreate(); + storagePoolDetails.put(OntapStorageConstants.IS_AFF, "false"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Minimum IOPS is not supported on FAS")); + verify(sanStrategy, never()).isAff(); + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + } + } + + @Test + void testCreateAsync_FasPoolWithMaxIopsOnly_CreatesQosPolicyWithoutNodeCall() { + stubIscsiVolumeCreate(); + storagePoolDetails.put(OntapStorageConstants.IS_AFF, "false"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(0L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_0_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).isAff(); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_0_to_200_iops_svm1"), eq(0L), eq(200L)); + } + } + + @Test + void testCreateAsync_MissingAffDetail_FetchesFromNodesAndPersists() { + stubIscsiVolumeCreate(); + storagePoolDetails.remove(OntapStorageConstants.IS_AFF); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(sanStrategy.isAff()).thenReturn(true); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).isAff(); + verify(storagePoolDetailsDao).addDetail(1L, OntapStorageConstants.IS_AFF, "true", false); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + } + } + + @Test + void testCreateAsync_MissingAffDetailOnFas_PersistsFalseAndRejectsMinIops() { + stubIscsiVolumeCreate(); + storagePoolDetails.remove(OntapStorageConstants.IS_AFF); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(sanStrategy.isAff()).thenReturn(false); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Minimum IOPS is not supported on FAS")); + verify(sanStrategy).isAff(); + verify(storagePoolDetailsDao).addDetail(1L, OntapStorageConstants.IS_AFF, "false", false); + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + } + } + + @Test + void testCreateAsync_RootDiskCustomIops_CreatesAndPersistsQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.ROOT); + when(volumeInfo.getMinIops()).thenReturn(111L); + when(volumeInfo.getMaxIops()).thenReturn(999L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-root-uuid", "cs_111_to_999_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_111_to_999_iops_svm1"), eq(111L), eq(999L)); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-root-uuid"), eq(false)); + } + } + + @Test + void testCreateAsync_NfsDataDiskWithIops_CreatesQosPolicy() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-nfs-uuid", "cs_100_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = new CloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, nasStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(nasStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(nasStrategy).createCloudStackVolume(argThat(request -> + request.getFile() != null && request.getFile().getQosPolicy() != null + && "qos-nfs-uuid".equals(request.getFile().getQosPolicy().getUuid()))); + } + } + + @Test + void testCreateAsync_MinIopsGreaterThanMaxIops_Fails() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(200L); + when(volumeInfo.getMaxIops()).thenReturn(100L); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains( + "Minimum IOPS cannot be greater than maximum IOPS")); + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + verify(sanStrategy, never()).createCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_NoIops_DoesNotCreateQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.createCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + verify(sanStrategy).createCloudStackVolume(argThat(request -> + request.getLun() != null && request.getLun().getQosPolicy() == null)); + } + } + + @Test + void testCreateAsync_SwapVolumeWithIops_DoesNotCreateQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.SWAP); + when(volumeInfo.getMinIops()).thenReturn(100L); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.createCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + } + } + + @Test + void testCreateAsync_QosCreateThenLunCreateFails_DeletesUnusedPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.createVolumeQosPolicy(nullable(String.class), nullable(Long.class), nullable(Long.class))) + .thenReturn(qosPolicy); + when(sanStrategy.createCloudStackVolume(any())).thenThrow(new CloudRuntimeException( + "Failed to create Lun: {\"error\":{\"code\":\"8454269\"}}")); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + @Test + void testDeleteAsync_DeletesUnusedQosPolicy() { + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false); + VolumeDetailVO lunUuidDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false); + VolumeDetailVO qosDetail = new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); + + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(lunUuidDetail); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(qosDetail); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + doNothing().when(sanStrategy).deleteCloudStackVolume(any()); + + driver.deleteAsync(dataStore, volumeInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(volumeDetailsDao).removeDetail(100L, OntapStorageConstants.QOS_POLICY_UUID); + verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + private void stubIscsiVolumeCreate() { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + lenient().when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + lenient().when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + lenient().when(volumeVO.getId()).thenReturn(100L); + } + + private CloudStackVolume iscsiCloudStackVolume() { + Lun mockLun = new Lun(); + mockLun.setName("/vol/vol1/lun1"); + mockLun.setUuid("lun-uuid-123"); + CloudStackVolume volume = new CloudStackVolume(); + volume.setLun(mockLun); + return volume; + } + + private CloudStackVolume nfsCloudStackVolume() { + FileInfo fileInfo = new FileInfo(); + CloudStackVolume volume = new CloudStackVolume(); + volume.setFlexVolumeUuid("flex-uuid"); + volume.setFile(fileInfo); + volume.setVolumeInfo(volumeInfo); + return volume; + } + + private VolumeQosPolicy qosPolicy(String uuid, String name) { + VolumeQosPolicy policy = new VolumeQosPolicy(); + policy.setUuid(uuid); + policy.setName(name); + return policy; + } + + private void stubQosCreateMocks(MockedStatic utilityMock, + StorageStrategy strategy, + CloudStackVolume cloudStackVolume, VolumeQosPolicy qosPolicy) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(strategy); + when(strategy.createVolumeQosPolicy(nullable(String.class), nullable(Long.class), nullable(Long.class))) + .thenReturn(qosPolicy); + lenient().when(strategy.createCloudStackVolume(any())).thenReturn(cloudStackVolume); + } + @Test void testCanHostAccessStoragePool_ReturnsTrue() { assertTrue(driver.canHostAccessStoragePool(host, storagePool)); diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java index f4e3b1196e4f..12ff93d71c11 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java @@ -49,6 +49,7 @@ import org.apache.cloudstack.storage.service.model.AccessGroup; import com.cloud.hypervisor.Hypervisor; import com.cloud.alert.AlertManager; +import com.cloud.capacity.CapacityManager; import java.util.Map; import java.util.List; import java.util.ArrayList; @@ -64,6 +65,7 @@ import static org.mockito.Mockito.never; import static org.mockito.ArgumentMatchers.contains; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -107,6 +109,9 @@ public class OntapPrimaryDatastoreLifecycleTest { @Mock private AlertManager _alertMgr; + @Mock + private CapacityManager _capacityMgr; + // Mock object that implements both DataStore and PrimaryDataStoreInfo // This is needed because attachCluster(DataStore) casts DataStore to PrimaryDataStoreInfo internally private DataStore dataStore; @@ -131,6 +136,7 @@ void setUp() { when(_clusterDao.findById(1L)).thenReturn(clusterVO); when(storageStrategy.connect()).thenReturn(true); + when(storageStrategy.isAff()).thenReturn(true); when(storageStrategy.getNetworkInterface()).thenReturn(new Pair<>("testNetworkInterface", null)); Volume volume = new Volume(); @@ -243,6 +249,68 @@ public void testInitialize_nfsPoolKeepsNetworkFilesystemType() { assertEquals(Storage.StoragePoolType.NetworkFilesystem, initializeAndCapturePoolType("NFS3")); } + private Long initializeAndCaptureCapacityIops(Map dsInfos) { + try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) { + storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy); + ontapPrimaryDatastoreLifecycle.initialize(dsInfos); + } + ArgumentCaptor captor = ArgumentCaptor.forClass(PrimaryDataStoreParameters.class); + verify(_dataStoreHelper).createPrimaryDataStore(captor.capture()); + return captor.getValue().getCapacityIops(); + } + + @Test + public void testInitialize_blankCapacityIopsLeavesPoolWithoutIopsCeiling() { + assertNull(initializeAndCaptureCapacityIops(buildDsInfosForProtocol("NFS3"))); + } + + @Test + public void testInitialize_capacityIopsIsStoredOnPool() { + Map dsInfos = buildDsInfosForProtocol("NFS3"); + dsInfos.put("capacityIops", 5000L); + + assertEquals(Long.valueOf(5000L), initializeAndCaptureCapacityIops(dsInfos)); + } + + @Test + public void testInitialize_persistsAffPlatformDetail() { + Map details = initializeAndCaptureDetails(buildDsInfosForProtocol("NFS3")); + + assertEquals("true", details.get(OntapStorageConstants.IS_AFF)); + verify(storageStrategy).isAff(); + } + + @Test + public void testInitialize_persistsFasPlatformDetail() { + when(storageStrategy.isAff()).thenReturn(false); + + Map details = initializeAndCaptureDetails(buildDsInfosForProtocol("NFS3")); + + assertEquals("false", details.get(OntapStorageConstants.IS_AFF)); + } + + private Map initializeAndCaptureDetails(Map dsInfos) { + try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) { + storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy); + ontapPrimaryDatastoreLifecycle.initialize(dsInfos); + } + ArgumentCaptor captor = ArgumentCaptor.forClass(PrimaryDataStoreParameters.class); + verify(_dataStoreHelper).createPrimaryDataStore(captor.capture()); + return captor.getValue().getDetails(); + } + + @Test + public void testInitialize_nonPositiveCapacityIopsIsRejected() { + Map dsInfos = buildDsInfosForProtocol("NFS3"); + dsInfos.put("capacityIops", 0L); + + Exception ex = assertThrows(InvalidParameterValueException.class, + () -> ontapPrimaryDatastoreLifecycle.initialize(dsInfos)); + + assertTrue(ex.getMessage().contains("IOPS capacity must be greater than 0")); + verify(_dataStoreHelper, never()).createPrimaryDataStore(any()); + } + @Test public void testInitialize_null_Arg() { Exception ex = assertThrows(CloudRuntimeException.class,() -> @@ -1246,6 +1314,44 @@ public void testUpdateStoragePool_missingVolumeUuid_throwsCloudRuntimeException( } } + @Test + public void testUpdateStoragePool_capacityIopsBelowAllocated_throwsInvalidParameterValueException() { + StoragePool storagePool = mock(StoragePool.class); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("test-pool"); + + Map details = new HashMap<>(); + details.put(PrimaryDataStoreLifeCycle.CAPACITY_IOPS, "400"); + details.put(OntapStorageConstants.VOLUME_UUID, "flex-vol-uuid-123"); + details.put("protocol", "NFS3"); + + when(_capacityMgr.getUsedIops(any(StoragePoolVO.class))).thenReturn(900L); + + Exception ex = assertThrows(InvalidParameterValueException.class, + () -> ontapPrimaryDatastoreLifecycle.updateStoragePool(storagePool, details)); + + assertTrue(ex.getMessage().contains("900 IOPS are already allocated")); + verify(storageStrategy, never()).updateStorageVolume(any(Volume.class)); + } + + @Test + public void testUpdateStoragePool_capacityIopsAboveAllocated_isAccepted() { + StoragePool storagePool = mock(StoragePool.class); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("test-pool"); + + Map details = new HashMap<>(); + details.put(PrimaryDataStoreLifeCycle.CAPACITY_IOPS, "2000"); + details.put("protocol", "NFS3"); + // No CAPACITY_BYTES key — only the IOPS ceiling is being raised. + + when(_capacityMgr.getUsedIops(any(StoragePoolVO.class))).thenReturn(900L); + + ontapPrimaryDatastoreLifecycle.updateStoragePool(storagePool, details); + + verify(storageStrategy, never()).updateStorageVolume(any(Volume.class)); + } + @Test public void testUpdateStoragePool_updateStorageVolumeThrows_propagatesCloudRuntimeException() { // Setup diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java index 2c516544cd49..c894496d46af 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java @@ -50,6 +50,7 @@ import org.apache.cloudstack.storage.service.model.ProtocolType; import org.apache.cloudstack.storage.utils.OntapStorageConstants; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -154,7 +155,7 @@ public CloudStackVolume createTemplateCache(org.apache.cloudstack.storage.datast } @Override - CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { + public CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { return null; } @@ -379,6 +380,35 @@ public void testGetClusterInfo_nodesGetFailureLeavesModelUnset() { assertNull(result.getPlatformType()); } + @Test + public void testIsAff_allNodesAllFlash_returnsTrue() { + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("AFF-A400", true, true, false), + clusterNode("AFF-A400", true, true, false)))); + + assertTrue(storageStrategy.isAff()); + } + + @Test + public void testIsAff_anyNodeNotAllFlash_returnsFalse() { + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("AFF-A400", true, true, false), + clusterNode("FAS8300", false, false, false)))); + + assertFalse(storageStrategy.isAff()); + } + + @Test + public void testIsAff_noNodes_throws() { + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of())); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.isAff()); + assertTrue(ex.getMessage().contains("Unable to determine whether the ONTAP cluster is AFF or FAS")); + } + private Cluster stubClusterGet() { Cluster cluster = new Cluster(); when(clusterFeignClient.getCluster(anyString(), eq(true))).thenReturn(cluster); diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java index dd90363af045..75b530d22d99 100755 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java @@ -29,6 +29,7 @@ import org.apache.cloudstack.engine.subsystem.api.storage.EndPointSelector; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.storage.command.CreateObjectCommand; +import org.apache.cloudstack.storage.command.DeleteCommand; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; @@ -45,6 +46,7 @@ import org.apache.cloudstack.storage.feign.model.FileInfo; import org.apache.cloudstack.storage.feign.model.Job; import org.apache.cloudstack.storage.feign.model.OntapStorage; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.model.AccessGroup; @@ -72,10 +74,12 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -226,6 +230,106 @@ public void testCreateTemplateCache_IsNoOp() { assertNull(result); } + + @Test + public void testCreateCloudStackVolume_AppliesQosPolicyToNfsFile() throws Exception { + CloudStackVolume cloudStackVolume = mock(CloudStackVolume.class); + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + EndPoint endPoint = mock(EndPoint.class); + Answer answer = new Answer(null, true, "Success"); + + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to200_iops_svm1"); + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); + + when(cloudStackVolume.getDatastoreId()).thenReturn("1"); + when(cloudStackVolume.getVolumeInfo()).thenReturn(volumeObject); + when(cloudStackVolume.getFlexVolumeUuid()).thenReturn("flex-uuid"); + when(cloudStackVolume.getFile()).thenReturn(fileInfo); + when(volumeObject.getId()).thenReturn(100L); + when(volumeObject.getUuid()).thenReturn("volume-uuid-123"); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.update(anyLong(), any(VolumeVO.class))).thenReturn(true); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(CreateObjectCommand.class))).thenReturn(answer); + + CloudStackVolume result = strategy.createCloudStackVolume(cloudStackVolume); + + assertNotNull(result); + verify(nasFeignClient).updateFile(anyString(), eq("flex-uuid"), eq("volume-uuid-123"), + argThat(file -> file.getQosPolicy() != null + && "cs_100_to200_iops_svm1".equals(file.getQosPolicy().getName()))); + } + + @Test + public void testCreateCloudStackVolume_QosAttachFails_DeletesLeftoverNfsFile() { + CloudStackVolume cloudStackVolume = mock(CloudStackVolume.class); + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + EndPoint endPoint = mock(EndPoint.class); + Answer createAnswer = new Answer(null, true, "Success"); + Answer deleteAnswer = new Answer(null, true, "Deleted"); + + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to200_iops_svm1"); + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); + + when(cloudStackVolume.getDatastoreId()).thenReturn("1"); + when(cloudStackVolume.getVolumeInfo()).thenReturn(volumeObject); + when(cloudStackVolume.getFlexVolumeUuid()).thenReturn("flex-uuid"); + when(cloudStackVolume.getFile()).thenReturn(fileInfo); + when(volumeObject.getId()).thenReturn(100L); + when(volumeObject.getUuid()).thenReturn("volume-uuid-123"); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.update(anyLong(), any(VolumeVO.class))).thenReturn(true); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(CreateObjectCommand.class))).thenReturn(createAnswer); + when(endPoint.sendMessage(any(DeleteCommand.class))).thenReturn(deleteAnswer); + + FeignException feignException = mock(FeignException.class); + when(feignException.contentUTF8()).thenReturn( + "{\"error\":{\"code\":\"8454269\",\"message\":\"Invalid QoS policy group specified\"}}"); + when(feignException.getMessage()).thenReturn("Bad Request"); + doThrow(feignException).when(nasFeignClient).updateFile(anyString(), eq("flex-uuid"), + eq("volume-uuid-123"), any(FileInfo.class)); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> strategy.createCloudStackVolume(cloudStackVolume)); + assertTrue(ex.getMessage().contains("Failed to apply QoS policy to NFS volume file")); + verify(endPoint).sendMessage(any(DeleteCommand.class)); + } + + @Test + public void testUpdateCloudStackVolume_AppliesQosPolicy() { + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getUuid()).thenReturn("volume-uuid-123"); + + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to200_iops_svm1"); + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeInfo); + request.setFlexVolumeUuid("flex-uuid"); + request.setFile(fileInfo); + + CloudStackVolume result = strategy.updateCloudStackVolume(request); + + assertSame(request, result); + verify(nasFeignClient).updateFile(anyString(), eq("flex-uuid"), eq("volume-uuid-123"), + argThat(file -> file.getQosPolicy() != null + && "cs_100_to200_iops_svm1".equals(file.getQosPolicy().getName()))); + } + + @Test + public void testUpdateCloudStackVolume_InvalidRequest_ThrowsException() { + assertThrows(CloudRuntimeException.class, () -> strategy.updateCloudStackVolume(new CloudStackVolume())); + } + // Test createCloudStackVolume - Volume Not Found @Test public void testCreateCloudStackVolume_VolumeNotFound() { diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedSANStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedSANStrategyTest.java index 700b63d15575..facaf81404bf 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedSANStrategyTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedSANStrategyTest.java @@ -30,6 +30,7 @@ import org.apache.cloudstack.storage.feign.model.Lun; import org.apache.cloudstack.storage.feign.model.LunMap; import org.apache.cloudstack.storage.feign.model.OntapStorage; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.model.AccessGroup; import org.apache.cloudstack.storage.service.model.CloudStackVolume; @@ -54,11 +55,13 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -354,6 +357,28 @@ void testCreateCloudStackVolume_FeignException_ThrowsCloudRuntimeException() { } } + @Test + void testCreateCloudStackVolume_MinThroughputRejected_PropagatesOntapError() { + Lun lun = new Lun(); + lun.setName("/vol/vol1/lun1"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + FeignException feignException = mock(FeignException.class); + when(feignException.getMessage()).thenReturn("Bad Request"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))) + .thenThrow(feignException); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createCloudStackVolume(request)); + assertTrue(ex.getMessage().contains("Failed to create Lun")); + } + } + @Test void testDeleteCloudStackVolume_Success() { // Setup @@ -367,7 +392,7 @@ void testDeleteCloudStackVolume_Success() { utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) .thenReturn(authHeader); - doNothing().when(sanFeignClient).deleteLun(eq(authHeader), eq("lun-uuid-123"), anyMap()); + when(sanFeignClient.deleteLun(eq(authHeader), eq("lun-uuid-123"), anyMap())).thenReturn(null); // Execute unifiedSANStrategy.deleteCloudStackVolume(request); @@ -1049,7 +1074,6 @@ void testResizeCloudStackVolume_FeignException_Throws() { request.setLun(lun); FeignException feignException = mock(FeignException.class); - when(feignException.status()).thenReturn(500); when(feignException.getMessage()).thenReturn("resize failed"); try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { @@ -1122,10 +1146,35 @@ void testSetOntapStorage() { } @Test - void testUpdateCloudStackVolume_ReturnsNull() { + void testUpdateCloudStackVolume_InvalidRequest_ThrowsException() { CloudStackVolume request = new CloudStackVolume(); - CloudStackVolume result = unifiedSANStrategy.updateCloudStackVolume(request); - assertNull(result); + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.updateCloudStackVolume(request)); + } + + @Test + void testUpdateCloudStackVolume_AppliesQosPolicyToLun() { + Lun lun = new Lun(); + lun.setUuid("lun-uuid-123"); + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_0_to5000_iops_svm1"); + lun.setQosPolicy(qosPolicy); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.updateLun(eq(authHeader), eq("lun-uuid-123"), any(Lun.class))) + .thenReturn(null); + + CloudStackVolume result = unifiedSANStrategy.updateCloudStackVolume(request); + + assertSame(request, result); + verify(sanFeignClient).updateLun(eq(authHeader), eq("lun-uuid-123"), argThat(update -> + update.getQosPolicy() != null + && "cs_0_to5000_iops_svm1".equals(update.getQosPolicy().getName()))); + } } @Test @@ -2182,4 +2231,35 @@ void testEnsureLunMapped_ExistingMapping_ReturnsExistingNumber() { verify(sanFeignClient, never()).createLunMap(any(), anyBoolean(), any(LunMap.class)); } } + + @Test + void testCreateCloudStackVolume_PassesQosPolicyOnLunCreate() { + Lun lun = new Lun(); + lun.setName("/vol/vol1/lun1"); + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to200_iops_svm1"); + lun.setQosPolicy(qosPolicy); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + Lun createdLun = new Lun(); + createdLun.setName("/vol/vol1/lun1"); + createdLun.setUuid("lun-uuid-123"); + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of(createdLun)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))) + .thenReturn(response); + + unifiedSANStrategy.createCloudStackVolume(request); + + ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); + verify(sanFeignClient).createLun(eq(authHeader), eq(true), lunCaptor.capture()); + assertNotNull(lunCaptor.getValue().getQosPolicy()); + assertEquals("cs_100_to200_iops_svm1", lunCaptor.getValue().getQosPolicy().getName()); + } + } } diff --git a/ui/src/views/infra/AddPrimaryStorage.vue b/ui/src/views/infra/AddPrimaryStorage.vue index 7d189032f098..e5d9e6aa81af 100644 --- a/ui/src/views/infra/AddPrimaryStorage.vue +++ b/ui/src/views/infra/AddPrimaryStorage.vue @@ -301,6 +301,12 @@ + + + +
diff --git a/ui/src/views/infra/zone/ZoneWizardAddResources.vue b/ui/src/views/infra/zone/ZoneWizardAddResources.vue index 9bd9c6d37aef..faf9a10679f6 100644 --- a/ui/src/views/infra/zone/ZoneWizardAddResources.vue +++ b/ui/src/views/infra/zone/ZoneWizardAddResources.vue @@ -611,7 +611,7 @@ export default { title: 'label.capacityiops', key: 'capacityIops', hidden: { - provider: ['DefaultPrimary', 'PowerFlex', 'Linstor', 'NetApp ONTAP'] + provider: ['DefaultPrimary', 'PowerFlex', 'Linstor'] } }, { diff --git a/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue b/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue index f21201572dff..9402fe99a9c9 100644 --- a/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue +++ b/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue @@ -1619,6 +1619,9 @@ export default { if (this.prefillContent.capacityBytes && this.prefillContent.capacityBytes.length > 0) { params.capacityBytes = this.prefillContent.capacityBytes.split(',').join('') } + if (this.prefillContent.capacityIops && this.prefillContent.capacityIops.length > 0) { + params.capacityIops = this.prefillContent.capacityIops.split(',').join('') + } } params.tags = this.prefillContent?.primaryStorageTags || '' diff --git a/ui/src/views/storage/ResizeVolume.vue b/ui/src/views/storage/ResizeVolume.vue index 5f9efd6ed506..b6d4f1899391 100644 --- a/ui/src/views/storage/ResizeVolume.vue +++ b/ui/src/views/storage/ResizeVolume.vue @@ -105,14 +105,21 @@ export default { }, fetchData () { this.loading = true + if (this.resource.size != null) { + this.form.size = this.resource.size / (1024 * 1024 * 1024) + } getAPI('listDiskOfferings', { zoneid: this.resource.zoneid, listall: true }).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 = this.offerings.find(offering => offering.id === this.resource.diskofferingid) + this.customDiskOffering = currentOffering?.iscustomized || false + this.customDiskOfferingIops = currentOffering?.iscustomizediops || false + if (this.customDiskOfferingIops) { + this.form.miniops = this.resource.miniops + this.form.maxiops = this.resource.maxiops + } }).finally(() => { this.loading = false })