diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ResizeVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ResizeVolumeCmd.java index f8f744285c04..60f4f308503d 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ResizeVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ResizeVolumeCmd.java @@ -15,14 +15,14 @@ // specific language governing permissions and limitations // under the License. package org.apache.cloudstack.api.command.user.volume; -import org.apache.cloudstack.api.BaseAsyncCmd; - import org.apache.cloudstack.acl.SecurityChecker.AccessType; import org.apache.cloudstack.api.ACL; import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiArgValidator; import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.ServerApiException; @@ -60,7 +60,7 @@ public class ResizeVolumeCmd extends BaseAsyncCmd implements UserCmd { @Parameter(name = ApiConstants.MAX_IOPS, type = CommandType.LONG, required = false, description = "New maximum number of IOPS") private Long maxIops; - @Parameter(name = ApiConstants.SIZE, type = CommandType.LONG, required = false, description = "New volume size in GB") + @Parameter(name = ApiConstants.SIZE, type = CommandType.LONG, required = false, description = "New volume size in GB",validations = {ApiArgValidator.PositiveNumber}) private Long size; @Parameter(name = ApiConstants.SHRINK_OK, type = CommandType.BOOLEAN, required = false, description = "Verify OK to Shrink") 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 40598d5e12b6..b6b009230a64 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 @@ -57,7 +57,6 @@ import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; -import org.apache.commons.lang3.StringUtils; import org.apache.cloudstack.framework.async.AsyncCompletionCallback; import org.apache.cloudstack.storage.command.CommandResult; import org.apache.cloudstack.storage.command.CopyCmdAnswer; @@ -83,6 +82,7 @@ import org.apache.cloudstack.storage.service.model.ProtocolType; import org.apache.cloudstack.storage.to.SnapshotObjectTO; import org.apache.cloudstack.storage.utils.OntapStorageUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; @@ -448,7 +448,9 @@ private CloudStackVolume cloneCloudStackVolumeFromTemplate(StoragePoolVO storage } if (!iscsi && qosPolicy != null) { - attachQosPolicy(storageStrategy, storagePool, details, volumeInfo, qosPolicy); + CloudStackVolume cloudStackVolume = createCloudStackVolumeRequestByProtocol( + storagePool, details, volumeInfo, qosPolicy); + storageStrategy.updateCloudStackVolume(cloudStackVolume); } persistQosPolicyDetails(volumeInfo.getId(), qosPolicy); return cloned; @@ -1024,72 +1026,93 @@ private void removeTemporarySnapshotCopyRecord(long volumeId) { } /** - * Applies IOPS/QoS on resize. Capacity (LUN/file size) is not updated here. - * TODO: apply volume size change once size-resize support is merged. + * Resizes a volume. Size and QoS from the resize window are applied together by + * {@link StorageStrategy#resizeCloudStackVolume}. */ @Override public void resize(DataObject data, AsyncCompletionCallback callback) { - String errMsg = null; - String path = null; + CreateCmdResult result = 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); - } + if (data != null && data.getType() == DataObjectType.VOLUME) { + if (data instanceof VolumeInfo) { + VolumeQosPolicy policyToApply = null; + VolumeInfo volumeInfo = (VolumeInfo) data; + Object rawPayload = volumeInfo.getpayload(); + ResizeVolumePayload payload = (rawPayload instanceof ResizeVolumePayload) + ? (ResizeVolumePayload) rawPayload : null; + if (payload == null || payload.newSize == null) { + throw new CloudRuntimeException("Invalid resize payload for volume " + volumeInfo.getId()); + } + if (volumeInfo.getDataStore() == null) { + throw new CloudRuntimeException("Data store not found for volume " + volumeInfo.getId()); + } - CreateCmdResult result = new CreateCmdResult(path, new Answer(null, errMsg == null, errMsg)); - result.setResult(errMsg); - callback.complete(result); - } + StoragePoolVO storagePool = storagePoolDao.findById(volumeInfo.getDataStore().getId()); + if (storagePool == null) { + throw new CloudRuntimeException("Storage pool not found for volume " + volumeInfo.getId()); + } + Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId()); - 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()); - } + VolumeVO volumeVO = volumeDao.findById(volumeInfo.getId()); + if (volumeVO == null) { + throw new CloudRuntimeException("Volume not found for id " + volumeInfo.getId()); + } + if (payload.newSize < volumeVO.getSize()) { + throw new CloudRuntimeException("Unable to shrink volume."); + } - verifySufficientIopsForStoragePool(storagePool, payload.newMinIops, volume.getId()); + verifySufficientIopsForStoragePool(storagePool, payload.newMinIops, volumeVO.getId()); + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); + VolumeDetailVO qosDetail = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.QOS_POLICY_UUID); + String previousUuid = qosDetail != null ? qosDetail.getValue() : null; + VolumeQosPolicy qosPolicy = createQosPolicyIfNeeded(storageStrategy, details, + payload.newMinIops, payload.newMaxIops, storagePool.getId()); + + if (qosPolicy != null && !Objects.equals(previousUuid, qosPolicy.getUuid())) { + policyToApply = qosPolicy; + } else if (qosPolicy == null && previousUuid != null) { + policyToApply = new VolumeQosPolicy(); + policyToApply.setName(OntapStorageConstants.QOS_POLICY_NONE); + } - 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()); + try { + CloudStackVolume cloudStackVolume = resizeCloudStackVolumeRequest(details, volumeInfo, policyToApply); + storageStrategy.resizeCloudStackVolume(cloudStackVolume, payload.newSize); + } catch (RuntimeException e) { + if (qosPolicy != null && !Objects.equals(previousUuid, qosPolicy.getUuid())) { + deleteUnusedQosPolicy(storageStrategy, qosPolicy.getUuid()); + } + throw e; + } + if (policyToApply != null) { + persistQosPolicyDetails(volumeVO.getId(), qosPolicy); + deleteUnusedQosPolicy(storageStrategy, previousUuid); + } - if (qosPolicy != null && Objects.equals(previousUuid, qosPolicy.getUuid())) { - return; - } - if (qosPolicy != null) { - try { - attachQosPolicy(storageStrategy, storagePool, details, volumeInfo, qosPolicy); - persistQosPolicyDetails(volume.getId(), qosPolicy); - } catch (RuntimeException e) { - deleteUnusedQosPolicy(storageStrategy, qosPolicy.getUuid()); - throw e; + volumeVO.setSize(payload.newSize); + if (!volumeDao.update(volumeVO.getId(), volumeVO)) { + throw new CloudRuntimeException("Failed to update volume " + volumeVO.getId() + + " after resizing the ONTAP backing object"); + } + result = new CreateCmdResult(volumeVO.getPath(), new Answer(null, true, null)); + logger.info("resize: Successfully resized volume [{}] to [{}] bytes", volumeInfo.getId(), payload.newSize); + }else{ + String errorMessage = "Invalid DataObjectType (" + data.getType() + ") passed to resize"; + result = new CreateCmdResult(null, new Answer(null, false, errorMessage)); + result.setResult(errorMessage); + return; + } + } else { + throw new CloudRuntimeException("Expected a VOLUME DataObject but received " + + (data != null ? data.getType() : "null")); } - deleteUnusedQosPolicy(storageStrategy, previousUuid); - return; - } - if (previousUuid != null) { - detachQosPolicy(storageStrategy, storagePool, details, volumeInfo); - persistQosPolicyDetails(volume.getId(), null); - deleteUnusedQosPolicy(storageStrategy, previousUuid); + } catch (Exception e) { + String errMsg = e.getMessage(); + logger.error("resize: Failed for volume [{}]: {}", data != null ? data.getId() : null, errMsg, e); + result = new CreateCmdResult(null, new Answer(null, false, errMsg)); + result.setResult(errMsg); + } finally { + callback.complete(result); } } @@ -1104,31 +1127,6 @@ private void deleteUnusedQosPolicy(StorageStrategy storageStrategy, String polic } } - 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) { return null; @@ -1566,9 +1564,59 @@ private boolean isTemplateCachedOnPool(VMTemplateStoragePoolVO templatePoolRef, return StringUtils.isNotBlank(templatePoolRef.getInstallPath()); } + /** + * Returns the bytes used on the FlexVolume backing this pool, read directly from ONTAP + * ({@code space.used}). + * + *

Fails closed when ONTAP cannot provide trustworthy usage data. Returning zero for an + * unreachable or incomplete backend would make capacity checks treat an unknown pool as empty + * and could incorrectly authorize a volume grow.

+ * + * @throws InvalidParameterValueException if {@code storagePool} is null + * @throws CloudRuntimeException if the pool has no FlexVolume UUID in its details, ONTAP + * cannot be queried, or used-space data is missing + */ @Override public long getUsedBytes(StoragePool storagePool) { - return 0; + if (storagePool == null) { + throw new InvalidParameterValueException("storagePool is null, ensure the pool exists and is fully initialised before querying used bytes"); + } + + Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId()); + String flexVolUuid = poolDetails != null ? poolDetails.get(OntapStorageConstants.VOLUME_UUID) : null; + + if (StringUtils.isBlank(flexVolUuid)) { + throw new CloudRuntimeException("FlexVolume UUID not found in pool details for pool " + storagePool.getId()); + } + + try { + StorageStrategy strategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails); + Map queryParams = new HashMap<>(); + queryParams.put(OntapStorageConstants.FIELDS, OntapStorageConstants.SPACE_USED); + var flexVol = strategy.getStorageVolume(flexVolUuid, queryParams); + + if (flexVol == null) { + throw new CloudRuntimeException(String.format( + "FlexVolume [%s] backing pool [%s] was not found on ONTAP", + flexVolUuid, storagePool.getId())); + } + if (flexVol.getSpace() == null) { + throw new CloudRuntimeException(String.format( + "ONTAP returned no space information for FlexVolume [%s] backing pool [%s]", + flexVolUuid, storagePool.getId())); + } + + logger.debug("getUsedBytes: FlexVolume [{}] backing pool [{}] reports {} bytes used", + flexVolUuid, storagePool.getId(), flexVol.getSpace().getUsed()); + return flexVol.getSpace().getUsed(); + } catch (CloudRuntimeException e) { + logger.error("getUsedBytes: Failed to get used bytes for pool [{}]", storagePool.getId(), e); + throw e; + } catch (Exception e) { + logger.error("getUsedBytes: Failed to get used bytes for pool [{}]", storagePool.getId(), e); + throw new CloudRuntimeException( + String.format("Could not read used space for pool [%s]: %s", storagePool.getId(), e.getMessage()), e); + } } /** @@ -1949,6 +1997,45 @@ private boolean isIscsi(Map details) { return ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL)); } + /** + * Resize request. Size is passed separately to {@code resizeCloudStackVolume}. + * When QoS changes, iSCSI carries the LUN uuid and policy, and NFS carries the FlexVol uuid and file policy. + */ + private CloudStackVolume resizeCloudStackVolumeRequest(Map details, VolumeInfo volumeInfo, + VolumeQosPolicy qosPolicy) { + CloudStackVolume resizeRequest = new CloudStackVolume(); + resizeRequest.setVolumeInfo(volumeInfo); + if (qosPolicy == null) { + return resizeRequest; + } + VolumeQosPolicy qosPolicyReference = new VolumeQosPolicy(); + qosPolicyReference.setName(qosPolicy.getName()); + qosPolicyReference.setUuid(qosPolicy.getUuid()); + + String protocol = details.get(OntapStorageConstants.PROTOCOL); + ProtocolType protocolType = ProtocolType.valueOf(protocol); + switch (protocolType) { + case NFS3: + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicyReference); + resizeRequest.setFile(fileInfo); + resizeRequest.setFlexVolumeUuid(details.get(OntapStorageConstants.VOLUME_UUID)); + return resizeRequest; + case ISCSI: + 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()); + } + Lun lun = new Lun(); + lun.setUuid(lunUuid.getValue()); + lun.setQosPolicy(qosPolicyReference); + resizeRequest.setLun(lun); + return resizeRequest; + default: + throw new CloudRuntimeException("Unsupported protocol " + protocol); + } + } + /** * 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. @@ -2057,6 +2144,7 @@ private CloudStackVolume createCloneLunRequest(StoragePoolVO storagePool, Map queryParams); @RequestLine("GET /api/storage/volumes") @Headers({"Authorization: {authHeader}"}) 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 4b0e8e29aabf..d27dfb690b3b 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 @@ -19,23 +19,25 @@ package org.apache.cloudstack.storage.service; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import com.cloud.utils.StringUtils; +import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; 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.EmsFeignClient; 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; import org.apache.cloudstack.storage.feign.client.SvmFeignClient; import org.apache.cloudstack.storage.feign.client.VolumeFeignClient; import org.apache.cloudstack.storage.feign.model.Aggregate; @@ -53,12 +55,12 @@ 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; import org.apache.cloudstack.storage.service.model.AccessGroup; import org.apache.cloudstack.storage.service.model.CloudStackVolume; import org.apache.cloudstack.storage.service.model.ProtocolType; import org.apache.cloudstack.storage.utils.OntapStorageConstants; import org.apache.cloudstack.storage.utils.OntapStorageUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -67,7 +69,6 @@ import feign.FeignException; -import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo; /** * Storage Strategy represents the communication path for all the ONTAP storage options * @@ -605,15 +606,38 @@ public void deleteStorageVolume(Volume volume) { } /** - * Gets ONTAP Flex-Volume - * Eligible only for Unified ONTAP storage - * throw exception in case of disaggregated ONTAP storage + * Gets an ONTAP FlexVolume by UUID. + * Eligible only for Unified ONTAP storage. + * Throws exception in case of disaggregated ONTAP storage. * - * @param volume the volume to retrieve - * @return the retrieved Volume object + *

Callers pass ONTAP query parameters when they need a partial record. For example, + * {@code fields=space.used} returns only used space instead of the full volume. + * A null or empty map returns the default volume representation.

+ * + * @param uuid the UUID of the volume to retrieve + * @param queryParams ONTAP query parameters applied to {@code GET /storage/volumes/{uuid}}; + * may be null + * @return the retrieved Volume object, or null if not found */ - public Volume getStorageVolume(Volume volume) { - return null; + public Volume getStorageVolume(String uuid, Map queryParams) { + if (StringUtils.isBlank(uuid)) { + throw new CloudRuntimeException("Cannot fetch ONTAP volume: UUID is null or empty"); + } + Map params = queryParams != null ? queryParams : Collections.emptyMap(); + logger.info("getStorageVolume: Fetching ONTAP volume [{}] with query params {}", uuid, params); + String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); + try { + Volume fetchedVolume = volumeFeignClient.getVolumeByUUID(authHeader, uuid, params); + logger.info("getStorageVolume: Volume [{}] fetched successfully", uuid); + return fetchedVolume; + } catch (FeignException e) { + if (OntapStorageUtils.isOntapObjectNotFoundError(e)) { + logger.warn("getStorageVolume: Volume [{}] not found in ONTAP", uuid); + return null; + } + logger.error("getStorageVolume: Exception while fetching volume [{}]: ", uuid, e); + throw new CloudRuntimeException("Failed to fetch volume: " + e.getMessage()); + } } /** @@ -867,6 +891,11 @@ abstract public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, * *

Needed after cloning a cached template, because a clone inherits the size of its source * while the service offering may ask for a larger disk.

+ * + *

When the request already carries a QoS policy on the LUN or file, that policy is applied + * as part of this resize. iSCSI sends one LUN update with the new size and the policy. + * NFS grows the file on the host, then updates the file policy. A request with no policy + * changes size only.

*/ abstract public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes); 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 49f04877a3ab..1c8fae171b67 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 @@ -60,6 +60,7 @@ import com.cloud.agent.api.storage.ResizeVolumeCommand; import com.cloud.agent.api.to.StorageFilerTO; import com.cloud.host.HostVO; +import com.cloud.storage.ResizeVolumePayload; import com.cloud.storage.Storage; import com.cloud.storage.VolumeVO; import com.cloud.storage.dao.VolumeDao; @@ -247,6 +248,9 @@ public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeI logger.error("resizeCloudStackVolume: " + errMsg); throw new CloudRuntimeException(errMsg); } + if (cloudstackVolume.getFile() != null && cloudstackVolume.getFile().getQosPolicy() != null) { + updateCloudStackVolume(cloudstackVolume); + } logger.info("resizeCloudStackVolume: Resized volume [{}] to {} bytes", volumeInfo.getUuid(), sizeInBytes); } @@ -262,8 +266,15 @@ private Answer resizeVolumeOnKVMHost(DataObject volumeInfo, long sizeInBytes) { throw new CloudRuntimeException("Storage Pool not found for id: " + volume.getPoolId()); } - ResizeVolumeCommand cmd = new ResizeVolumeCommand(volume.getPath(), new StorageFilerTO(storagePool), - volume.getSize(), sizeInBytes, false, null); + // instanceName is set by VolumeApiServiceImpl.orchestrateResizeVolume() before calling the + // driver — it is the VM instance name when attached, or "none" when the volume is detached. + ResizeVolumePayload resizePayload = volumeObject.getpayload() instanceof ResizeVolumePayload + ? (ResizeVolumePayload) volumeObject.getpayload() + : null; + String instanceName = resizePayload != null ? resizePayload.instanceName : "none"; + ResizeVolumeCommand cmd = new ResizeVolumeCommand(volume.getPath(), new StorageFilerTO(storagePool), + volume.getSize(), sizeInBytes, false, instanceName); + EndPoint ep = epSelector.select(volumeInfo); if (ep == null) { String errMsg = "No remote endpoint to send ResizeVolumeCommand, check if host is up"; 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 a632ecf78fb8..5d1a1b43586a 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 @@ -19,20 +19,24 @@ package org.apache.cloudstack.storage.service; -import com.cloud.host.HostVO; -import com.cloud.utils.exception.CloudRuntimeException; -import feign.FeignException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest; import org.apache.cloudstack.storage.feign.model.Igroup; import org.apache.cloudstack.storage.feign.model.Initiator; -import org.apache.cloudstack.storage.feign.model.Svm; -import org.apache.cloudstack.storage.feign.model.OntapStorage; import org.apache.cloudstack.storage.feign.model.Lun; import org.apache.cloudstack.storage.feign.model.LunMap; import org.apache.cloudstack.storage.feign.model.LunSpace; -import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest; +import org.apache.cloudstack.storage.feign.model.OntapStorage; +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.model.AccessGroup; @@ -41,18 +45,24 @@ import org.apache.cloudstack.storage.utils.OntapStorageConstants; import org.apache.cloudstack.storage.utils.OntapStorageUtils; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import javax.inject.Inject; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; + +import com.cloud.host.HostVO; +import com.cloud.storage.VolumeDetailVO; +import com.cloud.storage.dao.VolumeDetailsDao; +import com.cloud.utils.exception.CloudRuntimeException; + +import feign.FeignException; public class UnifiedSANStrategy extends SANStrategy { private static final Logger logger = LogManager.getLogger(UnifiedSANStrategy.class); @Inject private StoragePoolDetailsDao storagePoolDetailsDao; + @Inject + private VolumeDetailsDao volumeDetailsDao; public UnifiedSANStrategy(OntapStorage ontapStorage) { super(ontapStorage); @@ -186,24 +196,7 @@ private void bestEffortDeleteTemplateCacheLun(String svmName, String lunName, St @Override 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; + return null; } @Override @@ -267,6 +260,7 @@ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume) CloudStackVolume clonedCloudStackVolume = new CloudStackVolume(); clonedCloudStackVolume.setLun(lun); + clonedCloudStackVolume.setVolumeInfo(cloudstackVolume.getVolumeInfo()); return clonedCloudStackVolume; } catch (FeignException e) { logger.error("FeignException occurred while cloning LUN: {}, Status: {}, Exception: {}", @@ -299,29 +293,57 @@ private void validateCreatedLun(Lun lun, String requestName, String operation) { */ @Override public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes) { - if (cloudstackVolume == null || cloudstackVolume.getLun() == null || cloudstackVolume.getLun().getUuid() == null) { + if (cloudstackVolume == null || cloudstackVolume.getVolumeInfo() == null) { logger.error("resizeCloudStackVolume: Lun resize failed. Invalid request: {}", cloudstackVolume); throw new CloudRuntimeException("Failed to resize Lun, invalid request"); } if (sizeInBytes <= 0) { throw new CloudRuntimeException("Failed to resize Lun, invalid size " + sizeInBytes); } + + // Resolve LUN UUID from volume details when not pre-populated on the cloudstackVolume + // Keep a QoS policy already set on the request so size and QoS go out in one LUN update. + VolumeQosPolicy qosPolicy = cloudstackVolume.getLun() != null ? cloudstackVolume.getLun().getQosPolicy() : null; + if (cloudstackVolume.getLun() == null || cloudstackVolume.getLun().getUuid() == null) { + logger.debug("LUN details not present on cloudstackVolume, resolving UUID from volume details"); + long volumeId = cloudstackVolume.getVolumeInfo().getId(); + VolumeDetailVO lunUuidDetail = volumeDetailsDao.findDetail(volumeId, OntapStorageConstants.LUN_DOT_UUID); + if (lunUuidDetail == null || StringUtils.isBlank(lunUuidDetail.getValue())) { + throw new CloudRuntimeException("LUN UUID not found in volume details for volume " + volumeId); + } + Lun resolvedLun = new Lun(); + resolvedLun.setUuid(lunUuidDetail.getValue()); + cloudstackVolume.setLun(resolvedLun); + } else { + logger.debug("resizeCloudStackVolume: LUN UUID [{}] already present on cloudstackVolume, skipping volume details lookup", cloudstackVolume.getLun().getUuid()); + } + String lunUuid = cloudstackVolume.getLun().getUuid(); logger.trace("resizeCloudStackVolume: Resizing Lun {} to {} bytes", lunUuid, sizeInBytes); try { String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); LunSpace lunSpace = new LunSpace(); lunSpace.setSize(sizeInBytes); - Lun patch = new Lun(); - patch.setSpace(lunSpace); - JobResponse response = sanFeignClient.updateLun(authHeader, lunUuid, patch); + Lun lunPatch = new Lun(); + lunPatch.setSpace(lunSpace); + lunPatch.setQosPolicy(qosPolicy); + JobResponse response = sanFeignClient.updateLun(authHeader, lunUuid, lunPatch); pollJobIfPresent(response, "resize Lun [" + lunUuid + "]"); logger.debug("resizeCloudStackVolume: Lun {} resized to {} bytes", lunUuid, sizeInBytes); } catch (FeignException e) { - throw new CloudRuntimeException("Failed to resize Lun: " + e.getMessage(), e); + logger.error("FeignException occurred while resizing LUN [{}], Status: {}, Exception: {}", + lunUuid, e.status(), e.getMessage()); + if (OntapStorageUtils.isOntapObjectNotFoundError(e)) { + throw new CloudRuntimeException(String.format( + "LUN [%s] no longer exists on ONTAP; it may have been deleted externally. " + + "Verify the LUN is present before retrying the resize.", lunUuid)); + } + throw new CloudRuntimeException(String.format( + "Failed to resize LUN [%s]: %s",lunUuid, e.getMessage())); } catch (Exception e) { - logger.error("Exception occurred while resizing LUN: {}, Exception: {}", lunUuid, e.getMessage()); - throw new CloudRuntimeException("Failed to resize Lun: " + e.getMessage()); + logger.error("Exception occurred while resizing LUN [{}]: {}", lunUuid, e.getMessage()); + throw new CloudRuntimeException(String.format( + "Unexpected error while resizing LUN [%s]: %s", lunUuid, 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 9cf9b7356b15..aa3422c0c957 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 @@ -63,6 +63,8 @@ public class OntapStorageConstants { // Query params public static final String NAME = "name"; public static final String FIELDS = "fields"; + /** ONTAP volume field: bytes consumed, {@code space.used}. */ + public static final String SPACE_USED = "space.used"; public static final String CLUSTER_NODE_ASUP_FIELDS = "model,is_all_flash_optimized,is_capacity_optimized,is_perf_optimized"; public static final String INITIATORS = "initiators"; public static final String AGGREGATES = "aggregates"; 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 ce7a9b89171a..ff77fe04fa9d 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 @@ -40,6 +40,7 @@ import com.cloud.utils.exception.CloudRuntimeException; import org.apache.cloudstack.engine.subsystem.api.storage.CopyCommandResult; import org.apache.cloudstack.engine.subsystem.api.storage.CreateCmdResult; +import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine; import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStore; @@ -54,6 +55,8 @@ 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.VolumeSpace; +import org.apache.cloudstack.storage.feign.model.Svm; import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.service.StorageStrategy; import org.apache.cloudstack.storage.service.UnifiedNASStrategy; @@ -83,6 +86,7 @@ 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; @@ -99,6 +103,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -955,6 +960,7 @@ void testCreateAsync_VolumeClonedFromTemplate_ClonesWithoutGrowing() { verify(sanStrategy).cloneCloudStackVolume(requestCaptor.capture()); assertEquals("/vol/vol1/cs_tmpl_50", requestCaptor.getValue().getLun().getClone().getSource().getName()); assertEquals("template-lun-uuid", requestCaptor.getValue().getLun().getClone().getSource().getUuid()); + assertSame(volumeInfo, requestCaptor.getValue().getVolumeInfo()); verify(sanStrategy, never()).createCloudStackVolume(any()); verify(sanStrategy, never()).resizeCloudStackVolume(any(), anyLong()); verify(sanStrategy, never()).updateCloudStackVolume(any()); @@ -1010,6 +1016,7 @@ void testCreateAsync_VolumeClonedFromSnapshot_IscsiSuccessWithoutGrow() { assertNull(lunRequest.getClone().getSource().getUuid()); assertEquals("/vol/vol1/test_volume", lunRequest.getName()); assertEquals("svm1", lunRequest.getSvm().getName()); + assertSame(volumeInfo, requestCaptor.getValue().getVolumeInfo()); verify(sanStrategy, never()).createCloudStackVolume(any()); verify(sanStrategy, never()).resizeCloudStackVolume(any(), anyLong()); verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.LUN_DOT_UUID), eq("snap-cloned-lun-uuid"), eq(false)); @@ -1537,7 +1544,7 @@ void testCreateAsync_VolumeClonedFromTemplateNFS_RootWithIops_AttachesQosToFile( when(templatePoolRef.getInstallPath()).thenReturn("template-uuid"); CloudStackVolume cloned = new CloudStackVolume(); - VolumeQosPolicy qosPolicy = qosPolicy("qos-nfs-root-uuid", "cs_100_to_200_iops_svm1"); + VolumeQosPolicy qosPolicy = lookedUpQosPolicy("qos-nfs-root-uuid", "cs_100_to_200_iops_svm1"); try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { stubQosCreateMocks(utilityMock, nasStrategy, cloned, qosPolicy); @@ -1550,9 +1557,14 @@ void testCreateAsync_VolumeClonedFromTemplateNFS_RootWithIops_AttachesQosToFile( 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, never()).resizeCloudStackVolume(any(), anyLong()); verify(nasStrategy).updateCloudStackVolume(argThat(request -> - request.getFile() != null && request.getFile().getQosPolicy() != null - && "qos-nfs-root-uuid".equals(request.getFile().getQosPolicy().getUuid()))); + "1".equals(request.getDatastoreId()) + && "flex-uuid".equals(request.getFlexVolumeUuid()) + && request.getVolumeInfo() == volumeInfo + && request.getFile() != null + && isQosPolicyReference(request.getFile().getQosPolicy(), + "qos-nfs-root-uuid", "cs_100_to_200_iops_svm1"))); verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), eq("qos-nfs-root-uuid"), eq(false)); } @@ -1678,34 +1690,30 @@ void testGetUsedIops_SumsMinIopsIncludingCreatingVolumes() { @Test void testResize_IopsUpdate_AttachesQosPolicy() { long currentSize = 4L * 1024 * 1024 * 1024; - when(volumeInfo.getId()).thenReturn(100L); + stubResizeVolumeRecord(currentSize); 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); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false)); - VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_0_to_5000_iops_svm1"); - CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + VolumeQosPolicy qosPolicy = lookedUpQosPolicy("qos-uuid", "cs_0_to_5000_iops_svm1"); try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { - stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); - when(sanStrategy.updateCloudStackVolume(any())).thenReturn(cloudStackVolume); + stubQosCreateMocks(utilityMock, sanStrategy, iscsiCloudStackVolume(), qosPolicy); 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(sanStrategy).resizeCloudStackVolume(argThat(request -> + request.getLun() != null + && "lun-uuid-123".equals(request.getLun().getUuid()) + && isQosPolicyReference(request.getLun().getQosPolicy(), "qos-uuid", "cs_0_to_5000_iops_svm1")), + eq(currentSize)); + verify(sanStrategy, never()).updateCloudStackVolume(any()); verify(volumeDetailsDao).addDetail(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); } } @@ -1715,31 +1723,28 @@ 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); + stubResizeVolumeRecord(currentSize); 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(); + VolumeQosPolicy qosPolicy = lookedUpQosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { - stubQosCreateMocks(utilityMock, nasStrategy, cloudStackVolume, qosPolicy); - when(nasStrategy.updateCloudStackVolume(any())).thenReturn(cloudStackVolume); + stubQosCreateMocks(utilityMock, nasStrategy, nfsCloudStackVolume(), qosPolicy); driver.resize(volumeInfo, createCallback); ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); verify(createCallback).complete(resultCaptor.capture()); assertTrue(resultCaptor.getValue().isSuccess()); - verify(nasStrategy).updateCloudStackVolume(any()); + verify(nasStrategy).resizeCloudStackVolume(argThat(request -> + "flex-uuid".equals(request.getFlexVolumeUuid()) + && request.getFile() != null + && isQosPolicyReference(request.getFile().getQosPolicy(), "qos-uuid", "cs_100_to_200_iops_svm1")), + eq(currentSize)); + verify(nasStrategy, never()).updateCloudStackVolume(any()); verify(volumeDetailsDao).addDetail(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); } } @@ -1747,30 +1752,24 @@ void testResize_IopsUpdate_NfsAttachesQosPolicy() { @Test void testResize_SameQosPolicy_SkipsAttach() { long currentSize = 4L * 1024 * 1024 * 1024; - when(volumeInfo.getId()).thenReturn(100L); + stubResizeVolumeRecord(currentSize); 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); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false)); 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); + stubQosCreateMocks(utilityMock, sanStrategy, iscsiCloudStackVolume(), qosPolicy); driver.resize(volumeInfo, createCallback); ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); verify(createCallback).complete(resultCaptor.capture()); assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).resizeCloudStackVolume(argThat(request -> request.getLun() == null), eq(currentSize)); verify(sanStrategy, never()).updateCloudStackVolume(any()); } } @@ -1778,35 +1777,29 @@ void testResize_SameQosPolicy_SkipsAttach() { @Test void testResize_ClearIops_DetachesQosPolicy() { long currentSize = 4L * 1024 * 1024 * 1024; - when(volumeInfo.getId()).thenReturn(100L); + stubResizeVolumeRecord(currentSize); 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); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false)); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false)); - 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).resizeCloudStackVolume(argThat(request -> + request.getLun() != null + && isQosPolicyReference(request.getLun().getQosPolicy(), null, OntapStorageConstants.QOS_POLICY_NONE)), + eq(currentSize)); + verify(sanStrategy, never()).updateCloudStackVolume(any()); verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); } } @@ -1814,27 +1807,19 @@ void testResize_ClearIops_DetachesQosPolicy() { @Test void testResize_SwitchIops_OldPolicyDeleteFails_StillSucceeds() { long currentSize = 4L * 1024 * 1024 * 1024; - when(volumeInfo.getId()).thenReturn(100L); + stubResizeVolumeRecord(currentSize); 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); - VolumeDetailVO qosDetail = new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "old-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); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "old-qos-uuid", false)); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false)); - VolumeQosPolicy qosPolicy = qosPolicy("new-qos-uuid", "cs_0_to_5000_iops_svm1"); - CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + VolumeQosPolicy qosPolicy = lookedUpQosPolicy("new-qos-uuid", "cs_0_to_5000_iops_svm1"); try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { - stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); - when(sanStrategy.updateCloudStackVolume(any())).thenReturn(cloudStackVolume); + stubQosCreateMocks(utilityMock, sanStrategy, iscsiCloudStackVolume(), qosPolicy); doThrow(new CloudRuntimeException("ONTAP QoS delete failed")).when(sanStrategy) .deleteVolumeQosPolicy("old-qos-uuid"); @@ -1843,6 +1828,10 @@ void testResize_SwitchIops_OldPolicyDeleteFails_StillSucceeds() { ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); verify(createCallback).complete(resultCaptor.capture()); assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).resizeCloudStackVolume(argThat(request -> + request.getLun() != null + && isQosPolicyReference(request.getLun().getQosPolicy(), "new-qos-uuid", "cs_0_to_5000_iops_svm1")), + eq(currentSize)); verify(volumeDetailsDao).addDetail(100L, OntapStorageConstants.QOS_POLICY_UUID, "new-qos-uuid", false); verify(sanStrategy).deleteVolumeQosPolicy("old-qos-uuid"); verify(sanStrategy, never()).deleteVolumeQosPolicy("new-qos-uuid"); @@ -1852,26 +1841,18 @@ void testResize_SwitchIops_OldPolicyDeleteFails_StillSucceeds() { @Test void testResize_ClearIops_OldPolicyDeleteFails_StillSucceeds() { long currentSize = 4L * 1024 * 1024 * 1024; - when(volumeInfo.getId()).thenReturn(100L); + stubResizeVolumeRecord(currentSize); 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); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false)); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false)); - 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); doThrow(new CloudRuntimeException("ONTAP QoS delete failed")).when(sanStrategy) .deleteVolumeQosPolicy("qos-uuid"); @@ -1891,12 +1872,15 @@ void testResize_MinIopsBeyondPoolCapacity_Fails() { when(otherVolume.getId()).thenReturn(50L); when(otherVolume.getMinIops()).thenReturn(800L); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getDataStore()).thenReturn(dataStore); + when(dataStore.getId()).thenReturn(1L); 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(volumeVO.getSize()).thenReturn(currentSize); when(storagePoolDao.findById(1L)).thenReturn(storagePool); when(storagePool.getId()).thenReturn(1L); when(storagePool.getName()).thenReturn("ontap-pool"); @@ -1910,6 +1894,7 @@ void testResize_MinIopsBeyondPoolCapacity_Fails() { assertFalse(resultCaptor.getValue().isSuccess()); assertTrue(resultCaptor.getValue().getResult().contains( "requested total of 1100 IOPS exceeds the pool IOPS capacity of 1000")); + verify(sanStrategy, never()).resizeCloudStackVolume(any(), anyLong()); verify(sanStrategy, never()).updateCloudStackVolume(any()); } @@ -2436,6 +2421,31 @@ private VolumeQosPolicy qosPolicy(String uuid, String name) { return policy; } + /** + * Policy as returned by an ONTAP lookup: name and uuid plus fields that must not be sent on a resize. + */ + private VolumeQosPolicy lookedUpQosPolicy(String uuid, String name) { + VolumeQosPolicy policy = qosPolicy(uuid, name); + Svm svm = new Svm(); + svm.setName("svm1"); + policy.setSvm(svm); + VolumeQosPolicy.Fixed fixed = new VolumeQosPolicy.Fixed(); + fixed.setMinThroughputIops(100L); + fixed.setMaxThroughputIops(5000L); + policy.setFixed(fixed); + policy.setObjectCount(2); + return policy; + } + + private boolean isQosPolicyReference(VolumeQosPolicy sent, String uuid, String name) { + return sent != null + && name.equals(sent.getName()) + && (uuid == null ? sent.getUuid() == null : uuid.equals(sent.getUuid())) + && sent.getSvm() == null + && sent.getFixed() == null + && sent.getObjectCount() == null; + } + private void stubQosCreateMocks(MockedStatic utilityMock, StorageStrategy strategy, CloudStackVolume cloudStackVolume, VolumeQosPolicy qosPolicy) { @@ -2637,6 +2647,302 @@ void testCreateAsync_VolumeClonedFromTemplate_MissingSpoolRef_Fails() { } } + // ========================================================================= + // resize() tests + // ========================================================================= + + private void stubResizeVolumeRecord(long currentSize) { + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getDataStore()).thenReturn(dataStore); + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + when(volumeVO.getSize()).thenReturn(currentSize); + lenient().when(volumeVO.getPath()).thenReturn("/vol/vol1/lun1"); + when(volumeDao.update(100L, volumeVO)).thenReturn(true); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + } + + private DataObject stubResizeCommon(long currentSize, long newSize) { + ResizeVolumePayload payload = new ResizeVolumePayload(newSize, null, null, null, false, "i-2-VM", null, false); + DataObject data = volumeInfo; + when(data.getType()).thenReturn(VOLUME); + when(volumeInfo.getDataStore()).thenReturn(dataStore); + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn(payload); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + // storagePool.getName() is only needed for the shrink-error message path + lenient().when(storagePool.getName()).thenReturn("test-pool"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getSize()).thenReturn(currentSize); + // volumeVO.getId() is only needed in the success path (volumeDao.update call) + lenient().when(volumeVO.getId()).thenReturn(100L); + lenient().when(volumeVO.getPath()).thenReturn("/vol/vol1/lun1"); + lenient().when(volumeDao.update(100L, volumeVO)).thenReturn(true); + return data; + } + + @Test + void testResize_iSCSI_Success() { + long currentSize = 10737418240L; // 10 GB + long newSize = 21474836480L; // 20 GB + DataObject data = stubResizeCommon(currentSize, newSize); + assertEquals(VOLUME, data.getType()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + doNothing().when(sanStrategy).resizeCloudStackVolume(any(), eq(newSize)); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + // Driver only wires VolumeInfo; LUN UUID lookup is inside UnifiedSANStrategy + ArgumentCaptor volumeCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).resizeCloudStackVolume(volumeCaptor.capture(), eq(newSize)); + assertEquals(volumeInfo, volumeCaptor.getValue().getVolumeInfo()); + assertNull(volumeCaptor.getValue().getLun()); + verify(volumeDetailsDao, never()).findDetail(anyLong(), eq(OntapStorageConstants.LUN_DOT_UUID)); + + verify(volumeVO).setSize(newSize); + verify(volumeDao).update(eq(100L), any(VolumeVO.class)); + } + } + + @Test + void testResize_NFS_Success() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + long currentSize = 10737418240L; + long newSize = 21474836480L; + DataObject data = stubResizeCommon(currentSize, newSize); + assertEquals(VOLUME, data.getType()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(nasStrategy); + doNothing().when(nasStrategy).resizeCloudStackVolume(any(), eq(newSize)); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + ArgumentCaptor volumeCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(nasStrategy).resizeCloudStackVolume(volumeCaptor.capture(), eq(newSize)); + assertEquals(volumeInfo, volumeCaptor.getValue().getVolumeInfo()); + // NFS has no LUN – driver must not look up a LUN UUID + verify(volumeDetailsDao, never()).findDetail(anyLong(), eq(OntapStorageConstants.LUN_DOT_UUID)); + verify(volumeVO).setSize(newSize); + verify(volumeDao).update(eq(100L), any(VolumeVO.class)); + } + } + + @Test + void testResize_NullPayload_Fails() { + DataObject data = volumeInfo; + when(data.getType()).thenReturn(VOLUME); + assertEquals(VOLUME, data.getType()); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn(null); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Invalid resize payload")); + } + + @Test + void testResize_NullData_FailsAndCompletesCallbackOnce() { + driver.resize(null, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback, times(1)).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Expected a VOLUME DataObject")); + } + + @Test + void testResize_NonVolumeDataType_FailsAndCompletesCallbackOnce() { + DataObject data = templateInfo; + when(data.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback, times(1)).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Expected a VOLUME DataObject but received TEMPLATE")); + } + + @Test + void testResize_SnapshotDataType_FailsAndCompletesCallbackOnce() { + DataObject data = mock(DataObject.class); + when(data.getType()).thenReturn(SNAPSHOT); + when(data.getId()).thenReturn(75L); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback, times(1)).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Expected a VOLUME DataObject but received SNAPSHOT")); + } + + @Test + void testResize_VolumeTypeWithoutVolumeDetails_FailsWithoutInternalTypeName() { + DataObject data = mock(DataObject.class); + when(data.getType()).thenReturn(VOLUME); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback, times(1)).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + String message = resultCaptor.getValue().getResult(); + assertTrue(message.contains("Invalid DataObjectType (VOLUME) passed to resize")); + assertFalse(message.contains("VolumeInfo")); + } + + @Test + void testResize_NullNewSize_Fails() { + // payload.newSize is null + ResizeVolumePayload payload = new ResizeVolumePayload(null, null, null, null, false, "i-2-VM", null, false); + DataObject data = volumeInfo; + when(data.getType()).thenReturn(VOLUME); + assertEquals(VOLUME, data.getType()); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn(payload); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Invalid resize payload")); + } + + @Test + void testResize_StoragePoolNotFound_Fails() { + ResizeVolumePayload payload = new ResizeVolumePayload(21474836480L, null, null, null, false, "i-2-VM", null, false); + DataObject data = volumeInfo; + when(data.getType()).thenReturn(VOLUME); + assertEquals(VOLUME, data.getType()); + when(volumeInfo.getDataStore()).thenReturn(dataStore); + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn(payload); + when(storagePoolDao.findById(1L)).thenReturn(null); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Storage pool not found")); + } + + @Test + void testResize_MissingDataStore_Fails() { + ResizeVolumePayload payload = new ResizeVolumePayload(21474836480L, null, null, null, false, "none", null, false); + DataObject data = volumeInfo; + when(data.getType()).thenReturn(VOLUME); + assertEquals(VOLUME, data.getType()); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn(payload); + when(volumeInfo.getDataStore()).thenReturn(null); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Data store not found")); + } + + @Test + void testResize_VolumeVONotFound_Fails() { + long newSize = 21474836480L; + ResizeVolumePayload payload = new ResizeVolumePayload(newSize, null, null, null, false, "i-2-VM", null, false); + DataObject data = volumeInfo; + when(data.getType()).thenReturn(VOLUME); + assertEquals(VOLUME, data.getType()); + when(volumeInfo.getDataStore()).thenReturn(dataStore); + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getpayload()).thenReturn(payload); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(null); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Volume not found")); + } + } + + @Test + void testResize_MissingStoragePoolDetails_Fails() { + long currentSize = 10737418240L; + long newSize = 21474836480L; + DataObject data = stubResizeCommon(currentSize, newSize); + assertEquals(VOLUME, data.getType()); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(null); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(null)) + .thenThrow(new CloudRuntimeException("Storage pool details are missing")); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback, times(1)).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Storage pool details are missing")); + verify(volumeVO, never()).setSize(anyLong()); + } + } + + @Test + void testResize_ShrinkAttempt_Fails() { + long currentSize = 21474836480L; // 20 GB + long newSize = 10737418240L; // 10 GB – smaller than current + DataObject data = stubResizeCommon(currentSize, newSize); + assertEquals(VOLUME, data.getType()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Unable to shrink volume")); + } + } + private static VolumeVO temporarySnapshotCopyVolume() { return new VolumeVO(Volume.Type.DATADISK, "ROOT-5_20260924.TMP", 1L, 1L, 2L, 0L, Storage.ProvisioningType.THIN, 5368709120L, 0L, 0L, ""); @@ -2798,6 +3104,7 @@ void testCopyAsync_IscsiTemporarySnapshotCopy_ClonesLunAndRecordsIdentity() { assertEquals("/vol/vol1/.snapshot/snap_cs200/source_lun", requestCaptor.getValue().getLun().getClone().getSource().getName()); assertEquals("/vol/vol1/cs_tmp_snap_200_100", requestCaptor.getValue().getLun().getName()); + assertSame(volumeInfo, requestCaptor.getValue().getVolumeInfo()); assertEquals("cs_tmp_snap_200_100", dbVolume.getName()); assertEquals("cs_tmp_snap_200_100", inMemoryVolume.getName()); @@ -2810,6 +3117,30 @@ void testCopyAsync_IscsiTemporarySnapshotCopy_ClonesLunAndRecordsIdentity() { } } + @Test + void testResize_StrategyThrows_Fails() { + long currentSize = 10737418240L; + long newSize = 21474836480L; + DataObject data = stubResizeCommon(currentSize, newSize); + assertEquals(VOLUME, data.getType()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + doThrow(new com.cloud.utils.exception.CloudRuntimeException("ONTAP resize failed")) + .when(sanStrategy).resizeCloudStackVolume(any(), eq(newSize)); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("ONTAP resize failed")); + // volumeVO size must NOT be updated on failure + verify(volumeVO, never()).setSize(anyLong()); + } + } + @Test void testCopyAsync_NfsTemporarySnapshotCopy_SetsFilePath() { VolumeVO dbVolume = temporarySnapshotCopyVolume(); @@ -2842,6 +3173,25 @@ void testCopyAsync_NfsTemporarySnapshotCopy_SetsFilePath() { } } + @Test + void testResize_EqualSize_InvokesStrategyAndSucceeds() { + long size = 10737418240L; + DataObject data = stubResizeCommon(size, size); + assertEquals(VOLUME, data.getType()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + + driver.resize(data, createCallback); + + verify(sanStrategy).resizeCloudStackVolume(any(), eq(size)); + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + } + } + @Test void testCopyAsync_CloneFailsWithoutCallback_Throws() { SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(temporarySnapshotCopyVolume()); @@ -2860,6 +3210,107 @@ void testCopyAsync_CloneFailsWithoutCallback_Throws() { } } + @Test + void testResize_DatabaseUpdateFailure_ReturnsFailure() { + long currentSize = 10737418240L; + long newSize = 21474836480L; + DataObject data = stubResizeCommon(currentSize, newSize); + assertEquals(VOLUME, data.getType()); + when(volumeDao.update(100L, volumeVO)).thenReturn(false); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + + driver.resize(data, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback, times(1)).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Failed to update volume")); + } + } + + // ========================================================================= + // getUsedBytes() tests + // ========================================================================= + + @Test + void testGetUsedBytes_NullPool_ThrowsException() { + InvalidParameterValueException ex = assertThrows(InvalidParameterValueException.class, + () -> driver.getUsedBytes(null)); + assertTrue(ex.getMessage().contains("storagePool is null, ensure the pool exists and is fully initialised before querying used bytes")); + } + + @Test + void testGetUsedBytes_NoFlexVolUuid_ThrowsException() { + // VOLUME_UUID key is absent from pool details + storagePoolDetails.remove(OntapStorageConstants.VOLUME_UUID); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> driver.getUsedBytes(storagePool)); + assertTrue(ex.getMessage().contains("FlexVolume UUID not found in pool details for pool 1")); + } + + @Test + void testGetUsedBytes_BlankFlexVolUuid_ThrowsException() { + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, " "); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> driver.getUsedBytes(storagePool)); + assertTrue(ex.getMessage().contains("FlexVolume UUID not found in pool details for pool 1")); + } + + @Test + void testGetUsedBytes_NullPoolDetails_ThrowsException() { + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(null); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> driver.getUsedBytes(storagePool)); + assertTrue(ex.getMessage().contains("FlexVolume UUID not found in pool details for pool 1")); + } + + @Test + void testGetUsedBytes_Success_ReturnsUsedBytes() { + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-123"); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + var flexVol = new org.apache.cloudstack.storage.feign.model.Volume(); + VolumeSpace space = new VolumeSpace(); + space.setUsed(10737418240L); // 10 GB + flexVol.setSpace(space); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + Map queryParams = Map.of(OntapStorageConstants.FIELDS, OntapStorageConstants.SPACE_USED); + when(sanStrategy.getStorageVolume(eq("flexvol-uuid-123"), eq(queryParams))).thenReturn(flexVol); + + assertEquals(10737418240L, driver.getUsedBytes(storagePool)); + verify(sanStrategy).getStorageVolume(eq("flexvol-uuid-123"), eq(queryParams)); + } + } + + @Test + void testGetUsedBytes_FlexVolNotFound_ThrowsException() { + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-123"); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + when(sanStrategy.getStorageVolume(eq("flexvol-uuid-123"), any())).thenReturn(null); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> driver.getUsedBytes(storagePool)); + assertTrue(ex.getMessage().contains("was not found on ONTAP")); + } + } + @Test void testCopyAsync_CloneFailsWithCallback_CompletesWithFailure() { SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(temporarySnapshotCopyVolume()); @@ -2908,6 +3359,64 @@ void testDeleteAsync_TemporarySnapshotCopy_NullCallbackRemovesRecord() { } } + @Test + void testGetUsedBytes_FlexVolNullSpace_ThrowsException() { + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-123"); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + var flexVol = new org.apache.cloudstack.storage.feign.model.Volume(); + // space is intentionally left null + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + when(sanStrategy.getStorageVolume(eq("flexvol-uuid-123"), any())).thenReturn(flexVol); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> driver.getUsedBytes(storagePool)); + assertTrue(ex.getMessage().contains("no space information")); + } + } + + @Test + void testGetUsedBytes_OntapException_PropagatesException() { + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-123"); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + when(sanStrategy.getStorageVolume(eq("flexvol-uuid-123"), any())) + .thenThrow(new com.cloud.utils.exception.CloudRuntimeException("ONTAP unreachable")); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> driver.getUsedBytes(storagePool)); + assertEquals("ONTAP unreachable", ex.getMessage()); + } + } + + @Test + void testGetUsedBytes_UnexpectedException_IsWrapped() { + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-123"); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + when(sanStrategy.getStorageVolume(eq("flexvol-uuid-123"), any())) + .thenThrow(new IllegalStateException("invalid ONTAP response")); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> driver.getUsedBytes(storagePool)); + assertTrue(ex.getMessage().contains("Could not read used space")); + assertTrue(ex.getMessage().contains("invalid ONTAP response")); + assertTrue(ex.getCause() instanceof IllegalStateException); + } + } + @Test void testGrantAccess_TemporarySnapshotCopy_SyncsIscsiPathOnInMemoryVolume() { String iscsiPath = "/iqn.1992-08.com.netapp:sn.123456/0"; 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 db5f0a33b307..0e0d2c0e7388 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 @@ -53,6 +53,7 @@ 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 org.junit.jupiter.api.BeforeEach; @@ -64,6 +65,7 @@ import static org.mockito.ArgumentMatchers.eq; import org.mockito.Mock; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -1436,6 +1438,57 @@ void testDeleteFlexVolSnapshotForCloudStackVolume_Feign404_TreatedAsSuccess() { verify(jobFeignClient, never()).getJobByUUID(anyString(), anyString()); } + @Test + void testGetStorageVolume_ByUuid_Success() { + Volume expected = new Volume(); + expected.setUuid("flexvol-uuid-123"); + Map queryParams = Map.of(OntapStorageConstants.FIELDS, OntapStorageConstants.SPACE_USED); + when(volumeFeignClient.getVolumeByUUID(anyString(), eq("flexvol-uuid-123"), eq(queryParams))).thenReturn(expected); + + Volume result = storageStrategy.getStorageVolume("flexvol-uuid-123", queryParams); + + assertNotNull(result); + assertEquals("flexvol-uuid-123", result.getUuid()); + verify(volumeFeignClient).getVolumeByUUID(anyString(), eq("flexvol-uuid-123"), eq(queryParams)); + } + + @Test + void testGetStorageVolume_ByUuid_NotFound_ReturnsNull() { + FeignException notFound = mock(FeignException.class); + when(notFound.status()).thenReturn(404); + Map queryParams = Map.of(OntapStorageConstants.FIELDS, OntapStorageConstants.SPACE_USED); + doThrow(notFound).when(volumeFeignClient).getVolumeByUUID(anyString(), eq("missing-uuid"), eq(queryParams)); + + Volume result = storageStrategy.getStorageVolume("missing-uuid", queryParams); + + assertNull(result); + } + + @Test + void testGetStorageVolume_ByUuid_ServerError_Throws() { + FeignException serverError = mock(FeignException.class); + when(serverError.status()).thenReturn(500); + when(serverError.getMessage()).thenReturn("Internal Server Error"); + Map queryParams = Map.of(OntapStorageConstants.FIELDS, OntapStorageConstants.SPACE_USED); + doThrow(serverError).when(volumeFeignClient).getVolumeByUUID(anyString(), eq("flexvol-uuid-999"), eq(queryParams)); + + assertThrows(CloudRuntimeException.class, () -> storageStrategy.getStorageVolume("flexvol-uuid-999", queryParams)); + } + + @Test + void testGetStorageVolume_ByUuid_ForwardsQueryParams() { + Volume expected = new Volume(); + expected.setUuid("flexvol-uuid-123"); + Map queryParams = Map.of(OntapStorageConstants.FIELDS, OntapStorageConstants.SPACE_USED); + when(volumeFeignClient.getVolumeByUUID(anyString(), eq("flexvol-uuid-123"), eq(queryParams))).thenReturn(expected); + + Volume result = storageStrategy.getStorageVolume("flexvol-uuid-123", queryParams); + + assertNotNull(result); + assertEquals("flexvol-uuid-123", result.getUuid()); + verify(volumeFeignClient).getVolumeByUUID(anyString(), eq("flexvol-uuid-123"), eq(queryParams)); + } + // ========== updateStorageVolume() Tests ========== @Test @@ -1464,9 +1517,10 @@ public void testUpdateStorageVolume_positive() { Volume result = storageStrategy.updateStorageVolume(volume); // Verify - assertNotNull(result); + assertSame(volume, result); assertEquals(5368709120L, result.getSize()); verify(volumeFeignClient, times(1)).updateVolume(anyString(), eq("vol-uuid-resize"), any()); + verify(volumeFeignClient, never()).getVolumeByUUID(anyString(), anyString(), any()); verify(jobFeignClient, atLeastOnce()).getJobByUUID(anyString(), eq("resize-job-uuid")); } 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 4d78ce3d5c96..199a847e2136 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 @@ -22,6 +22,7 @@ import com.cloud.agent.api.Answer; import com.cloud.agent.api.storage.ResizeVolumeCommand; import com.cloud.host.HostVO; +import com.cloud.storage.ResizeVolumePayload; import com.cloud.storage.VolumeVO; import com.cloud.storage.dao.VolumeDao; import com.cloud.utils.exception.CloudRuntimeException; @@ -308,7 +309,8 @@ public void testUpdateCloudStackVolume_AppliesQosPolicy() { when(volumeInfo.getUuid()).thenReturn("volume-uuid-123"); VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); - qosPolicy.setName("cs_100_to200_iops_svm1"); + qosPolicy.setName("cs_100_to_200_iops_svm1"); + qosPolicy.setUuid("qos-uuid"); FileInfo fileInfo = new FileInfo(); fileInfo.setQosPolicy(qosPolicy); @@ -322,12 +324,14 @@ public void testUpdateCloudStackVolume_AppliesQosPolicy() { 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()))); + && "cs_100_to_200_iops_svm1".equals(file.getQosPolicy().getName()) + && "qos-uuid".equals(file.getQosPolicy().getUuid()))); } @Test public void testUpdateCloudStackVolume_InvalidRequest_ThrowsException() { assertThrows(CloudRuntimeException.class, () -> strategy.updateCloudStackVolume(new CloudStackVolume())); + verify(nasFeignClient, never()).updateFile(anyString(), anyString(), anyString(), any(FileInfo.class)); } // Test createCloudStackVolume - Volume Not Found @@ -1169,9 +1173,47 @@ public void testResizeCloudStackVolume_SendsResizeCommand() { VolumeVO volumeVO = mock(VolumeVO.class); StoragePoolVO storagePool = mock(StoragePoolVO.class); EndPoint endPoint = mock(EndPoint.class); + ResizeVolumePayload payload = new ResizeVolumePayload( + 21474836480L, null, null, null, false, "i-2-VM", null, false); + + when(volumeObject.getId()).thenReturn(100L); + when(volumeObject.getUuid()).thenReturn("volume-uuid"); + when(volumeObject.getpayload()).thenReturn(payload); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getPath()).thenReturn("volume-uuid"); + when(volumeVO.getSize()).thenReturn(5368709120L); + when(volumeVO.getPoolId()).thenReturn(1L); + when(primaryDataStoreDao.findById(1L)).thenReturn(storagePool); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(ResizeVolumeCommand.class))).thenReturn(new Answer(null, true, "Success")); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + strategy.resizeCloudStackVolume(request, 21474836480L); + + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(ResizeVolumeCommand.class); + verify(endPoint).sendMessage(commandCaptor.capture()); + ResizeVolumeCommand command = commandCaptor.getValue(); + assertEquals("volume-uuid", command.getPath()); + assertEquals(5368709120L, command.getCurrentSize()); + assertEquals(21474836480L, command.getNewSize()); + assertEquals("i-2-VM", command.getInstanceName()); + verify(nasFeignClient, never()).updateFile(anyString(), anyString(), anyString(), any(FileInfo.class)); + } + + @Test + public void testResizeCloudStackVolume_AppliesQosAfterFileGrow() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + StoragePoolVO storagePool = mock(StoragePoolVO.class); + EndPoint endPoint = mock(EndPoint.class); + ResizeVolumePayload payload = new ResizeVolumePayload( + 21474836480L, 100L, 200L, null, false, "i-2-VM", null, false); when(volumeObject.getId()).thenReturn(100L); when(volumeObject.getUuid()).thenReturn("volume-uuid"); + when(volumeObject.getpayload()).thenReturn(payload); when(volumeDao.findById(100L)).thenReturn(volumeVO); when(volumeVO.getPath()).thenReturn("volume-uuid"); when(volumeVO.getSize()).thenReturn(5368709120L); @@ -1180,12 +1222,95 @@ public void testResizeCloudStackVolume_SendsResizeCommand() { when(epSelector.select(volumeObject)).thenReturn(endPoint); when(endPoint.sendMessage(any(ResizeVolumeCommand.class))).thenReturn(new Answer(null, true, "Success")); + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to_200_iops_svm1"); + qosPolicy.setUuid("qos-uuid"); + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); CloudStackVolume request = new CloudStackVolume(); request.setVolumeInfo(volumeObject); + request.setFlexVolumeUuid("flex-uuid"); + request.setFile(fileInfo); strategy.resizeCloudStackVolume(request, 21474836480L); verify(endPoint).sendMessage(any(ResizeVolumeCommand.class)); + verify(nasFeignClient).updateFile(anyString(), eq("flex-uuid"), eq("volume-uuid"), + argThat(file -> file.getQosPolicy() != null + && "cs_100_to_200_iops_svm1".equals(file.getQosPolicy().getName()) + && "qos-uuid".equals(file.getQosPolicy().getUuid()))); + } + + @Test + public void testResizeCloudStackVolume_WithoutPayloadUsesDetachedInstanceName() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + StoragePoolVO storagePool = mock(StoragePoolVO.class); + EndPoint endPoint = mock(EndPoint.class); + + when(volumeObject.getId()).thenReturn(100L); + when(volumeObject.getUuid()).thenReturn("volume-uuid"); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getPath()).thenReturn("volume-uuid"); + when(volumeVO.getSize()).thenReturn(5368709120L); + when(volumeVO.getPoolId()).thenReturn(1L); + when(primaryDataStoreDao.findById(1L)).thenReturn(storagePool); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(ResizeVolumeCommand.class))).thenReturn(new Answer(null, true, "Success")); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + strategy.resizeCloudStackVolume(request, 21474836480L); + + ArgumentCaptor commandCaptor = ArgumentCaptor.forClass(ResizeVolumeCommand.class); + verify(endPoint).sendMessage(commandCaptor.capture()); + assertEquals("none", commandCaptor.getValue().getInstanceName()); + } + + @Test + public void testResizeCloudStackVolume_AgentFailureThrowsException() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + StoragePoolVO storagePool = mock(StoragePoolVO.class); + EndPoint endPoint = mock(EndPoint.class); + + when(volumeObject.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getPoolId()).thenReturn(1L); + when(primaryDataStoreDao.findById(1L)).thenReturn(storagePool); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(ResizeVolumeCommand.class))) + .thenReturn(new Answer(null, false, "qemu-img resize failed")); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> strategy.resizeCloudStackVolume(request, 21474836480L)); + assertEquals("qemu-img resize failed", exception.getMessage()); + } + + @Test + public void testResizeCloudStackVolume_InvalidSizeDoesNotSendCommand() { + VolumeObject volumeObject = mock(VolumeObject.class); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + assertThrows(CloudRuntimeException.class, () -> strategy.resizeCloudStackVolume(request, 0L)); + + verify(epSelector, never()).select(any(org.apache.cloudstack.engine.subsystem.api.storage.DataObject.class)); + } + + @Test + public void testResizeCloudStackVolume_NegativeSizeDoesNotSendCommand() { + VolumeObject volumeObject = mock(VolumeObject.class); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + assertThrows(CloudRuntimeException.class, () -> strategy.resizeCloudStackVolume(request, -1L)); + + verify(epSelector, never()).select(any(org.apache.cloudstack.engine.subsystem.api.storage.DataObject.class)); } @Test @@ -1316,4 +1441,105 @@ public void testResizeCloudStackVolume_NoEndpoint_Throws() { assertThrows(CloudRuntimeException.class, () -> strategy.resizeCloudStackVolume(request, 21474836480L)); } + + @Test + public void testResizeCloudStackVolume_VolumeNotFound_Throws() { + VolumeObject volumeObject = mock(VolumeObject.class); + when(volumeObject.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(null); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> strategy.resizeCloudStackVolume(request, 21474836480L)); + + assertTrue(exception.getMessage().contains("Volume not found")); + verify(epSelector, never()).select(any(org.apache.cloudstack.engine.subsystem.api.storage.DataObject.class)); + } + + @Test + public void testResizeCloudStackVolume_StoragePoolNotFound_Throws() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + when(volumeObject.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getPoolId()).thenReturn(1L); + when(primaryDataStoreDao.findById(1L)).thenReturn(null); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> strategy.resizeCloudStackVolume(request, 21474836480L)); + + assertTrue(exception.getMessage().contains("Storage Pool not found")); + verify(epSelector, never()).select(any(org.apache.cloudstack.engine.subsystem.api.storage.DataObject.class)); + } + + @Test + public void testResizeCloudStackVolume_NullAnswer_Throws() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + StoragePoolVO storagePool = mock(StoragePoolVO.class); + EndPoint endPoint = mock(EndPoint.class); + when(volumeObject.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getPoolId()).thenReturn(1L); + when(primaryDataStoreDao.findById(1L)).thenReturn(storagePool); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(ResizeVolumeCommand.class))).thenReturn(null); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> strategy.resizeCloudStackVolume(request, 21474836480L)); + + assertTrue(exception.getMessage().contains("Failed to resize qcow2 on KVM host")); + } + + @Test + public void testResizeCloudStackVolume_AgentException_Propagates() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + StoragePoolVO storagePool = mock(StoragePoolVO.class); + EndPoint endPoint = mock(EndPoint.class); + when(volumeObject.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getPoolId()).thenReturn(1L); + when(primaryDataStoreDao.findById(1L)).thenReturn(storagePool); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(ResizeVolumeCommand.class))) + .thenThrow(new CloudRuntimeException("KVM agent unreachable")); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> strategy.resizeCloudStackVolume(request, 21474836480L)); + + assertEquals("KVM agent unreachable", exception.getMessage()); + } + + @Test + public void testResizeCloudStackVolume_DeletedFile_ThrowsAgentError() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + StoragePoolVO storagePool = mock(StoragePoolVO.class); + EndPoint endPoint = mock(EndPoint.class); + when(volumeObject.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getPath()).thenReturn("deleted-volume.qcow2"); + when(volumeVO.getPoolId()).thenReturn(1L); + when(primaryDataStoreDao.findById(1L)).thenReturn(storagePool); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(ResizeVolumeCommand.class))) + .thenReturn(new Answer(null, false, + "qemu-img: Could not open 'deleted-volume.qcow2': No such file or directory")); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> strategy.resizeCloudStackVolume(request, 21474836480L)); + + assertTrue(exception.getMessage().contains("No such file or directory")); + assertTrue(exception.getMessage().contains("deleted-volume.qcow2")); + } } 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 0344a2fea17f..0ffb0166d960 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 @@ -18,11 +18,14 @@ */ package org.apache.cloudstack.storage.service; -import com.cloud.host.HostVO; -import com.cloud.utils.exception.CloudRuntimeException; -import feign.FeignException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreInfo; import org.apache.cloudstack.engine.subsystem.api.storage.Scope; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.feign.client.SANFeignClient; import org.apache.cloudstack.storage.feign.model.Igroup; @@ -37,19 +40,6 @@ import org.apache.cloudstack.storage.service.model.ProtocolType; import org.apache.cloudstack.storage.utils.OntapStorageConstants; import org.apache.cloudstack.storage.utils.OntapStorageUtils; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -58,11 +48,16 @@ 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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; 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 org.mockito.Mock; +import org.mockito.MockedStatic; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; @@ -71,6 +66,14 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.cloud.host.HostVO; +import com.cloud.storage.VolumeDetailVO; +import com.cloud.storage.dao.VolumeDetailsDao; +import com.cloud.utils.exception.CloudRuntimeException; + +import feign.FeignException; @ExtendWith(MockitoExtension.class) class UnifiedSANStrategyTest { @@ -93,6 +96,9 @@ class UnifiedSANStrategyTest { @Mock private StoragePoolDetailsDao storagePoolDetailsDao; + @Mock + private VolumeDetailsDao volumeDetailsDao; + private UnifiedSANStrategy unifiedSANStrategy; private String authHeader; @@ -124,6 +130,11 @@ void setUp() { java.lang.reflect.Field storagePoolDetailsDaoField = UnifiedSANStrategy.class.getDeclaredField("storagePoolDetailsDao"); storagePoolDetailsDaoField.setAccessible(true); storagePoolDetailsDaoField.set(unifiedSANStrategy, storagePoolDetailsDao); + + // Inject volumeDetailsDao, used to resolve the LUN UUID during resize + java.lang.reflect.Field volumeDetailsDaoField = UnifiedSANStrategy.class.getDeclaredField("volumeDetailsDao"); + volumeDetailsDaoField.setAccessible(true); + volumeDetailsDaoField.set(unifiedSANStrategy, volumeDetailsDao); } catch (Exception e) { throw new RuntimeException(e); } @@ -996,8 +1007,10 @@ void testCloneCloudStackVolume_Success() { lun.setName("/vol/vol1/cloned"); lun.setClone(clone); + VolumeInfo volumeInfo = mock(VolumeInfo.class); CloudStackVolume request = new CloudStackVolume(); request.setLun(lun); + request.setVolumeInfo(volumeInfo); Lun clonedLun = new Lun(); clonedLun.setName("/vol/vol1/cloned"); @@ -1017,6 +1030,7 @@ void testCloneCloudStackVolume_Success() { assertNotNull(result); assertEquals("cloned-lun-uuid", result.getLun().getUuid()); + assertSame(volumeInfo, result.getVolumeInfo()); verify(sanFeignClient).createLun(eq(authHeader), eq(true), any(Lun.class)); } } @@ -1078,29 +1092,103 @@ void testResizeCloudStackVolume_ValidRequest_PatchesSize() { lun.setUuid("lun-uuid-123"); CloudStackVolume request = new CloudStackVolume(); request.setLun(lun); + request.setVolumeInfo(mock(VolumeInfo.class)); unifiedSANStrategy.resizeCloudStackVolume(request, 21474836480L); ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); verify(sanFeignClient).updateLun(any(), eq("lun-uuid-123"), lunCaptor.capture()); assertEquals(21474836480L, lunCaptor.getValue().getSpace().getSize()); + assertNull(lunCaptor.getValue().getQosPolicy()); + } + + @Test + void testResizeCloudStackVolume_PatchesSizeAndQosTogether() { + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_0_to_5000_iops_svm1"); + qosPolicy.setUuid("qos-uuid"); + Lun lun = new Lun(); + lun.setUuid("lun-uuid-123"); + lun.setQosPolicy(qosPolicy); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + request.setVolumeInfo(mock(VolumeInfo.class)); + + unifiedSANStrategy.resizeCloudStackVolume(request, 21474836480L); + + ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); + verify(sanFeignClient).updateLun(any(), eq("lun-uuid-123"), lunCaptor.capture()); + assertEquals(21474836480L, lunCaptor.getValue().getSpace().getSize()); + assertEquals("cs_0_to_5000_iops_svm1", lunCaptor.getValue().getQosPolicy().getName()); + assertEquals("qos-uuid", lunCaptor.getValue().getQosPolicy().getUuid()); } @Test void testResizeCloudStackVolume_NoUuid_Throws() { + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getId()).thenReturn(100L); CloudStackVolume request = new CloudStackVolume(); request.setLun(new Lun()); + request.setVolumeInfo(volumeInfo); + + // LUN UUID is absent on the request and cannot be resolved from volume details + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(null); + + assertThrows(CloudRuntimeException.class, () -> unifiedSANStrategy.resizeCloudStackVolume(request, 100L)); + verify(sanFeignClient, never()).updateLun(any(), any(), any()); + } + + @Test + void testResizeCloudStackVolume_BlankUuidDetail_Throws() { + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getId()).thenReturn(100L); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeInfo); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, " ", false)); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.resizeCloudStackVolume(request, 100L)); + + assertTrue(exception.getMessage().contains("LUN UUID not found")); + verify(sanFeignClient, never()).updateLun(any(), any(), any()); + } + + @Test + void testResizeCloudStackVolume_NoVolumeInfo_Throws() { + Lun lun = new Lun(); + lun.setUuid("lun-uuid-123"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); assertThrows(CloudRuntimeException.class, () -> unifiedSANStrategy.resizeCloudStackVolume(request, 100L)); verify(sanFeignClient, never()).updateLun(any(), any(), any()); } + @Test + void testResizeCloudStackVolume_UuidResolvedFromVolumeDetails() { + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getId()).thenReturn(100L); + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeInfo); + + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false)); + + unifiedSANStrategy.resizeCloudStackVolume(request, 21474836480L); + + ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); + verify(sanFeignClient).updateLun(any(), eq("lun-uuid-123"), lunCaptor.capture()); + assertEquals(21474836480L, lunCaptor.getValue().getSpace().getSize()); + } + @Test void testResizeCloudStackVolume_InvalidSize_Throws() { Lun lun = new Lun(); lun.setUuid("lun-uuid-123"); CloudStackVolume request = new CloudStackVolume(); request.setLun(lun); + request.setVolumeInfo(mock(VolumeInfo.class)); assertThrows(CloudRuntimeException.class, () -> unifiedSANStrategy.resizeCloudStackVolume(request, 0L)); assertThrows(CloudRuntimeException.class, () -> unifiedSANStrategy.resizeCloudStackVolume(null, 100L)); @@ -1108,25 +1196,94 @@ void testResizeCloudStackVolume_InvalidSize_Throws() { } @Test - void testResizeCloudStackVolume_FeignException_Throws() { + void testResizeCloudStackVolume_FeignExceptions_Throw() { Lun lun = new Lun(); lun.setUuid("lun-uuid-123"); CloudStackVolume request = new CloudStackVolume(); request.setLun(lun); + request.setVolumeInfo(mock(VolumeInfo.class)); - FeignException feignException = mock(FeignException.class); - when(feignException.getMessage()).thenReturn("resize failed"); + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + for (int status : new int[] {401, 403, 409, 500, -1}) { + FeignException feignException = mock(FeignException.class); + when(feignException.status()).thenReturn(status); + when(feignException.getMessage()).thenReturn( + status == -1 ? "connection timed out" : "resize failed"); + doThrow(feignException).when(sanFeignClient) + .updateLun(eq(authHeader), eq("lun-uuid-123"), any(Lun.class)); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.resizeCloudStackVolume(request, 21474836480L)); + } + } + } + + @Test + void testResizeCloudStackVolume_DeletedLun_ThrowsSpecificMessage() { + Lun lun = new Lun(); + lun.setUuid("deleted-lun-uuid"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + request.setVolumeInfo(mock(VolumeInfo.class)); + FeignException notFound = mock(FeignException.class); + when(notFound.status()).thenReturn(404); + when(notFound.getMessage()).thenReturn("LUN not found"); try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) .thenReturn(authHeader); - doThrow(feignException).when(sanFeignClient).updateLun(eq(authHeader), eq("lun-uuid-123"), any(Lun.class)); + utilityMock.when(() -> OntapStorageUtils.isOntapObjectNotFoundError(notFound)) + .thenReturn(true); + doThrow(notFound).when(sanFeignClient) + .updateLun(eq(authHeader), eq("deleted-lun-uuid"), any(Lun.class)); - assertThrows(CloudRuntimeException.class, + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.resizeCloudStackVolume(request, 21474836480L)); + + assertTrue(exception.getMessage().contains("no longer exists on ONTAP")); + assertTrue(exception.getMessage().contains("deleted-lun-uuid")); + } + } + + @Test + void testResizeCloudStackVolume_UnexpectedException_IsWrapped() { + Lun lun = new Lun(); + lun.setUuid("lun-uuid-123"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + request.setVolumeInfo(mock(VolumeInfo.class)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + doThrow(new IllegalStateException("unexpected failure")).when(sanFeignClient) + .updateLun(eq(authHeader), eq("lun-uuid-123"), any(Lun.class)); + + CloudRuntimeException exception = assertThrows(CloudRuntimeException.class, () -> unifiedSANStrategy.resizeCloudStackVolume(request, 21474836480L)); + + assertTrue(exception.getMessage().contains("Unexpected error")); + assertTrue(exception.getMessage().contains("unexpected failure")); } } + @Test + void testResizeCloudStackVolume_SameSizePatchIsIdempotent() { + Lun lun = new Lun(); + lun.setUuid("lun-uuid-123"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + request.setVolumeInfo(mock(VolumeInfo.class)); + + unifiedSANStrategy.resizeCloudStackVolume(request, 10737418240L); + + ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); + verify(sanFeignClient).updateLun(any(), eq("lun-uuid-123"), lunCaptor.capture()); + assertEquals(10737418240L, lunCaptor.getValue().getSpace().getSize()); + } + @Test void testCloneCloudStackVolume_EmptyRecords_ThrowsException() { Lun.Source source = new Lun.Source(); @@ -1187,35 +1344,18 @@ void testSetOntapStorage() { } @Test - void testUpdateCloudStackVolume_InvalidRequest_ThrowsException() { - CloudStackVolume request = new CloudStackVolume(); - assertThrows(CloudRuntimeException.class, - () -> unifiedSANStrategy.updateCloudStackVolume(request)); - } - - @Test - void testUpdateCloudStackVolume_AppliesQosPolicyToLun() { + void testUpdateCloudStackVolume_ReturnsNull() { Lun lun = new Lun(); lun.setUuid("lun-uuid-123"); VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); - qosPolicy.setName("cs_0_to5000_iops_svm1"); + qosPolicy.setName("cs_0_to_5000_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()))); - } + assertNull(unifiedSANStrategy.updateCloudStackVolume(request)); + assertNull(unifiedSANStrategy.updateCloudStackVolume(new CloudStackVolume())); + verify(sanFeignClient, never()).updateLun(any(), any(), any()); } @Test