diff --git a/api/src/main/java/com/cloud/storage/Storage.java b/api/src/main/java/com/cloud/storage/Storage.java index 42a18b9c83c5..4202610bd87d 100644 --- a/api/src/main/java/com/cloud/storage/Storage.java +++ b/api/src/main/java/com/cloud/storage/Storage.java @@ -186,7 +186,8 @@ public static enum StoragePoolType { DatastoreCluster(true, true, EncryptionSupport.Unsupported), // for VMware, to abstract pool of clusters StorPool(true, true, EncryptionSupport.Hypervisor), FiberChannel(true, true, EncryptionSupport.Unsupported), // Fiber Channel Pool for KVM hypervisors is used to find the volume by WWN value (/dev/disk/by-id/wwn-) - NVMeTCP(true, true, EncryptionSupport.Unsupported); // NVMe over TCP (NVMe-oF/TCP) Pool for KVM hypervisors; volumes are identified by EUI-128 NGUID (/dev/disk/by-id/nvme-eui.) + NVMeTCP(true, true, EncryptionSupport.Unsupported), // NVMe over TCP (NVMe-oF/TCP) Pool for KVM hypervisors; volumes are identified by EUI-128 NGUID (/dev/disk/by-id/nvme-eui.) + OntapiSCSI(true, true, EncryptionSupport.Unsupported); // NetApp ONTAP iSCSI private final boolean shared; private final boolean overProvisioning; diff --git a/api/src/main/java/com/cloud/vm/VmDetailConstants.java b/api/src/main/java/com/cloud/vm/VmDetailConstants.java index 521633a0c597..13b6d28e6ed3 100644 --- a/api/src/main/java/com/cloud/vm/VmDetailConstants.java +++ b/api/src/main/java/com/cloud/vm/VmDetailConstants.java @@ -70,6 +70,10 @@ public interface VmDetailConstants { String CPU_SPEED = "cpuSpeed"; String MEMORY = "memory"; + // VM deployment with custom root disk offering params + String MIN_IOPS = "minIops"; + String MAX_IOPS = "maxIops"; + // Misc details for internal usage (not to be set/changed by user or admin) String CPU_OVER_COMMIT_RATIO = "cpuOvercommitRatio"; String MEMORY_OVER_COMMIT_RATIO = "memoryOvercommitRatio"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java index fa55b424eaf3..4640480d29e7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java @@ -53,7 +53,7 @@ public class UpdateStoragePoolCmd extends BaseCmd { @Parameter(name = ApiConstants.TAGS, type = CommandType.LIST, collectionType = CommandType.STRING, description = "Comma-separated list of tags for the storage pool") private List tags; - @Parameter(name = ApiConstants.CAPACITY_IOPS, type = CommandType.LONG, required = false, description = "IOPS CloudStack can provision from this storage pool", validations={ApiArgValidator.PositiveNumber}) + @Parameter(name = ApiConstants.CAPACITY_IOPS, type = CommandType.LONG, required = false, description = "IOPS CloudStack can provision from this storage pool", validations = {ApiArgValidator.PositiveNumber}) private Long capacityIops; @Parameter(name = ApiConstants.CAPACITY_BYTES, type = CommandType.LONG, required = false, description = "Bytes CloudStack can provision from this storage pool",validations={ApiArgValidator.PositiveNumber}) 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/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java index 9f6d02cc1234..9a06e7285e4d 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java @@ -61,14 +61,12 @@ import com.cloud.vm.NicProfile; import com.cloud.vm.VMInstanceVO; import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; import com.cloud.vm.VmDiskInfo; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDetailsDao; import com.cloud.vm.dao.VMInstanceDao; -import static org.apache.cloudstack.api.ApiConstants.MAX_IOPS; -import static org.apache.cloudstack.api.ApiConstants.MIN_IOPS; - @Component public class CloudOrchestrator implements OrchestrationService { @@ -205,8 +203,8 @@ public VirtualMachineEntity createVirtualMachine(String id, String owner, String Map userVmDetails = _vmInstanceDetailsDao.listDetailsKeyPairs(vm.getId()); if (userVmDetails != null) { - String minIops = userVmDetails.get(MIN_IOPS); - String maxIops = userVmDetails.get(MAX_IOPS); + String minIops = userVmDetails.get(VmDetailConstants.MIN_IOPS); + String maxIops = userVmDetails.get(VmDetailConstants.MAX_IOPS); rootDiskOfferingInfo.setMinIops(minIops != null && minIops.trim().length() > 0 ? Long.parseLong(minIops) : null); rootDiskOfferingInfo.setMaxIops(maxIops != null && maxIops.trim().length() > 0 ? Long.parseLong(maxIops) : null); diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java new file mode 100644 index 000000000000..fd7deac04548 --- /dev/null +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java @@ -0,0 +1,111 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.engine.orchestration; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.offering.DiskOfferingInfo; +import com.cloud.service.ServiceOfferingVO; +import com.cloud.service.dao.ServiceOfferingDao; +import com.cloud.storage.DiskOfferingVO; +import com.cloud.storage.VMTemplateVO; +import com.cloud.storage.dao.DiskOfferingDao; +import com.cloud.storage.dao.VMTemplateDao; +import com.cloud.utils.component.ComponentContext; +import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.VMInstanceDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; +import org.apache.cloudstack.engine.cloud.entity.api.VirtualMachineEntityImpl; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class CloudOrchestratorTest { + + private static final long VM_ID = 1L; + private static final long SERVICE_OFFERING_ID = 2L; + private static final long ROOT_DISK_OFFERING_ID = 3L; + private static final String TEMPLATE_ID = "4"; + + @InjectMocks + private CloudOrchestrator cloudOrchestrator = new CloudOrchestrator(); + + @Mock + private VirtualMachineManager _itMgr; + @Mock + private VMTemplateDao _templateDao; + @Mock + private VMInstanceDao _vmDao; + @Mock + private VMInstanceDetailsDao _vmInstanceDetailsDao; + @Mock + private ServiceOfferingDao _serviceOfferingDao; + @Mock + private DiskOfferingDao _diskOfferingDao; + + @Test + public void createVirtualMachineSetsCustomIopsFromVmDetails() throws Exception { + VMInstanceVO vm = Mockito.mock(VMInstanceVO.class); + ServiceOfferingVO serviceOffering = Mockito.mock(ServiceOfferingVO.class); + DiskOfferingVO rootDiskOffering = Mockito.mock(DiskOfferingVO.class); + VMTemplateVO template = Mockito.mock(VMTemplateVO.class); + VirtualMachineEntityImpl vmEntity = Mockito.mock(VirtualMachineEntityImpl.class); + + Mockito.when(_vmDao.findByUuid("vm-uuid")).thenReturn(vm); + Mockito.when(vm.getId()).thenReturn(VM_ID); + Mockito.when(vm.getServiceOfferingId()).thenReturn(SERVICE_OFFERING_ID); + Mockito.when(vm.getInstanceName()).thenReturn("i-1-1-VM"); + Mockito.when(_serviceOfferingDao.findById(VM_ID, SERVICE_OFFERING_ID)).thenReturn(serviceOffering); + Mockito.when(_diskOfferingDao.findById(ROOT_DISK_OFFERING_ID)).thenReturn(rootDiskOffering); + Mockito.when(rootDiskOffering.isCustomizedIops()).thenReturn(true); + Mockito.when(_templateDao.findById(Long.valueOf(TEMPLATE_ID))).thenReturn(template); + + Map details = new HashMap<>(); + details.put(VmDetailConstants.MIN_IOPS, "100"); + details.put(VmDetailConstants.MAX_IOPS, "1000"); + Mockito.when(_vmInstanceDetailsDao.listDetailsKeyPairs(VM_ID)).thenReturn(details); + + try (MockedStatic componentContext = Mockito.mockStatic(ComponentContext.class)) { + componentContext.when(() -> ComponentContext.inject(VirtualMachineEntityImpl.class)).thenReturn(vmEntity); + + cloudOrchestrator.createVirtualMachine("vm-uuid", "owner", TEMPLATE_ID, "host", "display", HypervisorType.KVM.name(), + 1, 1000, 1024, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null, + null, null, null, null, ROOT_DISK_OFFERING_ID, null, null, null, null); + } + + ArgumentCaptor rootDiskOfferingInfo = ArgumentCaptor.forClass(DiskOfferingInfo.class); + Mockito.verify(_itMgr).allocate(Mockito.eq("i-1-1-VM"), Mockito.eq(template), Mockito.eq(serviceOffering), rootDiskOfferingInfo.capture(), + Mockito.anyList(), Mockito.anyList(), Mockito.any(LinkedHashMap.class), Mockito.isNull(), Mockito.eq(HypervisorType.KVM), + Mockito.isNull(), Mockito.isNull(), Mockito.isNull(), Mockito.isNull()); + + Assert.assertEquals(Long.valueOf(100), rootDiskOfferingInfo.getValue().getMinIops()); + Assert.assertEquals(Long.valueOf(1000), rootDiskOfferingInfo.getValue().getMaxIops()); + } +} diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java index 05bbf13c9745..06dcb125ada5 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java @@ -1007,7 +1007,6 @@ private TemplateInfo createManagedTemplateVolume(TemplateInfo srcTemplateInfo, P } else { errMesg = callback.result.getResult(); } - templateOnPrimary.processEvent(Event.OperationFailed); throw new CloudRuntimeException(String.format("Unable to create template %s on primary storage %s: %s", templateOnPrimary.getImage(), destPrimaryDataStore, errMesg)); } @@ -1015,8 +1014,12 @@ private TemplateInfo createManagedTemplateVolume(TemplateInfo srcTemplateInfo, P } catch (Throwable e) { logger.debug("Failed to create template volume on storage", e); - templateOnPrimary.processEvent(Event.OperationFailed); - throw new CloudRuntimeException(e.getMessage()); + try { + templateOnPrimary.processEvent(Event.OperationFailed); + } catch (Exception stateEx) { + logger.warn("Unable to mark template {} as failed on primary storage {}: {}", templateOnPrimary.getImage(), destPrimaryDataStore, stateEx.getMessage()); + } + throw new CloudRuntimeException(e.getMessage(), e); } finally { _tmpltPoolDao.releaseFromLockTable(templatePoolRefId); } 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 89a937689c28..fb8d3936b308 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java @@ -27,6 +27,8 @@ import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.storage.DataStoreRole; +import com.cloud.storage.ResizeVolumePayload; import com.cloud.storage.Storage; import com.cloud.storage.StoragePool; import com.cloud.storage.Volume; @@ -34,9 +36,11 @@ import com.cloud.storage.VolumeVO; import com.cloud.storage.ScopeType; import com.cloud.storage.SnapshotVO; +import com.cloud.storage.VMTemplateStoragePoolVO; import com.cloud.storage.dao.SnapshotDao; import com.cloud.storage.dao.SnapshotDetailsDao; import com.cloud.storage.dao.SnapshotDetailsVO; +import com.cloud.storage.dao.VMTemplatePoolDao; import com.cloud.storage.dao.VolumeDao; import com.cloud.storage.dao.VolumeDetailsDao; import com.cloud.utils.Pair; @@ -47,38 +51,50 @@ 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.DataStoreCapabilities; +import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine; +import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStore; import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreDriver; 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.cloudstack.framework.async.AsyncCompletionCallback; import org.apache.cloudstack.storage.command.CommandResult; +import org.apache.cloudstack.storage.command.CopyCmdAnswer; import org.apache.cloudstack.storage.command.CreateObjectAnswer; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.feign.client.SnapshotFeignClient; +import org.apache.cloudstack.storage.feign.model.FileInfo; import org.apache.cloudstack.storage.feign.model.FlexVolSnapshot; import org.apache.cloudstack.storage.feign.model.Lun; +import org.apache.cloudstack.storage.feign.model.LunSpace; +import org.apache.cloudstack.storage.feign.model.Svm; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.SANStrategy; import org.apache.cloudstack.storage.service.StorageStrategy; +import org.apache.cloudstack.storage.service.UnifiedNASStrategy; import org.apache.cloudstack.storage.service.UnifiedSANStrategy; import org.apache.cloudstack.storage.service.model.AccessGroup; import org.apache.cloudstack.storage.service.model.CloudStackVolume; 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; import javax.inject.Inject; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * Primary datastore driver for NetApp ONTAP storage systems. @@ -94,6 +110,7 @@ public class OntapPrimaryDatastoreDriver implements PrimaryDataStoreDriver { @Inject private VolumeDetailsDao volumeDetailsDao; @Inject private SnapshotDetailsDao snapshotDetailsDao; @Inject private SnapshotDao snapshotDao; + @Inject private VMTemplatePoolDao vmTemplatePoolDao; @Override public Map getCapabilities() { @@ -102,6 +119,14 @@ public Map getCapabilities() { mapCapabilities.put(DataStoreCapabilities.STORAGE_SYSTEM_SNAPSHOT.toString(), Boolean.TRUE.toString()); mapCapabilities.put(DataStoreCapabilities.CAN_CREATE_VOLUME_FROM_SNAPSHOT.toString(), Boolean.TRUE.toString()); mapCapabilities.put(DataStoreCapabilities.CAN_REVERT_VOLUME_TO_SNAPSHOT.toString(), Boolean.TRUE.toString()); + // Enables the framework to cache a template on the FlexVolume once and serve every later + // deployment with an array-side clone instead of another copy from secondary storage. + mapCapabilities.put(DataStoreCapabilities.CAN_CREATE_VOLUME_FROM_VOLUME.toString(), Boolean.TRUE.toString()); + // createTemplate(snapshotid) reads the snapshot from this pool instead of backing it up to secondary first. + mapCapabilities.put(DataStoreCapabilities.CAN_CREATE_TEMPLATE_FROM_SNAPSHOT.toString(), Boolean.TRUE.toString()); + // Must be present and false: StorageSystemDataMotionStrategy then clones the snapshot into a + // temporary volume (see copyAsync) and copies that volume to secondary storage. + mapCapabilities.put(OntapStorageConstants.CAN_DIRECT_ATTACH_SNAPSHOT, Boolean.FALSE.toString()); return mapCapabilities; } @@ -148,6 +173,7 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet } Map details = storagePoolDetailsDao.listDetailsKeyPairs(dataStore.getId()); + validateProtocol(details, dataStore); if (dataObject.getType() == DataObjectType.VOLUME) { VolumeInfo volInfo = (VolumeInfo) dataObject; @@ -155,37 +181,43 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet // Update CloudStack volume record with storage pool association and protocol-specific details VolumeVO volumeVO = volumeDao.findById(volInfo.getId()); if (volumeVO != null) { - // Create the backend storage object (LUN for iSCSI, no-op for NFS) - CloudStackVolume created = createCloudStackVolume(storagePool, volInfo, details); - - volumeVO.setPoolType(storagePool.getPoolType()); - volumeVO.setPoolId(storagePool.getId()); - volumeVO.setFormat(getImageFormatByHypervisorAndProtocol(storagePool.getHypervisor(), details.get(OntapStorageConstants.PROTOCOL))); - logger.info("createAsync: Volume format set to [{}] for hypervisor [{}] and protocol [{}]", volumeVO.getFormat(), storagePool.getHypervisor(), details.get(OntapStorageConstants.PROTOCOL)); - - if (ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) { - String lunName = created != null && created.getLun() != null ? created.getLun().getName() : null; - if (lunName == null) { - throw new CloudRuntimeException("Missing LUN name for volume " + volInfo.getId()); - } - - // Persist LUN details for future operations (delete, grant/revoke access) - volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_UUID, created.getLun().getUuid(), false); - volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_NAME, lunName, false); - if (created.getLun().getUuid() != null) { - volumeVO.setFolder(created.getLun().getUuid()); - } - - logger.info("createAsync: Created LUN [{}] for volume [{}]. LUN mapping will occur during grantAccess() to per-host igroup.", - lunName, volumeVO.getId()); - createCmdResult = new CreateCmdResult(lunName, new Answer(null, true, null)); - } else if (ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) { - createCmdResult = new CreateCmdResult(volInfo.getUuid(), new Answer(null, true, null)); - logger.info("createAsync: Managed NFS volume [{}] with path [{}] associated with pool {}", - volumeVO.getId(), volInfo.getUuid(), storagePool.getId()); + /* + * Create-volume combinations on ONTAP primary (v1): + * + * 1) cloneOfSnapshot — StorageSystemDataMotionStrategy sets volume_details.cloneOfSnapshot + * when createVolume(snapshotid) targets a managed backend snapshot. + * - Same primary pool / FlexVol only (PRIMARY_POOL_ID must match dataStore). + * - DATA or ROOT snapshot → new attachable data volume (ROOT is never bootable + * via this path; bootable recovery is createTemplate(snapshotid) → deploy). + * - Backend: iSCSI → POST /api/storage/luns (clone.source in .snapshot/); + * NFS → POST /api/storage/file/clone with snapshot.name. + * - IOPS: the new volume's min/max (snapshot values, or the root offering when one + * is supplied). Applied here; the temporary template clone does not get a policy. + * + * 2) cloneOfTemplate — deploy / create from cached template on this pool. + * + * 3) else — blank LUN (iSCSI) or qcow2 (NFS). + * + * Mutually exclusive from the motion/orchestrator layer; snapshot is checked first + * (SolidFire-style) so a create-from-snapshot never accidentally falls through to blank create. + */ + Long cloneOfSnapshotId = getSnapshotIdForCloning(volInfo.getId()); + Long cloneOfTemplateId = getTemplateIdForCloning(volInfo.getId()); + CloudStackVolume clonedCloudStackVolume; + if (cloneOfSnapshotId != null) { + clonedCloudStackVolume = cloneCloudStackVolumeFromSnapshot( + storagePool, volInfo, details, cloneOfSnapshotId, true); + } else if (cloneOfTemplateId != null) { + clonedCloudStackVolume = cloneCloudStackVolumeFromTemplate( + storagePool, volInfo, details, cloneOfTemplateId); + } else { + clonedCloudStackVolume = createCloudStackVolume(storagePool, volInfo, details); } - volumeDao.update(volumeVO.getId(), volumeVO); + + createCmdResult = recordCreatedVolume(storagePool, volInfo, volumeVO, details, clonedCloudStackVolume); } + } else if (dataObject.getType() == DataObjectType.TEMPLATE) { + createCmdResult = createTemplateOnPrimary(storagePool, (TemplateInfo) dataObject, details); } else { errMsg = "Invalid DataObjectType (" + dataObject.getType() + ") passed to createAsync"; logger.error(errMsg); @@ -195,7 +227,7 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet errMsg = e.getMessage(); logger.error("createAsync: Failed for dataObject name [{}]: {}", dataObject.getName(), errMsg); createCmdResult = new CreateCmdResult(null, new Answer(null, false, errMsg)); - createCmdResult.setResult(e.toString()); + createCmdResult.setResult(errMsg); } finally { if (createCmdResult != null && createCmdResult.isSuccess()) { logger.info("createAsync: Operation completed successfully for {}", dataObject.getType()); @@ -204,13 +236,534 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet } } + /** + * Records pool association, image format and protocol-specific identity (LUN name/uuid for iSCSI) + * of a volume that was just created or cloned on ONTAP, and returns the create result whose path + * the framework stores on the volume. + */ + private CreateCmdResult recordCreatedVolume(StoragePoolVO storagePool, VolumeInfo volInfo, VolumeVO volumeVO, + Map details, CloudStackVolume createdCloudStackVolume) { + CreateCmdResult createCmdResult = null; + volumeVO.setPoolType(storagePool.getPoolType()); + volumeVO.setPoolId(storagePool.getId()); + volumeVO.setFormat(getImageFormat(storagePool)); + logger.info("createAsync: Volume format set to [{}] for pool type [{}]", volumeVO.getFormat(), storagePool.getPoolType()); + + if (ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) { + // createCloudStackVolume validates the Feign response (LUN name + uuid) before returning + Lun createdLun = createdCloudStackVolume.getLun(); + String lunName = createdLun.getName(); + + // Persist LUN details for future operations (delete, grant/revoke access) + volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_UUID, createdLun.getUuid(), false); + volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_NAME, lunName, false); + volumeVO.setFolder(createdLun.getUuid()); + + logger.info("createAsync: Created LUN [{}] for volume [{}]. LUN mapping will occur during grantAccess() to per-host igroup.", + lunName, volumeVO.getId()); + createCmdResult = new CreateCmdResult(lunName, new Answer(null, true, null)); + } else if (ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) { + createCmdResult = new CreateCmdResult(volInfo.getUuid(), new Answer(null, true, null)); + logger.info("createAsync: Managed NFS volume [{}] with path [{}] associated with pool {}", + volumeVO.getId(), volInfo.getUuid(), storagePool.getId()); + } + volumeDao.update(volumeVO.getId(), volumeVO); + return createCmdResult; + } + /** * Creates a volume on the ONTAP backend. */ private CloudStackVolume createCloudStackVolume(StoragePoolVO storagePool, VolumeInfo volumeObject, Map details) { + verifySufficientIopsForStoragePool(storagePool, volumeObject.getMinIops(), volumeObject.getId()); + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); + VolumeQosPolicy qosPolicy = null; + Volume.Type volumeType = volumeObject.getVolumeType(); + if (volumeType == Volume.Type.DATADISK || volumeType == Volume.Type.ROOT) { + qosPolicy = createQosPolicyIfNeeded(storageStrategy, details, + volumeObject.getMinIops(), volumeObject.getMaxIops(), storagePool.getId()); + } + CloudStackVolume request = createCloudStackVolumeRequestByProtocol( + storagePool, details, volumeObject, qosPolicy); + try { + CloudStackVolume created = storageStrategy.createCloudStackVolume(request); + persistQosPolicyDetails(volumeObject.getId(), qosPolicy); + return created; + } catch (RuntimeException e) { + if (qosPolicy != null) { + storageStrategy.deleteVolumeQosPolicy(qosPolicy.getUuid()); + } + throw e; + } + } + + private VolumeQosPolicy createQosPolicyIfNeeded(StorageStrategy storageStrategy, Map details, + Long minIops, Long maxIops, Long poolId) { + if (!validateIops(storageStrategy, details, poolId, minIops, maxIops)) { + return null; + } + String policyName = getQosPolicyName(details.get(OntapStorageConstants.SVM_NAME), minIops, maxIops); + return storageStrategy.createVolumeQosPolicy(policyName, minIops, maxIops); + } + + private boolean validateIops(StorageStrategy storageStrategy, Map details, + Long poolId, Long minIops, Long maxIops) { + long min = minIops == null ? 0 : minIops; + long max = maxIops == null ? 0 : maxIops; + if (min <= 0 && max <= 0) { + return false; + } + if (min > 0 && max > 0 && min > max) { + throw new CloudRuntimeException("Minimum IOPS cannot be greater than maximum IOPS"); + } + if (min > 0) { + String isAff = details.get(OntapStorageConstants.IS_AFF); + if (StringUtils.isBlank(isAff)) { + try { + isAff = Boolean.toString(storageStrategy.isAff()); + details.put(OntapStorageConstants.IS_AFF, isAff); + storagePoolDetailsDao.addDetail(poolId, OntapStorageConstants.IS_AFF, isAff, false); + } catch (Exception e) { + logger.error("Unable to determine whether storage pool [{}] is AFF or FAS: {}", + poolId, e.getMessage()); + return true; + } + } + if (!Boolean.parseBoolean(isAff)) { + throw new CloudRuntimeException(OntapStorageConstants.MIN_IOPS_NOT_SUPPORTED_ON_FAS); + } + } + return true; + } + + /** + * Builds a reusable SVM-scoped QoS policy name: cs_{min}_to_{max}_iops_{svmName}. + * Dots in the SVM name are replaced with underscores (ONTAP QoS names cannot contain '.'). + */ + private String getQosPolicyName(String svmName, Long minIops, Long maxIops) { + String sanitizedSvmName = svmName == null ? "" : svmName.replace(".", OntapStorageConstants.UNDERSCORE); + long min = minIops != null && minIops > 0 ? minIops : 0; + long max = maxIops != null && maxIops > 0 ? maxIops : 0; + return OntapStorageConstants.QOS_POLICY_NAME_PREFIX + min + OntapStorageConstants.UNDERSCORE + + OntapStorageConstants.QOS_POLICY_NAME_TO + max + OntapStorageConstants.UNDERSCORE + + OntapStorageConstants.QOS_POLICY_NAME_IOPS + sanitizedSvmName; + } + + private void persistQosPolicyDetails(long volumeId, VolumeQosPolicy qosPolicy) { + volumeDetailsDao.removeDetail(volumeId, OntapStorageConstants.QOS_POLICY_UUID); + if (qosPolicy == null) { + return; + } + volumeDetailsDao.addDetail(volumeId, OntapStorageConstants.QOS_POLICY_UUID, qosPolicy.getUuid(), false); + } + + /** + * Creates the backend object that caches a template on this pool's FlexVolume. + * + *

Protocol-specific work is delegated to {@link StorageStrategy#createTemplateCache}. + * This method maps the result to {@link CreateCmdResult} and records SAN identity on + * {@code template_spool_ref} ({@code local_download_path} = LUN uuid).

+ */ + private CreateCmdResult createTemplateOnPrimary(StoragePoolVO storagePool, TemplateInfo templateInfo, Map details) { + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); + long sizeInBytes = getDataObjectSizeIncludingHypervisorSnapshotReserve(templateInfo, storagePool); + CloudStackVolume created = null; + try { + created = storageStrategy.createTemplateCache(storagePool, templateInfo, details, sizeInBytes); + String path = recordTemplateCacheOnSpoolRef(storagePool, templateInfo, created, sizeInBytes); + return new CreateCmdResult(path, new Answer(null, true, null)); + } catch (Exception e) { + // Compensating delete for SAN: strategy cleans create-time LUN failures; this covers + // post-create failures (e.g. template_spool_ref update) after a LUN was returned. + if (created != null && created.getLun() != null) { + bestEffortDeleteTemplateCacheLun(storageStrategy, details.get(OntapStorageConstants.SVM_NAME), + created.getLun().getName(), created.getLun().getUuid()); + } + if (e instanceof CloudRuntimeException) { + throw (CloudRuntimeException) e; + } + throw new CloudRuntimeException("Failed to create template cache for template [" + templateInfo.getId() + + "]: " + e.getMessage(), e); + } + } + + /** + * Persists SAN cache identity on {@code template_spool_ref} when a LUN was created. + * NFS returns the template uuid as the create path; {@code install_path} is filled later. + */ + private String recordTemplateCacheOnSpoolRef(StoragePoolVO storagePool, TemplateInfo templateInfo, + CloudStackVolume created, long sizeInBytes) { + if (created == null || created.getLun() == null) { + return templateInfo.getUuid(); + } + Lun lun = created.getLun(); + VMTemplateStoragePoolVO templatePoolRef = findTemplatePoolRef(storagePool.getId(), templateInfo.getId()); + templatePoolRef.setLocalDownloadPath(lun.getUuid()); + templatePoolRef.setTemplateSize(sizeInBytes); + vmTemplatePoolDao.update(templatePoolRef.getId(), templatePoolRef); + return lun.getName(); + } + + /** + * Clones the cached template into a new volume on the same FlexVolume. + * + *

Invoked when {@code StorageSystemDataMotionStrategy} has recorded a + * {@code cloneOfTemplate} detail on the volume.

+ */ + private CloudStackVolume cloneCloudStackVolumeFromTemplate(StoragePoolVO storagePool, VolumeInfo volumeInfo, + Map details, long templateId) { + VMTemplateStoragePoolVO templatePoolRef = findTemplatePoolRef(storagePool.getId(), templateId); + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); + boolean iscsi = isIscsi(details); + + verifySufficientIopsForStoragePool(storagePool, volumeInfo.getMinIops(), volumeInfo.getId()); + VolumeQosPolicy qosPolicy = null; + Volume.Type volumeType = volumeInfo.getVolumeType(); + if (volumeType == Volume.Type.DATADISK || volumeType == Volume.Type.ROOT) { + qosPolicy = createQosPolicyIfNeeded(storageStrategy, details, + volumeInfo.getMinIops(), volumeInfo.getMaxIops(), storagePool.getId()); + } + + CloudStackVolume request = iscsi + ? createCloneLunRequest(storagePool, details, volumeInfo, templatePoolRef, templateId, qosPolicy) + : createCloneFileRequest(storagePool, volumeInfo, templatePoolRef, templateId); + + try { + CloudStackVolume cloned = storageStrategy.cloneCloudStackVolume(request); + if (cloned == null) { + throw new CloudRuntimeException("ONTAP returned nothing when cloning template [" + templateId + + "] for volume [" + volumeInfo.getId() + "]"); + } + + logger.info("cloneCloudStackVolumeFromTemplate: Cloned template [{}] for volume [{}] on pool [{}]", + templateId, volumeInfo.getId(), storagePool.getId()); + + long requestedSize = getDataObjectSizeIncludingHypervisorSnapshotReserve(volumeInfo, storagePool); + if (requestedSize > templatePoolRef.getTemplateSize()) { + logger.info("cloneCloudStackVolumeFromTemplate: Growing clone of template [{}] from {} to {} bytes for volume [{}]", + templateId, templatePoolRef.getTemplateSize(), requestedSize, volumeInfo.getId()); + resizeClonedVolumeOrCleanup(storageStrategy, cloned, requestedSize, + "cloneCloudStackVolumeFromTemplate template [" + templateId + "] volume [" + volumeInfo.getId() + "]"); + } + + if (!iscsi && qosPolicy != null) { + CloudStackVolume cloudStackVolume = createCloudStackVolumeRequestByProtocol( + storagePool, details, volumeInfo, qosPolicy); + storageStrategy.updateCloudStackVolume(cloudStackVolume); + } + persistQosPolicyDetails(volumeInfo.getId(), qosPolicy); + return cloned; + } catch (Exception e) { + if (qosPolicy != null) { + storageStrategy.deleteVolumeQosPolicy(qosPolicy.getUuid()); + } + throw e; + } + } + + /** + * Returns the CloudStack template id the volume should be cloned from, or null for a blank volume. + * + *

{@code StorageSystemDataMotionStrategy} persists this detail immediately before calling + * {@code createAsync} and removes it right after, so it is only visible during creation.

+ */ + private Long getTemplateIdForCloning(long volumeId) { + VolumeDetailVO detail = volumeDetailsDao.findDetail(volumeId, OntapStorageConstants.CLONE_OF_TEMPLATE); + if (detail == null || detail.getValue() == null || detail.getValue().isEmpty()) { + return null; + } + return Long.valueOf(detail.getValue()); + } + + /** + * Returns the CloudStack snapshot id to clone from when {@code volume_details.cloneOfSnapshot} + * is set, or null when this create is not a create-volume-from-snapshot. + * + *

Set by {@code StorageSystemDataMotionStrategy.handleCreateManagedVolumeFromManagedSnapshot} + * for the duration of {@code createAsync} only (same pattern as {@link #getTemplateIdForCloning}).

+ */ + private Long getSnapshotIdForCloning(long volumeId) { + VolumeDetailVO detail = volumeDetailsDao.findDetail(volumeId, OntapStorageConstants.CLONE_OF_SNAPSHOT); + if (detail == null || detail.getValue() == null || detail.getValue().isEmpty()) { + return null; + } + return Long.valueOf(detail.getValue()); + } + + /** + * Creates a new volume on this pool by cloning a file/LUN from a CloudStack volume snapshot + * that already lives on the same FlexVolume. + * + *

Combinations (product + plugin v1):

+ *
    + *
  • Same pool only — {@code snapshot_details.PRIMARY_POOL_ID} must equal this + * {@code storagePool}. Creating the volume on a different pool is descoped; use migrate later if needed.
  • + *
  • DATA snapshot → attachable data disk (disk offering usually inherited).
  • + *
  • ROOT snapshot → still a data disk here (not bootable). Bootable path remains + * {@code createTemplate(snapshotid)} then deploy.
  • + *
  • iSCSI — {@code POST /api/storage/luns} with + * {@code clone.source.name=/vol/<fv>/.snapshot/<snap>/<lun>}
  • + *
  • NFS3 — {@code POST /api/storage/file/clone} with {@code snapshot.name}
  • + *
  • Not this path — in-place revert ({@code revertSnapshot}); VM/instance snapshots; + * secondary-storage archive restore.
  • + *
+ * + *

Optional grow when the disk offering is larger than the snapshot size (same pattern as + * clone-from-template).

+ * + *

When {@code applyQos} is true, the new volume's min and max IOPS are applied the same way + * as a template clone: iSCSI carries the policy on the clone request, NFS attaches it after + * the file exists. The temporary volume used for {@code createTemplate(snapshotid)} passes + * false and is left without a policy.

+ */ + private CloudStackVolume cloneCloudStackVolumeFromSnapshot(StoragePoolVO storagePool, VolumeInfo volumeInfo, + Map details, long csSnapshotId, + boolean applyQos) { + String snapshotName = requireSnapshotDetail(csSnapshotId, OntapStorageConstants.ONTAP_SNAP_NAME); + String volumePath = requireSnapshotDetail(csSnapshotId, OntapStorageConstants.VOLUME_PATH); + String primaryPoolId = requireSnapshotDetail(csSnapshotId, OntapStorageConstants.PRIMARY_POOL_ID); + String snapProtocol = requireSnapshotDetail(csSnapshotId, OntapStorageConstants.PROTOCOL); + + // Same-pool / same-protocol gate (v1). Fail before any ONTAP call. + if (!String.valueOf(storagePool.getId()).equals(primaryPoolId)) { + throw new CloudRuntimeException("Create volume from snapshot [" + csSnapshotId + + "] requires the snapshot's primary pool [" + primaryPoolId + + "]; requested pool is [" + storagePool.getId() + "] (creating a volume from a snapshot on a different pool is not supported)"); + } + String poolProtocol = details.get(OntapStorageConstants.PROTOCOL); + if (poolProtocol == null || !poolProtocol.equalsIgnoreCase(snapProtocol)) { + throw new CloudRuntimeException("Create volume from snapshot [" + csSnapshotId + + "] protocol mismatch: snapshot=[" + snapProtocol + "], pool=[" + poolProtocol + "]"); + } + + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); + boolean iscsi = isIscsi(details); + VolumeQosPolicy qosPolicy = null; + + logger.info("cloneCloudStackVolumeFromSnapshot: Cloning from CS snapshot [{}] (ONTAP snap [{}], path [{}]) " + + "for volume [{}] on pool [{}] protocol [{}]", + csSnapshotId, snapshotName, volumePath, volumeInfo.getId(), storagePool.getId(), poolProtocol); + + try { + if (applyQos) { + verifySufficientIopsForStoragePool(storagePool, volumeInfo.getMinIops(), volumeInfo.getId()); + Volume.Type volumeType = volumeInfo.getVolumeType(); + if (volumeType == Volume.Type.DATADISK || volumeType == Volume.Type.ROOT) { + qosPolicy = createQosPolicyIfNeeded(storageStrategy, details, + volumeInfo.getMinIops(), volumeInfo.getMaxIops(), storagePool.getId()); + } + } + + CloudStackVolume request = iscsi + ? createCloneLunFromSnapshotRequest(storagePool, details, volumeInfo, volumePath, snapshotName, qosPolicy) + : createCloneFileFromSnapshotRequest(storagePool, details, volumeInfo, volumePath, snapshotName); + CloudStackVolume cloned = storageStrategy.cloneCloudStackVolume(request); + if (cloned == null) { + throw new CloudRuntimeException("ONTAP returned nothing when cloning snapshot [" + csSnapshotId + + "] for volume [" + volumeInfo.getId() + "]"); + } + + long requestedSize = getDataObjectSizeIncludingHypervisorSnapshotReserve(volumeInfo, storagePool); + long snapshotSize = resolveSnapshotSizeBytes(csSnapshotId); + if (snapshotSize > 0 && requestedSize > snapshotSize) { + logger.info("cloneCloudStackVolumeFromSnapshot: Growing clone of snapshot [{}] from {} to {} bytes for volume [{}]", + csSnapshotId, snapshotSize, requestedSize, volumeInfo.getId()); + resizeClonedVolumeOrCleanup(storageStrategy, cloned, requestedSize, + "cloneCloudStackVolumeFromSnapshot snapshot [" + csSnapshotId + "] volume [" + volumeInfo.getId() + "]"); + } + + if (!iscsi && qosPolicy != null) { + CloudStackVolume cloudStackVolume = createCloudStackVolumeRequestByProtocol( + storagePool, details, volumeInfo, qosPolicy); + storageStrategy.updateCloudStackVolume(cloudStackVolume); + } + if (applyQos) { + persistQosPolicyDetails(volumeInfo.getId(), qosPolicy); + } + return cloned; + } catch (Exception e) { + if (qosPolicy != null) { + storageStrategy.deleteVolumeQosPolicy(qosPolicy.getUuid()); + } + throw e; + } + } + + /** + * Grows a just-created ONTAP clone to the requested size. If resize fails, deletes the clone + * so the CloudStack create-failure path does not leave an orphaned LUN/file on the array. + */ + private void resizeClonedVolumeOrCleanup(StorageStrategy storageStrategy, CloudStackVolume cloned, + long requestedSize, String context) { + try { + storageStrategy.resizeCloudStackVolume(cloned, requestedSize); + } catch (RuntimeException resizeEx) { + logger.error("{}: Resize to {} bytes failed; deleting orphaned ONTAP clone: {}", + context, requestedSize, resizeEx.getMessage()); + bestEffortDeleteOrphanedClone(storageStrategy, cloned); + throw resizeEx; + } + } + + private void bestEffortDeleteOrphanedClone(StorageStrategy storageStrategy, CloudStackVolume cloned) { + try { + storageStrategy.deleteCloudStackVolume(cloned); + logger.info("Deleted orphaned ONTAP clone after resize failure"); + } catch (Exception cleanupEx) { + logger.warn("Failed to delete orphaned ONTAP clone after resize failure: {}", cleanupEx.getMessage()); + } + } + + private String requireSnapshotDetail(long csSnapshotId, String key) { + String value = getSnapshotDetail(csSnapshotId, key); + if (value == null || value.isEmpty()) { + throw new CloudRuntimeException("Missing snapshot_details [" + key + "] for snapshot [" + csSnapshotId + + "]; cannot create volume from snapshot"); + } + return value; + } + + private long resolveSnapshotSizeBytes(long csSnapshotId) { + SnapshotVO snapshotVO = snapshotDao.findById(csSnapshotId); + if (snapshotVO == null || snapshotVO.getSize() <= 0) { + return 0L; + } + return snapshotVO.getSize(); + } + + private VMTemplateStoragePoolVO findTemplatePoolRef(long poolId, long templateId) { + VMTemplateStoragePoolVO templatePoolRef = vmTemplatePoolDao.findByPoolTemplate(poolId, templateId, null); + if (templatePoolRef == null) { + throw new CloudRuntimeException("No template_spool_ref row for template [" + templateId + "] on pool [" + poolId + "]"); + } + return templatePoolRef; + } + + /** + * Deletes the LUN caching a template on this pool, invoked by template eviction + * ({@code TemplateManagerImpl.evictTemplateFromStoragePool}). + * + *

Volumes previously cloned from this LUN are unaffected: an ONTAP sis-clone shares blocks + * with its source through reference counting rather than depending on it, so the source can be + * removed while its clones stay online.

+ * + *

{@code deleteCloudStackVolume} already unmaps as it deletes ({@code allow_delete_while_mapped}) + * and treats a missing LUN as success.

+ */ + private void deleteTemplateOnPrimary(DataStore store, TemplateInfo templateInfo) { + StoragePoolVO storagePool = storagePoolDao.findById(store.getId()); + if (storagePool == null) { + throw new CloudRuntimeException("Storage Pool not found for id: " + store.getId()); + } + + Map details = storagePoolDetailsDao.listDetailsKeyPairs(store.getId()); + VMTemplateStoragePoolVO templatePoolRef = vmTemplatePoolDao.findByPoolTemplate(storagePool.getId(), templateInfo.getId(), null); + if (templatePoolRef == null) { + logger.warn("deleteTemplateOnPrimary: No template_spool_ref for template [{}] on pool [{}]; nothing to delete", + templateInfo.getId(), storagePool.getId()); + return; + } + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); - CloudStackVolume cloudStackVolumeRequest = OntapStorageUtils.createCloudStackVolumeRequestByProtocol(storagePool, details, volumeObject); - return storageStrategy.createCloudStackVolume(cloudStackVolumeRequest); + if (isIscsi(details)) { + deleteIscsiTemplateCache(storagePool, templateInfo, templatePoolRef, storageStrategy, details); + } else { + deleteNfsTemplateCache(details, templateInfo, templatePoolRef, storageStrategy); + } + } + + private void deleteIscsiTemplateCache(StoragePoolVO storagePool, TemplateInfo templateInfo, + VMTemplateStoragePoolVO templatePoolRef, StorageStrategy storageStrategy, + Map details) { + String lunName = getTemplateLunName(storagePool, templateInfo.getId()); + String lunUuid = templatePoolRef.getLocalDownloadPath(); + deleteTemplateCacheLun(storageStrategy, details.get(OntapStorageConstants.SVM_NAME), lunName, lunUuid); + logger.info("deleteIscsiTemplateCache: Deleted template cache LUN [{}] for template [{}] on pool [{}]", + lunName, templateInfo.getId(), storagePool.getId()); + } + + /** + * Deletes a template-cache LUN. When {@code lunUuid} is missing, resolves the LUN by its + * deterministic name so eviction and create-time rollback still work. + */ + private void deleteTemplateCacheLun(StorageStrategy storageStrategy, String svmName, String lunName, String lunUuid) { + String uuid = lunUuid; + if (uuid == null || uuid.isEmpty()) { + CloudStackVolume existing = getCloudStackVolumeByName(storageStrategy, svmName, lunName); + if (existing == null || existing.getLun() == null || existing.getLun().getUuid() == null) { + logger.warn("deleteTemplateCacheLun: LUN [{}] not found on SVM [{}]; nothing to delete", lunName, svmName); + return; + } + uuid = existing.getLun().getUuid(); + } + + Lun lun = new Lun(); + lun.setUuid(uuid); + lun.setName(lunName); + + CloudStackVolume deleteRequest = new CloudStackVolume(); + deleteRequest.setLun(lun); + storageStrategy.deleteCloudStackVolume(deleteRequest); + } + + /** + * Best-effort cleanup of a template-cache LUN after a failed create. Never masks the original + * create failure. + */ + private void bestEffortDeleteTemplateCacheLun(StorageStrategy storageStrategy, String svmName, String lunName, + String lunUuid) { + try { + deleteTemplateCacheLun(storageStrategy, svmName, lunName, lunUuid); + logger.info("bestEffortDeleteTemplateCacheLun: Removed leftover template cache LUN [{}] on SVM [{}]", + lunName, svmName); + } catch (Exception cleanupEx) { + logger.warn("bestEffortDeleteTemplateCacheLun: Failed to remove leftover template cache LUN [{}] on SVM [{}]: {}", + lunName, svmName, cleanupEx.getMessage()); + } + } + + private void deleteNfsTemplateCache(Map details, TemplateInfo templateInfo, + VMTemplateStoragePoolVO templatePoolRef, StorageStrategy storageStrategy) { + String filePath = templatePoolRef.getInstallPath(); + if (filePath == null || filePath.isEmpty()) { + logger.warn("deleteNfsTemplateCache: No install_path recorded for template [{}]; nothing to delete", + templateInfo.getId()); + return; + } + String flexVolUuid = details.get(OntapStorageConstants.VOLUME_UUID); + if (flexVolUuid == null || flexVolUuid.isEmpty()) { + // Misconfigured pool detail — fail eviction rather than calling ONTAP with a null + // volume UUID (which would hit /api/storage/volumes/null and still look like success + // upstream if we swallowed the error). + throw new CloudRuntimeException("FlexVolume UUID (volumeUUID) is missing from storage pool details; " + + "cannot delete NFS template cache file [" + filePath + "] for template [" + + templateInfo.getId() + "]"); + } + ((UnifiedNASStrategy) storageStrategy).deleteFileByPath(flexVolUuid, filePath); + logger.info("deleteNfsTemplateCache: Deleted template cache file [{}] for template [{}]", + filePath, templateInfo.getId()); + } + + /** + * Rejects the request when the minimum IOPS being asked for would push the pool past its + * configured IOPS capacity. Pools without an IOPS capacity enforce no ceiling. + * + * Used IOPS include volumes still in {@link Volume.State#Creating} so a second overlapping + * create sees the first reservation. The volume being checked is omitted so its own min IOPS + * are not counted twice (CloudStack has already moved it to Creating before this runs). + */ + private void verifySufficientIopsForStoragePool(StoragePoolVO storagePool, Long requestedMinIops, long excludeVolumeId) { + Long capacityIops = storagePool.getCapacityIops(); + if (capacityIops == null || requestedMinIops == null || requestedMinIops <= 0) { + return; + } + + long requestedTotalIops = getAllocatedMinIops(storagePool, excludeVolumeId) + requestedMinIops; + if (requestedTotalIops > capacityIops) { + throw new CloudRuntimeException(String.format( + "Insufficient IOPS capacity on storage pool %s: requested total of %d IOPS exceeds the pool IOPS capacity of %d", + storagePool.getName(), requestedTotalIops, capacityIops)); + } } /** @@ -243,9 +796,33 @@ public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallbac StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); logger.info("createCloudStackVolumeForTypeVolume: Connection to Ontap SVM [{}] successful, preparing CloudStackVolumeRequest", details.get(OntapStorageConstants.SVM_NAME)); VolumeInfo volumeInfo = (VolumeInfo) data; - CloudStackVolume cloudStackVolumeRequest = createDeleteCloudStackVolumeRequest(storagePool, details, volumeInfo); - storageStrategy.deleteCloudStackVolume(cloudStackVolumeRequest); + VolumeDetailVO qosPolicyDetail = volumeDetailsDao.findDetail(volumeInfo.getId(), OntapStorageConstants.QOS_POLICY_UUID); + + // NFS file delete leaves the QoS assignment in place unless the file is set to none first. + // iSCSI LUN delete already drops object_count. + // TODO: revisit this NFS none-policy step when the file is deleted from ONTAP instead of KVM. + boolean qosCleared = false; + if (!isIscsi(details)) { + qosCleared = clearQosPolicyBeforeDelete(storageStrategy, storagePool, details, volumeInfo, qosPolicyDetail); + } + try { + CloudStackVolume cloudStackVolumeRequest = createDeleteCloudStackVolumeRequest(storagePool, details, volumeInfo); + storageStrategy.deleteCloudStackVolume(cloudStackVolumeRequest); + } catch (Exception e) { + if (qosCleared) { + restoreQosPolicyAfterFailedDelete(storageStrategy, storagePool, details, volumeInfo, qosPolicyDetail); + } + throw e; + } + if (qosPolicyDetail != null) { + deleteUnusedQosPolicy(storageStrategy, qosPolicyDetail.getValue()); + } logger.info("deleteAsync: Volume deleted: " + volumeInfo.getId()); + removeTemporarySnapshotCopyRecord(volumeInfo.getId()); + commandResult.setResult(null); + commandResult.setSuccess(true); + } else if (data.getType() == DataObjectType.TEMPLATE) { + deleteTemplateOnPrimary(store, (TemplateInfo) data); commandResult.setResult(null); commandResult.setSuccess(true); } else if (data.getType() == DataObjectType.SNAPSHOT) { @@ -260,7 +837,55 @@ public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallbac commandResult.setSuccess(false); commandResult.setResult(e.getMessage()); } finally { - callback.complete(commandResult); + // StorageSystemDataMotionStrategy deletes its temporary snapshot copy with a null callback. + if (callback != null) { + callback.complete(commandResult); + } + } + } + + /** + * Sets the NFS file QoS policy to {@code none} before the file is deleted. + * Failure must not block volume delete. + * + * @return true when the file was moved off its policy + */ + private boolean clearQosPolicyBeforeDelete(StorageStrategy storageStrategy, StoragePoolVO storagePool, + Map details, VolumeInfo volumeInfo, + VolumeDetailVO qosPolicyDetail) { + if (qosPolicyDetail == null || qosPolicyDetail.getValue() == null) { + return false; + } + VolumeQosPolicy nonePolicy = new VolumeQosPolicy(); + nonePolicy.setName(OntapStorageConstants.QOS_POLICY_NONE); + try { + CloudStackVolume request = createCloudStackVolumeRequestByProtocol(storagePool, details, volumeInfo, nonePolicy); + storageStrategy.updateCloudStackVolume(request); + return true; + } catch (Exception e) { + logger.warn("Could not clear QoS policy [{}] on volume [{}] before delete: {}", + qosPolicyDetail.getValue(), volumeInfo.getId(), e.getMessage()); + return false; + } + } + + /** + * Puts the previous QoS policy back on an NFS file when delete fails after the policy was cleared. + */ + private void restoreQosPolicyAfterFailedDelete(StorageStrategy storageStrategy, StoragePoolVO storagePool, + Map details, VolumeInfo volumeInfo, + VolumeDetailVO qosPolicyDetail) { + if (qosPolicyDetail == null || qosPolicyDetail.getValue() == null) { + return; + } + VolumeQosPolicy previousPolicy = new VolumeQosPolicy(); + previousPolicy.setUuid(qosPolicyDetail.getValue()); + try { + CloudStackVolume request = createCloudStackVolumeRequestByProtocol(storagePool, details, volumeInfo, previousPolicy); + storageStrategy.updateCloudStackVolume(request); + } catch (Exception e) { + logger.warn("Could not restore QoS policy [{}] on volume [{}] after delete failed: {}", + qosPolicyDetail.getValue(), volumeInfo.getId(), e.getMessage()); } } @@ -351,21 +976,246 @@ private long resolveSnapshotPoolId(String poolIdStr, long snapshotId) { @Override public void copyAsync(DataObject srcData, DataObject destData, AsyncCompletionCallback callback) { - throw new UnsupportedOperationException("Copy operation is not supported for ONTAP primary storage."); + copyAsync(srcData, destData, null, callback); } + /** + * Dispatches a driver-level copy by (source type, destination type). Only the pairs accepted by + * {@link #canCopy} are handled; everything else is left to the data motion strategies. + * + *

Callers may pass a null callback (for example {@code StorageSystemDataMotionStrategy} for the + * temporary snapshot copy), in which case a failed copy is thrown.

+ */ @Override public void copyAsync(DataObject srcData, DataObject destData, Host destHost, AsyncCompletionCallback callback) { - throw new UnsupportedOperationException("Copy operation is not supported for ONTAP primary storage."); + CopyCommandResult result; + if (isSnapshotToTemporaryVolumeCopy(srcData, destData)) { + result = copySnapshotToTemporaryVolume(srcData, (VolumeInfo) destData); + } else { + throw new UnsupportedOperationException("Copy from " + describeForCopy(srcData) + " to " + + describeForCopy(destData) + " is not supported for ONTAP primary storage."); + } + + if (callback != null) { + callback.complete(result); + } else if (!result.isSuccess()) { + throw new CloudRuntimeException("Failed to copy " + srcData.getType() + " [" + srcData.getId() + "] to " + + destData.getType() + " [" + destData.getId() + "]: " + result.getResult()); + } } + /** + * Returns true only for the (source type, destination type) pairs the driver copies itself. + * + *

{@code DataMotionServiceImpl} consults {@code canCopy} before choosing a data motion strategy, + * so any pair accepted here bypasses {@code StorageSystemDataMotionStrategy} (volume migration, + * createVolume(snapshotid), template caching). Add a pair only together with its handler in + * {@link #copyAsync}.

+ */ @Override public boolean canCopy(DataObject srcData, DataObject destData) { - return false; + if (srcData == null || destData == null || srcData.getDataStore() == null || destData.getDataStore() == null) { + return false; + } + return isSnapshotToTemporaryVolumeCopy(srcData, destData); + } + + private String describeForCopy(DataObject data) { + return data == null ? "null" : data.getType() + " [" + data.getId() + "]"; + } + + /** + * Matches the temporary volume {@code StorageSystemDataMotionStrategy} creates on the snapshot's own + * pool while copying the snapshot to secondary storage for {@code createTemplate(snapshotid)}. + */ + private boolean isSnapshotToTemporaryVolumeCopy(DataObject srcData, DataObject destData) { + if (srcData == null || destData == null || srcData.getDataStore() == null || destData.getDataStore() == null) { + return false; + } + if (srcData.getType() != DataObjectType.SNAPSHOT || destData.getType() != DataObjectType.VOLUME) { + return false; + } + if (srcData.getDataStore().getRole() != DataStoreRole.Primary + || srcData.getDataStore().getId() != destData.getDataStore().getId()) { + return false; + } + return isTemporarySnapshotCopyVolume(volumeDao.findById(destData.getId())); + } + + /** + * Clones a CloudStack volume snapshot into the temporary volume used for + * {@code createTemplate(snapshotid)}. The strategy then maps that volume to a KVM host, copies it + * into the template and deletes it again through {@link #deleteAsync}. + */ + private CopyCommandResult copySnapshotToTemporaryVolume(DataObject srcData, VolumeInfo volInfo) { + try { + StoragePoolVO storagePool = storagePoolDao.findById(volInfo.getDataStore().getId()); + if (storagePool == null) { + throw new CloudRuntimeException("Storage Pool not found for id: " + volInfo.getDataStore().getId()); + } + Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId()); + validateProtocol(details, volInfo.getDataStore()); + VolumeVO volumeVO = volumeDao.findById(volInfo.getId()); + + renameTemporarySnapshotCopy(volInfo, volumeVO, srcData.getId()); + logger.info("copySnapshotToTemporaryVolume: Cloning CS snapshot [{}] into temporary volume [{}] on pool [{}] for template creation", + srcData.getId(), volInfo.getId(), storagePool.getId()); + CloudStackVolume cloned = cloneCloudStackVolumeFromSnapshot(storagePool, volInfo, details, srcData.getId(), false); + if (ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) { + volumeVO.setPath(volInfo.getUuid()); + } + recordCreatedVolume(storagePool, volInfo, volumeVO, details, cloned); + return new CopyCommandResult(null, new CopyCmdAnswer(volInfo.getTO())); + } catch (Exception e) { + logger.error("copySnapshotToTemporaryVolume: Failed to clone snapshot [{}] into volume [{}]: {}", + srcData.getId(), volInfo.getId(), e.getMessage()); + CopyCommandResult result = new CopyCommandResult(null, new CopyCmdAnswer(e.getMessage())); + result.setResult(e.getMessage()); + return result; + } + } + + /** + * The temporary volume created by {@code StorageSystemDataMotionStrategy} for a snapshot copy is + * persisted in Allocated state without a disk offering; user volumes always carry a disk offering. + */ + private boolean isTemporarySnapshotCopyVolume(VolumeVO volumeVO) { + if (volumeVO == null || volumeVO.getState() != Volume.State.Allocated) { + return false; + } + Long diskOfferingId = volumeVO.getDiskOfferingId(); + return diskOfferingId == null || diskOfferingId == 0L; + } + + /** + * The framework names the temporary volume {@code _.TMP}, which is not a valid + * ONTAP LUN name, so it is renamed before cloning. The in-memory object is updated as well because the + * SAN clone derives the LUN name from {@link VolumeInfo#getName()}. + */ + private void renameTemporarySnapshotCopy(VolumeInfo volInfo, VolumeVO volumeVO, long csSnapshotId) { + String name = OntapStorageConstants.TEMP_SNAPSHOT_COPY_NAME_PREFIX + csSnapshotId + OntapStorageConstants.UNDERSCORE + volInfo.getId(); + volumeVO.setName(name); + if (volInfo.getVolume() instanceof VolumeVO) { + ((VolumeVO) volInfo.getVolume()).setName(name); + } } + /** + * {@code StorageSystemDataMotionStrategy} deletes the temporary snapshot copy on the array but leaves its + * volume row behind on success, so it is removed here once the backend object is gone. + */ + private void removeTemporarySnapshotCopyRecord(long volumeId) { + VolumeVO volumeVO = volumeDao.findById(volumeId); + if (!isTemporarySnapshotCopyVolume(volumeVO) + || volumeVO.getName() == null || !volumeVO.getName().startsWith(OntapStorageConstants.TEMP_SNAPSHOT_COPY_NAME_PREFIX)) { + return; + } + volumeDetailsDao.removeDetails(volumeId); + volumeDao.remove(volumeId); + logger.info("deleteAsync: Removed temporary snapshot copy volume record [{}]", volumeId); + } + + /** + * 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) {} + public void resize(DataObject data, AsyncCompletionCallback callback) { + CreateCmdResult result = null; + try { + 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()); + } + + 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()); + + 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, 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); + } + + 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); + } + + 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")); + } + } 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); + } + } + + private void deleteUnusedQosPolicy(StorageStrategy storageStrategy, String policyUuid) { + if (policyUuid == null || policyUuid.isEmpty()) { + return; + } + try { + storageStrategy.deleteVolumeQosPolicy(policyUuid); + } catch (Exception e) { + logger.error("Unused QoS policy [{}] was not deleted: {}", policyUuid, e.getMessage()); + } + } @Override public ChapInfo getChapInfo(DataObject dataObject) { @@ -421,6 +1271,9 @@ public boolean grantAccess(DataObject dataObject, Host host, DataStore dataStore volumeVO.setPoolType(storagePool.getPoolType()); volumeVO.setPoolId(storagePool.getId()); volumeDao.update(volumeVO.getId(), volumeVO); + syncTemporarySnapshotCopyPath(dataObject, volumeVO); + } else if (dataObject.getType() == DataObjectType.TEMPLATE) { + grantAccessTemplate((TemplateInfo) dataObject, host, dataStore, storagePool); } else { logger.error("Invalid DataObjectType (" + dataObject.getType() + ") passed to grantAccess"); throw new CloudRuntimeException("Invalid DataObjectType (" + dataObject.getType() + ") passed to grantAccess"); @@ -432,28 +1285,114 @@ public boolean grantAccess(DataObject dataObject, Host host, DataStore dataStore } } + /** + * For the temporary snapshot copy, {@code StorageSystemDataMotionStrategy} builds the CopyCommand from + * the same in-memory volume object it passed to grantAccess, so the iSCSI path resolved by the LUN + * mapping must be reflected on that object too. + */ + private void syncTemporarySnapshotCopyPath(DataObject dataObject, VolumeVO volumeVO) { + if (!isTemporarySnapshotCopyVolume(volumeVO) || !(dataObject instanceof VolumeInfo)) { + return; + } + Volume inMemoryVolume = ((VolumeInfo) dataObject).getVolume(); + if (inMemoryVolume instanceof VolumeVO) { + ((VolumeVO) inMemoryVolume).setPath(volumeVO.getPath()); + ((VolumeVO) inMemoryVolume).set_iScsiName(volumeVO.get_iScsiName()); + } + } + private void grantAccessIscsi(Host host, VolumeVO volumeVO, Map details, String svmName, StoragePoolVO storagePool) { String cloudStackVolumeName = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_NAME).getValue(); UnifiedSANStrategy sanStrategy = (UnifiedSANStrategy) OntapStorageUtils.getStrategyByStoragePoolDetails(details); String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid()); - // Validate if Igroup exist ONTAP for this host as we may be using delete_on_unmap= true and igroup may be deleted by ONTAP automatically + ensureAccessGroupForHost(sanStrategy, host, storagePool, svmName, accessGroupName); + + // Create or retrieve existing LUN mapping + String lunNumber = sanStrategy.ensureLunMapped(svmName, cloudStackVolumeName, accessGroupName); + + // Update volume path if changed (e.g., after migration or re-mapping) + String iscsiPath = buildIscsiPath(storagePool, lunNumber); + if (volumeVO.getPath() == null || !volumeVO.getPath().equals(iscsiPath)) { + volumeVO.set_iScsiName(iscsiPath); + volumeVO.setPath(iscsiPath); + } + } + + /** + * Maps the cached template LUN to the host so the KVM agent can write the image into it. + * + *

Called by the framework from {@code copyTemplateToManagedTemplateVolume} just before it + * issues the {@code CopyCommand}. That method reads {@code managedStoreTarget} from the pool + * details before this call, when the LUN number does not exist yet, so the stale + * value is corrected here. The datastore details are re-read when the command is built, so the + * update lands in time.

+ */ + private void grantAccessTemplate(TemplateInfo templateInfo, Host host, DataStore dataStore, StoragePoolVO storagePool) { + Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId()); + if (!ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) { + logger.debug("grantAccessTemplate: NFS template [{}], no igroup mapping required", templateInfo.getUuid()); + return; + } + + String svmName = details.get(OntapStorageConstants.SVM_NAME); + VMTemplateStoragePoolVO templatePoolRef = findTemplatePoolRef(storagePool.getId(), templateInfo.getId()); + String lunName = getTemplateLunName(storagePool, templateInfo.getId()); + + UnifiedSANStrategy sanStrategy = (UnifiedSANStrategy) OntapStorageUtils.getStrategyByStoragePoolDetails(details); + String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid()); + + ensureAccessGroupForHost(sanStrategy, host, storagePool, svmName, accessGroupName); + + boolean lunMapped = false; + try { + String lunNumber = sanStrategy.ensureLunMapped(svmName, lunName, accessGroupName); + lunMapped = true; + String iscsiPath = buildIscsiPath(storagePool, lunNumber); + + templatePoolRef.setInstallPath(iscsiPath); + vmTemplatePoolDao.update(templatePoolRef.getId(), templatePoolRef); + refreshManagedStoreTarget(dataStore, iscsiPath); + + logger.info("grantAccessTemplate: Mapped template cache LUN [{}] to igroup [{}] as [{}] for template [{}]", + lunName, accessGroupName, iscsiPath, templateInfo.getId()); + } catch (RuntimeException e) { + // Undo only this call's side effect (map). Do not delete the cache LUN — create already succeeded. + if (lunMapped) { + try { + unmapLunFromHost(sanStrategy, svmName, lunName, accessGroupName, host); + } catch (Exception rollbackEx) { + logger.warn("grantAccessTemplate: Failed to unmap template cache LUN [{}] from igroup [{}] after grant failure: {}", + lunName, accessGroupName, rollbackEx.getMessage()); + } + } + throw e; + } + } + + /** + * Ensures an igroup containing this host's initiator exists on the SVM. + * + *

The igroup may be absent even for a host that used the pool before, because LUN maps are + * created with {@code delete_on_unmap}, which lets ONTAP remove the igroup on its own.

+ */ + private void ensureAccessGroupForHost(UnifiedSANStrategy sanStrategy, Host host, StoragePoolVO storagePool, + String svmName, String accessGroupName) { Map getAccessGroupMap = Map.of( OntapStorageConstants.NAME, accessGroupName, OntapStorageConstants.SVM_DOT_NAME, svmName ); AccessGroup accessGroup = sanStrategy.getAccessGroup(getAccessGroupMap); - if(accessGroup == null || accessGroup.getIgroup() == null) { - logger.info("grantAccess: Igroup {} does not exist for the host {} : Need to create Igroup for the host ", accessGroupName, host.getName()); - // create the igroup for the host and perform lun-mapping + if (accessGroup == null || accessGroup.getIgroup() == null) { + logger.info("ensureAccessGroupForHost: Igroup {} does not exist for the host {} : Need to create Igroup for the host ", accessGroupName, host.getName()); accessGroup = new AccessGroup(); List hosts = new ArrayList<>(); hosts.add((HostVO) host); accessGroup.setHostsToConnect(hosts); accessGroup.setStoragePoolId(storagePool.getId()); accessGroup = sanStrategy.createAccessGroup(accessGroup); - }else{ - logger.info("grantAccess: Igroup {} already exist for the host {}: ", accessGroup.getIgroup().getName() , host.getName()); + } else { + logger.info("ensureAccessGroupForHost: Igroup {} already exist for the host {}: ", accessGroup.getIgroup().getName(), host.getName()); /* TODO Below cases will be covered later, for now they will be a pre-requisite on customer side 1. Igroup exist with the same name but host initiator has been removed 2. Igroup exist with the same name but host initiator has been changed may be due to new NIC or new adapter @@ -461,16 +1400,28 @@ private void grantAccessIscsi(Host host, VolumeVO volumeVO, Map Incase it is not , add it and proceed for lun-mapping */ } - logger.info("grantAccess: Igroup {} is present now with initiators {} ", accessGroup.getIgroup().getName(), accessGroup.getIgroup().getInitiators()); - // Create or retrieve existing LUN mapping - String lunNumber = sanStrategy.ensureLunMapped(svmName, cloudStackVolumeName, accessGroupName); + logger.info("ensureAccessGroupForHost: Igroup {} is present now with initiators {} ", accessGroup.getIgroup().getName(), accessGroup.getIgroup().getInitiators()); + } - // Update volume path if changed (e.g., after migration or re-mapping) - String iscsiPath = OntapStorageConstants.SLASH + storagePool.getPath() + OntapStorageConstants.SLASH + lunNumber; - if (volumeVO.getPath() == null || !volumeVO.getPath().equals(iscsiPath)) { - volumeVO.set_iScsiName(iscsiPath); - volumeVO.setPath(iscsiPath); + /** + * Builds the volume path the KVM agent expects for managed iSCSI: {@code //}. + */ + private String buildIscsiPath(StoragePoolVO storagePool, String lunNumber) { + return OntapStorageConstants.SLASH + storagePool.getPath() + OntapStorageConstants.SLASH + lunNumber; + } + + private void refreshManagedStoreTarget(DataStore dataStore, String iscsiPath) { + if (!(dataStore instanceof PrimaryDataStore)) { + return; } + PrimaryDataStore primaryDataStore = (PrimaryDataStore) dataStore; + Map storeDetails = primaryDataStore.getDetails(); + if (storeDetails == null) { + return; + } + Map updated = new HashMap<>(storeDetails); + updated.put(PrimaryDataStore.MANAGED_STORE_TARGET, iscsiPath); + primaryDataStore.setDetails(updated); } /** @@ -507,6 +1458,8 @@ public void revokeAccess(DataObject dataObject, Host host, DataStore dataStore) throw new CloudRuntimeException("CloudStack Volume not found for id: " + dataObject.getId()); } revokeAccessForVolume(storagePool, volumeVO, host); + } else if (dataObject.getType() == DataObjectType.TEMPLATE) { + revokeAccessForTemplate(storagePool, (TemplateInfo) dataObject, host); } else { logger.error("revokeAccess: Invalid DataObjectType (" + dataObject.getType() + ") passed to revokeAccess"); throw new CloudRuntimeException("Invalid DataObjectType (" + dataObject.getType() + ") passed to revokeAccess"); @@ -532,46 +1485,73 @@ private void revokeAccessForVolume(StoragePoolVO storagePool, VolumeVO volumeVO, // Retrieve LUN name from volume details; if missing, volume may not have been fully created VolumeDetailVO lunDetail = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_NAME); - ValidateRevoke result = getValidateRevoke(volumeVO, host, lunDetail, storageStrategy, svmName, accessGroupName); - if (result == null) return; - - // Remove the LUN mapping from the igroup - Map disableLogicalAccessMap = new HashMap<>(); - disableLogicalAccessMap.put(OntapStorageConstants.LUN_DOT_UUID, result.cloudStackVolume.getLun().getUuid()); - disableLogicalAccessMap.put(OntapStorageConstants.IGROUP_DOT_UUID, result.accessGroup.getIgroup().getUuid()); - storageStrategy.disableLogicalAccess(disableLogicalAccessMap); + String lunName = lunDetail != null ? lunDetail.getValue() : null; + if (lunName == null) { + logger.warn("revokeAccessForVolume: No LUN name found for volume [{}]; skipping revoke", volumeVO.getId()); + return; + } + unmapLunFromHost(storageStrategy, svmName, lunName, accessGroupName, host); + } + } - logger.info("revokeAccessForVolume: Successfully revoked access to LUN [{}] for host [{}]", - result.lunName, host.getName()); + /** + * Unmaps the cached template LUN once the framework has finished writing the image into it. + */ + private void revokeAccessForTemplate(StoragePoolVO storagePool, TemplateInfo templateInfo, Host host) { + Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId()); + if (!ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) { + logger.debug("revokeAccessForTemplate: NFS template [{}], no igroup mapping to remove", templateInfo.getUuid()); + return; } + + String svmName = details.get(OntapStorageConstants.SVM_NAME); + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); + String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid()); + String lunName = getTemplateLunName(storagePool, templateInfo.getId()); + + logger.info("revokeAccessForTemplate: Revoking access to template cache LUN [{}] for host [{}]", lunName, host.getName()); + unmapLunFromHost(storageStrategy, svmName, lunName, accessGroupName, host); } - @Nullable - private ValidateRevoke getValidateRevoke(VolumeVO volumeVO, Host host, VolumeDetailVO lunDetail, StorageStrategy storageStrategy, String svmName, String accessGroupName) { - String lunName = lunDetail != null ? lunDetail.getValue() : null; - if (lunName == null) { - logger.warn("revokeAccessForVolume: No LUN name found for volume [{}]; skipping revoke", volumeVO.getId()); - return null; + /** + * Removes a LUN-to-igroup mapping, skipping quietly when the LUN, the igroup or the host + * initiator is already gone. + */ + private void unmapLunFromHost(StorageStrategy storageStrategy, String svmName, String lunName, + String accessGroupName, Host host) { + ValidateRevoke result = getValidateRevoke(lunName, host, storageStrategy, svmName, accessGroupName); + if (result == null) { + return; } + Map disableLogicalAccessMap = new HashMap<>(); + disableLogicalAccessMap.put(OntapStorageConstants.LUN_DOT_UUID, result.cloudStackVolume.getLun().getUuid()); + disableLogicalAccessMap.put(OntapStorageConstants.IGROUP_DOT_UUID, result.accessGroup.getIgroup().getUuid()); + storageStrategy.disableLogicalAccess(disableLogicalAccessMap); + + logger.info("unmapLunFromHost: Successfully revoked access to LUN [{}] for host [{}]", result.lunName, host.getName()); + } + + @Nullable + private ValidateRevoke getValidateRevoke(String lunName, Host host, StorageStrategy storageStrategy, String svmName, String accessGroupName) { // Verify LUN still exists on ONTAP (may have been manually deleted) CloudStackVolume cloudStackVolume = getCloudStackVolumeByName(storageStrategy, svmName, lunName); if (cloudStackVolume == null || cloudStackVolume.getLun() == null || cloudStackVolume.getLun().getUuid() == null) { - logger.warn("revokeAccessForVolume: LUN for volume [{}] not found on ONTAP, skipping revoke", volumeVO.getId()); + logger.warn("getValidateRevoke: LUN [{}] not found on ONTAP, skipping revoke", lunName); return null; } // Verify igroup still exists on ONTAP AccessGroup accessGroup = getAccessGroupByName(storageStrategy, svmName, accessGroupName); if (accessGroup == null || accessGroup.getIgroup() == null || accessGroup.getIgroup().getUuid() == null) { - logger.warn("revokeAccessForVolume: iGroup [{}] not found on ONTAP, skipping revoke", accessGroupName); + logger.warn("getValidateRevoke: iGroup [{}] not found on ONTAP, skipping revoke", accessGroupName); return null; } // Verify host initiator is in the igroup before attempting to remove mapping SANStrategy sanStrategy = (UnifiedSANStrategy) storageStrategy; if (!sanStrategy.validateInitiatorInAccessGroup(host.getStorageUrl(), svmName, accessGroup.getIgroup())) { - logger.warn("revokeAccessForVolume: Initiator [{}] is not in iGroup [{}], skipping revoke", + logger.warn("getValidateRevoke: Initiator [{}] is not in iGroup [{}], skipping revoke", host.getStorageUrl(), accessGroupName); return null; } @@ -622,24 +1602,138 @@ private AccessGroup getAccessGroupByName(StorageStrategy storageStrategy, String return accessGroup; } + /** + * ONTAP is only supported with KVM, which does not take hypervisor-side snapshots into the + * volume itself, so no reserve is added on top of the requested size. + * + *

For a template this returns the virtual size ({@code VMTemplateVO.size}), not the + * compressed size on secondary storage. The cached template LUN is written by the KVM agent + * with {@code qemu-img convert} from QCOW2 to RAW, so it must be able to hold the fully + * expanded image.

+ */ @Override public long getDataObjectSizeIncludingHypervisorSnapshotReserve(DataObject dataObject, StoragePool storagePool) { - return 0; + if (dataObject == null) { + return 0; + } + Long size = dataObject.getSize(); + return size != null && size > 0 ? size : 0; } @Override public long getBytesRequiredForTemplate(TemplateInfo templateInfo, StoragePool storagePool) { - return 0; + if (templateInfo == null || storagePool == null) { + return 0; + } + // template_spool_ref is inserted in Allocated before the cache exists; + // only skip reservation when the template is Ready and has a backend identity. + VMTemplateStoragePoolVO templatePoolRef = + vmTemplatePoolDao.findByPoolTemplate(storagePool.getId(), templateInfo.getId(), null); + Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId()); + if (isTemplateCachedOnPool(templatePoolRef, details)) { + return 0; + } + return getDataObjectSizeIncludingHypervisorSnapshotReserve(templateInfo, storagePool); + } + + /** + * Returns true when the primary template cache is present and usable for clone/deploy. + * A spool_ref row alone is not enough: CloudStack creates it in Allocated before the LUN/file exists. + * Ready is sufficient; downloadState is set alongside Ready on the managed-cache success path. + */ + private boolean isTemplateCachedOnPool(VMTemplateStoragePoolVO templatePoolRef, Map details) { + if (templatePoolRef == null) { + return false; + } + if (templatePoolRef.getState() != ObjectInDataStoreStateMachine.State.Ready) { + return false; + } + if (details != null && isIscsi(details)) { + return StringUtils.isNotBlank(templatePoolRef.getLocalDownloadPath()); + } + 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); + } } + /** + * Returns min IOPS reserved on the pool, including volumes still being created. + * Destroyed and expunged volumes are omitted by the DAO query. + */ @Override public long getUsedIops(StoragePool storagePool) { - return 0; + return getAllocatedMinIops(storagePool, null); + } + + private long getAllocatedMinIops(StoragePool storagePool, Long excludeVolumeId) { + long usedIops = 0; + + List volumes = volumeDao.findNonDestroyedVolumesByPoolId(storagePool.getId(), null); + if (volumes != null) { + for (VolumeVO volume : volumes) { + if (excludeVolumeId != null && excludeVolumeId.equals(volume.getId())) { + continue; + } + if (volume.getMinIops() != null) { + usedIops += volume.getMinIops(); + } + } + } + + return usedIops; } /** @@ -736,9 +1830,23 @@ public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback details, + DataObject volumeObject, VolumeQosPolicy qosPolicy) { + VolumeQosPolicy qosPolicyReference = null; + if (qosPolicy != null) { + qosPolicyReference = new VolumeQosPolicy(); + qosPolicyReference.setName(qosPolicy.getName()); + qosPolicyReference.setUuid(qosPolicy.getUuid()); + } + + String protocol = details.get(OntapStorageConstants.PROTOCOL); + ProtocolType protocolType = ProtocolType.valueOf(protocol); + switch (protocolType) { + case NFS3: + CloudStackVolume nfsRequest = new CloudStackVolume(); + nfsRequest.setDatastoreId(String.valueOf(storagePool.getId())); + nfsRequest.setFlexVolumeUuid(details.get(OntapStorageConstants.VOLUME_UUID)); + nfsRequest.setVolumeInfo(volumeObject); + if (qosPolicyReference != null) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicyReference); + nfsRequest.setFile(fileInfo); + } + return nfsRequest; + case ISCSI: + Svm svm = new Svm(); + svm.setName(details.get(OntapStorageConstants.SVM_NAME)); + CloudStackVolume iscsiRequest = new CloudStackVolume(); + Lun lunRequest = new Lun(); + lunRequest.setSvm(svm); + + LunSpace lunSpace = new LunSpace(); + lunSpace.setSize(volumeObject.getSize()); + lunRequest.setSpace(lunSpace); + String lunName = volumeObject.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE); + if (!OntapStorageUtils.isValidName(lunName)) { + throw new InvalidParameterValueException("Invalid dataObject name [" + lunName + + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long."); + } + lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), lunName)); + lunRequest.setOsType(Lun.OsTypeEnum.valueOf( + OntapStorageUtils.getOSTypeFromHypervisor(storagePool.getHypervisor().name()))); + lunRequest.setQosPolicy(qosPolicyReference); + iscsiRequest.setLun(lunRequest); + return iscsiRequest; + default: + throw new CloudRuntimeException("Unsupported protocol " + protocol); + } + } + + /** + * LUN path used to cache a template on this pool: {@code /vol//cs_tmpl_}. + */ + private String getTemplateLunName(StoragePoolVO storagePool, long templateId) { + return OntapStorageUtils.getLunName(storagePool.getName(), OntapStorageConstants.TEMPLATE_LUN_PREFIX + templateId); + } + + /** + * Builds the request that clones the cached template LUN into a new volume LUN. + * + *

Source identity mirrors the NFS file-clone path workflow: {@code clone.source.name} is + * the same deterministic ONTAP path used at template create + * ({@code /vol//cs_tmpl_}). {@code local_download_path} (LUN uuid) is + * still sent as a secondary identity.

+ * + *

Size is omitted: ONTAP rejects a size on a clone create, and the clone inherits the + * source size. Growing to the requested volume size is a separate PATCH.

+ */ + private CloudStackVolume createCloneLunRequest(StoragePoolVO storagePool, Map details, + VolumeInfo volumeObject, VMTemplateStoragePoolVO templatePoolRef, + long templateId, VolumeQosPolicy qosPolicy) { + String sourceLunUuid = templatePoolRef.getLocalDownloadPath(); + if (sourceLunUuid == null || sourceLunUuid.isEmpty()) { + throw new CloudRuntimeException("Template [" + templateId + "] has no cached LUN on pool [" + + storagePool.getId() + "]; cannot clone volume [" + volumeObject.getId() + "]"); + } + + Svm svm = new Svm(); + svm.setName(details.get(OntapStorageConstants.SVM_NAME)); + + String lunName = volumeObject.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE); + if (!OntapStorageUtils.isValidName(lunName)) { + throw new InvalidParameterValueException("Invalid dataObject name [" + lunName + + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long."); + } + + Lun.Source source = new Lun.Source(); + source.setName(getTemplateLunName(storagePool, templateId)); + source.setUuid(sourceLunUuid); + Lun.Clone clone = new Lun.Clone(); + clone.setSource(source); + + Lun lunRequest = new Lun(); + lunRequest.setSvm(svm); + lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), lunName)); + lunRequest.setClone(clone); + if (qosPolicy != null) { + VolumeQosPolicy qosPolicyReference = new VolumeQosPolicy(); + qosPolicyReference.setName(qosPolicy.getName()); + qosPolicyReference.setUuid(qosPolicy.getUuid()); + lunRequest.setQosPolicy(qosPolicyReference); + } + + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lunRequest); + request.setVolumeInfo(volumeObject); + return request; + } + + /** + * Builds the request that clones the cached qcow2 ({@code install_path}) into a new file + * named after the volume uuid, inside the same FlexVolume. + */ + private CloudStackVolume createCloneFileRequest(StoragePoolVO storagePool, VolumeInfo volumeInfo, + VMTemplateStoragePoolVO templatePoolRef, long templateId) { + String sourcePath = templatePoolRef.getInstallPath(); + if (sourcePath == null || sourcePath.isEmpty()) { + throw new CloudRuntimeException("Template [" + templateId + "] has no cached file on pool [" + + storagePool.getId() + "]; cannot clone volume [" + volumeInfo.getId() + "]"); + } + + FileInfo file = new FileInfo(); + file.setPath(sourcePath); + + CloudStackVolume request = new CloudStackVolume(); + request.setDatastoreId(String.valueOf(storagePool.getId())); + request.setVolumeInfo(volumeInfo); + request.setFile(file); + request.setDestinationPath(volumeInfo.getUuid()); + return request; + } + + /** + * Builds the request that clones a LUN out of a FlexVolume snapshot into a new volume LUN. + * + *

{@code clone.source.name} is {@code /vol//.snapshot//}; ONTAP cannot + * resolve a snapshot-resident LUN by uuid, so no uuid is sent.

+ */ + private CloudStackVolume createCloneLunFromSnapshotRequest(StoragePoolVO storagePool, Map details, + VolumeInfo volumeInfo, String sourceVolumePath, + String snapshotName, VolumeQosPolicy qosPolicy) { + String lunName = volumeInfo.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE); + if (!OntapStorageUtils.isValidName(lunName)) { + throw new CloudRuntimeException("Invalid dataObject name [" + lunName + + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long."); + } + + String flexVolName = details.get(OntapStorageConstants.VOLUME_NAME); + if (flexVolName == null || flexVolName.isEmpty()) { + flexVolName = storagePool.getName(); + } + + Svm svm = new Svm(); + svm.setName(details.get(OntapStorageConstants.SVM_NAME)); + + Lun.Source source = new Lun.Source(); + source.setName(OntapStorageUtils.toLunCloneSourcePathInSnapshot(sourceVolumePath, flexVolName, snapshotName)); + Lun.Clone clone = new Lun.Clone(); + clone.setSource(source); + + Lun lunRequest = new Lun(); + lunRequest.setSvm(svm); + lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), lunName)); + lunRequest.setClone(clone); + if (qosPolicy != null) { + VolumeQosPolicy qosPolicyReference = new VolumeQosPolicy(); + qosPolicyReference.setName(qosPolicy.getName()); + qosPolicyReference.setUuid(qosPolicy.getUuid()); + lunRequest.setQosPolicy(qosPolicyReference); + } + + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lunRequest); + request.setVolumeInfo(volumeInfo); + return request; + } + + /** + * Builds the request that clones a file out of a FlexVolume snapshot into a new file named after + * the volume uuid, inside the same FlexVolume. + */ + private CloudStackVolume createCloneFileFromSnapshotRequest(StoragePoolVO storagePool, Map details, + VolumeInfo volumeInfo, String sourceVolumePath, + String snapshotName) { + String flexVolName = details.get(OntapStorageConstants.VOLUME_NAME); + + FileInfo file = new FileInfo(); + file.setPath(OntapStorageUtils.toFlexVolRelativePath(sourceVolumePath, flexVolName)); + + CloudStackVolume request = new CloudStackVolume(); + request.setDatastoreId(String.valueOf(storagePool.getId())); + request.setVolumeInfo(volumeInfo); + request.setFile(file); + request.setDestinationPath(OntapStorageUtils.toFlexVolRelativePath(volumeInfo.getUuid(), flexVolName)); + request.setSnapshotName(snapshotName); + return request; + } + // ────────────────────────────────────────────────────────────────────────── // Snapshot Helper Methods // ────────────────────────────────────────────────────────────────────────── @@ -987,77 +2339,74 @@ private String buildSnapshotName(String cloudStackSnapshotName, long snapshotId) return OntapStorageUtils.buildOntapSnapshotName(cloudStackSnapshotName, OntapStorageConstants.CS + snapshotId); } + /** + * Only ISCSI and NFS3 pools can be provisioned; any other protocol would fall through + * createAsync without producing a result, leaving the caller with a null callback value. + */ + private void validateProtocol(Map details, DataStore dataStore) { + String protocol = details == null ? null : details.get(OntapStorageConstants.PROTOCOL); + boolean supported = protocol != null && Arrays.stream(ProtocolType.values()) + .anyMatch(type -> type.name().equalsIgnoreCase(protocol)); + if (!supported) { + throw new CloudRuntimeException("Unsupported protocol [" + protocol + "] on storage pool [" + + dataStore.getName() + "]; supported protocols are " + Arrays.toString(ProtocolType.values())); + } + } - private Storage.ImageFormat getImageFormatByHypervisorAndProtocol(HypervisorType hypervisorType, String protocol) { - if (HypervisorType.KVM.equals(hypervisorType)) { - ProtocolType protocolType = ProtocolType.valueOf(protocol); - switch (protocolType) { - case NFS3: - return Storage.ImageFormat.QCOW2; - case ISCSI: - return Storage.ImageFormat.RAW; - default: - throw new CloudRuntimeException("Unsupported protocol [" + protocol + "] for ONTAP image format resolution"); - } + private Storage.ImageFormat getImageFormat(StoragePoolVO storagePool) { + HypervisorType hypervisorType = storagePool.getHypervisor(); + if (!HypervisorType.KVM.equals(hypervisorType)) { + throw new CloudRuntimeException("Unsupported hypervisor [" + hypervisorType + "] for ONTAP image format resolution"); + } + Storage.StoragePoolType spType = storagePool.getPoolType(); + switch (spType) { + case OntapiSCSI: + return Storage.ImageFormat.RAW; + case NetworkFilesystem: + return Storage.ImageFormat.QCOW2; + default: + throw new CloudRuntimeException("Unsupported pool type [" + spType + "] for ONTAP image format resolution"); } - throw new CloudRuntimeException("Unsupported hypervisor [" + hypervisorType + "] for ONTAP image format resolution"); } + /** - * Persists snapshot metadata in snapshot_details table. - * - * Persists ONTAP snapshot metadata in {@code snapshot_details} for revert and delete. + * Persists ONTAP snapshot metadata in {@code snapshot_details} for revert, delete, and + * create-volume-from-snapshot. * *

Volume-snapshot delete reads {@code base_ontap_fv_id} and {@code ontap_snap_id} here * during {@link #deleteCloudStackVolumeSnapshot}; missing rows prevent ONTAP cleanup.

* - * @param csSnapshotId CloudStack snapshot ID - * @param csVolumeId Source CloudStack volume ID - * @param flexVolUuid ONTAP FlexVolume UUID - * @param ontapSnapshotUuid ONTAP FlexVolume snapshot UUID - * @param snapshotName ONTAP snapshot name - * @param volumePath Path of the volume file/LUN within the FlexVolume (for restore) - * @param storagePoolId Primary storage pool ID - * @param protocol Storage protocol (NFS3 or ISCSI) - * @param lunUuid LUN UUID (only for iSCSI, null for NFS) + *

All rows go through {@link #persistSnapshotDetail} so DAO writes stay consistent; + * optional fields (LUN uuid, IOPS) are skipped when unset.

+ * + * @param csSnapshotId CloudStack snapshot ID + * @param details {@code snapshot_details} name to value, keyed by {@link OntapStorageConstants}; + * entries with null or blank values are skipped + */ + private void updateSnapshotDetails(long csSnapshotId, Map details) { + for (Map.Entry detail : details.entrySet()) { + persistSnapshotDetail(csSnapshotId, detail.getKey(), detail.getValue()); + } + } + + /** + * Adds an IOPS entry to {@code details} only when {@code iops} is non-null and positive. + */ + private static void putPositiveIopsDetail(Map details, String detailName, Long iops) { + if (iops != null && iops > 0) { + details.put(detailName, String.valueOf(iops)); + } + } + + /** + * Persists one {@code snapshot_details} row. No-op when {@code value} is null or blank so + * optional keys (e.g. LUN uuid on NFS) share the same DAO path as required keys. */ - private void updateSnapshotDetails(long csSnapshotId, long csVolumeId, String flexVolUuid, - String ontapSnapshotUuid, String snapshotName, - String volumePath, long storagePoolId, String protocol, - String lunUuid) { - SnapshotDetailsVO snapshotDetail = new SnapshotDetailsVO(csSnapshotId, - OntapStorageConstants.SRC_CS_VOLUME_ID, String.valueOf(csVolumeId), false); - snapshotDetailsDao.persist(snapshotDetail); - - snapshotDetail = new SnapshotDetailsVO(csSnapshotId, - OntapStorageConstants.BASE_ONTAP_FV_ID, flexVolUuid, false); - snapshotDetailsDao.persist(snapshotDetail); - - snapshotDetail = new SnapshotDetailsVO(csSnapshotId, - OntapStorageConstants.ONTAP_SNAP_ID, ontapSnapshotUuid, false); - snapshotDetailsDao.persist(snapshotDetail); - - snapshotDetail = new SnapshotDetailsVO(csSnapshotId, - OntapStorageConstants.ONTAP_SNAP_NAME, snapshotName, false); - snapshotDetailsDao.persist(snapshotDetail); - - snapshotDetail = new SnapshotDetailsVO(csSnapshotId, - OntapStorageConstants.VOLUME_PATH, volumePath, false); - snapshotDetailsDao.persist(snapshotDetail); - - snapshotDetail = new SnapshotDetailsVO(csSnapshotId, - OntapStorageConstants.PRIMARY_POOL_ID, String.valueOf(storagePoolId), false); - snapshotDetailsDao.persist(snapshotDetail); - - snapshotDetail = new SnapshotDetailsVO(csSnapshotId, - OntapStorageConstants.PROTOCOL, protocol, false); - snapshotDetailsDao.persist(snapshotDetail); - - // Store LUN UUID for iSCSI volumes (required for LUN restore API) - if (lunUuid != null && !lunUuid.isEmpty()) { - snapshotDetail = new SnapshotDetailsVO(csSnapshotId, - OntapStorageConstants.LUN_DOT_UUID, lunUuid, false); - snapshotDetailsDao.persist(snapshotDetail); + private void persistSnapshotDetail(long csSnapshotId, String detailName, String value) { + if (value == null || value.isEmpty()) { + return; } + snapshotDetailsDao.persist(new SnapshotDetailsVO(csSnapshotId, detailName, value, false)); } } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/ClusterFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/ClusterFeignClient.java index 582fb58e6f3b..3127e4a63335 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/ClusterFeignClient.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/ClusterFeignClient.java @@ -19,9 +19,15 @@ package org.apache.cloudstack.storage.feign.client; +import java.util.Map; + import org.apache.cloudstack.storage.feign.model.Cluster; +import org.apache.cloudstack.storage.feign.model.ClusterNode; +import org.apache.cloudstack.storage.feign.model.response.OntapResponse; + import feign.Headers; import feign.Param; +import feign.QueryMap; import feign.RequestLine; public interface ClusterFeignClient { @@ -29,4 +35,9 @@ public interface ClusterFeignClient { @RequestLine("GET /api/cluster") @Headers({"Authorization: {authHeader}", "return_records: {returnRecords}"}) Cluster getCluster(@Param("authHeader") String authHeader, @Param("returnRecords") boolean returnRecords); + + @RequestLine("GET /api/cluster/nodes") + @Headers({"Authorization: {authHeader}"}) + OntapResponse getClusterNodes(@Param("authHeader") String authHeader, + @QueryMap Map queryMap); } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java index 8cf21b94b2f1..ba38f57ef1c7 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java @@ -21,7 +21,9 @@ import feign.QueryMap; import org.apache.cloudstack.storage.feign.model.ExportPolicy; +import org.apache.cloudstack.storage.feign.model.FileCloneRequest; import org.apache.cloudstack.storage.feign.model.FileInfo; +import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import feign.Headers; import feign.Param; @@ -58,6 +60,15 @@ void createFile(@Param("authHeader") String authHeader, @Param("path") String filePath, FileInfo file); + /** + * Creates a space-efficient clone of a file within a FlexVolume. + * + *

ONTAP REST: {@code POST /api/storage/file/clone}

+ */ + @RequestLine("POST /api/storage/file/clone") + @Headers({"Authorization: {authHeader}", "Content-Type: application/json"}) + JobResponse cloneFile(@Param("authHeader") String authHeader, FileCloneRequest request); + // Export Policy Operations @RequestLine("POST /api/protocols/nfs/export-policies") @Headers({"Authorization: {authHeader}"}) diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/QosFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/QosFeignClient.java new file mode 100644 index 000000000000..669ea935124d --- /dev/null +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/QosFeignClient.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.feign.client; + +import java.util.Map; + +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 feign.Headers; +import feign.Param; +import feign.QueryMap; +import feign.RequestLine; + +public interface QosFeignClient { + + @RequestLine("POST /api/storage/qos/policies") + @Headers({"Authorization: {authHeader}"}) + JobResponse createPolicy(@Param("authHeader") String authHeader, VolumeQosPolicy policy); + + @RequestLine("GET /api/storage/qos/policies") + @Headers({"Authorization: {authHeader}"}) + OntapResponse getPolicies(@Param("authHeader") String authHeader, + @QueryMap Map queryParams); + + @RequestLine("GET /api/storage/qos/policies/{uuid}") + @Headers({"Authorization: {authHeader}"}) + VolumeQosPolicy getPolicy(@Param("authHeader") String authHeader, @Param("uuid") String uuid, + @QueryMap Map queryParams); + + @RequestLine("DELETE /api/storage/qos/policies/{uuid}") + @Headers({"Authorization: {authHeader}"}) + JobResponse deletePolicy(@Param("authHeader") String authHeader, @Param("uuid") String uuid); +} diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java index 7281dc2ecbeb..8a60a9a3e2bb 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java @@ -50,13 +50,13 @@ public interface SANFeignClient { @Headers({"Authorization: {authHeader}"}) Lun getLunByUUID(@Param("authHeader") String authHeader, @Param("uuid") String uuid); - @RequestLine("PATCH /{uuid}") - @Headers({"Authorization: {authHeader}"}) - void updateLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Lun lun); + @RequestLine("PATCH /api/storage/luns/{uuid}") + @Headers({"Authorization: {authHeader}", "Content-Type: application/json"}) + JobResponse updateLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Lun lun); @RequestLine("DELETE /api/storage/luns/{uuid}") @Headers({"Authorization: {authHeader}"}) - void deleteLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, @QueryMap Map queryMap); + JobResponse deleteLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, @QueryMap Map queryMap); // iGroup Operation APIs @RequestLine("POST /api/protocols/san/igroups?return_records={returnRecords}") diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/VolumeFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/VolumeFeignClient.java index 6384566487d4..67cddceff8ca 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/VolumeFeignClient.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/VolumeFeignClient.java @@ -44,7 +44,7 @@ public interface VolumeFeignClient { @RequestLine("GET /api/storage/volumes/{uuid}") @Headers({"Authorization: {authHeader}"}) - Volume getVolumeByUUID(@Param("authHeader") String authHeader, @Param("uuid") String uuid); + Volume getVolumeByUUID(@Param("authHeader") String authHeader, @Param("uuid") String uuid, @QueryMap Map queryParams); @RequestLine("GET /api/storage/volumes") @Headers({"Authorization: {authHeader}"}) @@ -53,4 +53,8 @@ public interface VolumeFeignClient { @RequestLine("PATCH /api/storage/volumes/{uuid}") @Headers({ "Authorization: {authHeader}"}) JobResponse updateVolumeRebalancing(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Volume volumeRequest); + + @RequestLine("PATCH /api/storage/volumes/{uuid}") + @Headers({ "Authorization: {authHeader}"}) + JobResponse updateVolume(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Volume volumeRequest); } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Cluster.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Cluster.java index 061372756175..861430ecf3c8 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Cluster.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Cluster.java @@ -19,6 +19,7 @@ package org.apache.cloudstack.storage.feign.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -48,6 +49,11 @@ public class Cluster { @JsonProperty("disaggregated") private Boolean disaggregated = null; + @JsonIgnore + private String model; + + @JsonIgnore + private String platformType; public String getHealth() { return health; @@ -104,6 +110,22 @@ public void setDisaggregated(Boolean disaggregated) { this.disaggregated = disaggregated; } + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getPlatformType() { + return platformType; + } + + public void setPlatformType(String platformType) { + this.platformType = platformType; + } + @Override public int hashCode() { return Objects.hash(getName(), getUuid()); diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ClusterNode.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ClusterNode.java new file mode 100644 index 000000000000..8d067c05790c --- /dev/null +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ClusterNode.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.storage.feign.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Subset of {@code GET /api/cluster/nodes} used for ASUP + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ClusterNode { + + @JsonProperty("model") + private String model; + + @JsonProperty("is_all_flash_optimized") + private Boolean allFlashOptimized; + + @JsonProperty("is_perf_optimized") + private Boolean performanceOptimized; + + @JsonProperty("is_capacity_optimized") + private Boolean capacityOptimized; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public Boolean getAllFlashOptimized() { + return allFlashOptimized; + } + + public void setAllFlashOptimized(Boolean allFlashOptimized) { + this.allFlashOptimized = allFlashOptimized; + } + + public Boolean getPerformanceOptimized() { + return performanceOptimized; + } + + public void setPerformanceOptimized(Boolean performanceOptimized) { + this.performanceOptimized = performanceOptimized; + } + + public Boolean getCapacityOptimized() { + return capacityOptimized; + } + + public void setCapacityOptimized(Boolean capacityOptimized) { + this.capacityOptimized = capacityOptimized; + } +} diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileCloneRequest.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileCloneRequest.java new file mode 100644 index 000000000000..cefff9fd282f --- /dev/null +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileCloneRequest.java @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.feign.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Request body for the ONTAP file clone API. + * + *

ONTAP REST endpoint: {@code POST /api/storage/file/clone}

+ * + *

Creates a space-efficient copy of a file. Source and destination paths are relative to the + * root of {@code volume}, and both must live in that same FlexVolume.

+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class FileCloneRequest { + + @JsonProperty("volume") + private VolumeRef volume; + + @JsonProperty("source_path") + private String sourcePath; + + @JsonProperty("destination_path") + private String destinationPath; + + @JsonProperty("overwrite_destination") + private Boolean overwriteDestination; + + /** + * Optional FlexVolume snapshot to clone from. When set, ONTAP clones {@code source_path} + * as it existed in that snapshot rather than from the live file/LUN. + * + *

Used by create-volume-from-snapshot (same FlexVol). Omitted for live template-cache clones.

+ */ + @JsonProperty("snapshot") + private SnapshotRef snapshot; + + public FileCloneRequest() { + } + + public FileCloneRequest(String flexVolUuid, String flexVolName, String sourcePath, String destinationPath) { + this.volume = new VolumeRef(flexVolUuid, flexVolName); + this.sourcePath = sourcePath; + this.destinationPath = destinationPath; + } + + public FileCloneRequest(String flexVolUuid, String flexVolName, String sourcePath, String destinationPath, + String snapshotName) { + this(flexVolUuid, flexVolName, sourcePath, destinationPath); + if (snapshotName != null && !snapshotName.isEmpty()) { + this.snapshot = new SnapshotRef(snapshotName); + } + } + + public VolumeRef getVolume() { + return volume; + } + + public void setVolume(VolumeRef volume) { + this.volume = volume; + } + + public String getSourcePath() { + return sourcePath; + } + + public void setSourcePath(String sourcePath) { + this.sourcePath = sourcePath; + } + + public String getDestinationPath() { + return destinationPath; + } + + public void setDestinationPath(String destinationPath) { + this.destinationPath = destinationPath; + } + + public Boolean getOverwriteDestination() { + return overwriteDestination; + } + + public void setOverwriteDestination(Boolean overwriteDestination) { + this.overwriteDestination = overwriteDestination; + } + + public SnapshotRef getSnapshot() { + return snapshot; + } + + public void setSnapshot(SnapshotRef snapshot) { + this.snapshot = snapshot; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class VolumeRef { + + @JsonProperty("uuid") + private String uuid; + + @JsonProperty("name") + private String name; + + public VolumeRef() { + } + + public VolumeRef(String uuid, String name) { + this.uuid = uuid; + this.name = name; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + /** + * Snapshot identity for {@code POST /api/storage/file/clone} when cloning from a FlexVol snapshot. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class SnapshotRef { + + @JsonProperty("name") + private String name; + + public SnapshotRef() { + } + + public SnapshotRef(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @Override + public String toString() { + return "FileCloneRequest{volume=" + (volume != null ? volume.getUuid() : null) + + ", sourcePath=" + sourcePath + + ", destinationPath=" + destinationPath + + ", snapshot=" + (snapshot != null ? snapshot.getName() : null) + "}"; + } +} diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java index a5dd24a3a286..71bf4c980bd4 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java @@ -49,6 +49,8 @@ public class FileInfo { private Boolean overwriteEnabled = null; @JsonProperty("path") private String path = null; + @JsonProperty("qos_policy") + private VolumeQosPolicy qosPolicy = null; @JsonProperty("size") private Long size = null; @JsonProperty("target") @@ -178,6 +180,15 @@ public String getPath() { public void setPath(String path) { this.path = path; } + + public VolumeQosPolicy getQosPolicy() { + return qosPolicy; + } + + public void setQosPolicy(VolumeQosPolicy qosPolicy) { + this.qosPolicy = qosPolicy; + } + public Long getSize() { return size; } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java index 364790958c8a..0b2e09dbec45 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java @@ -83,6 +83,9 @@ public static PropertyClassEnum fromValue(String value) { @JsonProperty("name") private String name = null; + @JsonProperty("qos_policy") + private VolumeQosPolicy qosPolicy = null; + @JsonProperty("clone") private Clone clone = null; @@ -202,6 +205,14 @@ public void setName(String name) { this.name = name; } + public VolumeQosPolicy getQosPolicy() { + return qosPolicy; + } + + public void setQosPolicy(VolumeQosPolicy qosPolicy) { + this.qosPolicy = qosPolicy; + } + public Lun osType(OsTypeEnum osType) { this.osType = osType; return this; @@ -308,6 +319,7 @@ private String toIndentedString(Object o) { } + @JsonInclude(JsonInclude.Include.NON_NULL) public static class Clone { @JsonProperty("source") private Source source = null; @@ -319,6 +331,7 @@ public void setSource(Source source) { } } + @JsonInclude(JsonInclude.Include.NON_NULL) public static class Source { @JsonProperty("name") private String name = null; diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java index 7a9a4307ab1a..2e6ce1b6fa3c 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java @@ -26,43 +26,23 @@ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class VolumeQosPolicy { - @JsonProperty("max_throughput_iops") - private Integer maxThroughputIops = null; - - @JsonProperty("max_throughput_mbps") - private Integer maxThroughputMbps = null; - - @JsonProperty("min_throughput_iops") - private Integer minThroughputIops = null; - + @JsonProperty("fixed") + private Fixed fixed; @JsonProperty("name") private String name = null; - @JsonProperty("uuid") private String uuid = null; + @JsonProperty("svm") + private Svm svm; + @JsonProperty("object_count") + private Integer objectCount; - public Integer getMaxThroughputIops() { - return maxThroughputIops; - } - - public void setMaxThroughputIops(Integer maxThroughputIops) { - this.maxThroughputIops = maxThroughputIops; - } - - public Integer getMaxThroughputMbps() { - return maxThroughputMbps; + public Fixed getFixed() { + return fixed; } - public void setMaxThroughputMbps(Integer maxThroughputMbps) { - this.maxThroughputMbps = maxThroughputMbps; - } - - public Integer getMinThroughputIops() { - return minThroughputIops; - } - - public void setMinThroughputIops(Integer minThroughputIops) { - this.minThroughputIops = minThroughputIops; + public void setFixed(Fixed fixed) { + this.fixed = fixed; } public String getName() { @@ -80,4 +60,57 @@ public String getUuid() { public void setUuid(String uuid) { this.uuid = uuid; } + + public Svm getSvm() { + return svm; + } + + public void setSvm(Svm svm) { + this.svm = svm; + } + + public Integer getObjectCount() { + return objectCount; + } + + public void setObjectCount(Integer objectCount) { + this.objectCount = objectCount; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Fixed { + @JsonProperty("capacity_shared") + private Boolean capacityShared; + + @JsonProperty("min_throughput_iops") + private Long minThroughputIops; + + @JsonProperty("max_throughput_iops") + private Long maxThroughputIops; + + public Boolean getCapacityShared() { + return capacityShared; + } + + public void setCapacityShared(Boolean capacityShared) { + this.capacityShared = capacityShared; + } + + public Long getMinThroughputIops() { + return minThroughputIops; + } + + public void setMinThroughputIops(Long minThroughputIops) { + this.minThroughputIops = minThroughputIops; + } + + public Long getMaxThroughputIops() { + return maxThroughputIops; + } + + public void setMaxThroughputIops(Long maxThroughputIops) { + this.maxThroughputIops = maxThroughputIops; + } + } } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java index 8e8150e24e2c..55fb7c868c49 100755 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java @@ -57,6 +57,7 @@ import com.cloud.agent.api.StoragePoolInfo; import com.cloud.alert.AlertManager; +import com.cloud.capacity.CapacityManager; import com.cloud.dc.ClusterVO; import com.cloud.dc.dao.ClusterDao; import com.cloud.exception.InvalidParameterValueException; @@ -81,6 +82,7 @@ public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycl @Inject private PrimaryDataStoreDao storagePoolDao; @Inject private StoragePoolDetailsDao storagePoolDetailsDao; @Inject private AlertManager _alertMgr; + @Inject private CapacityManager _capacityMgr; private static final Logger logger = LogManager.getLogger(OntapPrimaryDatastoreLifecycle.class); private static final long ONTAP_MIN_VOLUME_SIZE_IN_BYTES = 20971520L; @@ -101,6 +103,7 @@ public DataStore initialize(Map dsInfos) { String storagePoolName = (String) dsInfos.get("name"); String providerName = (String) dsInfos.get("providerName"); Long capacityBytes = (Long) dsInfos.get("capacityBytes"); + Long capacityIops = (Long) dsInfos.get("capacityIops"); boolean managed = (boolean) dsInfos.get("managed"); String tags = (String) dsInfos.get("tags"); Boolean isTagARule = (Boolean) dsInfos.get("isTagARule"); @@ -113,7 +116,7 @@ public DataStore initialize(Map dsInfos) { @SuppressWarnings("unchecked") Map details = (Map) dsInfos.get("details"); - validateInitializeInputs(capacityBytes, podId, clusterId, zoneId, storagePoolName, providerName, managed, details); + validateInitializeInputs(capacityBytes, capacityIops, podId, clusterId, zoneId, storagePoolName, providerName, managed, details); PrimaryDataStoreParameters parameters = new PrimaryDataStoreParameters(); if (clusterId != null) { @@ -140,6 +143,7 @@ public DataStore initialize(Map dsInfos) { StorageStrategy storageStrategy = StorageProviderFactory.getStrategy(ontapStorage); boolean isValid = storageStrategy.connect(); if (isValid) { + details.put(OntapStorageConstants.IS_AFF, Boolean.toString(storageStrategy.isAff())); if (storageStrategy.getResolvedSvmUuid() != null && !storageStrategy.getResolvedSvmUuid().isEmpty()) { details.put(OntapStorageConstants.SVM_UUID, storageStrategy.getResolvedSvmUuid()); } @@ -184,7 +188,7 @@ public DataStore initialize(Map dsInfos) { logger.info("Setting NFS path for storage pool: " + path + ", port: " + port + " with mount option: vers=3"); break; case ISCSI: - parameters.setType(Storage.StoragePoolType.Iscsi); + parameters.setType(Storage.StoragePoolType.OntapiSCSI); path = storageStrategy.getStoragePath(); port = OntapStorageConstants.ISCSI_PORT; logger.info("Setting iSCSI path for storage pool: " + path + ", port: " + port); @@ -207,12 +211,13 @@ public DataStore initialize(Map dsInfos) { parameters.setProviderName(providerName); parameters.setManaged(managed); parameters.setCapacityBytes(capacityBytes); + parameters.setCapacityIops(capacityIops); parameters.setUsedBytes(0); return _dataStoreHelper.createPrimaryDataStore(parameters); } - private void validateInitializeInputs(Long capacityBytes, Long podId, Long clusterId, Long zoneId, + private void validateInitializeInputs(Long capacityBytes, Long capacityIops, Long podId, Long clusterId, Long zoneId, String storagePoolName, String providerName, boolean managed, Map details) { if (capacityBytes == null || capacityBytes <= 0) { @@ -223,6 +228,10 @@ private void validateInitializeInputs(Long capacityBytes, Long podId, Long clust throw new InvalidParameterValueException("Storage pool capacity " + capacityBytes + " bytes is below the ONTAP minimum volume size of " + ONTAP_MIN_VOLUME_SIZE_IN_BYTES + " bytes (20 MB)"); } + // IOPS capacity is optional; when left blank no pool-level IOPS ceiling is enforced. + if (capacityIops != null && capacityIops <= 0) { + throw new InvalidParameterValueException("Storage pool IOPS capacity must be greater than 0"); + } // Validate scope if (podId == null ^ clusterId == null) { @@ -551,7 +560,62 @@ public boolean migrateToObjectStore(DataStore store) { @Override public void updateStoragePool(StoragePool storagePool, Map details) { + String newCapacityIopsStr = details.get(PrimaryDataStoreLifeCycle.CAPACITY_IOPS); + if (newCapacityIopsStr != null) { + validateUpdatedCapacityIops(storagePool, newCapacityIopsStr); + } + + String newCapacityStr = details.get(PrimaryDataStoreLifeCycle.CAPACITY_BYTES); + if (newCapacityStr == null) { + logger.debug("No capacity change requested for pool: {}, skipping FlexVolume resize", storagePool.getName()); + return; + } + long currentCapacityBytes = storagePool.getCapacityBytes(); + long newCapacityBytes = Long.parseLong(newCapacityStr); + StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details); + + String volumeUuid = details.get(OntapStorageConstants.VOLUME_UUID); + if (volumeUuid == null || volumeUuid.isEmpty()) { + logger.error("Volume UUID or name not found in details for pool: {}, cannot resize", storagePool.getName()); + throw new CloudRuntimeException("Volume UUID or name not found in details, cannot resize ONTAP FlexVolume"); + } + + Volume volume = new Volume(); + volume.setUuid(volumeUuid); + volume.setName(details.get(OntapStorageConstants.VOLUME_NAME)); + volume.setSize(newCapacityBytes); + try { + storageStrategy.updateStorageVolume(volume); + logger.info("Successfully resized ONTAP FlexVolume '{}' (UUID: {}) for pool '{}' from {} bytes to {} bytes", + volume.getName(), volume.getUuid(), storagePool.getName(), currentCapacityBytes, newCapacityBytes); + } catch (Exception e) { + logger.error("Exception while resizing FlexVolume for pool: {}. Error: {}", storagePool.getName(), e.getMessage(), e); + throw new CloudRuntimeException("Failed to resize ONTAP FlexVolume for pool: " + storagePool.getName() + ". " + e.getMessage(), e); + } + } + + private void validateUpdatedCapacityIops(StoragePool storagePool, String newCapacityIopsStr) { + long newCapacityIops; + try { + newCapacityIops = Long.parseLong(newCapacityIopsStr); + } catch (NumberFormatException e) { + throw new InvalidParameterValueException("Invalid storage pool IOPS capacity: " + newCapacityIopsStr); + } + if (newCapacityIops <= 0) { + throw new InvalidParameterValueException("Storage pool IOPS capacity must be greater than 0"); + } + + StoragePoolVO storagePoolVO = storagePoolDao.findById(storagePool.getId()); + if (storagePoolVO == null) { + throw new InvalidParameterValueException("Storage pool not found for id: " + storagePool.getId()); + } + long allocatedIops = _capacityMgr.getUsedIops(storagePoolVO); + if (newCapacityIops < allocatedIops) { + throw new InvalidParameterValueException(String.format( + "Cannot set IOPS capacity of storage pool %s to %d IOPS because %d IOPS are already allocated", + storagePool.getName(), newCapacityIops, allocatedIops)); + } } @Override 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 b385269d5f2a..47df8e008c8c 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,29 +19,38 @@ package org.apache.cloudstack.storage.service; -import java.util.ArrayList; +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 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.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.SvmFeignClient; import org.apache.cloudstack.storage.feign.client.VolumeFeignClient; import org.apache.cloudstack.storage.feign.model.Aggregate; +import org.apache.cloudstack.storage.feign.model.Cluster; +import org.apache.cloudstack.storage.feign.model.ClusterNode; import org.apache.cloudstack.storage.feign.model.IpInterface; import org.apache.cloudstack.storage.feign.model.IscsiService; import org.apache.cloudstack.storage.feign.model.Job; import org.apache.cloudstack.storage.feign.model.Nas; import org.apache.cloudstack.storage.feign.model.OntapStorage; import org.apache.cloudstack.storage.feign.model.Svm; +import org.apache.cloudstack.storage.feign.model.Version; import org.apache.cloudstack.storage.feign.model.Volume; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.model.AccessGroup; @@ -49,6 +58,7 @@ 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; @@ -72,9 +82,11 @@ public abstract class StorageStrategy { protected SvmFeignClient svmFeignClient; protected JobFeignClient jobFeignClient; protected NetworkFeignClient networkFeignClient; + protected QosFeignClient qosFeignClient; protected SANFeignClient sanFeignClient; protected NASFeignClient nasFeignClient; protected SnapshotFeignClient snapshotFeignClient; + protected ClusterFeignClient clusterFeignClient; protected OntapStorage storage; @@ -103,9 +115,140 @@ public StorageStrategy(OntapStorage ontapStorage) { this.svmFeignClient = feignClientFactory.createClient(SvmFeignClient.class, baseURL); this.jobFeignClient = feignClientFactory.createClient(JobFeignClient.class, baseURL); this.networkFeignClient = feignClientFactory.createClient(NetworkFeignClient.class, baseURL); + this.qosFeignClient = feignClientFactory.createClient(QosFeignClient.class, baseURL); this.sanFeignClient = feignClientFactory.createClient(SANFeignClient.class, baseURL); this.nasFeignClient = feignClientFactory.createClient(NASFeignClient.class, baseURL); this.snapshotFeignClient = feignClientFactory.createClient(SnapshotFeignClient.class, baseURL); + this.clusterFeignClient = feignClientFactory.createClient(ClusterFeignClient.class, baseURL); + } + + /** + * Fetches the ONTAP {@link Cluster} (name, uuid, version) and, for ASUP, the rolled-up node + * {@code model} and {@code platformType} from {@code GET /api/cluster/nodes}. Best-effort: + * returns {@code null} if the cluster GET fails. Node fields are left unset if the nodes GET + * fails. Callers must never fail a storage operation because of this. + * + * @return the ONTAP {@link Cluster}, or {@code null} if it cannot be resolved + */ + public Cluster getClusterInfo() { + try { + String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); + Cluster cluster = clusterFeignClient.getCluster(authHeader, true); + if (cluster != null) { + populateNodeAsupFields(cluster, authHeader); + } + return cluster; + } catch (Exception e) { + logger.warn("getClusterInfo: failed to fetch ONTAP cluster info for storage IP {}: {}", + storage.getStorageIP(), e.getMessage()); + return null; + } + } + + /** + * Sets {@link Cluster#setModel(String)} and {@link Cluster#setPlatformType(String)} from one + * {@code GET /api/cluster/nodes}. Distinct models are joined with a comma. Platform type is + * {@code performance}, {@code capacity}, {@code fas}, or {@code composite} when personalities mix. + */ + private void populateNodeAsupFields(Cluster cluster, String authHeader) { + try { + Map query = new HashMap<>(); + query.put(OntapStorageConstants.FIELDS, OntapStorageConstants.CLUSTER_NODE_ASUP_FIELDS); + OntapResponse response = clusterFeignClient.getClusterNodes(authHeader, query); + if (response == null || response.getRecords() == null || response.getRecords().isEmpty()) { + return; + } + LinkedHashSet models = new LinkedHashSet<>(); + LinkedHashSet platformTypes = new LinkedHashSet<>(); + for (ClusterNode node : response.getRecords()) { + if (node == null) { + continue; + } + if (node.getModel() != null && !node.getModel().isBlank()) { + models.add(node.getModel().trim()); + } + platformTypes.add(classifyNodePlatformType(node)); + } + if (!models.isEmpty()) { + cluster.setModel(String.join(OntapStorageConstants.COMMA, models)); + } + cluster.setPlatformType(rollupPlatformType(platformTypes)); + } catch (Exception e) { + logger.warn("getClusterInfo: failed to fetch ONTAP node hardware for storage IP {}: {}", + storage.getStorageIP(), e.getMessage()); + } + } + + /** + * Extracts a clean, parser-friendly ONTAP version string from a {@link Cluster}. + * + *

Prefers the compact "generation.major.minor" numeric form (e.g. "9.17.1"), which avoids + * the colon/date noise in {@code version.full}. Falls back to the verbose {@code version.full} + * banner only when the numeric fields are unavailable.

+ * + * @param cluster the cluster (may be {@code null}) + * @return the ONTAP version string, or {@code null} if it cannot be resolved + */ + public String getClusterVersion(Cluster cluster) { + if (cluster == null || cluster.getVersion() == null) { + return null; + } + Version version = cluster.getVersion(); + if (version.getGeneration() != null && version.getMajor() != null && version.getMinor() != null) { + return version.getGeneration() + OntapStorageConstants.DOT + version.getMajor() + + OntapStorageConstants.DOT + version.getMinor(); + } + if (version.getFull() != null && !version.getFull().isEmpty()) { + return version.getFull(); + } + return null; + } + + /** + * Classifies one node: not all-flash → {@code fas}; all-flash + capacity → {@code capacity}; + * otherwise all-flash (including performance-optimized or classic AFF) → {@code performance}. + */ + private static String classifyNodePlatformType(ClusterNode node) { + boolean allFlash = Boolean.TRUE.equals(node.getAllFlashOptimized()); + boolean capacity = Boolean.TRUE.equals(node.getCapacityOptimized()); + if (!allFlash) { + return OntapStorageConstants.ASUP_PLATFORM_TYPE_FAS; + } + if (capacity) { + return OntapStorageConstants.ASUP_PLATFORM_TYPE_CAPACITY; + } + return OntapStorageConstants.ASUP_PLATFORM_TYPE_PERFORMANCE; + } + + private static String rollupPlatformType(LinkedHashSet platformTypes) { + if (platformTypes == null || platformTypes.isEmpty()) { + return null; + } + if (platformTypes.size() == 1) { + return platformTypes.iterator().next(); + } + return OntapStorageConstants.ASUP_PLATFORM_TYPE_COMPOSITE; + } + + /** + * True when every cluster node reports {@code is_all_flash_optimized} (AFF, including C-series). + * A FAS node, or a node that omits the flag, makes this false. Used for min-throughput QoS support. + */ + public boolean isAff() { + Map query = new HashMap<>(); + query.put(OntapStorageConstants.FIELDS, OntapStorageConstants.CLUSTER_NODE_ASUP_FIELDS); + OntapResponse response = clusterFeignClient.getClusterNodes(getAuthHeader(), query); + if (response == null || response.getRecords() == null + || response.getRecords().stream().allMatch(node -> node == null)) { + throw new CloudRuntimeException( + "Unable to determine whether the ONTAP cluster is AFF or FAS"); + } + for (ClusterNode node : response.getRecords()) { + if (node == null || !Boolean.TRUE.equals(node.getAllFlashOptimized())) { + return false; + } + } + return true; } /** @@ -191,7 +334,6 @@ private void validateAndSelectAggregatesForVolumeCreation(String authHeader, Str logger.error("No aggregates are assigned to SVM " + svmName); throw new CloudRuntimeException("No aggregates are assigned to SVM " + svmName); } - List eligibleAggregates = new ArrayList<>(); for (Aggregate aggr : aggrs) { logger.debug("Found aggregate: " + aggr.getName() + " with UUID: " + aggr.getUuid()); Aggregate aggrResp = aggregateFeignClient.getAggregateByUUID(authHeader, aggr.getUuid(), @@ -211,13 +353,12 @@ private void validateAndSelectAggregatesForVolumeCreation(String authHeader, Str continue; } logger.info("Selected aggregate: " + aggr.getName() + " for volume operations."); - eligibleAggregates.add(aggrResp); + this.aggregates = List.of(aggr); } - if (eligibleAggregates.isEmpty()) { + if (this.aggregates == null || this.aggregates.isEmpty()) { logger.error("No suitable aggregates found on SVM " + svmName + " for volume creation."); throw new CloudRuntimeException("No suitable aggregates found on SVM " + svmName + " for volume creation."); } - this.aggregates = eligibleAggregates; } // Common methods like create/delete etc., should be here @@ -387,7 +528,25 @@ public Volume createStorageVolume(String volumeName, Long size) { * @return the updated Volume object */ public Volume updateStorageVolume(Volume volume) { - return null; + logger.info("Resizing ONTAP FlexVolume '{}' (UUID: {}) to {} bytes", volume.getName(), volume.getUuid(), volume.getSize()); + String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); + try { + Volume resizeRequest = new Volume(); + resizeRequest.setSize(volume.getSize()); + JobResponse jobResponse = volumeFeignClient.updateVolume(authHeader, volume.getUuid(), resizeRequest); + pollJobIfPresent(jobResponse, "resize FlexVolume [" + volume.getUuid() + "]", + OntapStorageConstants.ONTAP_VOLUME_JOB_MAX_RETRIES, OntapStorageConstants.ONTAP_VOLUME_JOB_POLL_INTERVAL_MS); + logger.info("FlexVolume '{}' (UUID: {}) resized successfully to {} bytes", volume.getName(), volume.getUuid(), volume.getSize()); + } catch (FeignException e) { + if (OntapStorageUtils.isOntapObjectNotFoundError(e)) { + String msg = String.format("Cannot resize FlexVolume '%s' (UUID: %s): volume not found on ONTAP (404). ", volume.getName(), volume.getUuid()); + logger.error(msg); + throw new CloudRuntimeException(msg, e); + } + logger.error("Exception while resizing FlexVolume '{}' (UUID: {}): {}", volume.getName(), volume.getUuid(), e.getMessage(), e); + throw new CloudRuntimeException("Failed to resize ONTAP FlexVolume: " + e.getMessage(), e); + } + return volume; } /** @@ -420,15 +579,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. + * + *

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 volume the volume to retrieve - * @return the retrieved Volume object + * @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()); + } } /** @@ -531,10 +713,7 @@ public Pair getNetworkInterface() { IpInterface fallbackInterface = null; for (IpInterface iface : response.getRecords()) { - if (iface == null || !Boolean.TRUE.equals(iface.getEnabled()) || !OntapStorageConstants.LIF_STATE_UP.equals(iface.getState())) { - continue; - } - if (iface.getIp() == null || iface.getIp().getAddress() == null) { + if (!Boolean.TRUE.equals(iface.getEnabled()) || !OntapStorageConstants.LIF_STATE_UP.equals(iface.getState())) { continue; } if (!isIPv4Address(iface.getIp().getAddress())) { @@ -612,6 +791,27 @@ private boolean isIPv4Address(String address) { */ abstract public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume); + /** + * Creates the protocol-specific backend object that caches a template on this pool. + * + *

iSCSI creates an empty LUN ({@code /vol/<flexVol>/cs_tmpl_<id>}) sized to + * {@code sizeInBytes}. NFS is a no-op on the array: the KVM agent later writes the qcow2 + * into the mounted FlexVolume.

+ * + *

Returns a {@link CloudStackVolume} so the driver can map it to {@code CreateCmdResult} + * and update {@code template_spool_ref}. SAN populates {@code lun}; NAS returns an empty + * volume (no LUN / file yet).

+ * + * @param storagePool CloudStack primary storage pool (one FlexVolume) + * @param templateInfo template being cached + * @param details pool details (SVM, protocol, etc.) + * @param sizeInBytes virtual size for the cache object (required for SAN; ignored for NAS) + * @return created cache identity, or an empty {@link CloudStackVolume} when nothing is + * pre-created on the array + */ + abstract public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, TemplateInfo templateInfo, + Map details, long sizeInBytes); + /** * Method encapsulates the behavior based on the opted protocol in subclasses. * it is going to mimic @@ -622,7 +822,7 @@ private boolean isIPv4Address(String address) { * @param cloudstackVolume the CloudStack volume to update * @return the updated CloudStackVolume object */ - abstract CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume); + public abstract CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume); /** * Method encapsulates the behavior based on the opted protocol in subclasses. @@ -636,14 +836,41 @@ private boolean isIPv4Address(String address) { abstract public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume); /** - * Method encapsulates the behavior based on the opted protocol in subclasses. + * Creates a space-efficient clone of an existing object inside the same FlexVolume. * it is going to mimic * cloneLun for iSCSI, FC protocols * cloneFile for NFS3.0 and NFS4.1 protocols * cloneNameSpace for Nvme/TCP and Nvme/FC protocol - * @param cloudstackVolume the CloudStack volume to copy + * + *

ONTAP requires the source and the destination to live in the same FlexVolume, which + * holds because a CloudStack primary storage pool maps one-to-one onto a FlexVolume.

+ * + *

The source may be an active object or one inside a FlexVolume snapshot:

+ *
    + *
  • SAN — {@code lun.clone.source.name} set to + * {@code /vol/<fv>/.snapshot/<snap>/<lun>}
  • + *
  • NAS — {@link CloudStackVolume#getSnapshotName()} set; it is sent as + * {@code snapshot.name} on {@code POST /api/storage/file/clone}
  • + *
+ * + * @param cloudstackVolume describes the clone to create; the source is carried in the + * protocol-specific clone reference (for SAN, {@code lun.clone.source}) + * @return the created CloudStackVolume, populated with the backend identity of the clone */ - abstract public void copyCloudStackVolume(CloudStackVolume cloudstackVolume); + abstract public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume); + + /** + * Grows an existing backend object to {@code sizeInBytes}. + * + *

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); /** * Method encapsulates the behavior based on the opted protocol in subclasses. @@ -765,6 +992,112 @@ public String getAuthHeader() { return OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); } + public VolumeQosPolicy createVolumeQosPolicy(String policyName, Long minIops, Long maxIops) { + VolumeQosPolicy policy = buildVolumeQosPolicy(policyName, minIops, maxIops); + Svm svm = new Svm(); + svm.setName(storage.getSvmName()); + policy.setSvm(svm); + try { + JobResponse response = qosFeignClient.createPolicy(getAuthHeader(), policy); + pollJobIfPresent(response, "create QoS policy [" + policyName + "]"); + } catch (FeignException e) { + if (e.status() != 409) { + throw new CloudRuntimeException("Failed to create ONTAP QoS policy [" + policyName + "]: " + + e.getMessage(), e); + } + logger.info("QoS policy [{}] already exists; using the existing policy", policyName); + } + + VolumeQosPolicy createdPolicy = getVolumeQosPolicy(policyName); + return createdPolicy; + } + + public void deleteVolumeQosPolicy(String policyUuid) { + if (policyUuid == null || policyUuid.isEmpty()) { + return; + } + VolumeQosPolicy policy = getVolumeQosPolicyByUuid(policyUuid); + if (policy == null) { + logger.info("QoS policy [{}] not found on ONTAP; skipping delete", policyUuid); + return; + } + if (policy.getObjectCount() != null && policy.getObjectCount() > 0) { + logger.info("QoS policy [{}] still has object_count={}; skipping delete", + policyUuid, policy.getObjectCount()); + return; + } + try { + JobResponse response = qosFeignClient.deletePolicy(getAuthHeader(), policyUuid); + pollJobIfPresent(response, "delete QoS policy [" + policyUuid + "]"); + } catch (Exception e) { + if ((e instanceof FeignException && ((FeignException) e).status() == 409) + || OntapStorageUtils.isOntapObjectNotFoundError(e)) { + logger.info("QoS policy [{}] was not deleted on ONTAP (already absent or conflict): {}", + policyUuid, e.getMessage()); + return; + } + throw new CloudRuntimeException("Failed to delete ONTAP QoS policy [" + policyUuid + "]: " + + e.getMessage(), e); + } + } + + private VolumeQosPolicy getVolumeQosPolicyByUuid(String policyUuid) { + Map queryParams = new HashMap<>(); + queryParams.put(OntapStorageConstants.FIELDS, OntapStorageConstants.QOS_POLICY_OBJECT_COUNT_FIELDS); + try { + VolumeQosPolicy policy = qosFeignClient.getPolicy(getAuthHeader(), policyUuid, queryParams); + if (policy == null || StringUtils.isEmpty(policy.getUuid())) { + throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyUuid + + "]: empty response"); + } + return policy; + } catch (FeignException e) { + if (OntapStorageUtils.isOntapObjectNotFoundError(e)) { + return null; + } + throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyUuid + "]: " + + e.getMessage(), e); + } + } + + private VolumeQosPolicy getVolumeQosPolicy(String policyName) { + Map queryParams = new HashMap<>(); + queryParams.put(OntapStorageConstants.NAME, policyName); + queryParams.put(OntapStorageConstants.SVM_DOT_NAME, storage.getSvmName()); + try { + OntapResponse response = qosFeignClient.getPolicies(getAuthHeader(), queryParams); + if (response == null || response.getRecords() == null || response.getRecords().size() <= 0) { + throw new CloudRuntimeException("Unable to get ONTAP QoS policy [" + policyName + "] after creation"); + } + VolumeQosPolicy policy = response.getRecords().get(0); + if (policy == null || StringUtils.isEmpty(policy.getUuid())) { + throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyName + + "]: empty response"); + } + return policy; + } catch (FeignException e) { + throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyName + "]: " + + e.getMessage(), e); + } + } + + private VolumeQosPolicy buildVolumeQosPolicy(String policyName, Long minIops, Long maxIops) { + VolumeQosPolicy.Fixed fixed = new VolumeQosPolicy.Fixed(); + fixed.setCapacityShared(false); + // ONTAP rejects a policy whose throughput limit is zero, so only unlimited-side values are omitted. + if (minIops != null && minIops > 0) { + fixed.setMinThroughputIops(minIops); + } + if (maxIops != null && maxIops > 0) { + fixed.setMaxThroughputIops(maxIops); + } + + VolumeQosPolicy policy = new VolumeQosPolicy(); + policy.setName(policyName); + policy.setFixed(fixed); + return policy; + } + /** * Polls an ONTAP async job for successful completion. * diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java index 39e947b17b51..bd200412536f 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 @@ -19,6 +19,16 @@ package org.apache.cloudstack.storage.service; +import com.cloud.agent.api.Answer; +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; +import com.cloud.utils.exception.CloudRuntimeException; +import feign.FeignException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -30,18 +40,23 @@ import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint; import org.apache.cloudstack.engine.subsystem.api.storage.EndPointSelector; +import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo; import org.apache.cloudstack.storage.command.CreateObjectCommand; import org.apache.cloudstack.storage.command.DeleteCommand; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest; import org.apache.cloudstack.storage.feign.model.ExportPolicy; import org.apache.cloudstack.storage.feign.model.ExportRule; +import org.apache.cloudstack.storage.feign.model.FileCloneRequest; import org.apache.cloudstack.storage.feign.model.FileInfo; import org.apache.cloudstack.storage.feign.model.Job; import org.apache.cloudstack.storage.feign.model.Nas; import org.apache.cloudstack.storage.feign.model.OntapStorage; import org.apache.cloudstack.storage.feign.model.Svm; import org.apache.cloudstack.storage.feign.model.Volume; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.model.AccessGroup; @@ -54,20 +69,12 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import com.cloud.agent.api.Answer; -import com.cloud.host.HostVO; -import com.cloud.storage.Storage; -import com.cloud.storage.VolumeVO; -import com.cloud.storage.dao.VolumeDao; -import com.cloud.utils.exception.CloudRuntimeException; - -import feign.FeignException; - public class UnifiedNASStrategy extends NASStrategy { private static final Logger logger = LogManager.getLogger(UnifiedNASStrategy.class); @Inject private VolumeDao volumeDao; @Inject private EndPointSelector epSelector; @Inject private StoragePoolDetailsDao storagePoolDetailsDao; + @Inject private PrimaryDataStoreDao primaryDataStoreDao; public UnifiedNASStrategy(OntapStorage ontapStorage) { super(ontapStorage); @@ -90,18 +97,71 @@ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume logger.error("createCloudStackVolume: " + errMsg); throw new CloudRuntimeException(errMsg); } + if (cloudstackVolume.getFile() != null && cloudstackVolume.getFile().getQosPolicy() != null) { + try { + updateCloudStackVolume(cloudstackVolume); + } catch (RuntimeException qosError) { + logger.error("createCloudStackVolume: QoS attach failed; deleting leftover NFS volume file", qosError); + try { + Answer cleanup = deleteVolumeOnKVMHost(cloudstackVolume.getVolumeInfo()); + if (cleanup == null || !cleanup.getResult()) { + logger.error("createCloudStackVolume: leftover NFS file may remain after QoS attach failure: {}", + cleanup != null ? cleanup.getDetails() : "null answer"); + } + } catch (Exception cleanupError) { + logger.error("createCloudStackVolume: failed to delete leftover NFS volume file after QoS attach failure", + cleanupError); + } + throw qosError; + } + } return cloudstackVolume; - }catch (Exception e) { + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { logger.error("createCloudStackVolume: error occured " + e); throw new CloudRuntimeException(e); } } + /** + * NFS template cache: nothing is pre-created on the array. The KVM agent writes the qcow2 + * into the mounted FlexVolume; the framework records {@code install_path} afterward. + * + * @return {@code null}. The driver records the template uuid as the create path. + */ @Override - CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { + public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, TemplateInfo templateInfo, + Map details, long sizeInBytes) { + logger.info("createTemplateCache: NFS pool [{}], template [{}] will be written directly to the mounted FlexVolume", + storagePool.getId(), templateInfo.getId()); return null; } + @Override + public CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { + if (cloudstackVolume == null || cloudstackVolume.getVolumeInfo() == null + || cloudstackVolume.getFlexVolumeUuid() == null || cloudstackVolume.getFile() == null) { + throw new CloudRuntimeException("Invalid NFS volume request"); + } + VolumeQosPolicy qosPolicy = cloudstackVolume.getFile().getQosPolicy(); + if (qosPolicy == null) { + return cloudstackVolume; + } + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); + String filePath = cloudstackVolume.getVolumeInfo().getUuid(); + try { + nasFeignClient.updateFile(getAuthHeader(), cloudstackVolume.getFlexVolumeUuid(), filePath, fileInfo); + } catch (FeignException e) { + throw new CloudRuntimeException("Failed to update NFS volume file: " + e.getMessage(), e); + } catch (Exception e) { + throw new CloudRuntimeException("Failed to update NFS volume file: " + e.getMessage(), e); + } + logger.info("Updated NFS volume file [{}] with QoS policy [{}]", filePath, qosPolicy.getName()); + return cloudstackVolume; + } + @Override public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) { logger.info("deleteCloudStackVolume: Delete cloudstack volume " + cloudstackVolume); @@ -119,9 +179,121 @@ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) { } } + /** + * Clones a file inside the FlexVolume using ONTAP's file clone API. + * + *

The source is taken from {@code file.path} and the destination from + * {@code destinationPath}, both relative to the root of the FlexVolume backing the pool. + * When {@code snapshotName} is set, the source is read from that FlexVolume snapshot.

+ */ + @Override + public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume) { + if (cloudstackVolume == null || cloudstackVolume.getFile() == null + || cloudstackVolume.getFile().getPath() == null || cloudstackVolume.getDestinationPath() == null) { + logger.error("cloneCloudStackVolume: File clone failed. Invalid request: {}", cloudstackVolume); + throw new CloudRuntimeException("Failed to clone file, invalid request"); + } + if (cloudstackVolume.getDatastoreId() == null) { + throw new CloudRuntimeException("Failed to clone file, no datastore id in the request"); + } + if (cloudstackVolume.getVolumeInfo() == null) { + throw new CloudRuntimeException("Failed to clone file, volume info is missing from the request"); + } + final long poolId = parseDatastoreId(cloudstackVolume.getDatastoreId()); + + Map details = storagePoolDetailsDao.listDetailsKeyPairs(poolId); + String flexVolUuid = details.get(OntapStorageConstants.VOLUME_UUID); + String flexVolName = details.get(OntapStorageConstants.VOLUME_NAME); + if (flexVolUuid == null || flexVolUuid.isEmpty()) { + throw new CloudRuntimeException("Failed to clone file, FlexVolume uuid is missing from pool details"); + } + String sourcePath = cloudstackVolume.getFile().getPath(); + String destinationPath = cloudstackVolume.getDestinationPath(); + String snapshotName = cloudstackVolume.getSnapshotName(); + + logger.info("cloneCloudStackVolume: Cloning file [{}] to [{}] in FlexVol [{}] from snapshot [{}]", + sourcePath, destinationPath, flexVolName, snapshotName); + try { + FileCloneRequest request = new FileCloneRequest(flexVolUuid, flexVolName, sourcePath, destinationPath, snapshotName); + JobResponse jobResponse = nasFeignClient.cloneFile(getAuthHeader(), request); + pollJobIfPresent(jobResponse, "clone file [" + sourcePath + "] to [" + destinationPath + "]"); + + updateCloudStackVolumeMetadata(cloudstackVolume.getDatastoreId(), cloudstackVolume.getVolumeInfo()); + + FileInfo clonedFile = new FileInfo(); + clonedFile.setPath(destinationPath); + + CloudStackVolume clonedCloudStackVolume = new CloudStackVolume(); + clonedCloudStackVolume.setFile(clonedFile); + clonedCloudStackVolume.setDatastoreId(cloudstackVolume.getDatastoreId()); + clonedCloudStackVolume.setVolumeInfo(cloudstackVolume.getVolumeInfo()); + clonedCloudStackVolume.setSnapshotName(snapshotName); + return clonedCloudStackVolume; + } catch (FeignException e) { + logger.error("FeignException occurred while cloning file [{}], Status: {}, Exception: {}", + sourcePath, e.status(), e.getMessage()); + throw new CloudRuntimeException("Failed to clone file: " + e.getMessage()); + } catch (Exception e) { + logger.error("Exception occurred while cloning file [{}], Exception: {}", sourcePath, e.getMessage()); + throw new CloudRuntimeException("Failed to clone file: " + e.getMessage()); + } + } + + /** + * Grows the cloned qcow2 to the requested size via a host-side {@code qemu-img resize}. + */ @Override - public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) { + public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes) { + if (cloudstackVolume == null || cloudstackVolume.getVolumeInfo() == null) { + logger.error("resizeCloudStackVolume: Resize failed. Invalid request: {}", cloudstackVolume); + throw new CloudRuntimeException("Failed to resize file, invalid request"); + } + if (sizeInBytes <= 0) { + throw new CloudRuntimeException("Failed to resize file, invalid size " + sizeInBytes); + } + + DataObject volumeInfo = cloudstackVolume.getVolumeInfo(); + Answer answer = resizeVolumeOnKVMHost(volumeInfo, sizeInBytes); + if (answer == null || !answer.getResult()) { + String errMsg = answer != null ? answer.getDetails() : "Failed to resize qcow2 on KVM host"; + 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); + } + private Answer resizeVolumeOnKVMHost(DataObject volumeInfo, long sizeInBytes) { + VolumeObject volumeObject = (VolumeObject) volumeInfo; + VolumeVO volume = volumeDao.findById(volumeObject.getId()); + if (volume == null) { + throw new CloudRuntimeException("Volume not found with id: " + volumeObject.getId()); + } + + StoragePoolVO storagePool = primaryDataStoreDao.findById(volume.getPoolId()); + if (storagePool == null) { + throw new CloudRuntimeException("Storage Pool not found for id: " + volume.getPoolId()); + } + + // 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"; + logger.error(errMsg); + return new Answer(cmd, false, errMsg); + } + logger.info("resizeVolumeOnKVMHost: Sending command to endpoint: {}", ep.getHostAddr()); + return ep.sendMessage(cmd); } @Override @@ -473,8 +645,23 @@ private ExportPolicy createExportPolicyRequest(AccessGroup accessGroup,String sv return exportPolicy; } + /** + * Parses the pool id carried on a file-clone request. A non-numeric value fails before any + * ONTAP call, with the stored value in the error. + */ + private long parseDatastoreId(String datastoreId) { + try { + return Long.parseLong(datastoreId); + } catch (NumberFormatException e) { + throw new CloudRuntimeException("Failed to clone file, datastore id [" + datastoreId + "] is not a number", e); + } + } + private String updateCloudStackVolumeMetadata(String dataStoreId, DataObject volumeInfo) { logger.info("updateCloudStackVolumeMetadata called with datastoreID: {} volumeInfo: {} ", dataStoreId, volumeInfo ); + if (volumeInfo == null) { + throw new CloudRuntimeException("Failed to update volume metadata, volume info is missing"); + } try { VolumeObject volumeObject = (VolumeObject) volumeInfo; long volumeId = volumeObject.getId(); @@ -550,6 +737,28 @@ private Answer deleteVolumeOnKVMHost(DataObject volumeInfo) { } } + /** + * Deletes a file from a FlexVolume, treating an already-absent file as success. + */ + public void deleteFileByPath(String flexVolUuid, String filePath) { + logger.info("deleteFileByPath: Deleting file [{}] from FlexVol [{}]", filePath, flexVolUuid); + try { + nasFeignClient.deleteFile(getAuthHeader(), flexVolUuid, filePath); + logger.debug("deleteFileByPath: Deleted file [{}]", filePath); + } catch (FeignException e) { + if (e.status() == 404) { + logger.warn("deleteFileByPath: File [{}] does not exist (status 404), skipping deletion", filePath); + return; + } + logger.error("FeignException occurred while deleting file [{}], Status: {}, Exception: {}", + filePath, e.status(), e.getMessage()); + throw new CloudRuntimeException("Failed to delete file: " + e.getMessage()); + } catch (Exception e) { + logger.error("Exception occurred while deleting file [{}], Exception: {}", filePath, e.getMessage()); + throw new CloudRuntimeException("Failed to delete file: " + e.getMessage()); + } + } + private FileInfo getFile(String volumeUuid, String filePath) { logger.info("Get File: {} for volume: {}", filePath, volumeUuid); 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 2b0e65f9f7ea..203c5ce48f57 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,17 +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.CliSnapshotRestoreRequest; +import org.apache.cloudstack.storage.feign.model.LunSpace; +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; @@ -38,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); @@ -78,6 +91,7 @@ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume throw new CloudRuntimeException("Failed to create Lun: " + cloudstackVolume.getLun().getName()); } Lun lun = createdLun.getRecords().get(0); + validateCreatedLun(lun, cloudstackVolume.getLun().getName(), "createCloudStackVolume"); logger.debug("createCloudStackVolume: LUN created successfully. Lun: {}", lun); CloudStackVolume createdCloudStackVolume = new CloudStackVolume(); @@ -86,16 +100,129 @@ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume } catch (FeignException e) { logger.error("FeignException occurred while creating LUN: {}, Status: {}, Exception: {}", cloudstackVolume.getLun().getName(), e.status(), e.getMessage()); - throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage()); + throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage(), e); + } catch (CloudRuntimeException e) { + throw e; } catch (Exception e) { logger.error("Exception occurred while creating LUN: {}, Exception: {}", cloudstackVolume.getLun().getName(), e.getMessage()); - throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage()); + throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage(), e); } } + /** + * Creates an empty LUN that caches a template on this pool's FlexVolume. + * + *

Sized to the template virtual disk size (what KVM writes after {@code qemu-img convert} + * to RAW). On create failure, best-effort deletes any leftover LUN before rethrowing.

+ */ @Override - CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { - return null; + public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, TemplateInfo templateInfo, + Map details, long sizeInBytes) { + if (sizeInBytes <= 0) { + throw new CloudRuntimeException("Unknown virtual size for template [" + templateInfo.getId() + + "]; cannot size the template LUN on pool [" + storagePool.getId() + "]. The template size in vm_template" + + " is unset; verify the template was registered/seeded with its virtual size (virtualsize in" + + " template.properties on secondary storage)."); + } + + CloudStackVolume request = buildTemplateLunRequest(storagePool, details, templateInfo.getId(), sizeInBytes); + String lunName = request.getLun().getName(); + CloudStackVolume created = null; + try { + created = createCloudStackVolume(request); + Lun lun = created.getLun(); + logger.info("createTemplateCache: Created template cache LUN [{}] (uuid [{}], {} bytes) on pool [{}] for template [{}]", + lun.getName(), lun.getUuid(), sizeInBytes, storagePool.getId(), templateInfo.getId()); + return created; + } catch (Exception e) { + bestEffortDeleteTemplateCacheLun(details.get(OntapStorageConstants.SVM_NAME), lunName, + created != null && created.getLun() != null ? created.getLun().getUuid() : null); + if (e instanceof CloudRuntimeException) { + throw (CloudRuntimeException) e; + } + throw new CloudRuntimeException("Failed to create template cache LUN for template [" + templateInfo.getId() + + "]: " + e.getMessage(), e); + } + } + + private CloudStackVolume buildTemplateLunRequest(StoragePoolVO storagePool, Map details, + long templateId, long sizeInBytes) { + Svm svm = new Svm(); + svm.setName(details.get(OntapStorageConstants.SVM_NAME)); + + Lun lunRequest = new Lun(); + lunRequest.setSvm(svm); + lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), + OntapStorageConstants.TEMPLATE_LUN_PREFIX + templateId)); + lunRequest.setOsType(Lun.OsTypeEnum.valueOf( + OntapStorageUtils.getOSTypeFromHypervisor(storagePool.getHypervisor().name()))); + LunSpace lunSpace = new LunSpace(); + lunSpace.setSize(sizeInBytes); + lunRequest.setSpace(lunSpace); + + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lunRequest); + return request; + } + + private void bestEffortDeleteTemplateCacheLun(String svmName, String lunName, String lunUuid) { + try { + String uuid = lunUuid; + if (uuid == null || uuid.isEmpty()) { + if (svmName == null || svmName.trim().isEmpty() || lunName == null || lunName.trim().isEmpty()) { + logger.warn("bestEffortDeleteTemplateCacheLun: Cannot look up leftover template cache LUN; " + + "svm [{}] or lun name [{}] is missing", svmName, lunName); + return; + } + Map lookup = Map.of( + OntapStorageConstants.NAME, lunName, + OntapStorageConstants.SVM_DOT_NAME, svmName); + CloudStackVolume existing = getCloudStackVolume(lookup); + if (existing == null || existing.getLun() == null || existing.getLun().getUuid() == null) { + logger.warn("bestEffortDeleteTemplateCacheLun: LUN [{}] not found on SVM [{}]; nothing to delete", + lunName, svmName); + return; + } + uuid = existing.getLun().getUuid(); + } + Lun lun = new Lun(); + lun.setUuid(uuid); + lun.setName(lunName); + CloudStackVolume deleteRequest = new CloudStackVolume(); + deleteRequest.setLun(lun); + deleteCloudStackVolume(deleteRequest); + logger.info("bestEffortDeleteTemplateCacheLun: Removed leftover template cache LUN [{}] on SVM [{}]", + lunName, svmName); + } catch (Exception cleanupEx) { + logger.warn("bestEffortDeleteTemplateCacheLun: Failed to remove leftover template cache LUN [{}] on SVM [{}]: {}", + lunName, svmName, cleanupEx.getMessage()); + } + } + + @Override + public CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { + if (cloudstackVolume == null || cloudstackVolume.getLun() == null + || cloudstackVolume.getLun().getUuid() == null) { + throw new CloudRuntimeException("Invalid iSCSI volume request"); + } + VolumeQosPolicy qosPolicy = cloudstackVolume.getLun().getQosPolicy(); + if (qosPolicy == null) { + return cloudstackVolume; + } + Lun lunUpdate = new Lun(); + lunUpdate.setQosPolicy(qosPolicy); + try { + JobResponse response = sanFeignClient.updateLun( + getAuthHeader(), cloudstackVolume.getLun().getUuid(), lunUpdate); + pollJobIfPresent(response, "update LUN [" + cloudstackVolume.getLun().getUuid() + "]"); + } catch (FeignException e) { + throw new CloudRuntimeException("Failed to update LUN: " + e.getMessage(), e); + } catch (Exception e) { + throw new CloudRuntimeException("Failed to update LUN: " + e.getMessage(), e); + } + logger.info("Updated LUN [{}] with QoS policy [{}]", + cloudstackVolume.getLun().getUuid(), qosPolicy.getName()); + return cloudstackVolume; } @Override @@ -109,7 +236,8 @@ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) { String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); Map queryParams = Map.of("allow_delete_while_mapped", "true"); try { - sanFeignClient.deleteLun(authHeader, cloudstackVolume.getLun().getUuid(), queryParams); + JobResponse response = sanFeignClient.deleteLun(authHeader, cloudstackVolume.getLun().getUuid(), queryParams); + pollJobIfPresent(response, "delete Lun [" + cloudstackVolume.getLun().getName() + "]"); } catch (FeignException feignEx) { if (feignEx.status() == 404) { logger.warn("deleteCloudStackVolume: Lun {} does not exist (status 404), skipping deletion", cloudstackVolume.getLun().getName()); @@ -124,8 +252,128 @@ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) { } } + /** + * Clones a LUN inside the FlexVolume using ONTAP's {@code clone.source} form of LUN create. + * + *

The resulting LUN shares blocks with its source and is created in constant time. + * It is a sis-clone, so it stays readable after the source LUN is deleted.

+ */ @Override - public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) {} + public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume) { + if (cloudstackVolume == null || cloudstackVolume.getLun() == null) { + logger.error("cloneCloudStackVolume: LUN clone failed. Invalid request: {}", cloudstackVolume); + throw new CloudRuntimeException("Failed to clone Lun, invalid request"); + } + Lun lunRequest = cloudstackVolume.getLun(); + if (lunRequest.getClone() == null || lunRequest.getClone().getSource() == null) { + logger.error("cloneCloudStackVolume: LUN clone failed. No clone source in request for Lun {}", lunRequest.getName()); + throw new CloudRuntimeException("Failed to clone Lun, no clone source provided"); + } + logger.trace("cloneCloudStackVolume: Cloning Lun {} from source name={} uuid={}", + lunRequest.getName(), + lunRequest.getClone().getSource().getName(), + lunRequest.getClone().getSource().getUuid()); + try { + String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); + OntapResponse clonedLun = sanFeignClient.createLun(authHeader, true, lunRequest); + if (clonedLun == null || CollectionUtils.isEmpty(clonedLun.getRecords())) { + logger.error("cloneCloudStackVolume: LUN clone returned no records for Lun {}", lunRequest.getName()); + throw new CloudRuntimeException("Failed to clone Lun: " + lunRequest.getName()); + } + Lun lun = clonedLun.getRecords().get(0); + validateCreatedLun(lun, lunRequest.getName(), "cloneCloudStackVolume"); + logger.debug("cloneCloudStackVolume: LUN cloned successfully. Lun: {}", lun); + + 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: {}", + lunRequest.getName(), e.status(), e.getMessage()); + throw new CloudRuntimeException("Failed to clone Lun: " + e.getMessage()); + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + logger.error("Exception occurred while cloning LUN: {}, Exception: {}", lunRequest.getName(), e.getMessage()); + throw new CloudRuntimeException("Failed to clone Lun: " + e.getMessage()); + } + } + + /** + * Ensures ONTAP returned a usable LUN identity from create/clone. Callers in the datastore + * driver rely on non-null name and uuid, so reject incomplete records at the Feign boundary. + */ + private void validateCreatedLun(Lun lun, String requestName, String operation) { + if (lun == null || lun.getName() == null || lun.getUuid() == null) { + logger.error("{}: ONTAP returned incomplete LUN for {}", operation, requestName); + throw new CloudRuntimeException("ONTAP returned incomplete LUN for: " + requestName); + } + } + + /** + * Grows an existing LUN to {@code sizeInBytes}. + * + *

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.

+ */ + @Override + public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes) { + 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 lunPatch = new Lun(); + lunPatch.setSpace(lunSpace); + if (qosPolicy != null) { + 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) { + 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 [{}]: {}", lunUuid, e.getMessage()); + throw new CloudRuntimeException(String.format( + "Unexpected error while resizing LUN [%s]: %s", lunUuid, e.getMessage())); + } + } @Override public CloudStackVolume getCloudStackVolume(Map values) { diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/model/CloudStackVolume.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/model/CloudStackVolume.java index ab38e3045f51..5147e9ff7ac2 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/model/CloudStackVolume.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/model/CloudStackVolume.java @@ -51,6 +51,12 @@ public class CloudStackVolume { */ private String destinationPath; + /** + * ONTAP FlexVolume snapshot name when cloning a new file/LUN from a snapshot + * (create-volume-from-snapshot). Null for live clones (e.g. template cache). + */ + private String snapshotName; + private DataObject volumeInfo; // This is needed as we need DataObject to be passed to agent to create volume public FileInfo getFile() { @@ -101,4 +107,12 @@ public void setDestinationPath(String destinationPath) { this.destinationPath = destinationPath; } + public String getSnapshotName() { + return snapshotName; + } + + public void setSnapshotName(String snapshotName) { + this.snapshotName = snapshotName; + } + } 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 745448ec746a..2b173f41e385 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 @@ -59,14 +59,20 @@ public class OntapStorageConstants { public static final String JOB_SUCCESS = "success"; public static final String TRUE = "true"; - public static final String FALSE = "false"; // 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 INITIATORS = "initiators"; public static final String AGGREGATES = "aggregates"; public static final String STATE = "state"; + public static final String CLUSTER_NODE_ASUP_FIELDS = "model,is_all_flash_optimized,is_capacity_optimized,is_perf_optimized"; + public static final String ASUP_PLATFORM_TYPE_PERFORMANCE = "performance"; + public static final String ASUP_PLATFORM_TYPE_CAPACITY = "capacity"; + public static final String ASUP_PLATFORM_TYPE_FAS = "fas"; + public static final String ASUP_PLATFORM_TYPE_COMPOSITE = "composite"; public static final String AGGREGATE_NODE = "node"; public static final String AGGREGATE_SPACE = "space"; public static final String DATA_NFS = "data_nfs"; @@ -89,10 +95,17 @@ public class OntapStorageConstants { public static final String EQUALS = "="; public static final String SEMICOLON = ";"; public static final String COMMA = ","; + public static final String DOT = "."; public static final String HYPHEN = "-"; public static final String VOLUME_PATH_PREFIX = "/vol/"; + /** + * Path segment inserted after the FlexVol name when identifying a LUN inside a FlexVol snapshot + * for {@code POST /api/storage/luns} clone ({@code /vol/<fv>/.snapshot/<snap>/<lun>}). + */ + public static final String SNAPSHOT_PATH_SEGMENT = "/.snapshot/"; + public static final String ONTAP_NAME_REGEX = "^[a-zA-Z][a-zA-Z0-9_]*$"; public static final String KVM = "KVM"; @@ -101,6 +114,15 @@ public class OntapStorageConstants { public static final String LUN_DOT_NAME = "lun.name"; public static final String IQN = "iqn"; public static final String LUN_DOT_UUID = "lun.uuid"; + public static final String QOS_POLICY_UUID = "qosPolicyUuid"; + public static final String IS_AFF = "isAFF"; + public static final String MIN_IOPS_NOT_SUPPORTED_ON_FAS = "Minimum IOPS must be 0 for FAS. Only a Maximum IOPS limit is supported"; + public static final String QOS_POLICY_NONE = "none"; + public static final String QOS_POLICY_NAME_PREFIX = "cs_"; + public static final String QOS_POLICY_NAME_TO = "to_"; + public static final String QOS_POLICY_NAME_IOPS = "iops_"; + public static final String UUID = "uuid"; + public static final String QOS_POLICY_OBJECT_COUNT_FIELDS = "uuid,name,object_count"; public static final String LOGICAL_UNIT_NUMBER = "logical_unit_number"; public static final String IGROUP_DOT_NAME = "igroup.name"; public static final String IGROUP_DOT_UUID = "igroup.uuid"; @@ -114,6 +136,13 @@ public class OntapStorageConstants { public static final String VOLUME_PATH = "volume_path"; public static final String PRIMARY_POOL_ID = "primary_pool_id"; public static final String ONTAP_SNAP_SIZE = "ontap_snap_size"; + /** + * Optional {@code snapshot_details} keys: min/max IOPS from the source volume at take-snapshot + * time. Persisted only when the volume has configured values; applied to volumes created from + * the snapshot in a later change (see TODO on create-from-snapshot). + */ + public static final String MIN_IOPS = "min_iops"; + public static final String MAX_IOPS = "max_iops"; public static final String FILE_PATH = "file_path"; public static final int MAX_SNAPSHOT_NAME_LENGTH = 255; public static final String ONTAP_TEMP_CG_PREFIX = "cs-temp-cg-"; @@ -127,9 +156,43 @@ public class OntapStorageConstants { public static final int ONTAP_SFSR_JOB_POLL_INTERVAL_MS = 2000; public static final int ONTAP_SNAPSHOT_DELETE_JOB_MAX_RETRIES = 30; public static final int ONTAP_SNAPSHOT_DELETE_JOB_POLL_INTERVAL_MS = 2000; + /** Retry settings for FlexVolume create/resize/delete job polling. */ + public static final int ONTAP_VOLUME_JOB_MAX_RETRIES = 10; + public static final int ONTAP_VOLUME_JOB_POLL_INTERVAL_MS = 1000; public static final int ONTAP_FLEXVOL_JOB_POLL_INTERVAL_MS = 2000; public static final int ONTAP_FLEXVOL_RESOLVE_MAX_RETRIES = 30; /** vm_snapshot_details key for ONTAP FlexVolume-level VM snapshots. */ public static final String ONTAP_FLEXVOL_SNAPSHOT = "ontapFlexVolSnapshot"; + + /** Name prefix of the LUN that caches a template on the FlexVol, suffixed with the template id. */ + public static final String TEMPLATE_LUN_PREFIX = "cs_tmpl_"; + + /** + * Key of the {@code volume_details} row that {@code StorageSystemDataMotionStrategy} writes + * immediately before {@code createAsync} when a volume is to be cloned from a template already + * cached on this pool. The value is the CloudStack template id. The literal must stay in sync + * with the string used by the orchestrator. + */ + public static final String CLONE_OF_TEMPLATE = "cloneOfTemplate"; + + /** + * Key of the {@code volume_details} row that {@code StorageSystemDataMotionStrategy} writes + * immediately before {@code createAsync} when a volume is to be created from a CloudStack + * snapshot already present on this pool. The value is the CloudStack snapshot id. The literal + * must stay in sync with the string used by the orchestrator. + */ + public static final String CLONE_OF_SNAPSHOT = "cloneOfSnapshot"; + + /** + * Driver capability read by {@code StorageSystemDataMotionStrategy} when copying a snapshot to + * secondary storage. The literal must stay in sync with the string used by the orchestrator. + */ + public static final String CAN_DIRECT_ATTACH_SNAPSHOT = "CAN_DIRECT_ATTACH_SNAPSHOT"; + + /** + * Name prefix of the temporary volume a snapshot is cloned into for createTemplate(snapshotid), + * suffixed with the CloudStack snapshot id and volume id. + */ + public static final String TEMP_SNAPSHOT_COPY_NAME_PREFIX = "cs_tmp_snap_"; } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java index 7f09b5584b40..0e19d767c4a0 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java @@ -19,28 +19,23 @@ package org.apache.cloudstack.storage.utils; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.StringUtils; +import com.cloud.utils.exception.CloudRuntimeException; import java.nio.charset.StandardCharsets; import java.util.Map; import feign.FeignException; -import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; -import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.feign.model.Lun; -import org.apache.cloudstack.storage.feign.model.LunSpace; import org.apache.cloudstack.storage.feign.model.OntapStorage; -import org.apache.cloudstack.storage.feign.model.Svm; import org.apache.cloudstack.storage.provider.StorageProviderFactory; import org.apache.cloudstack.storage.service.StorageStrategy; -import org.apache.cloudstack.storage.service.model.CloudStackVolume; import org.apache.cloudstack.storage.service.model.ProtocolType; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.util.Base64Utils; import com.cloud.alert.AlertManager; -import com.cloud.exception.InvalidParameterValueException; -import com.cloud.utils.StringUtils; -import com.cloud.utils.exception.CloudRuntimeException; public class OntapStorageUtils { @@ -60,48 +55,6 @@ public static String generateAuthHeader (String username, String password) { return BASIC + StringUtils.SPACE + new String(encodedBytes); } - public static CloudStackVolume createCloudStackVolumeRequestByProtocol(StoragePoolVO storagePool, Map details, DataObject volumeObject) { - CloudStackVolume cloudStackVolumeRequest = null; - - String protocol = details.get(OntapStorageConstants.PROTOCOL); - ProtocolType protocolType = ProtocolType.valueOf(protocol); - switch (protocolType) { - case NFS3: - cloudStackVolumeRequest = new CloudStackVolume(); - cloudStackVolumeRequest.setDatastoreId(String.valueOf(storagePool.getId())); - cloudStackVolumeRequest.setVolumeInfo(volumeObject); - break; - case ISCSI: - Svm svm = new Svm(); - svm.setName(details.get(OntapStorageConstants.SVM_NAME)); - cloudStackVolumeRequest = new CloudStackVolume(); - Lun lunRequest = new Lun(); - lunRequest.setSvm(svm); - - LunSpace lunSpace = new LunSpace(); - lunSpace.setSize(volumeObject.getSize()); - lunRequest.setSpace(lunSpace); - //Lun name is full path like in unified "/vol/VolumeName/LunName" - String lunName = volumeObject.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE); - if(!isValidName(lunName)) { - String errMsg = "createAsync: Invalid dataObject name [" + lunName + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long."; - throw new InvalidParameterValueException(errMsg); - } - String lunFullName = getLunName(storagePool.getName(), lunName); - lunRequest.setName(lunFullName); - - String osType = getOSTypeFromHypervisor(storagePool.getHypervisor().name()); - lunRequest.setOsType(Lun.OsTypeEnum.valueOf(osType)); - - cloudStackVolumeRequest.setLun(lunRequest); - break; - default: - throw new CloudRuntimeException("Unsupported protocol " + protocol); - - } - return cloudStackVolumeRequest; - } - public static boolean isValidName(String name) { // Check for null and length constraint first if (name == null || name.length() > 200) { @@ -177,6 +130,49 @@ public static String getLunName(String volName, String lunName) { return OntapStorageConstants.VOLUME_PATH_PREFIX + volName + OntapStorageConstants.SLASH + lunName; } + /** + * Converts a path stored in CloudStack (absolute LUN {@code /vol/<flexVol>/...} or already + * relative NFS file path) into the FlexVol-relative path expected by + * {@code POST /api/storage/file/clone}. + */ + public static String toFlexVolRelativePath(String path, String flexVolName) { + if (path == null || path.isEmpty()) { + return path; + } + if (flexVolName != null && !flexVolName.isEmpty()) { + String prefix = OntapStorageConstants.VOLUME_PATH_PREFIX + flexVolName + OntapStorageConstants.SLASH; + if (path.startsWith(prefix)) { + return path.substring(prefix.length()); + } + } + // Already relative (typical NFS uuid path) or unexpected absolute form — strip a leading slash. + return path.startsWith(OntapStorageConstants.SLASH) ? path.substring(1) : path; + } + + /** + * Builds the ONTAP LUN clone source name that points at a LUN inside a FlexVol snapshot. + * + *

Format required by {@code POST /api/storage/luns} when cloning from a snapshot: + * {@code /vol/<flexVol>/.snapshot/<snapshotName>/<relativeLunPath>}.

+ * + *

{@code clone.source.uuid} cannot identify a snapshot-resident LUN; name must be used.

+ */ + public static String toLunCloneSourcePathInSnapshot(String lunPath, String flexVolName, String snapshotName) { + if (flexVolName == null || flexVolName.isEmpty()) { + throw new InvalidParameterValueException("FlexVolume name is required to build a snapshot LUN path"); + } + if (snapshotName == null || snapshotName.isEmpty()) { + throw new InvalidParameterValueException("Snapshot name is required to build a snapshot LUN path"); + } + String relativeLunPath = toFlexVolRelativePath(lunPath, flexVolName); + if (relativeLunPath == null || relativeLunPath.isEmpty()) { + throw new InvalidParameterValueException("LUN path is required to build a snapshot LUN path"); + } + return OntapStorageConstants.VOLUME_PATH_PREFIX + flexVolName + + OntapStorageConstants.SNAPSHOT_PATH_SEGMENT + snapshotName + + OntapStorageConstants.SLASH + relativeLunPath; + } + /** * Builds an ONTAP-safe name token from user-provided snapshot text. */ 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 11f2a88f20e9..5b8aeaa6ef97 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java @@ -22,23 +22,43 @@ import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.DataStoreRole; +import com.cloud.storage.ResizeVolumePayload; import com.cloud.storage.ScopeType; import com.cloud.storage.Storage; +import com.cloud.storage.SnapshotVO; +import com.cloud.storage.VMTemplateStoragePoolVO; +import com.cloud.storage.Volume; import com.cloud.storage.VolumeVO; import com.cloud.storage.VolumeDetailVO; +import com.cloud.storage.dao.SnapshotDao; +import com.cloud.storage.dao.SnapshotDetailsDao; +import com.cloud.storage.dao.SnapshotDetailsVO; +import com.cloud.storage.dao.VMTemplatePoolDao; import com.cloud.storage.dao.VolumeDao; import com.cloud.storage.dao.VolumeDetailsDao; 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; +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.cloudstack.framework.async.AsyncCompletionCallback; import org.apache.cloudstack.storage.command.CommandResult; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.feign.model.FileInfo; import org.apache.cloudstack.storage.feign.model.Igroup; import org.apache.cloudstack.storage.feign.model.Lun; +import org.apache.cloudstack.storage.feign.model.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; import org.apache.cloudstack.storage.service.UnifiedSANStrategy; import org.apache.cloudstack.storage.service.model.AccessGroup; @@ -56,23 +76,34 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.HashMap; +import java.util.List; import java.util.Map; +import static com.cloud.agent.api.to.DataObjectType.SNAPSHOT; +import static com.cloud.agent.api.to.DataObjectType.TEMPLATE; import static com.cloud.agent.api.to.DataObjectType.VOLUME; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; 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; @@ -91,12 +122,27 @@ class OntapPrimaryDatastoreDriverTest { @Mock private VolumeDetailsDao volumeDetailsDao; + @Mock + private SnapshotDetailsDao snapshotDetailsDao; + + @Mock + private SnapshotDao snapshotDao; + + @Mock + private VMTemplatePoolDao vmTemplatePoolDao; + + @Mock + private VMTemplateStoragePoolVO templatePoolRef; + @Mock private DataStore dataStore; @Mock private VolumeInfo volumeInfo; + @Mock + private TemplateInfo templateInfo; + @Mock private StoragePoolVO storagePool; @@ -128,6 +174,11 @@ void setUp() { storagePoolDetails = new HashMap<>(); storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.ISCSI.name()); storagePoolDetails.put(OntapStorageConstants.SVM_NAME, "svm1"); + storagePoolDetails.put(OntapStorageConstants.IS_AFF, "true"); + lenient().when(volumeInfo.getName()).thenReturn("test-volume"); + lenient().when(volumeInfo.getSize()).thenReturn(1073741824L); + lenient().when(storagePool.getName()).thenReturn("vol1"); + lenient().when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); } @Test @@ -140,6 +191,9 @@ void testGetCapabilities() { assertEquals(Boolean.TRUE.toString(), capabilities.get("STORAGE_SYSTEM_SNAPSHOT")); assertEquals(Boolean.TRUE.toString(), capabilities.get("CAN_CREATE_VOLUME_FROM_SNAPSHOT")); assertEquals(Boolean.TRUE.toString(), capabilities.get("CAN_REVERT_VOLUME_TO_SNAPSHOT")); + assertEquals(Boolean.TRUE.toString(), capabilities.get("CAN_CREATE_VOLUME_FROM_VOLUME")); + assertEquals(Boolean.TRUE.toString(), capabilities.get("CAN_CREATE_TEMPLATE_FROM_SNAPSHOT")); + assertEquals(Boolean.FALSE.toString(), capabilities.get("CAN_DIRECT_ATTACH_SNAPSHOT")); } @Test @@ -170,8 +224,10 @@ void testCreateAsync_VolumeWithISCSI_Success() { when(volumeInfo.getName()).thenReturn("test-volume"); when(storagePoolDao.findById(1L)).thenReturn(storagePool); - when(storagePool.getId()).thenReturn(1L); - when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.Iscsi); + lenient().when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getName()).thenReturn("vol1"); + + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); @@ -181,18 +237,12 @@ void testCreateAsync_VolumeWithISCSI_Success() { Lun mockLun = new Lun(); mockLun.setName("/vol/vol1/lun1"); mockLun.setUuid("lun-uuid-123"); - // Create request volume (returned by Utility.createCloudStackVolumeRequestByProtocol) - CloudStackVolume requestVolume = new CloudStackVolume(); - requestVolume.setLun(mockLun); - // Create response volume (returned by sanStrategy.createCloudStackVolume) CloudStackVolume responseVolume = new CloudStackVolume(); responseVolume.setLun(mockLun); - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(sanStrategy); - utilityMock.when(() -> OntapStorageUtils.createCloudStackVolumeRequestByProtocol( - any(), any(), any())).thenReturn(requestVolume); when(sanStrategy.createCloudStackVolume(any())).thenReturn(responseVolume); // Execute @@ -234,11 +284,9 @@ void testCreateAsync_VolumeWithNFS_Success() { CloudStackVolume mockCloudStackVolume = new CloudStackVolume(); - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) .thenReturn(nasStrategy); - utilityMock.when(() -> OntapStorageUtils.createCloudStackVolumeRequestByProtocol( - any(), any(), any())).thenReturn(mockCloudStackVolume); when(nasStrategy.createCloudStackVolume(any())).thenReturn(mockCloudStackVolume); @@ -259,7 +307,9 @@ void testCreateAsync_VolumeWithNFS_Success() { @Test void testCreateAsync_UnsupportedHypervisor_FailsWithError() { - storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.ISCSI.name()); + // Use NFS so LUN OS-type resolution does not fail earlier in getOSTypeFromHypervisor; + // the failure under test is image-format resolution for non-KVM hypervisors. + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); when(dataStore.getId()).thenReturn(1L); when(dataStore.getName()).thenReturn("ontap-pool"); @@ -269,12 +319,16 @@ void testCreateAsync_UnsupportedHypervisor_FailsWithError() { when(storagePoolDao.findById(1L)).thenReturn(storagePool); when(storagePool.getId()).thenReturn(1L); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.VMware); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); when(volumeDao.findById(100L)).thenReturn(volumeVO); - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { - utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + CloudStackVolume mockCloudStackVolume = new CloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(nasStrategy); + when(nasStrategy.createCloudStackVolume(any())).thenReturn(mockCloudStackVolume); driver.createAsync(dataStore, volumeInfo, createCallback); @@ -292,25 +346,17 @@ void testCreateAsync_KvmUnsupportedProtocol_FailsWithError() { when(dataStore.getId()).thenReturn(1L); when(dataStore.getName()).thenReturn("ontap-pool"); when(volumeInfo.getType()).thenReturn(VOLUME); - when(volumeInfo.getId()).thenReturn(100L); when(volumeInfo.getName()).thenReturn("test-volume"); when(storagePoolDao.findById(1L)).thenReturn(storagePool); - when(storagePool.getId()).thenReturn(1L); - when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); - when(volumeDao.findById(100L)).thenReturn(volumeVO); - - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { - utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); - driver.createAsync(dataStore, volumeInfo, createCallback); + driver.createAsync(dataStore, volumeInfo, createCallback); - ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); - verify(createCallback).complete(resultCaptor.capture()); - assertFalse(resultCaptor.getValue().isSuccess()); - assertTrue(resultCaptor.getValue().getResult().contains("No enum constant")); - } + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("Unsupported protocol [FC]")); } @Test @@ -340,8 +386,9 @@ void testDeleteAsync_ISCSIVolume_Success() { when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail); when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(lunUuidDetail); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(null); - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) .thenReturn(sanStrategy); @@ -358,6 +405,8 @@ void testDeleteAsync_ISCSIVolume_Success() { assertNotNull(result); assertTrue(result.isSuccess()); verify(sanStrategy).deleteCloudStackVolume(any(CloudStackVolume.class)); + verify(volumeDao, never()).remove(anyLong()); + verify(volumeDetailsDao, never()).removeDetails(anyLong()); } } @@ -384,6 +433,93 @@ void testDeleteAsync_NFSVolume_Success() { // NFS deletion doesn't fail, handled by hypervisor } + @Test + void testDeleteAsync_Template_DeletesCacheLun() { + when(dataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getLocalDownloadPath()).thenReturn("template-lun-uuid"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(sanStrategy); + + driver.deleteAsync(dataStore, templateInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).deleteCloudStackVolume(requestCaptor.capture()); + assertEquals("template-lun-uuid", requestCaptor.getValue().getLun().getUuid()); + } + } + + + @Test + void testDeleteAsync_Template_ResolvesLunByNameWhenUuidMissing() { + when(dataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getLocalDownloadPath()).thenReturn(null); + + Lun templateLun = new Lun(); + templateLun.setName("/vol/vol1/cs_tmpl_50"); + templateLun.setUuid("resolved-lun-uuid"); + CloudStackVolume existing = new CloudStackVolume(); + existing.setLun(templateLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(sanStrategy); + when(sanStrategy.getCloudStackVolume(argThat(map -> + map != null && "/vol/vol1/cs_tmpl_50".equals(map.get("name"))))) + .thenReturn(existing); + doNothing().when(sanStrategy).deleteCloudStackVolume(any()); + + driver.deleteAsync(dataStore, templateInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).deleteCloudStackVolume(deleteCaptor.capture()); + assertEquals("resolved-lun-uuid", deleteCaptor.getValue().getLun().getUuid()); + assertEquals("/vol/vol1/cs_tmpl_50", deleteCaptor.getValue().getLun().getName()); + } + } + + @Test + void testDeleteAsync_Template_NoCachedLun_SucceedsWithoutCallingOntap() { + when(dataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(null); + + driver.deleteAsync(dataStore, templateInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).deleteCloudStackVolume(any()); + } + @Test void testGrantAccess_NullParameters_ThrowsException() { assertThrows(CloudRuntimeException.class, @@ -425,7 +561,7 @@ void testGrantAccess_ClusterScope_Success() { existingIgroup.setName("igroup1"); existingAccessGroup.setIgroup(existingIgroup); - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) .thenReturn(sanStrategy); utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString())) @@ -443,6 +579,7 @@ void testGrantAccess_ClusterScope_Success() { verify(sanStrategy).getAccessGroup(any()); verify(sanStrategy).ensureLunMapped(anyString(), anyString(), anyString()); verify(sanStrategy, never()).validateInitiatorInAccessGroup(anyString(), anyString(), any(Igroup.class)); + verify(volumeInfo, never()).getVolume(); } } @@ -476,7 +613,7 @@ void testGrantAccess_IgroupNotFound_CreatesNewIgroup() { createdIgroup.setName("igroup1"); createdAccessGroup.setIgroup(createdIgroup); - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) .thenReturn(sanStrategy); utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString())) @@ -501,12 +638,12 @@ void testGrantAccess_IgroupNotFound_CreatesNewIgroup() { @Test void testRevokeAccess_NFSVolume_SkipsRevoke() { // Setup - NFS volumes have no LUN mapping, so revokeAccess is a no-op + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); when(dataStore.getId()).thenReturn(1L); when(volumeInfo.getType()).thenReturn(VOLUME); when(volumeInfo.getId()).thenReturn(100L); when(volumeDao.findById(100L)).thenReturn(volumeVO); - when(volumeVO.getId()).thenReturn(100L); when(volumeVO.getName()).thenReturn("test-volume"); when(storagePoolDao.findById(1L)).thenReturn(storagePool); @@ -515,7 +652,7 @@ void testRevokeAccess_NFSVolume_SkipsRevoke() { when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); when(host.getName()).thenReturn("host1"); - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) .thenReturn(sanStrategy); @@ -562,7 +699,7 @@ void testRevokeAccess_ISCSIVolume_Success() { AccessGroup mockAccessGroup = new AccessGroup(); mockAccessGroup.setIgroup(mockIgroup); - try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) .thenReturn(sanStrategy); utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString())) @@ -605,32 +742,3006 @@ void testRevokeAccess_ISCSIVolume_Success() { } @Test - void testCanHostAccessStoragePool_ReturnsTrue() { - assertTrue(driver.canHostAccessStoragePool(host, storagePool)); + void testGetDataObjectSizeIncludingHypervisorSnapshotReserve_NoReserveAdded() { + when(templateInfo.getSize()).thenReturn(5368709120L); + + assertEquals(5368709120L, driver.getDataObjectSizeIncludingHypervisorSnapshotReserve(templateInfo, storagePool)); } @Test - void testIsVmInfoNeeded_ReturnsTrue() { - assertTrue(driver.isVmInfoNeeded()); + void testGetBytesRequiredForTemplate_AlreadyCached_ReturnsZero() { + when(storagePool.getId()).thenReturn(1L); + when(templateInfo.getId()).thenReturn(50L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getState()).thenReturn(ObjectInDataStoreStateMachine.State.Ready); + when(templatePoolRef.getLocalDownloadPath()).thenReturn("template-lun-uuid"); + + assertEquals(0L, driver.getBytesRequiredForTemplate(templateInfo, storagePool)); } @Test - void testIsStorageSupportHA_ReturnsTrue() { - assertTrue(driver.isStorageSupportHA(Storage.StoragePoolType.NetworkFilesystem)); + void testGetBytesRequiredForTemplate_NotCached_ReturnsVirtualSize() { + when(storagePool.getId()).thenReturn(1L); + when(templateInfo.getId()).thenReturn(50L); + when(templateInfo.getSize()).thenReturn(5368709120L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(null); + + assertEquals(5368709120L, driver.getBytesRequiredForTemplate(templateInfo, storagePool)); } @Test - void testGetChapInfo_ReturnsNull() { - assertNull(driver.getChapInfo(volumeInfo)); + void testGetBytesRequiredForTemplate_SpoolRefNotReady_ReturnsVirtualSize() { + when(storagePool.getId()).thenReturn(1L); + when(templateInfo.getId()).thenReturn(50L); + when(templateInfo.getSize()).thenReturn(5368709120L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getState()).thenReturn(ObjectInDataStoreStateMachine.State.Allocated); + + assertEquals(5368709120L, driver.getBytesRequiredForTemplate(templateInfo, storagePool)); } @Test - void testCanProvideStorageStats_ReturnsFalse() { - assertFalse(driver.canProvideStorageStats()); + void testGetBytesRequiredForTemplate_ReadyWithoutBackendIdentity_ReturnsVirtualSize() { + when(storagePool.getId()).thenReturn(1L); + when(templateInfo.getId()).thenReturn(50L); + when(templateInfo.getSize()).thenReturn(5368709120L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getState()).thenReturn(ObjectInDataStoreStateMachine.State.Ready); + when(templatePoolRef.getLocalDownloadPath()).thenReturn(null); + + assertEquals(5368709120L, driver.getBytesRequiredForTemplate(templateInfo, storagePool)); } @Test - void testCanProvideVolumeStats_ReturnsFalse() { - assertFalse(driver.canProvideVolumeStats()); + void testGetBytesRequiredForTemplate_NfsCached_ReturnsZero() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + when(storagePool.getId()).thenReturn(1L); + when(templateInfo.getId()).thenReturn(50L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getState()).thenReturn(ObjectInDataStoreStateMachine.State.Ready); + when(templatePoolRef.getInstallPath()).thenReturn("/mnt/pool/template-uuid"); + + assertEquals(0L, driver.getBytesRequiredForTemplate(templateInfo, storagePool)); + } + + @Test + void testCreateAsync_TemplateWithISCSI_CreatesLunAndRecordsCloneSource() { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + when(templateInfo.getSize()).thenReturn(5368709120L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getId()).thenReturn(7L); + + Lun templateLun = new Lun(); + templateLun.setName("/vol/vol1/cs_tmpl_50"); + templateLun.setUuid("template-lun-uuid"); + CloudStackVolume created = new CloudStackVolume(); + created.setLun(templateLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.createTemplateCache(any(), any(), any(), anyLong())).thenReturn(created); + + driver.createAsync(dataStore, templateInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + // local_download_path carries the clone source; install_path is left for grantAccess + verify(templatePoolRef).setLocalDownloadPath("template-lun-uuid"); + verify(templatePoolRef).setTemplateSize(5368709120L); + verify(templatePoolRef, never()).setInstallPath(any()); + verify(vmTemplatePoolDao).update(eq(7L), any(VMTemplateStoragePoolVO.class)); + } + } + + + @Test + void testCreateAsync_TemplateWithISCSI_RollsBackLunWhenDbUpdateFails() { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + when(templateInfo.getSize()).thenReturn(5368709120L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getId()).thenReturn(7L); + doThrow(new RuntimeException("db update failed")).when(vmTemplatePoolDao) + .update(eq(7L), any(VMTemplateStoragePoolVO.class)); + + Lun templateLun = new Lun(); + templateLun.setName("/vol/vol1/cs_tmpl_50"); + templateLun.setUuid("template-lun-uuid"); + CloudStackVolume created = new CloudStackVolume(); + created.setLun(templateLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.createTemplateCache(any(), any(), any(), anyLong())).thenReturn(created); + doNothing().when(sanStrategy).deleteCloudStackVolume(any()); + + driver.createAsync(dataStore, templateInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + + ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).deleteCloudStackVolume(deleteCaptor.capture()); + assertEquals("template-lun-uuid", deleteCaptor.getValue().getLun().getUuid()); + assertEquals("/vol/vol1/cs_tmpl_50", deleteCaptor.getValue().getLun().getName()); + } + } + + @Test + void testCreateAsync_TemplateWithISCSI_UnknownSize_Fails() { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getSize()).thenReturn(0L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.createTemplateCache(any(), any(), any(), anyLong())) + .thenThrow(new CloudRuntimeException("Unknown virtual size for template [50]")); + + driver.createAsync(dataStore, templateInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + } + } + + @Test + void testCreateAsync_TemplateWithNFS_IsMetadataOnly() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getUuid()).thenReturn("template-uuid"); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(nasStrategy); + when(nasStrategy.createTemplateCache(any(), any(), any(), anyLong())).thenReturn(null); + + driver.createAsync(dataStore, templateInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(vmTemplatePoolDao, never()).update(any(Long.class), any(VMTemplateStoragePoolVO.class)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplate_ClonesWithoutGrowing() { + stubVolumeCloneFromTemplate(5368709120L, 5368709120L); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + 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()); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.LUN_DOT_UUID), eq("cloned-lun-uuid"), eq(false)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplate_GrowsWhenOfferingIsLarger() { + stubVolumeCloneFromTemplate(5368709120L, 21474836480L); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(sanStrategy).resizeCloudStackVolume(eq(cloned), eq(21474836480L)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_IscsiSuccessWithoutGrow() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("snap-cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).cloneCloudStackVolume(requestCaptor.capture()); + Lun lunRequest = requestCaptor.getValue().getLun(); + assertEquals("/vol/vol1/.snapshot/snap_cs200/source_lun", lunRequest.getClone().getSource().getName()); + 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)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_IscsiWithIops_SetsQosOnLunCreate() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("snap-cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + VolumeQosPolicy qosPolicy = qosPolicy("qos-snap-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloned, qosPolicy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).cloneCloudStackVolume(requestCaptor.capture()); + assertTrue(isQosPolicyReference(requestCaptor.getValue().getLun().getQosPolicy(), + "qos-snap-uuid", "cs_100_to_200_iops_svm1")); + verify(sanStrategy, never()).updateCloudStackVolume(any()); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-snap-uuid"), eq(false)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_NfsWithIops_AttachesQosAfterClone() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flex-uuid"); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(volumeInfo.getUuid()).thenReturn("new-volume-uuid"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); + + CloudStackVolume cloned = new CloudStackVolume(); + VolumeQosPolicy qosPolicy = lookedUpQosPolicy("qos-nfs-snap-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, nasStrategy, cloned, qosPolicy); + when(nasStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + when(nasStrategy.updateCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(nasStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + ArgumentCaptor cloneCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(nasStrategy).cloneCloudStackVolume(cloneCaptor.capture()); + assertNull(cloneCaptor.getValue().getFile().getQosPolicy()); + verify(nasStrategy).updateCloudStackVolume(argThat(request -> + "1".equals(request.getDatastoreId()) + && "flex-uuid".equals(request.getFlexVolumeUuid()) + && request.getVolumeInfo() == volumeInfo + && request.getFile() != null + && isQosPolicyReference(request.getFile().getQosPolicy(), + "qos-nfs-snap-uuid", "cs_100_to_200_iops_svm1"))); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-nfs-snap-uuid"), eq(false)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_NoIops_DoesNotCreatePolicy() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(null); + when(volumeInfo.getMaxIops()).thenReturn(null); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("snap-cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(sanStrategy, never()).createVolumeQosPolicy(nullable(String.class), nullable(Long.class), nullable(Long.class)); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).cloneCloudStackVolume(requestCaptor.capture()); + assertNull(requestCaptor.getValue().getLun().getQosPolicy()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_CloneFails_DeletesNewPolicy() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.ROOT); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-snap-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, new CloudStackVolume(), qosPolicy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenThrow(new CloudRuntimeException("clone failed")); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).deleteVolumeQosPolicy("qos-snap-uuid"); + verify(volumeDetailsDao, never()).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + anyString(), eq(false)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_GrowsWhenOfferingIsLarger() { + stubVolumeCloneFromSnapshot(5368709120L, 21474836480L, ProtocolType.ISCSI.name()); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("snap-cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(sanStrategy).resizeCloudStackVolume(eq(cloned), eq(21474836480L)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_ResizeFails_DeletesOrphanedClone() { + stubVolumeCloneFromSnapshot(5368709120L, 21474836480L, ProtocolType.ISCSI.name()); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("snap-cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + doThrow(new CloudRuntimeException("resize failed")) + .when(sanStrategy).resizeCloudStackVolume(eq(cloned), eq(21474836480L)); + doNothing().when(sanStrategy).deleteCloudStackVolume(eq(cloned)); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).deleteCloudStackVolume(eq(cloned)); + verify(volumeDetailsDao, never()).addDetail(eq(100L), eq(OntapStorageConstants.LUN_DOT_UUID), anyString(), anyBoolean()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_NfsSuccess() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(volumeInfo.getUuid()).thenReturn("new-volume-uuid"); + + CloudStackVolume cloned = new CloudStackVolume(); + FileInfo file = new FileInfo(); + file.setPath("new-volume-uuid"); + cloned.setFile(file); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(nasStrategy); + when(nasStrategy.cloneCloudStackVolume(any())) + .thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(nasStrategy).cloneCloudStackVolume(requestCaptor.capture()); + CloudStackVolume request = requestCaptor.getValue(); + assertEquals("source-file-uuid", request.getFile().getPath()); + assertEquals("new-volume-uuid", request.getDestinationPath()); + assertEquals("snap_cs200", request.getSnapshotName()); + assertEquals("1", request.getDatastoreId()); + verify(nasStrategy, never()).createCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_PoolMismatch_Fails() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.PRIMARY_POOL_ID)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.PRIMARY_POOL_ID, "999", false)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).cloneCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_MissingDetail_Fails() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.ONTAP_SNAP_NAME)).thenReturn(null); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).cloneCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_ProtocolMismatch_Fails() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + // Pool is iSCSI (default stub details) but snapshot was taken on NFS. + when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.PROTOCOL)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name(), false)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).cloneCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_NullStrategyResult_Fails() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenReturn(null); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_StrategyThrows_Fails() { + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenThrow(new CloudRuntimeException("ONTAP clone failed")); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).resizeCloudStackVolume(any(), anyLong()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_PrefersSnapshotOverTemplate() { + // Corner: when both details are present, the snapshot clone wins over the template clone. + stubVolumeCloneFromSnapshot(5368709120L, 5368709120L, ProtocolType.ISCSI.name()); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.CLONE_OF_TEMPLATE)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.CLONE_OF_TEMPLATE, "50", false)); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("snap-cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).cloneCloudStackVolume(requestCaptor.capture()); + assertEquals("/vol/vol1/.snapshot/snap_cs200/source_lun", + requestCaptor.getValue().getLun().getClone().getSource().getName()); + verify(vmTemplatePoolDao, never()).findByPoolTemplate(anyLong(), anyLong(), any()); + verify(sanStrategy, never()).createCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_NfsGrowsWhenOfferingIsLarger() { + stubVolumeCloneFromSnapshot(5368709120L, 21474836480L, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(volumeInfo.getUuid()).thenReturn("new-volume-uuid"); + + CloudStackVolume cloned = new CloudStackVolume(); + FileInfo file = new FileInfo(); + file.setPath("new-volume-uuid"); + cloned.setFile(file); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(nasStrategy); + when(nasStrategy.cloneCloudStackVolume(any())) + .thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(nasStrategy).resizeCloudStackVolume(eq(cloned), eq(21474836480L)); + verify(sanStrategy, never()).cloneCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromSnapshot_SkipsGrowWhenSnapshotSizeUnknown() { + stubVolumeCloneFromSnapshot(0L, 21474836480L, ProtocolType.ISCSI.name()); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("snap-cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(sanStrategy, never()).resizeCloudStackVolume(any(), anyLong()); + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + } + } + + /** + * Sets up a volume create that the orchestrator has marked as a clone of a CloudStack snapshot. + */ + private void stubVolumeCloneFromSnapshot(long snapshotSize, long volumeSize, String protocol) { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + lenient().when(volumeInfo.getSize()).thenReturn(volumeSize); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + // getId is only used after snapshot_details validation succeeds; lenient for early-fail tests. + lenient().when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getName()).thenReturn("vol1"); + lenient().when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + lenient().when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + when(volumeDao.findById(100L)).thenReturn(volumeVO); + lenient().when(volumeVO.getId()).thenReturn(100L); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.CLONE_OF_SNAPSHOT)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.CLONE_OF_SNAPSHOT, "200", false)); + + String volumePath = ProtocolType.NFS3.name().equalsIgnoreCase(protocol) + ? "source-file-uuid" + : "/vol/vol1/source_lun"; + if (!ProtocolType.NFS3.name().equalsIgnoreCase(protocol)) { + storagePoolDetails.put(OntapStorageConstants.VOLUME_NAME, "vol1"); + } + // Lenient so early-fail tests can override/null individual keys without STRICT_STUBS noise. + lenient().when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.ONTAP_SNAP_NAME)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.ONTAP_SNAP_NAME, "snap_cs200", false)); + lenient().when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.VOLUME_PATH)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.VOLUME_PATH, volumePath, false)); + lenient().when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.PRIMARY_POOL_ID)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.PRIMARY_POOL_ID, "1", false)); + lenient().when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.PROTOCOL)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.PROTOCOL, protocol, false)); + + SnapshotVO snapshotVO = mock(SnapshotVO.class); + lenient().when(snapshotDao.findById(200L)).thenReturn(snapshotVO); + lenient().when(snapshotVO.getSize()).thenReturn(snapshotSize); + } + + /** + * Sets up a volume create that the orchestrator has marked as a clone of a cached template. + */ + private void stubVolumeCloneFromTemplate(long templateSize, long volumeSize) { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + lenient().when(volumeInfo.getSize()).thenReturn(volumeSize); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getName()).thenReturn("vol1"); + lenient().when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + lenient().when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + when(volumeDao.findById(100L)).thenReturn(volumeVO); + lenient().when(volumeVO.getId()).thenReturn(100L); + // createAsync checks cloneOfSnapshot before cloneOfTemplate; under STRICT_STUBS an + // unstubbed alternate key on the same method is treated as an argument mismatch. + lenient().when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.CLONE_OF_SNAPSHOT)).thenReturn(null); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.CLONE_OF_TEMPLATE)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.CLONE_OF_TEMPLATE, "50", false)); + + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + lenient().when(templatePoolRef.getLocalDownloadPath()).thenReturn("template-lun-uuid"); + lenient().when(templatePoolRef.getTemplateSize()).thenReturn(templateSize); + } + + @Test + void testGrantAccess_Template_WritesInstallPathAndRefreshesStoreTarget() { + PrimaryDataStore primaryDataStore = mock(PrimaryDataStore.class); + Map dataStoreDetails = new HashMap<>(); + dataStoreDetails.put(PrimaryDataStore.MANAGED_STORE_TARGET, "stale-value"); + + when(primaryDataStore.getId()).thenReturn(1L); + when(primaryDataStore.getDetails()).thenReturn(dataStoreDetails); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER); + when(storagePool.getPath()).thenReturn("iqn.1992-08.com.netapp:sn.123456"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getId()).thenReturn(7L); + + when(host.getName()).thenReturn("host1"); + when(host.getUuid()).thenReturn("host-uuid-1"); + + AccessGroup existingAccessGroup = new AccessGroup(); + Igroup existingIgroup = new Igroup(); + existingIgroup.setName("igroup1"); + existingAccessGroup.setIgroup(existingIgroup); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(sanStrategy); + utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString())).thenReturn("igroup1"); + + when(sanStrategy.getAccessGroup(any())).thenReturn(existingAccessGroup); + when(sanStrategy.ensureLunMapped(eq("svm1"), eq("/vol/vol1/cs_tmpl_50"), eq("igroup1"))).thenReturn("3"); + + assertTrue(driver.grantAccess(templateInfo, host, primaryDataStore)); + + String expectedPath = "/iqn.1992-08.com.netapp:sn.123456/3"; + verify(templatePoolRef).setInstallPath(expectedPath); + verify(vmTemplatePoolDao).update(eq(7L), any(VMTemplateStoragePoolVO.class)); + + ArgumentCaptor> detailsCaptor = ArgumentCaptor.forClass(Map.class); + verify(primaryDataStore).setDetails(detailsCaptor.capture()); + assertEquals(expectedPath, detailsCaptor.getValue().get(PrimaryDataStore.MANAGED_STORE_TARGET)); + } + } + + + @Test + void testGrantAccess_Template_UnmapsWhenInstallPathUpdateFails() { + PrimaryDataStore primaryDataStore = mock(PrimaryDataStore.class); + + when(primaryDataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER); + when(storagePool.getPath()).thenReturn("iqn.1992-08.com.netapp:sn.123456"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getId()).thenReturn(7L); + doThrow(new RuntimeException("install path update failed")).when(vmTemplatePoolDao) + .update(eq(7L), any(VMTemplateStoragePoolVO.class)); + + when(host.getName()).thenReturn("host1"); + when(host.getUuid()).thenReturn("host-uuid-1"); + when(host.getStorageUrl()).thenReturn("iqn.1993-08.org.debian:01:host1"); + + AccessGroup existingAccessGroup = new AccessGroup(); + Igroup existingIgroup = mock(Igroup.class); + when(existingIgroup.getName()).thenReturn("igroup1"); + when(existingIgroup.getUuid()).thenReturn("igroup-uuid-123"); + existingAccessGroup.setIgroup(existingIgroup); + + Lun templateLun = new Lun(); + templateLun.setName("/vol/vol1/cs_tmpl_50"); + templateLun.setUuid("template-lun-uuid"); + CloudStackVolume cachedTemplate = new CloudStackVolume(); + cachedTemplate.setLun(templateLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(sanStrategy); + utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString())).thenReturn("igroup1"); + + when(sanStrategy.getAccessGroup(any())).thenReturn(existingAccessGroup); + when(sanStrategy.ensureLunMapped(eq("svm1"), eq("/vol/vol1/cs_tmpl_50"), eq("igroup1"))).thenReturn("3"); + when(sanStrategy.getCloudStackVolume(argThat(map -> + map != null && "/vol/vol1/cs_tmpl_50".equals(map.get("name"))))) + .thenReturn(cachedTemplate); + when(sanStrategy.validateInitiatorInAccessGroup(anyString(), anyString(), any(Igroup.class))).thenReturn(true); + doNothing().when(sanStrategy).disableLogicalAccess(any()); + + assertThrows(CloudRuntimeException.class, + () -> driver.grantAccess(templateInfo, host, primaryDataStore)); + + verify(sanStrategy).disableLogicalAccess(argThat(map -> + map != null && "template-lun-uuid".equals(map.get("lun.uuid")) + && "igroup-uuid-123".equals(map.get("igroup.uuid")))); + } + } + + @Test + void testGrantAccess_TemplateOnNFS_SkipsMapping() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + + when(dataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getUuid()).thenReturn("template-uuid"); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + assertTrue(driver.grantAccess(templateInfo, host, dataStore)); + verify(vmTemplatePoolDao, never()).update(any(Long.class), any(VMTemplateStoragePoolVO.class)); + } + + @Test + void testCreateAsync_VolumeClonedFromTemplateNFS_ClonesFile() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + stubVolumeCloneFromTemplate(5368709120L, 5368709120L); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(volumeInfo.getUuid()).thenReturn("volume-uuid"); + when(templatePoolRef.getInstallPath()).thenReturn("template-uuid"); + + CloudStackVolume cloned = new CloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(nasStrategy); + when(nasStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(nasStrategy).cloneCloudStackVolume(requestCaptor.capture()); + assertEquals("template-uuid", requestCaptor.getValue().getFile().getPath()); + assertEquals("volume-uuid", requestCaptor.getValue().getDestinationPath()); + verify(nasStrategy, never()).resizeCloudStackVolume(any(), anyLong()); + verify(nasStrategy, never()).updateCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplate_RootWithIops_SetsQosOnLunCreate() { + stubVolumeCloneFromTemplate(5368709120L, 5368709120L); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.ROOT); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/test_volume"); + clonedLun.setUuid("cloned-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + VolumeQosPolicy qosPolicy = qosPolicy("qos-root-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloned, qosPolicy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(sanStrategy).cloneCloudStackVolume(argThat(request -> + request.getLun() != null && request.getLun().getQosPolicy() != null + && "qos-root-uuid".equals(request.getLun().getQosPolicy().getUuid()))); + verify(sanStrategy, never()).updateCloudStackVolume(any()); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-root-uuid"), eq(false)); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplateNFS_RootWithIops_AttachesQosToFile() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flex-uuid"); + stubVolumeCloneFromTemplate(5368709120L, 5368709120L); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(volumeInfo.getUuid()).thenReturn("volume-uuid"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.ROOT); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(templatePoolRef.getInstallPath()).thenReturn("template-uuid"); + + CloudStackVolume cloned = new CloudStackVolume(); + VolumeQosPolicy qosPolicy = 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); + when(nasStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + when(nasStrategy.updateCloudStackVolume(any())).thenReturn(cloned); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(nasStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(nasStrategy, never()).resizeCloudStackVolume(any(), anyLong()); + verify(nasStrategy).updateCloudStackVolume(argThat(request -> + "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)); + } + } + + @Test + void testDeleteAsync_Template_NFS_DeletesCachedFile() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid"); + + when(dataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getInstallPath()).thenReturn("template-uuid"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(nasStrategy); + + driver.deleteAsync(dataStore, templateInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(nasStrategy).deleteFileByPath("flexvol-uuid", "template-uuid"); + } + } + + @Test + void testDeleteAsync_Template_NFS_FailsWhenFlexVolUuidMissing() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.remove(OntapStorageConstants.VOLUME_UUID); + + when(dataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getInstallPath()).thenReturn("template-uuid"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(nasStrategy); + + driver.deleteAsync(dataStore, templateInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains("volumeUUID")); + verify(nasStrategy, never()).deleteFileByPath(any(), any()); + } + } + + @Test + void testRevokeAccess_Template_UnmapsCacheLun() { + when(dataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + when(host.getStorageUrl()).thenReturn("iqn.1993-08.org.debian:01:host1"); + when(host.getName()).thenReturn("host1"); + when(host.getUuid()).thenReturn("host-uuid-1"); + + Lun templateLun = new Lun(); + templateLun.setName("/vol/vol1/cs_tmpl_50"); + templateLun.setUuid("template-lun-uuid"); + CloudStackVolume cachedTemplate = new CloudStackVolume(); + cachedTemplate.setLun(templateLun); + + Igroup igroup = mock(Igroup.class); + when(igroup.getName()).thenReturn("igroup1"); + when(igroup.getUuid()).thenReturn("igroup-uuid-123"); + AccessGroup accessGroup = new AccessGroup(); + accessGroup.setIgroup(igroup); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(sanStrategy); + utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString())).thenReturn("igroup1"); + + when(sanStrategy.getCloudStackVolume(argThat(map -> + map != null && "/vol/vol1/cs_tmpl_50".equals(map.get("name"))))) + .thenReturn(cachedTemplate); + when(sanStrategy.getAccessGroup(any())).thenReturn(accessGroup); + when(sanStrategy.validateInitiatorInAccessGroup(anyString(), anyString(), any(Igroup.class))).thenReturn(true); + + driver.revokeAccess(templateInfo, host, dataStore); + + verify(sanStrategy).disableLogicalAccess(argThat(map -> + map != null && "template-lun-uuid".equals(map.get("lun.uuid")) + && "igroup-uuid-123".equals(map.get("igroup.uuid")))); + } + } + + @Test + void testGetUsedIops_SumsMinIopsIncludingCreatingVolumes() { + VolumeVO readyVolume = mock(VolumeVO.class); + VolumeVO creatingVolume = mock(VolumeVO.class); + VolumeVO volumeWithoutMinIops = mock(VolumeVO.class); + + when(storagePool.getId()).thenReturn(1L); + when(readyVolume.getMinIops()).thenReturn(700L); + when(creatingVolume.getMinIops()).thenReturn(300L); + when(volumeWithoutMinIops.getMinIops()).thenReturn(null); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)) + .thenReturn(List.of(readyVolume, creatingVolume, volumeWithoutMinIops)); + + assertEquals(1000L, driver.getUsedIops(storagePool)); + } + + @Test + void testResize_IopsUpdate_AttachesQosPolicy() { + long currentSize = 4L * 1024 * 1024 * 1024; + stubResizeVolumeRecord(currentSize); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 0L, 5000L, null, false, null, null, true)); + when(storagePool.getCapacityIops()).thenReturn(null); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(null); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false)); + + VolumeQosPolicy qosPolicy = lookedUpQosPolicy("qos-uuid", "cs_0_to_5000_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + 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 + && "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); + } + } + + @Test + void testResize_IopsUpdate_NfsAttachesQosPolicy() { + long currentSize = 4L * 1024 * 1024 * 1024; + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flex-uuid"); + stubResizeVolumeRecord(currentSize); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 100L, 200L, null, false, null, null, true)); + when(storagePool.getCapacityIops()).thenReturn(null); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(null); + + VolumeQosPolicy qosPolicy = lookedUpQosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + 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).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); + } + } + + @Test + void testResize_SameQosPolicy_SkipsAttach() { + long currentSize = 4L * 1024 * 1024 * 1024; + stubResizeVolumeRecord(currentSize); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 0L, 3333L, null, false, null, null, true)); + when(storagePool.getCapacityIops()).thenReturn(null); + 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"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + 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()); + } + } + + @Test + void testResize_ClearIops_DetachesQosPolicy() { + long currentSize = 4L * 1024 * 1024 * 1024; + stubResizeVolumeRecord(currentSize); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 0L, 0L, null, false, null, null, true)); + when(storagePool.getCapacityIops()).thenReturn(null); + 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)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + + 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 + && isQosPolicyReference(request.getLun().getQosPolicy(), null, OntapStorageConstants.QOS_POLICY_NONE)), + eq(currentSize)); + verify(sanStrategy, never()).updateCloudStackVolume(any()); + verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + @Test + void testResize_SwitchIops_OldPolicyDeleteFails_StillSucceeds() { + long currentSize = 4L * 1024 * 1024 * 1024; + stubResizeVolumeRecord(currentSize); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 0L, 5000L, null, false, null, null, true)); + when(storagePool.getCapacityIops()).thenReturn(null); + 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 = lookedUpQosPolicy("new-qos-uuid", "cs_0_to_5000_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, iscsiCloudStackVolume(), qosPolicy); + doThrow(new CloudRuntimeException("ONTAP QoS delete failed")).when(sanStrategy) + .deleteVolumeQosPolicy("old-qos-uuid"); + + 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 + && 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"); + } + } + + @Test + void testResize_ClearIops_OldPolicyDeleteFails_StillSucceeds() { + long currentSize = 4L * 1024 * 1024 * 1024; + stubResizeVolumeRecord(currentSize); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 0L, 0L, null, false, null, null, true)); + when(storagePool.getCapacityIops()).thenReturn(null); + 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)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + doThrow(new CloudRuntimeException("ONTAP QoS delete failed")).when(sanStrategy) + .deleteVolumeQosPolicy("qos-uuid"); + + driver.resize(volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + @Test + void testResize_MinIopsBeyondPoolCapacity_Fails() { + long currentSize = 4L * 1024 * 1024 * 1024; + VolumeVO otherVolume = mock(VolumeVO.class); + when(otherVolume.getId()).thenReturn(50L); + when(otherVolume.getMinIops()).thenReturn(800L); + + when(volumeInfo.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.getSize()).thenReturn(currentSize); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("ontap-pool"); + when(storagePool.getCapacityIops()).thenReturn(1000L); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)).thenReturn(List.of(otherVolume, volumeVO)); + + driver.resize(volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains( + "requested total of 1100 IOPS exceeds the pool IOPS capacity of 1000")); + verify(sanStrategy, never()).resizeCloudStackVolume(any(), anyLong()); + verify(sanStrategy, never()).updateCloudStackVolume(any()); + } + + @Test + void testResize_MinIopsEqualToMaxIops_AttachesQosPolicy() { + long currentSize = 4L * 1024 * 1024 * 1024; + stubResizeVolumeRecord(currentSize); + when(volumeInfo.getpayload()).thenReturn( + new ResizeVolumePayload(currentSize, 100L, 100L, null, false, null, null, true)); + when(storagePool.getCapacityIops()).thenReturn(null); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(null); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false)); + + VolumeQosPolicy qosPolicy = lookedUpQosPolicy("qos-uuid", "cs_100_to_100_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + 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).createVolumeQosPolicy(eq("cs_100_to_100_iops_svm1"), eq(100L), eq(100L)); + verify(sanStrategy).resizeCloudStackVolume(argThat(request -> + request.getLun() != null + && "lun-uuid-123".equals(request.getLun().getUuid()) + && isQosPolicyReference(request.getLun().getQosPolicy(), "qos-uuid", "cs_100_to_100_iops_svm1")), + eq(currentSize)); + } + } + + @Test + void testCreateAsync_MinIopsBeyondPoolCapacity_FailsWithCapacityDetails() { + VolumeVO allocatedVolume = mock(VolumeVO.class); + when(allocatedVolume.getId()).thenReturn(50L); + when(allocatedVolume.getMinIops()).thenReturn(800L); + + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(volumeInfo.getMinIops()).thenReturn(300L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("ontap-pool"); + when(storagePool.getCapacityIops()).thenReturn(1000L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)).thenReturn(List.of(allocatedVolume)); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains( + "storage pool ontap-pool: requested total of 1100 IOPS exceeds the pool IOPS capacity of 1000")); + verify(sanStrategy, never()).createCloudStackVolume(any()); + } + + @Test + void testCreateAsync_OtherCreatingVolumeCountsAgainstCapacity() { + VolumeVO readyVolume = mock(VolumeVO.class); + when(readyVolume.getId()).thenReturn(50L); + when(readyVolume.getMinIops()).thenReturn(500L); + + VolumeVO otherCreatingVolume = mock(VolumeVO.class); + when(otherCreatingVolume.getId()).thenReturn(60L); + when(otherCreatingVolume.getMinIops()).thenReturn(300L); + + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(volumeInfo.getMinIops()).thenReturn(300L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("ontap-pool"); + when(storagePool.getCapacityIops()).thenReturn(1000L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)) + .thenReturn(List.of(readyVolume, otherCreatingVolume)); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains( + "storage pool ontap-pool: requested total of 1100 IOPS exceeds the pool IOPS capacity of 1000")); + verify(sanStrategy, never()).createCloudStackVolume(any()); + } + + @Test + void testCreateAsync_DoesNotDoubleCountSelfWhenAlreadyCreating() { + VolumeVO readyVolume = mock(VolumeVO.class); + when(readyVolume.getId()).thenReturn(50L); + when(readyVolume.getMinIops()).thenReturn(500L); + + VolumeVO selfCreatingVolume = mock(VolumeVO.class); + when(selfCreatingVolume.getId()).thenReturn(100L); + + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(volumeInfo.getMinIops()).thenReturn(300L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + when(storagePool.getCapacityIops()).thenReturn(1000L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + when(volumeDao.findNonDestroyedVolumesByPoolId(1L, null)) + .thenReturn(List.of(readyVolume, selfCreatingVolume)); + + Lun mockLun = new Lun(); + mockLun.setName("/vol/vol1/lun1"); + mockLun.setUuid("lun-uuid-123"); + CloudStackVolume cloudStackVolume = new CloudStackVolume(); + cloudStackVolume.setLun(mockLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.createCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_DataDiskFixedIops_CreatesAndPersistsQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(sanStrategy).createCloudStackVolume(argThat(request -> + request.getLun() != null && request.getLun().getQosPolicy() != null + && "qos-uuid".equals(request.getLun().getQosPolicy().getUuid()))); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-uuid"), eq(false)); + verify(sanStrategy, never()).isAff(); + } + } + + @Test + void testCreateAsync_FasPoolWithMinIops_FailsWithoutCallingOntap() { + stubIscsiVolumeCreate(); + storagePoolDetails.put(OntapStorageConstants.IS_AFF, "false"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains(OntapStorageConstants.MIN_IOPS_NOT_SUPPORTED_ON_FAS)); + verify(sanStrategy, never()).isAff(); + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + } + } + + @Test + void testCreateAsync_FasPoolWithMaxIopsOnly_CreatesQosPolicyWithoutNodeCall() { + stubIscsiVolumeCreate(); + storagePoolDetails.put(OntapStorageConstants.IS_AFF, "false"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(0L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_0_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).isAff(); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_0_to_200_iops_svm1"), eq(0L), eq(200L)); + } + } + + @Test + void testCreateAsync_MissingAffDetail_FetchesFromNodesAndPersists() { + stubIscsiVolumeCreate(); + storagePoolDetails.remove(OntapStorageConstants.IS_AFF); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(sanStrategy.isAff()).thenReturn(true); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).isAff(); + verify(storagePoolDetailsDao).addDetail(1L, OntapStorageConstants.IS_AFF, "true", false); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + } + } + + @Test + void testCreateAsync_MissingAffDetailOnFas_PersistsFalseAndRejectsMinIops() { + stubIscsiVolumeCreate(); + storagePoolDetails.remove(OntapStorageConstants.IS_AFF); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(sanStrategy.isAff()).thenReturn(false); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains(OntapStorageConstants.MIN_IOPS_NOT_SUPPORTED_ON_FAS)); + verify(sanStrategy).isAff(); + verify(storagePoolDetailsDao).addDetail(1L, OntapStorageConstants.IS_AFF, "false", false); + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + } + } + + @Test + void testCreateAsync_MissingAffDetail_IsAffThrows_StillCreatesWithMinIops() { + stubIscsiVolumeCreate(); + storagePoolDetails.remove(OntapStorageConstants.IS_AFF); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(sanStrategy.isAff()).thenThrow(new CloudRuntimeException( + "Unable to determine whether the ONTAP cluster is AFF or FAS")); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).isAff(); + verify(storagePoolDetailsDao, never()).addDetail(eq(1L), eq(OntapStorageConstants.IS_AFF), + anyString(), eq(false)); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(sanStrategy).createCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_RootDiskCustomIops_CreatesAndPersistsQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.ROOT); + when(volumeInfo.getMinIops()).thenReturn(111L); + when(volumeInfo.getMaxIops()).thenReturn(999L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-root-uuid", "cs_111_to_999_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_111_to_999_iops_svm1"), eq(111L), eq(999L)); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.QOS_POLICY_UUID), + eq("qos-root-uuid"), eq(false)); + } + } + + @Test + void testCreateAsync_NfsDataDiskWithIops_CreatesQosPolicy() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeVO.getId()).thenReturn(100L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-nfs-uuid", "cs_100_to_200_iops_svm1"); + CloudStackVolume cloudStackVolume = new CloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, nasStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(nasStrategy).createVolumeQosPolicy(eq("cs_100_to_200_iops_svm1"), eq(100L), eq(200L)); + verify(nasStrategy).createCloudStackVolume(argThat(request -> + request.getFile() != null && request.getFile().getQosPolicy() != null + && "qos-nfs-uuid".equals(request.getFile().getQosPolicy().getUuid()))); + } + } + + @Test + void testCreateAsync_MinIopsGreaterThanMaxIops_Fails() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(200L); + when(volumeInfo.getMaxIops()).thenReturn(100L); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + assertTrue(resultCaptor.getValue().getResult().contains( + "Minimum IOPS cannot be greater than maximum IOPS")); + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + verify(sanStrategy, never()).createCloudStackVolume(any()); + } + } + + @Test + void testCreateAsync_MinIopsEqualToMaxIops_CreatesQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(100L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_100_iops_svm1"); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + stubQosCreateMocks(utilityMock, sanStrategy, cloudStackVolume, qosPolicy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).createVolumeQosPolicy(eq("cs_100_to_100_iops_svm1"), eq(100L), eq(100L)); + verify(sanStrategy).createCloudStackVolume(argThat(request -> + request.getLun() != null && request.getLun().getQosPolicy() != null + && "qos-uuid".equals(request.getLun().getQosPolicy().getUuid()))); + } + } + + @Test + void testCreateAsync_NoIops_DoesNotCreateQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.createCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + verify(sanStrategy).createCloudStackVolume(argThat(request -> + request.getLun() != null && request.getLun().getQosPolicy() == null)); + } + } + + @Test + void testCreateAsync_SwapVolumeWithIops_DoesNotCreateQosPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.SWAP); + when(volumeInfo.getMinIops()).thenReturn(100L); + CloudStackVolume cloudStackVolume = iscsiCloudStackVolume(); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.createCloudStackVolume(any())).thenReturn(cloudStackVolume); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + verify(sanStrategy, never()).createVolumeQosPolicy(any(), any(), any()); + } + } + + @Test + void testCreateAsync_QosCreateThenLunCreateFails_DeletesUnusedPolicy() { + stubIscsiVolumeCreate(); + when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + when(volumeInfo.getMinIops()).thenReturn(100L); + when(volumeInfo.getMaxIops()).thenReturn(200L); + + VolumeQosPolicy qosPolicy = qosPolicy("qos-uuid", "cs_100_to_200_iops_svm1"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(sanStrategy); + when(sanStrategy.createVolumeQosPolicy(nullable(String.class), nullable(Long.class), nullable(Long.class))) + .thenReturn(qosPolicy); + when(sanStrategy.createCloudStackVolume(any())).thenThrow(new CloudRuntimeException( + "Failed to create Lun: {\"error\":{\"code\":\"8454269\"}}")); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + @Test + void testDeleteAsync_DeletesUnusedQosPolicy() { + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false); + VolumeDetailVO lunUuidDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false); + VolumeDetailVO qosDetail = new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); + + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(lunUuidDetail); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(qosDetail); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + doNothing().when(sanStrategy).deleteCloudStackVolume(any()); + + driver.deleteAsync(dataStore, volumeInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(volumeDetailsDao, never()).removeDetail(100L, OntapStorageConstants.QOS_POLICY_UUID); + verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + @Test + void testDeleteAsync_NfsClearsQosBeforeFileDelete() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flex-uuid"); + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(nasStrategy); + when(nasStrategy.updateCloudStackVolume(any())).thenAnswer(invocation -> invocation.getArgument(0)); + doNothing().when(nasStrategy).deleteCloudStackVolume(any()); + + driver.deleteAsync(dataStore, volumeInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(nasStrategy).updateCloudStackVolume(argThat(request -> + "flex-uuid".equals(request.getFlexVolumeUuid()) + && request.getFile() != null + && isQosPolicyReference(request.getFile().getQosPolicy(), null, OntapStorageConstants.QOS_POLICY_NONE))); + verify(nasStrategy).deleteCloudStackVolume(any()); + verify(nasStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + @Test + void testDeleteAsync_NfsRestoresQosWhenFileDeleteFails() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + storagePoolDetails.put(OntapStorageConstants.VOLUME_UUID, "flex-uuid"); + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(nasStrategy); + when(nasStrategy.updateCloudStackVolume(any())).thenAnswer(invocation -> invocation.getArgument(0)); + doThrow(new CloudRuntimeException("file delete failed")).when(nasStrategy).deleteCloudStackVolume(any()); + + driver.deleteAsync(dataStore, volumeInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + ArgumentCaptor qosCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(nasStrategy, times(2)).updateCloudStackVolume(qosCaptor.capture()); + assertTrue(isQosPolicyReference(qosCaptor.getAllValues().get(0).getFile().getQosPolicy(), + null, OntapStorageConstants.QOS_POLICY_NONE)); + VolumeQosPolicy restored = qosCaptor.getAllValues().get(1).getFile().getQosPolicy(); + assertEquals("qos-uuid", restored.getUuid()); + assertNull(restored.getName()); + assertNull(restored.getSvm()); + assertNull(restored.getFixed()); + verify(nasStrategy, never()).deleteVolumeQosPolicy(any()); + } + } + + @Test + void testDeleteAsync_QosPolicyDeleteFails_VolumeDeleteStillSucceeds() { + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false); + VolumeDetailVO lunUuidDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false); + VolumeDetailVO qosDetail = new VolumeDetailVO(100L, OntapStorageConstants.QOS_POLICY_UUID, "qos-uuid", false); + + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(lunUuidDetail); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(qosDetail); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)) + .thenReturn(sanStrategy); + doNothing().when(sanStrategy).deleteCloudStackVolume(any()); + doThrow(new CloudRuntimeException("ONTAP QoS delete failed")).when(sanStrategy) + .deleteVolumeQosPolicy("qos-uuid"); + + driver.deleteAsync(dataStore, volumeInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(volumeDetailsDao, never()).removeDetail(100L, OntapStorageConstants.QOS_POLICY_UUID); + verify(sanStrategy).deleteVolumeQosPolicy("qos-uuid"); + } + } + + private void stubIscsiVolumeCreate() { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + lenient().when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + lenient().when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + lenient().when(storagePool.getCapacityIops()).thenReturn(null); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + lenient().when(volumeVO.getId()).thenReturn(100L); + } + + private CloudStackVolume iscsiCloudStackVolume() { + Lun mockLun = new Lun(); + mockLun.setName("/vol/vol1/lun1"); + mockLun.setUuid("lun-uuid-123"); + CloudStackVolume volume = new CloudStackVolume(); + volume.setLun(mockLun); + return volume; + } + + private CloudStackVolume nfsCloudStackVolume() { + FileInfo fileInfo = new FileInfo(); + CloudStackVolume volume = new CloudStackVolume(); + volume.setFlexVolumeUuid("flex-uuid"); + volume.setFile(fileInfo); + volume.setVolumeInfo(volumeInfo); + return volume; + } + + private VolumeQosPolicy qosPolicy(String uuid, String name) { + VolumeQosPolicy policy = new VolumeQosPolicy(); + policy.setUuid(uuid); + policy.setName(name); + return policy; + } + + /** + * 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) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(strategy); + when(strategy.createVolumeQosPolicy(nullable(String.class), nullable(Long.class), nullable(Long.class))) + .thenReturn(qosPolicy); + lenient().when(strategy.createCloudStackVolume(any())).thenReturn(cloudStackVolume); + } + + @Test + void testCanHostAccessStoragePool_ReturnsTrue() { + assertTrue(driver.canHostAccessStoragePool(host, storagePool)); + } + + @Test + void testIsVmInfoNeeded_ReturnsTrue() { + assertTrue(driver.isVmInfoNeeded()); + } + + @Test + void testIsStorageSupportHA_ReturnsTrue() { + assertTrue(driver.isStorageSupportHA(Storage.StoragePoolType.NetworkFilesystem)); + } + + @Test + void testGetChapInfo_ReturnsNull() { + assertNull(driver.getChapInfo(volumeInfo)); + } + + @Test + void testCanProvideStorageStats_ReturnsFalse() { + assertFalse(driver.canProvideStorageStats()); + } + + @Test + void testCanProvideVolumeStats_ReturnsFalse() { + assertFalse(driver.canProvideVolumeStats()); + } + + @Test + void testGetBytesRequiredForTemplate_NullArgs_ReturnsZero() { + assertEquals(0L, driver.getBytesRequiredForTemplate(null, storagePool)); + assertEquals(0L, driver.getBytesRequiredForTemplate(templateInfo, null)); + } + + @Test + void testCreateAsync_Template_StrategyThrows_Fails() { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + when(templateInfo.getSize()).thenReturn(5368709120L); + when(templateInfo.getName()).thenReturn("tmpl"); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.createTemplateCache(any(), any(), any(), anyLong())) + .thenThrow(new RuntimeException("unexpected")); + + driver.createAsync(dataStore, templateInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplate_NullCloneResult_Fails() { + stubVolumeCloneFromTemplate(5368709120L, 5368709120L); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(null); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplateNFS_MissingInstallPath_Fails() { + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name()); + stubVolumeCloneFromTemplate(5368709120L, 5368709120L); + when(templatePoolRef.getInstallPath()).thenReturn(null); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(nasStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(nasStrategy, never()).cloneCloudStackVolume(any()); + } + } + + @Test + void testDeleteAsync_Template_NoSpoolRef_Succeeds() { + when(dataStore.getId()).thenReturn(1L); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(null); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + + driver.deleteAsync(dataStore, templateInfo, commandCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class); + verify(commandCallback).complete(resultCaptor.capture()); + assertTrue(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).deleteCloudStackVolume(any()); + } + } + + @Test + void testGrantAccess_Template_CreatesIgroupWhenMissing() { + PrimaryDataStore primaryDataStore = mock(PrimaryDataStore.class); + Map dataStoreDetails = new HashMap<>(); + dataStoreDetails.put(PrimaryDataStore.MANAGED_STORE_TARGET, "stale-value"); + + when(primaryDataStore.getId()).thenReturn(1L); + when(primaryDataStore.getDetails()).thenReturn(dataStoreDetails); + when(templateInfo.getType()).thenReturn(TEMPLATE); + when(templateInfo.getId()).thenReturn(50L); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER); + when(storagePool.getPath()).thenReturn("iqn.1992-08.com.netapp:sn.123456"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(templatePoolRef); + when(templatePoolRef.getId()).thenReturn(7L); + + HostVO hostVo = mock(HostVO.class); + when(hostVo.getName()).thenReturn("host1"); + when(hostVo.getUuid()).thenReturn("host-uuid-1"); + + AccessGroup createdAccessGroup = new AccessGroup(); + Igroup createdIgroup = new Igroup(); + createdIgroup.setName("igroup1"); + createdAccessGroup.setIgroup(createdIgroup); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(sanStrategy); + utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString())).thenReturn("igroup1"); + + when(sanStrategy.getAccessGroup(any())).thenReturn(null); + when(sanStrategy.createAccessGroup(any())).thenReturn(createdAccessGroup); + when(sanStrategy.ensureLunMapped(eq("svm1"), eq("/vol/vol1/cs_tmpl_50"), eq("igroup1"))).thenReturn("3"); + + assertTrue(driver.grantAccess(templateInfo, hostVo, primaryDataStore)); + + verify(sanStrategy).createAccessGroup(any()); + verify(templatePoolRef).setInstallPath("/iqn.1992-08.com.netapp:sn.123456/3"); + } + } + + @Test + void testCreateAsync_VolumeClonedFromTemplate_MissingSpoolRef_Fails() { + when(dataStore.getId()).thenReturn(1L); + when(dataStore.getName()).thenReturn("ontap-pool"); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getName()).thenReturn("test-volume"); + + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + lenient().when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.CLONE_OF_SNAPSHOT)).thenReturn(null); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.CLONE_OF_TEMPLATE)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.CLONE_OF_TEMPLATE, "50", false)); + when(vmTemplatePoolDao.findByPoolTemplate(1L, 50L, null)).thenReturn(null); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + + driver.createAsync(dataStore, volumeInfo, createCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class); + verify(createCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + verify(sanStrategy, never()).cloneCloudStackVolume(any()); + } + } + + // ========================================================================= + // 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"); + lenient().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, ""); + } + + private SnapshotInfo stubSnapshotToVolumeCopy(VolumeVO destVolume) { + SnapshotInfo snapshotInfo = mock(SnapshotInfo.class); + lenient().when(snapshotInfo.getType()).thenReturn(SNAPSHOT); + lenient().when(snapshotInfo.getDataStore()).thenReturn(dataStore); + lenient().when(snapshotInfo.getId()).thenReturn(200L); + lenient().when(volumeInfo.getType()).thenReturn(VOLUME); + lenient().when(volumeInfo.getDataStore()).thenReturn(dataStore); + lenient().when(volumeInfo.getId()).thenReturn(100L); + // VolumeObject.getName() reads the VolumeVO, which copyAsync renames before cloning. + lenient().when(volumeInfo.getName()).thenReturn("cs_tmp_snap_200_100"); + lenient().when(dataStore.getId()).thenReturn(1L); + lenient().when(dataStore.getRole()).thenReturn(DataStoreRole.Primary); + lenient().when(volumeDao.findById(100L)).thenReturn(destVolume); + return snapshotInfo; + } + + private void stubSnapshotCloneSource(String protocol) { + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + lenient().when(storagePool.getId()).thenReturn(1L); + lenient().when(storagePool.getName()).thenReturn("vol1"); + lenient().when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM); + storagePoolDetails.put(OntapStorageConstants.PROTOCOL, protocol); + storagePoolDetails.put(OntapStorageConstants.VOLUME_NAME, "vol1"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + lenient().when(volumeInfo.getSize()).thenReturn(5368709120L); + + String volumePath = ProtocolType.NFS3.name().equals(protocol) ? "source-file-uuid" : "/vol/vol1/source_lun"; + lenient().when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.ONTAP_SNAP_NAME)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.ONTAP_SNAP_NAME, "snap_cs200", false)); + lenient().when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.VOLUME_PATH)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.VOLUME_PATH, volumePath, false)); + lenient().when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.PRIMARY_POOL_ID)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.PRIMARY_POOL_ID, "1", false)); + lenient().when(snapshotDetailsDao.findDetail(200L, OntapStorageConstants.PROTOCOL)) + .thenReturn(new SnapshotDetailsVO(200L, OntapStorageConstants.PROTOCOL, protocol, false)); + SnapshotVO snapshotVO = mock(SnapshotVO.class); + lenient().when(snapshotDao.findById(200L)).thenReturn(snapshotVO); + lenient().when(snapshotVO.getSize()).thenReturn(5368709120L); + } + + @Test + void testCanCopy_TemporarySnapshotCopyOnSamePool_ReturnsTrue() { + SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(temporarySnapshotCopyVolume()); + + assertTrue(driver.canCopy(snapshotInfo, volumeInfo)); + } + + @Test + void testCanCopy_CreateVolumeFromSnapshot_ReturnsFalse() { + VolumeVO userVolume = temporarySnapshotCopyVolume(); + userVolume.setDiskOfferingId(5L); + userVolume.setState(Volume.State.Creating); + SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(userVolume); + + assertFalse(driver.canCopy(snapshotInfo, volumeInfo)); + } + + @Test + void testCanCopy_AllocatedVolumeWithDiskOffering_ReturnsFalse() { + VolumeVO userVolume = temporarySnapshotCopyVolume(); + userVolume.setDiskOfferingId(5L); + SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(userVolume); + + assertFalse(driver.canCopy(snapshotInfo, volumeInfo)); + } + + @Test + void testCanCopy_DifferentPool_ReturnsFalse() { + SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(temporarySnapshotCopyVolume()); + DataStore otherStore = mock(DataStore.class); + when(otherStore.getId()).thenReturn(2L); + when(volumeInfo.getDataStore()).thenReturn(otherStore); + + assertFalse(driver.canCopy(snapshotInfo, volumeInfo)); + verify(volumeDao, never()).findById(anyLong()); + } + + @Test + void testCanCopy_TemplateCacheCopies_ReturnFalse() { + TemplateInfo templateOnPrimary = mock(TemplateInfo.class); + lenient().when(templateInfo.getType()).thenReturn(TEMPLATE); + lenient().when(templateInfo.getDataStore()).thenReturn(dataStore); + lenient().when(templateOnPrimary.getType()).thenReturn(TEMPLATE); + lenient().when(templateOnPrimary.getDataStore()).thenReturn(dataStore); + lenient().when(volumeInfo.getType()).thenReturn(VOLUME); + lenient().when(volumeInfo.getDataStore()).thenReturn(dataStore); + + assertFalse(driver.canCopy(templateInfo, templateOnPrimary)); + assertFalse(driver.canCopy(templateOnPrimary, volumeInfo)); + verify(volumeDao, never()).findById(anyLong()); + } + + @Test + void testCanCopy_VolumeMigration_ReturnsFalse() { + VolumeInfo destVolumeInfo = mock(VolumeInfo.class); + DataStore destStore = mock(DataStore.class); + lenient().when(volumeInfo.getType()).thenReturn(VOLUME); + lenient().when(volumeInfo.getDataStore()).thenReturn(dataStore); + lenient().when(destVolumeInfo.getType()).thenReturn(VOLUME); + lenient().when(destVolumeInfo.getDataStore()).thenReturn(destStore); + + assertFalse(driver.canCopy(volumeInfo, destVolumeInfo)); + verify(volumeDao, never()).findById(anyLong()); + } + + @Test + void testCopyAsync_VolumeMigration_Throws() { + VolumeInfo destVolumeInfo = mock(VolumeInfo.class); + DataStore destStore = mock(DataStore.class); + lenient().when(volumeInfo.getType()).thenReturn(VOLUME); + lenient().when(volumeInfo.getDataStore()).thenReturn(dataStore); + lenient().when(destVolumeInfo.getType()).thenReturn(VOLUME); + lenient().when(destVolumeInfo.getDataStore()).thenReturn(destStore); + + assertThrows(UnsupportedOperationException.class, + () -> driver.copyAsync(volumeInfo, destVolumeInfo, null, null)); + verify(sanStrategy, never()).cloneCloudStackVolume(any()); + verify(nasStrategy, never()).cloneCloudStackVolume(any()); + } + + @Test + void testCopyAsync_UnsupportedPair_Throws() { + lenient().when(templateInfo.getType()).thenReturn(TEMPLATE); + lenient().when(templateInfo.getDataStore()).thenReturn(dataStore); + lenient().when(volumeInfo.getType()).thenReturn(VOLUME); + lenient().when(volumeInfo.getDataStore()).thenReturn(dataStore); + + assertThrows(UnsupportedOperationException.class, () -> driver.copyAsync(templateInfo, volumeInfo, null, null)); + } + + @Test + void testCopyAsync_IscsiTemporarySnapshotCopy_ClonesLunAndRecordsIdentity() { + VolumeVO dbVolume = temporarySnapshotCopyVolume(); + VolumeVO inMemoryVolume = temporarySnapshotCopyVolume(); + SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(dbVolume); + when(volumeInfo.getVolume()).thenReturn(inMemoryVolume); + stubSnapshotCloneSource(ProtocolType.ISCSI.name()); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/cs_tmp_snap_200_100"); + clonedLun.setUuid("tmp-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.copyAsync(snapshotInfo, volumeInfo, null, null); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).cloneCloudStackVolume(requestCaptor.capture()); + 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()); + assertEquals(Storage.ImageFormat.RAW, dbVolume.getFormat()); + assertEquals("tmp-lun-uuid", dbVolume.getFolder()); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.LUN_DOT_UUID), eq("tmp-lun-uuid"), eq(false)); + verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.LUN_DOT_NAME), eq("/vol/vol1/cs_tmp_snap_200_100"), eq(false)); + verify(volumeDao).update(anyLong(), eq(dbVolume)); + verify(sanStrategy, never()).resizeCloudStackVolume(any(), anyLong()); + verify(sanStrategy, never()).createVolumeQosPolicy(anyString(), any(), any()); + } + } + + @Test + void testCopyAsync_IscsiTemporarySnapshotCopy_IgnoresVolumeIops() { + VolumeVO dbVolume = temporarySnapshotCopyVolume(); + SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(dbVolume); + when(volumeInfo.getVolume()).thenReturn(temporarySnapshotCopyVolume()); + stubSnapshotCloneSource(ProtocolType.ISCSI.name()); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + lenient().when(volumeInfo.getVolumeType()).thenReturn(Volume.Type.DATADISK); + lenient().when(volumeInfo.getMinIops()).thenReturn(100L); + lenient().when(volumeInfo.getMaxIops()).thenReturn(200L); + + Lun clonedLun = new Lun(); + clonedLun.setName("/vol/vol1/cs_tmp_snap_200_100"); + clonedLun.setUuid("tmp-lun-uuid"); + CloudStackVolume cloned = new CloudStackVolume(); + cloned.setLun(clonedLun); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.copyAsync(snapshotInfo, volumeInfo, null, null); + + verify(sanStrategy, never()).createVolumeQosPolicy(nullable(String.class), nullable(Long.class), nullable(Long.class)); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(sanStrategy).cloneCloudStackVolume(requestCaptor.capture()); + assertNull(requestCaptor.getValue().getLun().getQosPolicy()); + } + } + + @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(); + SnapshotInfo snapshotInfo = stubSnapshotToVolumeCopy(dbVolume); + when(volumeInfo.getVolume()).thenReturn(temporarySnapshotCopyVolume()); + when(volumeInfo.getUuid()).thenReturn("tmp-volume-uuid"); + stubSnapshotCloneSource(ProtocolType.NFS3.name()); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + + CloudStackVolume cloned = new CloudStackVolume(); + FileInfo file = new FileInfo(); + file.setPath("tmp-volume-uuid"); + cloned.setFile(file); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(nasStrategy); + when(nasStrategy.cloneCloudStackVolume(any())).thenReturn(cloned); + + driver.copyAsync(snapshotInfo, volumeInfo, null, null); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CloudStackVolume.class); + verify(nasStrategy).cloneCloudStackVolume(requestCaptor.capture()); + assertEquals("source-file-uuid", requestCaptor.getValue().getFile().getPath()); + assertEquals("tmp-volume-uuid", requestCaptor.getValue().getDestinationPath()); + assertEquals("snap_cs200", requestCaptor.getValue().getSnapshotName()); + + assertEquals("tmp-volume-uuid", dbVolume.getPath()); + assertEquals(Storage.ImageFormat.QCOW2, dbVolume.getFormat()); + verify(volumeDao).update(anyLong(), eq(dbVolume)); + } + } + + @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()); + when(volumeInfo.getVolume()).thenReturn(temporarySnapshotCopyVolume()); + stubSnapshotCloneSource(ProtocolType.ISCSI.name()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenThrow(new CloudRuntimeException("clone failed")); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> driver.copyAsync(snapshotInfo, volumeInfo, null, null)); + assertTrue(ex.getMessage().contains("clone failed")); + verify(volumeDetailsDao, never()).addDetail(anyLong(), anyString(), anyString(), anyBoolean()); + } + } + + @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()); + when(volumeInfo.getVolume()).thenReturn(temporarySnapshotCopyVolume()); + stubSnapshotCloneSource(ProtocolType.ISCSI.name()); + @SuppressWarnings("unchecked") + AsyncCompletionCallback copyCallback = mock(AsyncCompletionCallback.class); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())).thenReturn(sanStrategy); + when(sanStrategy.cloneCloudStackVolume(any())) + .thenThrow(new CloudRuntimeException("clone failed")); + + driver.copyAsync(snapshotInfo, volumeInfo, null, copyCallback); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CopyCommandResult.class); + verify(copyCallback).complete(resultCaptor.capture()); + assertFalse(resultCaptor.getValue().isSuccess()); + } + } + + @Test + void testDeleteAsync_TemporarySnapshotCopy_NullCallbackRemovesRecord() { + VolumeVO tempVolume = temporarySnapshotCopyVolume(); + tempVolume.setName("cs_tmp_snap_200_100"); + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(tempVolume); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/cs_tmp_snap_200_100", false)); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "tmp-lun-uuid", false)); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.QOS_POLICY_UUID)).thenReturn(null); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(sanStrategy); + + driver.deleteAsync(dataStore, volumeInfo, null); + + verify(sanStrategy).deleteCloudStackVolume(any(CloudStackVolume.class)); + verify(volumeDetailsDao).removeDetails(100L); + verify(volumeDao).remove(100L); + } + } + + @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"; + VolumeVO dbVolume = mock(VolumeVO.class); + when(dbVolume.getId()).thenReturn(100L); + when(dbVolume.getState()).thenReturn(Volume.State.Allocated); + when(dbVolume.getDiskOfferingId()).thenReturn(0L); + when(dbVolume.getPath()).thenReturn(iscsiPath); + when(dbVolume.get_iScsiName()).thenReturn(iscsiPath); + VolumeVO inMemoryVolume = temporarySnapshotCopyVolume(); + + when(dataStore.getId()).thenReturn(1L); + when(volumeInfo.getType()).thenReturn(VOLUME); + when(volumeInfo.getId()).thenReturn(100L); + when(volumeInfo.getVolume()).thenReturn(inMemoryVolume); + when(storagePoolDao.findById(1L)).thenReturn(storagePool); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER); + when(storagePool.getPath()).thenReturn("iqn.1992-08.com.netapp:sn.123456"); + when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.OntapiSCSI); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails); + when(volumeDao.findById(100L)).thenReturn(dbVolume); + when(host.getUuid()).thenReturn("host-uuid-1"); + when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)) + .thenReturn(new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/cs_tmp_snap_200_100", false)); + + AccessGroup existingAccessGroup = new AccessGroup(); + Igroup existingIgroup = new Igroup(); + existingIgroup.setName("igroup1"); + existingAccessGroup.setIgroup(existingIgroup); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails)).thenReturn(sanStrategy); + utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString())).thenReturn("igroup1"); + when(sanStrategy.getAccessGroup(any())).thenReturn(existingAccessGroup); + when(sanStrategy.ensureLunMapped(anyString(), anyString(), anyString())).thenReturn("0"); + + assertTrue(driver.grantAccess(volumeInfo, host, dataStore)); + + assertEquals(iscsiPath, inMemoryVolume.getPath()); + assertEquals(iscsiPath, inMemoryVolume.get_iScsiName()); + } } } diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java index 0a82cee17e6c..12ff93d71c11 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java @@ -23,6 +23,7 @@ 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.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; @@ -37,15 +38,18 @@ import com.cloud.dc.ClusterVO; import com.cloud.host.HostVO; import com.cloud.resource.ResourceManager; +import com.cloud.storage.Storage; import com.cloud.storage.StorageManager; import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreInfo; +import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreParameters; import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.service.model.AccessGroup; import com.cloud.hypervisor.Hypervisor; import com.cloud.alert.AlertManager; +import com.cloud.capacity.CapacityManager; import java.util.Map; import java.util.List; import java.util.ArrayList; @@ -57,11 +61,17 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; import static org.mockito.Mockito.withSettings; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.ArgumentMatchers.contains; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse; import java.util.HashMap; +import com.cloud.storage.StoragePool; +import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreLifeCycle; import org.apache.cloudstack.storage.provider.StorageProviderFactory; import org.apache.cloudstack.storage.service.StorageStrategy; import org.apache.cloudstack.storage.volume.datastore.PrimaryDataStoreHelper; @@ -99,6 +109,9 @@ public class OntapPrimaryDatastoreLifecycleTest { @Mock private AlertManager _alertMgr; + @Mock + private CapacityManager _capacityMgr; + // Mock object that implements both DataStore and PrimaryDataStoreInfo // This is needed because attachCluster(DataStore) casts DataStore to PrimaryDataStoreInfo internally private DataStore dataStore; @@ -123,6 +136,7 @@ void setUp() { when(_clusterDao.findById(1L)).thenReturn(clusterVO); when(storageStrategy.connect()).thenReturn(true); + when(storageStrategy.isAff()).thenReturn(true); when(storageStrategy.getNetworkInterface()).thenReturn(new Pair<>("testNetworkInterface", null)); Volume volume = new Volume(); @@ -191,6 +205,112 @@ public void testInitialize_positive() { } } + private Map buildDsInfosForProtocol(String protocol) { + HashMap detailsMap = new HashMap(); + detailsMap.put(OntapStorageConstants.USERNAME, "testUser"); + detailsMap.put(OntapStorageConstants.PASSWORD, "testPassword"); + detailsMap.put(OntapStorageConstants.STORAGE_IP, "10.10.10.10"); + detailsMap.put(OntapStorageConstants.SVM_NAME, "vs0"); + detailsMap.put(OntapStorageConstants.PROTOCOL, protocol); + + Map dsInfos = new HashMap<>(); + dsInfos.put("zoneId", 1L); + dsInfos.put("podId", 1L); + dsInfos.put("clusterId", 1L); + dsInfos.put("name", "testStoragePool"); + dsInfos.put("providerName", "testProvider"); + dsInfos.put("capacityBytes", 1073741824L); + dsInfos.put("managed", true); + dsInfos.put("tags", "testTag"); + dsInfos.put("isTagARule", false); + dsInfos.put("details", detailsMap); + return dsInfos; + } + + private Storage.StoragePoolType initializeAndCapturePoolType(String protocol) { + try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) { + storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy); + ontapPrimaryDatastoreLifecycle.initialize(buildDsInfosForProtocol(protocol)); + } + ArgumentCaptor captor = ArgumentCaptor.forClass(PrimaryDataStoreParameters.class); + verify(_dataStoreHelper).createPrimaryDataStore(captor.capture()); + return captor.getValue().getType(); + } + + @Test + public void testInitialize_iscsiPoolUsesOntapIscsiType() { + when(storageStrategy.getStoragePath()).thenReturn("iqn.1992-08.com.netapp:sn.abc123"); + + assertEquals(Storage.StoragePoolType.OntapiSCSI, initializeAndCapturePoolType("ISCSI")); + } + + @Test + public void testInitialize_nfsPoolKeepsNetworkFilesystemType() { + assertEquals(Storage.StoragePoolType.NetworkFilesystem, initializeAndCapturePoolType("NFS3")); + } + + private Long initializeAndCaptureCapacityIops(Map dsInfos) { + try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) { + storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy); + ontapPrimaryDatastoreLifecycle.initialize(dsInfos); + } + ArgumentCaptor captor = ArgumentCaptor.forClass(PrimaryDataStoreParameters.class); + verify(_dataStoreHelper).createPrimaryDataStore(captor.capture()); + return captor.getValue().getCapacityIops(); + } + + @Test + public void testInitialize_blankCapacityIopsLeavesPoolWithoutIopsCeiling() { + assertNull(initializeAndCaptureCapacityIops(buildDsInfosForProtocol("NFS3"))); + } + + @Test + public void testInitialize_capacityIopsIsStoredOnPool() { + Map dsInfos = buildDsInfosForProtocol("NFS3"); + dsInfos.put("capacityIops", 5000L); + + assertEquals(Long.valueOf(5000L), initializeAndCaptureCapacityIops(dsInfos)); + } + + @Test + public void testInitialize_persistsAffPlatformDetail() { + Map details = initializeAndCaptureDetails(buildDsInfosForProtocol("NFS3")); + + assertEquals("true", details.get(OntapStorageConstants.IS_AFF)); + verify(storageStrategy).isAff(); + } + + @Test + public void testInitialize_persistsFasPlatformDetail() { + when(storageStrategy.isAff()).thenReturn(false); + + Map details = initializeAndCaptureDetails(buildDsInfosForProtocol("NFS3")); + + assertEquals("false", details.get(OntapStorageConstants.IS_AFF)); + } + + private Map initializeAndCaptureDetails(Map dsInfos) { + try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) { + storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy); + ontapPrimaryDatastoreLifecycle.initialize(dsInfos); + } + ArgumentCaptor captor = ArgumentCaptor.forClass(PrimaryDataStoreParameters.class); + verify(_dataStoreHelper).createPrimaryDataStore(captor.capture()); + return captor.getValue().getDetails(); + } + + @Test + public void testInitialize_nonPositiveCapacityIopsIsRejected() { + Map dsInfos = buildDsInfosForProtocol("NFS3"); + dsInfos.put("capacityIops", 0L); + + Exception ex = assertThrows(InvalidParameterValueException.class, + () -> ontapPrimaryDatastoreLifecycle.initialize(dsInfos)); + + assertTrue(ex.getMessage().contains("IOPS capacity must be greater than 0")); + verify(_dataStoreHelper, never()).createPrimaryDataStore(any()); + } + @Test public void testInitialize_null_Arg() { Exception ex = assertThrows(CloudRuntimeException.class,() -> @@ -1121,4 +1241,142 @@ public void testAttachZone_kvmHypervisorSetsAndUpdatesPool() throws Exception { } } + // ========== updateStoragePool() Tests ========== + + @Test + public void testUpdateStoragePool_positive_resizesFlexVolume() { + // Setup + StoragePool storagePool = mock(StoragePool.class); + when(storagePool.getName()).thenReturn("test-pool"); + when(storagePool.getCapacityBytes()).thenReturn(2147483648L); // 2 GB current + + Map details = new HashMap<>(); + details.put(PrimaryDataStoreLifeCycle.CAPACITY_BYTES, String.valueOf(5368709120L)); // 5 GB new + details.put(OntapStorageConstants.VOLUME_UUID, "flex-vol-uuid-123"); + details.put(OntapStorageConstants.VOLUME_NAME, "flexvol-name"); + details.put("protocol", "NFS3"); + + Volume updatedVolume = new Volume(); + updatedVolume.setUuid("flex-vol-uuid-123"); + updatedVolume.setSize(5368709120L); + when(storageStrategy.updateStorageVolume(any(Volume.class))).thenReturn(updatedVolume); + + try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(storageStrategy); + + // Execute + ontapPrimaryDatastoreLifecycle.updateStoragePool(storagePool, details); + + // Verify + verify(storageStrategy, times(1)).updateStorageVolume(any(Volume.class)); + } + } + + @Test + public void testUpdateStoragePool_noCapacityBytesInDetails_skipsResize() { + // Setup + StoragePool storagePool = mock(StoragePool.class); + when(storagePool.getName()).thenReturn("test-pool"); + + Map details = new HashMap<>(); + details.put(OntapStorageConstants.VOLUME_UUID, "flex-vol-uuid-123"); + details.put("protocol", "NFS3"); + // No CAPACITY_BYTES key — resize should be skipped + + // Execute + ontapPrimaryDatastoreLifecycle.updateStoragePool(storagePool, details); + + // Verify — storageStrategy should never be called + verify(storageStrategy, never()).updateStorageVolume(any()); + } + + @Test + public void testUpdateStoragePool_missingVolumeUuid_throwsCloudRuntimeException() { + // Setup + StoragePool storagePool = mock(StoragePool.class); + when(storagePool.getName()).thenReturn("test-pool"); + when(storagePool.getCapacityBytes()).thenReturn(1073741824L); + + Map details = new HashMap<>(); + details.put(PrimaryDataStoreLifeCycle.CAPACITY_BYTES, String.valueOf(3221225472L)); + details.put("protocol", "NFS3"); + // No VOLUME_UUID — cannot resize without it + + try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(storageStrategy); + + // Execute & Verify + assertThrows(CloudRuntimeException.class, + () -> ontapPrimaryDatastoreLifecycle.updateStoragePool(storagePool, details)); + verify(storageStrategy, never()).updateStorageVolume(any()); + } + } + + @Test + public void testUpdateStoragePool_capacityIopsBelowAllocated_throwsInvalidParameterValueException() { + StoragePool storagePool = mock(StoragePool.class); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("test-pool"); + + Map details = new HashMap<>(); + details.put(PrimaryDataStoreLifeCycle.CAPACITY_IOPS, "400"); + details.put(OntapStorageConstants.VOLUME_UUID, "flex-vol-uuid-123"); + details.put("protocol", "NFS3"); + + when(_capacityMgr.getUsedIops(any(StoragePoolVO.class))).thenReturn(900L); + + Exception ex = assertThrows(InvalidParameterValueException.class, + () -> ontapPrimaryDatastoreLifecycle.updateStoragePool(storagePool, details)); + + assertTrue(ex.getMessage().contains("900 IOPS are already allocated")); + verify(storageStrategy, never()).updateStorageVolume(any(Volume.class)); + } + + @Test + public void testUpdateStoragePool_capacityIopsAboveAllocated_isAccepted() { + StoragePool storagePool = mock(StoragePool.class); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("test-pool"); + + Map details = new HashMap<>(); + details.put(PrimaryDataStoreLifeCycle.CAPACITY_IOPS, "2000"); + details.put("protocol", "NFS3"); + // No CAPACITY_BYTES key — only the IOPS ceiling is being raised. + + when(_capacityMgr.getUsedIops(any(StoragePoolVO.class))).thenReturn(900L); + + ontapPrimaryDatastoreLifecycle.updateStoragePool(storagePool, details); + + verify(storageStrategy, never()).updateStorageVolume(any(Volume.class)); + } + + @Test + public void testUpdateStoragePool_updateStorageVolumeThrows_propagatesCloudRuntimeException() { + // Setup + StoragePool storagePool = mock(StoragePool.class); + when(storagePool.getName()).thenReturn("test-pool"); + when(storagePool.getCapacityBytes()).thenReturn(1073741824L); + + Map details = new HashMap<>(); + details.put(PrimaryDataStoreLifeCycle.CAPACITY_BYTES, String.valueOf(3221225472L)); + details.put(OntapStorageConstants.VOLUME_UUID, "flex-vol-uuid-err"); + details.put(OntapStorageConstants.VOLUME_NAME, "flexvol-err"); + details.put("protocol", "NFS3"); + + when(storageStrategy.updateStorageVolume(any(Volume.class))) + .thenThrow(new CloudRuntimeException("ONTAP resize failed")); + + try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) + .thenReturn(storageStrategy); + + // Execute & Verify + assertThrows(CloudRuntimeException.class, + () -> ontapPrimaryDatastoreLifecycle.updateStoragePool(storagePool, details)); + verify(storageStrategy, times(1)).updateStorageVolume(any(Volume.class)); + } + } + } 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 3518d7fe1bc8..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 @@ -27,6 +27,7 @@ import java.util.Map; import org.apache.cloudstack.storage.feign.client.AggregateFeignClient; +import org.apache.cloudstack.storage.feign.client.ClusterFeignClient; import org.apache.cloudstack.storage.feign.client.JobFeignClient; import org.apache.cloudstack.storage.feign.client.NetworkFeignClient; import org.apache.cloudstack.storage.feign.client.SANFeignClient; @@ -34,6 +35,8 @@ import org.apache.cloudstack.storage.feign.client.SvmFeignClient; import org.apache.cloudstack.storage.feign.client.VolumeFeignClient; import org.apache.cloudstack.storage.feign.model.Aggregate; +import org.apache.cloudstack.storage.feign.model.Cluster; +import org.apache.cloudstack.storage.feign.model.ClusterNode; import org.apache.cloudstack.storage.feign.model.IpInterface; import org.apache.cloudstack.storage.feign.model.IscsiService; import org.apache.cloudstack.storage.feign.model.Job; @@ -47,7 +50,10 @@ import org.apache.cloudstack.storage.service.model.ProtocolType; import org.apache.cloudstack.storage.utils.OntapStorageConstants; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; @@ -59,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; @@ -99,6 +106,9 @@ public class StorageStrategyTest { @Mock private SnapshotFeignClient snapshotFeignClient; + @Mock + private ClusterFeignClient clusterFeignClient; + private TestableStorageStrategy storageStrategy; // Concrete implementation for testing abstract class @@ -110,7 +120,8 @@ public TestableStorageStrategy(OntapStorage ontapStorage, JobFeignClient jobFeignClient, NetworkFeignClient networkFeignClient, SANFeignClient sanFeignClient, - SnapshotFeignClient snapshotFeignClient) { + SnapshotFeignClient snapshotFeignClient, + ClusterFeignClient clusterFeignClient) { super(ontapStorage); // Use reflection to replace the private Feign client fields with mocked ones injectMockedClient("aggregateFeignClient", aggregateFeignClient); @@ -120,6 +131,7 @@ public TestableStorageStrategy(OntapStorage ontapStorage, injectMockedClient("networkFeignClient", networkFeignClient); injectMockedClient("sanFeignClient", sanFeignClient); injectMockedClient("snapshotFeignClient", snapshotFeignClient); + injectMockedClient("clusterFeignClient", clusterFeignClient); } private void injectMockedClient(String fieldName, Object mockedClient) { @@ -138,7 +150,14 @@ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume } @Override - CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { + public CloudStackVolume createTemplateCache(org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool, + org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo templateInfo, + Map details, long sizeInBytes) { + return null; + } + + @Override + public CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) { return null; } @@ -147,8 +166,12 @@ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) { } @Override - public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) { + public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume) { + return null; + } + @Override + public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes) { } @Override @@ -205,7 +228,8 @@ void setUp() { // For testing, we'll need to mock the FeignClientFactory behavior storageStrategy = new TestableStorageStrategy(ontapStorage, aggregateFeignClient, volumeFeignClient, svmFeignClient, - jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient); + jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient, + clusterFeignClient); } // ========== connect() Tests ========== @@ -264,6 +288,154 @@ public void testConnect_operationsOnly_skipsAggregateValidation() { assertTrue(result, "connect() should succeed for an online aggregate even when its free space is below the pool capacity"); } + @Test + public void testGetClusterInfo_dedupesIdenticalNodeModels() { + Cluster cluster = stubClusterGet(); + ClusterNode node1 = new ClusterNode(); + node1.setModel("AFF-A400"); + ClusterNode node2 = new ClusterNode(); + node2.setModel("AFF-A400"); + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of(node1, node2))); + + Cluster result = storageStrategy.getClusterInfo(); + assertEquals(cluster, result); + assertEquals("AFF-A400", result.getModel()); + } + + @Test + public void testGetClusterInfo_joinsDistinctModels() { + stubClusterGet(); + ClusterNode node1 = new ClusterNode(); + node1.setModel("AFF-A400"); + ClusterNode node2 = new ClusterNode(); + node2.setModel("FAS8300"); + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of(node1, node2))); + + assertEquals("AFF-A400,FAS8300", storageStrategy.getClusterInfo().getModel()); + } + + @Test + public void testGetClusterInfo_allFlashPerformance() { + stubClusterGet(); + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("AFF-A400", true, true, false), + clusterNode("AFF-A400", true, true, false)))); + + Cluster result = storageStrategy.getClusterInfo(); + assertEquals("AFF-A400", result.getModel()); + assertEquals(OntapStorageConstants.ASUP_PLATFORM_TYPE_PERFORMANCE, result.getPlatformType()); + } + + @Test + public void testGetClusterInfo_allFlashCapacity() { + stubClusterGet(); + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("AFF-C800", true, false, true), + clusterNode("AFF-C800", true, false, true)))); + + Cluster result = storageStrategy.getClusterInfo(); + assertEquals("AFF-C800", result.getModel()); + assertEquals(OntapStorageConstants.ASUP_PLATFORM_TYPE_CAPACITY, result.getPlatformType()); + } + + @Test + public void testGetClusterInfo_notAllFlashIsFas() { + stubClusterGet(); + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("FAS8300", false, false, false), + clusterNode("FAS8300", false, false, false)))); + + Cluster result = storageStrategy.getClusterInfo(); + assertEquals("FAS8300", result.getModel()); + assertEquals(OntapStorageConstants.ASUP_PLATFORM_TYPE_FAS, result.getPlatformType()); + } + + @Test + public void testGetClusterInfo_mixedPersonalitiesIsComposite() { + stubClusterGet(); + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("AFF-A400", true, true, false), + clusterNode("AFF-A400", true, true, false), + clusterNode("FAS8300", false, false, false), + clusterNode("FAS8300", false, false, false)))); + + Cluster result = storageStrategy.getClusterInfo(); + assertEquals("AFF-A400,FAS8300", result.getModel()); + assertEquals(OntapStorageConstants.ASUP_PLATFORM_TYPE_COMPOSITE, result.getPlatformType()); + } + + @Test + public void testGetClusterInfo_nodesGetFailureLeavesModelUnset() { + stubClusterGet(); + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenThrow(new RuntimeException("connection refused")); + + Cluster result = storageStrategy.getClusterInfo(); + assertNotNull(result); + assertNull(result.getModel()); + assertNull(result.getPlatformType()); + } + + @Test + public void testIsAff_allNodesAllFlash_returnsTrue() { + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("AFF-A400", true, true, false), + clusterNode("AFF-A400", true, true, false)))); + + assertTrue(storageStrategy.isAff()); + } + + @Test + public void testIsAff_anyNodeNotAllFlash_returnsFalse() { + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("AFF-A400", true, true, false), + clusterNode("FAS8300", false, false, false)))); + + assertFalse(storageStrategy.isAff()); + } + + @Test + public void testIsAff_nullAllFlashOptimized_returnsFalse() { + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of( + clusterNode("AFF-A400", true, true, false), + clusterNode("unknown", null, null, null)))); + + assertFalse(storageStrategy.isAff()); + } + + @Test + public void testIsAff_noNodes_throws() { + when(clusterFeignClient.getClusterNodes(anyString(), anyMap())) + .thenReturn(new OntapResponse<>(List.of())); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.isAff()); + assertTrue(ex.getMessage().contains("Unable to determine whether the ONTAP cluster is AFF or FAS")); + } + + private Cluster stubClusterGet() { + Cluster cluster = new Cluster(); + when(clusterFeignClient.getCluster(anyString(), eq(true))).thenReturn(cluster); + return cluster; + } + + private static ClusterNode clusterNode(String model, Boolean allFlash, Boolean performance, Boolean capacity) { + ClusterNode node = new ClusterNode(); + node.setModel(model); + node.setAllFlashOptimized(allFlash); + node.setPerformanceOptimized(performance); + node.setCapacityOptimized(capacity); + return node; + } + @Test public void testConnect_noOnlineAggregates() { // Setup - aggregate assigned to the SVM exists but is not ONLINE @@ -359,7 +531,8 @@ public void testConnect_iscsiNotEnabled() { "svm1", 5000000000L, ProtocolType.ISCSI); storageStrategy = new TestableStorageStrategy(iscsiStorage, aggregateFeignClient, volumeFeignClient, svmFeignClient, - jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient); + jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient, + clusterFeignClient); Svm svm = new Svm(); svm.setName("svm1"); @@ -703,7 +876,8 @@ public void testGetStoragePath_iscsi() { "svm1", null, ProtocolType.ISCSI); storageStrategy = new TestableStorageStrategy(iscsiStorage, aggregateFeignClient, volumeFeignClient, svmFeignClient, - jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient); + jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient, + clusterFeignClient); IscsiService.IscsiServiceTarget target = new IscsiService.IscsiServiceTarget(); target.setName("iqn.1992-08.com.netapp:sn.123456:vs.1"); @@ -733,7 +907,8 @@ public void testGetStoragePath_iscsi_noService() { "svm1", null, ProtocolType.ISCSI); storageStrategy = new TestableStorageStrategy(iscsiStorage, aggregateFeignClient, volumeFeignClient, svmFeignClient, - jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient); + jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient, + clusterFeignClient); OntapResponse emptyResponse = new OntapResponse<>(); emptyResponse.setRecords(new ArrayList<>()); @@ -754,7 +929,8 @@ public void testGetStoragePath_iscsi_noTargetIqn() { "svm1", null, ProtocolType.ISCSI); storageStrategy = new TestableStorageStrategy(iscsiStorage, aggregateFeignClient, volumeFeignClient, svmFeignClient, - jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient); + jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient, + clusterFeignClient); IscsiService iscsiService = new IscsiService(); iscsiService.setTarget(null); @@ -807,7 +983,8 @@ public void testGetNetworkInterface_iscsi() { "svm1", null, ProtocolType.ISCSI); storageStrategy = new TestableStorageStrategy(iscsiStorage, aggregateFeignClient, volumeFeignClient, svmFeignClient, - jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient); + jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient, + clusterFeignClient); IpInterface.IpInfo ipInfo = new IpInterface.IpInfo(); ipInfo.setAddress("192.168.1.51"); @@ -883,7 +1060,8 @@ public void testGetNetworkInterface_iscsi_lifDown() { "svm1", null, ProtocolType.ISCSI); storageStrategy = new TestableStorageStrategy(iscsiStorage, aggregateFeignClient, volumeFeignClient, svmFeignClient, - jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient); + jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient, + clusterFeignClient); IpInterface.IpInfo ipInfo = new IpInterface.IpInfo(); ipInfo.setAddress("192.168.1.51"); @@ -1247,16 +1425,169 @@ void testDeleteFlexVolSnapshotForCloudStackVolume_AlreadyAbsentOnOntap() { verify(snapshotFeignClient).deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1")); } + @Test + void testDeleteFlexVolSnapshotForCloudStackVolume_Feign404_TreatedAsSuccess() { + FeignException notFoundException = mock(FeignException.class); + when(notFoundException.status()).thenReturn(404); + when(snapshotFeignClient.deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1"))) + .thenThrow(notFoundException); + + storageStrategy.deleteFlexVolSnapshotForCloudStackVolume("fv-uuid-1", "snap-uuid-1", "snap-name-1"); + + verify(snapshotFeignClient).deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1")); + verify(jobFeignClient, never()).getJobByUUID(anyString(), anyString()); + } + @Test - void testDeleteFlexVolSnapshotForCloudStackVolume_Feign404_TreatedAsSuccess() { - FeignException notFoundException = mock(FeignException.class); - when(notFoundException.status()).thenReturn(404); - when(snapshotFeignClient.deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1"))) - .thenThrow(notFoundException); + 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); - storageStrategy.deleteFlexVolSnapshotForCloudStackVolume("fv-uuid-1", "snap-uuid-1", "snap-name-1"); + Volume result = storageStrategy.getStorageVolume("flexvol-uuid-123", queryParams); - verify(snapshotFeignClient).deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1")); - verify(jobFeignClient, never()).getJobByUUID(anyString(), anyString()); + 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 + public void testUpdateStorageVolume_positive() { + // Setup + Volume volume = new Volume(); + volume.setUuid("vol-uuid-resize"); + volume.setName("flexvol-resize"); + volume.setSize(5368709120L); // 5 GB + + Job job = new Job(); + job.setUuid("resize-job-uuid"); + JobResponse jobResponse = new JobResponse(); + jobResponse.setJob(job); + + when(volumeFeignClient.updateVolume(anyString(), eq("vol-uuid-resize"), any())) + .thenReturn(jobResponse); + + Job completedJob = new Job(); + completedJob.setUuid("resize-job-uuid"); + completedJob.setState(OntapStorageConstants.JOB_SUCCESS); + when(jobFeignClient.getJobByUUID(anyString(), eq("resize-job-uuid"))) + .thenReturn(completedJob); + + // Execute + Volume result = storageStrategy.updateStorageVolume(volume); + + // Verify + 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")); + } + + @Test + public void testUpdateStorageVolume_jobFailed() { + // Setup + Volume volume = new Volume(); + volume.setUuid("vol-uuid-resize"); + volume.setName("flexvol-resize"); + volume.setSize(5368709120L); + + Job job = new Job(); + job.setUuid("resize-job-uuid"); + JobResponse jobResponse = new JobResponse(); + jobResponse.setJob(job); + + when(volumeFeignClient.updateVolume(anyString(), eq("vol-uuid-resize"), any())) + .thenReturn(jobResponse); + + Job failedJob = new Job(); + failedJob.setUuid("resize-job-uuid"); + failedJob.setState(OntapStorageConstants.JOB_FAILURE); + failedJob.setMessage("Resize failed"); + when(jobFeignClient.getJobByUUID(anyString(), eq("resize-job-uuid"))) + .thenReturn(failedJob); + + // Execute & Verify + Exception ex = assertThrows(CloudRuntimeException.class, + () -> storageStrategy.updateStorageVolume(volume)); + assertTrue(ex.getMessage().contains("Job failed")); + } + + @Test + public void testUpdateStorageVolume_feignException() { + // Setup + Volume volume = new Volume(); + volume.setUuid("vol-uuid-fail"); + volume.setName("flexvol-fail"); + volume.setSize(3221225472L); + + FeignException feignException = mock(FeignException.class); + when(feignException.status()).thenReturn(500); + when(volumeFeignClient.updateVolume(anyString(), eq("vol-uuid-fail"), any())) + .thenThrow(feignException); + + // Execute & Verify + Exception ex = assertThrows(CloudRuntimeException.class, + () -> storageStrategy.updateStorageVolume(volume)); + assertTrue(ex.getMessage().contains("Failed to resize ONTAP FlexVolume")); + } + + @Test + public void testUpdateStorageVolume_notFound_404_throwsCloudRuntimeException() { + // Setup + Volume volume = new Volume(); + volume.setUuid("vol-uuid-notfound"); + volume.setName("flexvol-notfound"); + volume.setSize(1073741824L); + + FeignException feignEx = mock(FeignException.class); + when(feignEx.status()).thenReturn(404); + when(volumeFeignClient.updateVolume(anyString(), eq("vol-uuid-notfound"), any())) + .thenThrow(feignEx); + + // Execute & Verify — 404 means volume not found on ONTAP, should throw + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> storageStrategy.updateStorageVolume(volume)); + assertTrue(ex.getMessage().contains("not found on ONTAP")); } } 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 15d4de0ebb44..10f2b0770003 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 @@ -20,15 +20,21 @@ package org.apache.cloudstack.storage.service; 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; +import feign.FeignException; import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint; import org.apache.cloudstack.engine.subsystem.api.storage.EndPointSelector; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.storage.command.CreateObjectCommand; +import org.apache.cloudstack.storage.command.DeleteCommand; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.feign.client.JobFeignClient; import org.apache.cloudstack.storage.feign.client.NASFeignClient; import org.apache.cloudstack.storage.feign.client.VolumeFeignClient; @@ -38,8 +44,11 @@ import org.apache.cloudstack.storage.feign.client.SANFeignClient; import org.apache.cloudstack.storage.feign.model.ExportPolicy; import org.apache.cloudstack.storage.feign.model.ExportRule; +import org.apache.cloudstack.storage.feign.model.FileCloneRequest; +import org.apache.cloudstack.storage.feign.model.FileInfo; import org.apache.cloudstack.storage.feign.model.Job; import org.apache.cloudstack.storage.feign.model.OntapStorage; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.model.AccessGroup; @@ -64,12 +73,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; @@ -79,8 +91,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import feign.FeignException; - @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) public class UnifiedNASStrategyTest { @@ -115,6 +125,9 @@ public class UnifiedNASStrategyTest { @Mock private StoragePoolDetailsDao storagePoolDetailsDao; + @Mock + private PrimaryDataStoreDao primaryDataStoreDao; + private TestableUnifiedNASStrategy strategy; private OntapStorage ontapStorage; @@ -133,6 +146,7 @@ public void setUp() throws Exception { injectField("volumeDao", volumeDao); injectField("epSelector", epSelector); injectField("storagePoolDetailsDao", storagePoolDetailsDao); + injectField("primaryDataStoreDao", primaryDataStoreDao); } private void injectField(String fieldName, Object mockedField) throws Exception { @@ -201,6 +215,177 @@ public void testCreateCloudStackVolume_Success() throws Exception { verify(endPoint).sendMessage(any(CreateObjectCommand.class)); } + @Test + public void testCreateTemplateCache_IsNoOp() { + org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool = + mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class); + when(storagePool.getId()).thenReturn(1L); + org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo templateInfo = + mock(org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo.class); + when(templateInfo.getId()).thenReturn(50L); + + CloudStackVolume result = strategy.createTemplateCache(storagePool, templateInfo, Map.of(), 0L); + + // NFS seeds via host CopyCommand; strategy intentionally returns null (no LUN/file yet). + assertNull(result); + } + + + @Test + public void testCreateCloudStackVolume_AppliesQosPolicyToNfsFile() throws Exception { + CloudStackVolume cloudStackVolume = mock(CloudStackVolume.class); + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + EndPoint endPoint = mock(EndPoint.class); + Answer answer = new Answer(null, true, "Success"); + + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to200_iops_svm1"); + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); + + when(cloudStackVolume.getDatastoreId()).thenReturn("1"); + when(cloudStackVolume.getVolumeInfo()).thenReturn(volumeObject); + when(cloudStackVolume.getFlexVolumeUuid()).thenReturn("flex-uuid"); + when(cloudStackVolume.getFile()).thenReturn(fileInfo); + when(volumeObject.getId()).thenReturn(100L); + when(volumeObject.getUuid()).thenReturn("volume-uuid-123"); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.update(anyLong(), any(VolumeVO.class))).thenReturn(true); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(CreateObjectCommand.class))).thenReturn(answer); + + CloudStackVolume result = strategy.createCloudStackVolume(cloudStackVolume); + + assertNotNull(result); + verify(nasFeignClient).updateFile(anyString(), eq("flex-uuid"), eq("volume-uuid-123"), + argThat(file -> file.getQosPolicy() != null + && "cs_100_to200_iops_svm1".equals(file.getQosPolicy().getName()))); + } + + @Test + public void testCreateCloudStackVolume_QosAttachFails_DeletesLeftoverNfsFile() { + CloudStackVolume cloudStackVolume = mock(CloudStackVolume.class); + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + EndPoint endPoint = mock(EndPoint.class); + Answer createAnswer = new Answer(null, true, "Success"); + Answer deleteAnswer = new Answer(null, true, "Deleted"); + + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to200_iops_svm1"); + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); + + when(cloudStackVolume.getDatastoreId()).thenReturn("1"); + when(cloudStackVolume.getVolumeInfo()).thenReturn(volumeObject); + when(cloudStackVolume.getFlexVolumeUuid()).thenReturn("flex-uuid"); + when(cloudStackVolume.getFile()).thenReturn(fileInfo); + when(volumeObject.getId()).thenReturn(100L); + when(volumeObject.getUuid()).thenReturn("volume-uuid-123"); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.update(anyLong(), any(VolumeVO.class))).thenReturn(true); + when(epSelector.select(volumeObject)).thenReturn(endPoint); + when(endPoint.sendMessage(any(CreateObjectCommand.class))).thenReturn(createAnswer); + when(endPoint.sendMessage(any(DeleteCommand.class))).thenReturn(deleteAnswer); + + FeignException feignException = mock(FeignException.class); + when(feignException.contentUTF8()).thenReturn( + "{\"error\":{\"code\":\"8454269\",\"message\":\"Invalid QoS policy group specified\"}}"); + when(feignException.getMessage()).thenReturn("Bad Request"); + doThrow(feignException).when(nasFeignClient).updateFile(anyString(), eq("flex-uuid"), + eq("volume-uuid-123"), any(FileInfo.class)); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> strategy.createCloudStackVolume(cloudStackVolume)); + assertTrue(ex.getMessage().contains("Failed to update NFS volume file")); + verify(endPoint).sendMessage(any(DeleteCommand.class)); + } + + @Test + public void testUpdateCloudStackVolume_AppliesQosPolicy() { + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getUuid()).thenReturn("volume-uuid-123"); + + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to_200_iops_svm1"); + qosPolicy.setUuid("qos-uuid"); + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeInfo); + request.setFlexVolumeUuid("flex-uuid"); + request.setFile(fileInfo); + + CloudStackVolume result = strategy.updateCloudStackVolume(request); + + assertSame(request, result); + verify(nasFeignClient).updateFile(anyString(), eq("flex-uuid"), eq("volume-uuid-123"), + argThat(file -> file.getQosPolicy() != null + && "cs_100_to_200_iops_svm1".equals(file.getQosPolicy().getName()) + && "qos-uuid".equals(file.getQosPolicy().getUuid()))); + } + + @Test + public void testUpdateCloudStackVolume_UnexpectedException_IsWrapped() { + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getUuid()).thenReturn("volume-uuid-123"); + + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to_200_iops_svm1"); + FileInfo fileInfo = new FileInfo(); + fileInfo.setQosPolicy(qosPolicy); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeInfo); + request.setFlexVolumeUuid("flex-uuid"); + request.setFile(fileInfo); + + doThrow(new IllegalStateException("decoder failed")).when(nasFeignClient) + .updateFile(anyString(), eq("flex-uuid"), eq("volume-uuid-123"), any(FileInfo.class)); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> strategy.updateCloudStackVolume(request)); + assertTrue(ex.getMessage().contains("Failed to update NFS volume file")); + assertTrue(ex.getCause() instanceof IllegalStateException); + } + + @Test + public void testUpdateCloudStackVolume_NoQosPolicy_SkipsPatch() { + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getUuid()).thenReturn("volume-uuid-123"); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeInfo); + request.setFlexVolumeUuid("flex-uuid"); + request.setFile(new FileInfo()); + + CloudStackVolume result = strategy.updateCloudStackVolume(request); + + assertSame(request, result); + verify(nasFeignClient, never()).updateFile(anyString(), anyString(), anyString(), any(FileInfo.class)); + } + + @Test + public void testUpdateCloudStackVolume_InvalidRequest_ThrowsException() { + CloudStackVolume missingFile = new CloudStackVolume(); + missingFile.setVolumeInfo(mock(VolumeInfo.class)); + missingFile.setFlexVolumeUuid("flex-uuid"); + + assertInvalidNfsVolumeUpdate(null); + assertInvalidNfsVolumeUpdate(new CloudStackVolume()); + assertInvalidNfsVolumeUpdate(missingFile); + + verify(nasFeignClient, never()).updateFile(anyString(), anyString(), anyString(), any(FileInfo.class)); + } + + private void assertInvalidNfsVolumeUpdate(CloudStackVolume request) { + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> strategy.updateCloudStackVolume(request)); + assertEquals("Invalid NFS volume request", ex.getMessage()); + } + // Test createCloudStackVolume - Volume Not Found @Test public void testCreateCloudStackVolume_VolumeNotFound() { @@ -296,7 +481,7 @@ public void testCreateAccessGroup_Success() throws Exception { when(accessGroup.getHostsToConnect()).thenReturn(hosts); doNothing().when(nasFeignClient).createExportPolicy(anyString(), any(ExportPolicy.class)); when(nasFeignClient.getExportPolicyResponse(anyString(), anyMap())).thenReturn(policyResponse); - when(volumeFeignClient.updateVolumeRebalancing(anyString(), anyString(), any())).thenReturn(jobResponse); + when(volumeFeignClient.updateVolume(anyString(), anyString(), any())).thenReturn(jobResponse); when(jobFeignClient.getJobByUUID(anyString(), anyString())).thenReturn(job); doNothing().when(storagePoolDetailsDao).addDetail(anyLong(), anyString(), anyString(), eq(true)); @@ -307,7 +492,7 @@ public void testCreateAccessGroup_Success() throws Exception { assertNotNull(result); verify(nasFeignClient).createExportPolicy(anyString(), any(ExportPolicy.class)); verify(nasFeignClient).getExportPolicyResponse(anyString(), anyMap()); - verify(volumeFeignClient).updateVolumeRebalancing(anyString(), eq("vol-uuid-123"), any()); + verify(volumeFeignClient).updateVolume(anyString(), eq("vol-uuid-123"), any()); verify(storagePoolDetailsDao, times(2)).addDetail(anyLong(), anyString(), anyString(), eq(true)); } @@ -402,7 +587,7 @@ public void testCreateAccessGroup_JobFailure() throws Exception { when(accessGroup.getHostsToConnect()).thenReturn(hosts); doNothing().when(nasFeignClient).createExportPolicy(anyString(), any(ExportPolicy.class)); when(nasFeignClient.getExportPolicyResponse(anyString(), anyMap())).thenReturn(policyResponse); - when(volumeFeignClient.updateVolumeRebalancing(anyString(), anyString(), any())).thenReturn(jobResponse); + when(volumeFeignClient.updateVolume(anyString(), anyString(), any())).thenReturn(jobResponse); when(jobFeignClient.getJobByUUID(anyString(), anyString())).thenReturn(job); assertThrows(CloudRuntimeException.class, () -> { @@ -446,7 +631,7 @@ public void testCreateAccessGroup_HostWithPrivateIP() throws Exception { when(accessGroup.getHostsToConnect()).thenReturn(hosts); doNothing().when(nasFeignClient).createExportPolicy(anyString(), any(ExportPolicy.class)); when(nasFeignClient.getExportPolicyResponse(anyString(), anyMap())).thenReturn(policyResponse); - when(volumeFeignClient.updateVolumeRebalancing(anyString(), anyString(), any())).thenReturn(jobResponse); + when(volumeFeignClient.updateVolume(anyString(), anyString(), any())).thenReturn(jobResponse); when(jobFeignClient.getJobByUUID(anyString(), anyString())).thenReturn(job); doNothing().when(storagePoolDetailsDao).addDetail(anyLong(), anyString(), anyString(), eq(true)); @@ -954,4 +1139,459 @@ public void testUpdateAccessGroup_TrimsWhitespaceFromPrivateIp() { assertEquals(1, clients.size()); assertEquals("192.168.1.10/32", clients.get(0).getMatch()); } + + @Test + public void testCloneCloudStackVolume_Success() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + when(volumeObject.getId()).thenReturn(100L); + when(volumeObject.getUuid()).thenReturn("volume-uuid"); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.update(anyLong(), any(VolumeVO.class))).thenReturn(true); + + Map details = new HashMap<>(); + details.put(OntapStorageConstants.SVM_NAME, "svm1"); + details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1"); + details.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-1"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(details); + + FileInfo source = new FileInfo(); + source.setPath("template-uuid"); + CloudStackVolume request = new CloudStackVolume(); + request.setDatastoreId("1"); + request.setVolumeInfo(volumeObject); + request.setFile(source); + request.setDestinationPath("volume-uuid"); + + when(nasFeignClient.cloneFile(anyString(), any(FileCloneRequest.class))).thenReturn(new JobResponse()); + + CloudStackVolume result = strategy.cloneCloudStackVolume(request); + + assertNotNull(result); + assertEquals("volume-uuid", result.getFile().getPath()); + ArgumentCaptor captor = ArgumentCaptor.forClass(FileCloneRequest.class); + verify(nasFeignClient).cloneFile(anyString(), captor.capture()); + assertEquals("template-uuid", captor.getValue().getSourcePath()); + assertEquals("volume-uuid", captor.getValue().getDestinationPath()); + assertEquals("flexvol1", captor.getValue().getVolume().getName()); + assertEquals("flexvol-uuid-1", captor.getValue().getVolume().getUuid()); + assertNull(captor.getValue().getSnapshot()); + } + + @Test + public void testCloneCloudStackVolume_SnapshotSource_SendsSnapshotName() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + when(volumeObject.getId()).thenReturn(100L); + when(volumeDao.findById(100L)).thenReturn(volumeVO); + when(volumeDao.update(anyLong(), any(VolumeVO.class))).thenReturn(true); + + Map details = new HashMap<>(); + details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1"); + details.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-1"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(details); + + FileInfo source = new FileInfo(); + source.setPath("source-file-uuid"); + CloudStackVolume request = new CloudStackVolume(); + request.setDatastoreId("1"); + request.setVolumeInfo(volumeObject); + request.setFile(source); + request.setDestinationPath("new-volume-uuid"); + request.setSnapshotName("snap_cs200"); + + when(nasFeignClient.cloneFile(anyString(), any(FileCloneRequest.class))).thenReturn(new JobResponse()); + + CloudStackVolume result = strategy.cloneCloudStackVolume(request); + + assertEquals("new-volume-uuid", result.getFile().getPath()); + assertEquals("snap_cs200", result.getSnapshotName()); + ArgumentCaptor captor = ArgumentCaptor.forClass(FileCloneRequest.class); + verify(nasFeignClient).cloneFile(anyString(), captor.capture()); + assertEquals("source-file-uuid", captor.getValue().getSourcePath()); + assertEquals("new-volume-uuid", captor.getValue().getDestinationPath()); + assertNotNull(captor.getValue().getSnapshot()); + assertEquals("snap_cs200", captor.getValue().getSnapshot().getName()); + } + + @Test + public void testCloneCloudStackVolume_InvalidRequest_ThrowsException() { + assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolume(null)); + } + + @Test + public void testResizeCloudStackVolume_SendsResizeCommand() { + 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, 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); + 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")); + + 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 + public void testDeleteFileByPath_Treats404AsSuccess() { + FeignException feignException = mock(FeignException.class); + when(feignException.status()).thenReturn(404); + doThrow(feignException).when(nasFeignClient).deleteFile(anyString(), eq("flexvol-uuid"), eq("template-uuid")); + + strategy.deleteFileByPath("flexvol-uuid", "template-uuid"); + + verify(nasFeignClient).deleteFile(anyString(), eq("flexvol-uuid"), eq("template-uuid")); + } + + @Test + public void testDeleteFileByPath_Non404Feign_Throws() { + FeignException feignException = mock(FeignException.class); + when(feignException.status()).thenReturn(500); + when(feignException.getMessage()).thenReturn("server error"); + doThrow(feignException).when(nasFeignClient).deleteFile(anyString(), eq("flexvol-uuid"), eq("template-uuid")); + + assertThrows(CloudRuntimeException.class, + () -> strategy.deleteFileByPath("flexvol-uuid", "template-uuid")); + } + + @Test + public void testCloneCloudStackVolume_MissingFlexVolUuid_Throws() { + FileInfo source = new FileInfo(); + source.setPath("template-uuid"); + CloudStackVolume request = new CloudStackVolume(); + request.setDatastoreId("1"); + request.setFile(source); + request.setDestinationPath("volume-uuid"); + + Map details = new HashMap<>(); + details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(details); + + assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolume(request)); + verify(nasFeignClient, never()).cloneFile(anyString(), any(FileCloneRequest.class)); + } + + @Test + public void testCloneCloudStackVolume_MissingDatastoreId_Throws() { + FileInfo source = new FileInfo(); + source.setPath("template-uuid"); + CloudStackVolume request = new CloudStackVolume(); + request.setFile(source); + request.setDestinationPath("volume-uuid"); + + assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolume(request)); + } + + @Test + public void testCloneCloudStackVolume_FeignException_Throws() { + VolumeObject volumeObject = mock(VolumeObject.class); + Map details = new HashMap<>(); + details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1"); + details.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-1"); + when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(details); + + FileInfo source = new FileInfo(); + source.setPath("template-uuid"); + CloudStackVolume request = new CloudStackVolume(); + request.setDatastoreId("1"); + request.setVolumeInfo(volumeObject); + request.setFile(source); + request.setDestinationPath("volume-uuid"); + + FeignException feignException = mock(FeignException.class); + when(feignException.status()).thenReturn(500); + when(feignException.getMessage()).thenReturn("clone failed"); + when(nasFeignClient.cloneFile(anyString(), any(FileCloneRequest.class))).thenThrow(feignException); + + assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolume(request)); + } + + @Test + public void testResizeCloudStackVolume_InvalidRequest_Throws() { + assertThrows(CloudRuntimeException.class, () -> strategy.resizeCloudStackVolume(null, 100L)); + CloudStackVolume empty = new CloudStackVolume(); + assertThrows(CloudRuntimeException.class, () -> strategy.resizeCloudStackVolume(empty, 100L)); + + VolumeObject volumeObject = mock(VolumeObject.class); + CloudStackVolume withVol = new CloudStackVolume(); + withVol.setVolumeInfo(volumeObject); + assertThrows(CloudRuntimeException.class, () -> strategy.resizeCloudStackVolume(withVol, 0L)); + } + + @Test + public void testResizeCloudStackVolume_KvmHostFails_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(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, false, "resize failed")); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + assertThrows(CloudRuntimeException.class, () -> strategy.resizeCloudStackVolume(request, 21474836480L)); + } + + @Test + public void testResizeCloudStackVolume_NoEndpoint_Throws() { + VolumeObject volumeObject = mock(VolumeObject.class); + VolumeVO volumeVO = mock(VolumeVO.class); + StoragePoolVO storagePool = mock(StoragePoolVO.class); + + when(volumeObject.getId()).thenReturn(100L); + 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(null); + + CloudStackVolume request = new CloudStackVolume(); + request.setVolumeInfo(volumeObject); + + 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 ec9023a6c760..a6fcafa33c57 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; @@ -30,36 +33,31 @@ import org.apache.cloudstack.storage.feign.model.Lun; import org.apache.cloudstack.storage.feign.model.LunMap; import org.apache.cloudstack.storage.feign.model.OntapStorage; +import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.model.AccessGroup; import org.apache.cloudstack.storage.service.model.CloudStackVolume; 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; 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; +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.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; @@ -68,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 { @@ -75,6 +81,9 @@ class UnifiedSANStrategyTest { @Mock private SANFeignClient sanFeignClient; + @Mock + private org.apache.cloudstack.storage.feign.client.NASFeignClient nasFeignClient; + @Mock private OntapStorage ontapStorage; @@ -87,6 +96,9 @@ class UnifiedSANStrategyTest { @Mock private StoragePoolDetailsDao storagePoolDetailsDao; + @Mock + private VolumeDetailsDao volumeDetailsDao; + private UnifiedSANStrategy unifiedSANStrategy; private String authHeader; @@ -105,6 +117,10 @@ void setUp() { sanFeignClientField.setAccessible(true); sanFeignClientField.set(unifiedSANStrategy, sanFeignClient); + java.lang.reflect.Field nasFeignClientField = StorageStrategy.class.getDeclaredField("nasFeignClient"); + nasFeignClientField.setAccessible(true); + nasFeignClientField.set(unifiedSANStrategy, nasFeignClient); + // Also inject the storage field from parent class to ensure proper mocking java.lang.reflect.Field storageField = StorageStrategy.class.getDeclaredField("storage"); storageField.setAccessible(true); @@ -114,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); } @@ -157,6 +178,200 @@ void testCreateCloudStackVolume_Success() { } } + @Test + void testCreateTemplateCache_Success() { + org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool = + mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class); + when(storagePool.getId()).thenReturn(1L); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getHypervisor()).thenReturn(com.cloud.hypervisor.Hypervisor.HypervisorType.KVM); + + org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo templateInfo = + mock(org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo.class); + when(templateInfo.getId()).thenReturn(50L); + + Map details = new HashMap<>(); + details.put(OntapStorageConstants.SVM_NAME, "svm1"); + + Lun createdLun = new Lun(); + createdLun.setName("/vol/vol1/cs_tmpl_50"); + createdLun.setUuid("template-lun-uuid"); + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of(createdLun)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenReturn(response); + + CloudStackVolume result = unifiedSANStrategy.createTemplateCache( + storagePool, templateInfo, details, 5368709120L); + + assertNotNull(result); + assertEquals("template-lun-uuid", result.getLun().getUuid()); + assertEquals("/vol/vol1/cs_tmpl_50", result.getLun().getName()); + + ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); + verify(sanFeignClient).createLun(eq(authHeader), eq(true), lunCaptor.capture()); + assertEquals("/vol/vol1/cs_tmpl_50", lunCaptor.getValue().getName()); + assertEquals(5368709120L, lunCaptor.getValue().getSpace().getSize()); + } + } + + @Test + void testCreateTemplateCache_UnknownSize_ThrowsException() { + org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool = + mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class); + when(storagePool.getId()).thenReturn(1L); + org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo templateInfo = + mock(org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo.class); + when(templateInfo.getId()).thenReturn(50L); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createTemplateCache(storagePool, templateInfo, Map.of(), 0L)); + verify(sanFeignClient, never()).createLun(any(), anyBoolean(), any()); + } + + @Test + void testCreateTemplateCache_CreateFails_BestEffortDeletesLeftoverLun() { + org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool = + mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getHypervisor()).thenReturn(com.cloud.hypervisor.Hypervisor.HypervisorType.KVM); + + org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo templateInfo = + mock(org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo.class); + when(templateInfo.getId()).thenReturn(50L); + + Map details = new HashMap<>(); + details.put(OntapStorageConstants.SVM_NAME, "svm1"); + + FeignException createEx = mock(FeignException.class); + when(createEx.status()).thenReturn(500); + when(createEx.getMessage()).thenReturn("create failed"); + + Lun leftover = new Lun(); + leftover.setName("/vol/vol1/cs_tmpl_50"); + leftover.setUuid("leftover-uuid"); + OntapResponse leftoverResponse = new OntapResponse<>(); + leftoverResponse.setRecords(List.of(leftover)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenThrow(createEx); + when(sanFeignClient.getLunResponse(eq(authHeader), anyMap())).thenReturn(leftoverResponse); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createTemplateCache(storagePool, templateInfo, details, 5368709120L)); + + verify(sanFeignClient).deleteLun(eq(authHeader), eq("leftover-uuid"), anyMap()); + } + } + + @Test + void testCreateTemplateCache_CreateFails_NoLeftoverLun_SkipsDelete() { + org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool = + mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getHypervisor()).thenReturn(com.cloud.hypervisor.Hypervisor.HypervisorType.KVM); + + org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo templateInfo = + mock(org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo.class); + when(templateInfo.getId()).thenReturn(50L); + + Map details = new HashMap<>(); + details.put(OntapStorageConstants.SVM_NAME, "svm1"); + + FeignException createEx = mock(FeignException.class); + when(createEx.status()).thenReturn(500); + when(createEx.getMessage()).thenReturn("create failed"); + + OntapResponse empty = new OntapResponse<>(); + empty.setRecords(List.of()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenThrow(createEx); + when(sanFeignClient.getLunResponse(eq(authHeader), anyMap())).thenReturn(empty); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createTemplateCache(storagePool, templateInfo, details, 5368709120L)); + + verify(sanFeignClient, never()).deleteLun(any(), any(), anyMap()); + } + } + + @Test + void testCreateTemplateCache_CleanupFailureDoesNotMaskCreateError() { + org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool = + mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getHypervisor()).thenReturn(com.cloud.hypervisor.Hypervisor.HypervisorType.KVM); + + org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo templateInfo = + mock(org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo.class); + when(templateInfo.getId()).thenReturn(50L); + + Map details = new HashMap<>(); + details.put(OntapStorageConstants.SVM_NAME, "svm1"); + + FeignException createEx = mock(FeignException.class); + when(createEx.status()).thenReturn(500); + when(createEx.getMessage()).thenReturn("create failed"); + + Lun leftover = new Lun(); + leftover.setName("/vol/vol1/cs_tmpl_50"); + leftover.setUuid("leftover-uuid"); + OntapResponse leftoverResponse = new OntapResponse<>(); + leftoverResponse.setRecords(List.of(leftover)); + + FeignException deleteEx = mock(FeignException.class); + when(deleteEx.status()).thenReturn(500); + when(deleteEx.getMessage()).thenReturn("delete failed"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenThrow(createEx); + when(sanFeignClient.getLunResponse(eq(authHeader), anyMap())).thenReturn(leftoverResponse); + doThrow(deleteEx).when(sanFeignClient).deleteLun(eq(authHeader), eq("leftover-uuid"), anyMap()); + + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createTemplateCache(storagePool, templateInfo, details, 5368709120L)); + assertTrue(thrown.getMessage().contains("create failed") || thrown.getMessage().contains("Failed to create")); + } + } + + @Test + void testCreateTemplateCache_CreateFails_MissingSvmName_SkipsLookup() { + org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool = + mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class); + when(storagePool.getName()).thenReturn("vol1"); + when(storagePool.getHypervisor()).thenReturn(com.cloud.hypervisor.Hypervisor.HypervisorType.KVM); + + org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo templateInfo = + mock(org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo.class); + when(templateInfo.getId()).thenReturn(50L); + + FeignException createEx = mock(FeignException.class); + when(createEx.status()).thenReturn(500); + when(createEx.getMessage()).thenReturn("create failed"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenThrow(createEx); + + CloudRuntimeException thrown = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createTemplateCache(storagePool, templateInfo, new HashMap<>(), 5368709120L)); + assertTrue(thrown.getMessage().contains("create failed") || thrown.getMessage().contains("Failed to create")); + verify(sanFeignClient, never()).getLunResponse(any(), anyMap()); + verify(sanFeignClient, never()).deleteLun(any(), any(), anyMap()); + } + } + @Test void testCreateCloudStackVolume_NullRequest_ThrowsException() { assertThrows(CloudRuntimeException.class, @@ -188,6 +403,28 @@ void testCreateCloudStackVolume_FeignException_ThrowsCloudRuntimeException() { } } + @Test + void testCreateCloudStackVolume_MinThroughputRejected_PropagatesOntapError() { + Lun lun = new Lun(); + lun.setName("/vol/vol1/lun1"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + FeignException feignException = mock(FeignException.class); + when(feignException.getMessage()).thenReturn("Bad Request"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))) + .thenThrow(feignException); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createCloudStackVolume(request)); + assertTrue(ex.getMessage().contains("Failed to create Lun")); + } + } + @Test void testDeleteCloudStackVolume_Success() { // Setup @@ -201,7 +438,7 @@ void testDeleteCloudStackVolume_Success() { utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) .thenReturn(authHeader); - doNothing().when(sanFeignClient).deleteLun(eq(authHeader), eq("lun-uuid-123"), anyMap()); + when(sanFeignClient.deleteLun(eq(authHeader), eq("lun-uuid-123"), anyMap())).thenReturn(null); // Execute unifiedSANStrategy.deleteCloudStackVolume(request); @@ -789,29 +1026,343 @@ void testValidateInitiatorInAccessGroup_IgroupNotFound_ReturnsFalse() { } @Test - void testCopyCloudStackVolume_NullRequest_DoesNotThrow() { - // copyCloudStackVolume is not yet implemented (no-op), so it should not throw - assertDoesNotThrow(() -> unifiedSANStrategy.copyCloudStackVolume(null)); + void testCloneCloudStackVolume_Success() { + Lun.Source source = new Lun.Source(); + source.setUuid("source-lun-uuid"); + Lun.Clone clone = new Lun.Clone(); + clone.setSource(source); + Lun lun = new Lun(); + 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"); + clonedLun.setUuid("cloned-lun-uuid"); + + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of(clonedLun)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))) + .thenReturn(response); + + CloudStackVolume result = unifiedSANStrategy.cloneCloudStackVolume(request); + + assertNotNull(result); + assertEquals("cloned-lun-uuid", result.getLun().getUuid()); + assertSame(volumeInfo, result.getVolumeInfo()); + verify(sanFeignClient).createLun(eq(authHeader), eq(true), any(Lun.class)); + } + } + + @Test + void testCloneCloudStackVolume_NullRequest_ThrowsException() { + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.cloneCloudStackVolume(null)); + } + + @Test + void testCloneCloudStackVolume_MissingSource_ThrowsException() { + Lun lun = new Lun(); + lun.setName("/vol/vol1/cloned"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.cloneCloudStackVolume(request)); } @Test - void testCopyCloudStackVolume_NullLun_DoesNotThrow() { - // copyCloudStackVolume is not yet implemented (no-op), so it should not throw + void testCloneCloudStackVolume_SnapshotSource_SendsSnapshotQualifiedName() { + Lun.Source source = new Lun.Source(); + source.setName("/vol/vol1/.snapshot/snap_cs200/source_lun"); + Lun.Clone clone = new Lun.Clone(); + clone.setSource(source); + Lun lun = new Lun(); + lun.setName("/vol/vol1/new_lun"); + lun.setClone(clone); CloudStackVolume request = new CloudStackVolume(); - request.setLun(null); + request.setLun(lun); - assertDoesNotThrow(() -> unifiedSANStrategy.copyCloudStackVolume(request)); + Lun createdLun = new Lun(); + createdLun.setName("/vol/vol1/new_lun"); + createdLun.setUuid("new-lun-uuid"); + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of(createdLun)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenReturn(response); + + CloudStackVolume result = unifiedSANStrategy.cloneCloudStackVolume(request); + + assertEquals("new-lun-uuid", result.getLun().getUuid()); + ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); + verify(sanFeignClient).createLun(eq(authHeader), eq(true), lunCaptor.capture()); + assertEquals("/vol/vol1/.snapshot/snap_cs200/source_lun", + lunCaptor.getValue().getClone().getSource().getName()); + assertNull(lunCaptor.getValue().getClone().getSource().getUuid()); + } } @Test - void testCopyCloudStackVolume_ValidRequest_DoesNotThrow() { - // copyCloudStackVolume is not yet implemented (no-op), so it should not throw + void testResizeCloudStackVolume_ValidRequest_PatchesSize() { Lun lun = new Lun(); - lun.setName("/vol/vol1/lun1"); + 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); - assertDoesNotThrow(() -> unifiedSANStrategy.copyCloudStackVolume(request)); + 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)); + verify(sanFeignClient, never()).updateLun(any(), any(), any()); + } + + @Test + 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)); + + 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); + utilityMock.when(() -> OntapStorageUtils.isOntapObjectNotFoundError(notFound)) + .thenReturn(true); + doThrow(notFound).when(sanFeignClient) + .updateLun(eq(authHeader), eq("deleted-lun-uuid"), any(Lun.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(); + source.setUuid("source-lun-uuid"); + Lun.Clone clone = new Lun.Clone(); + clone.setSource(source); + Lun lun = new Lun(); + lun.setName("/vol/vol1/cloned"); + lun.setClone(clone); + + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of()); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenReturn(response); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.cloneCloudStackVolume(request)); + } + } + + @Test + void testCloneCloudStackVolume_FeignException_ThrowsException() { + Lun.Source source = new Lun.Source(); + source.setUuid("source-lun-uuid"); + Lun.Clone clone = new Lun.Clone(); + clone.setSource(source); + Lun lun = new Lun(); + lun.setName("/vol/vol1/cloned"); + lun.setClone(clone); + + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + FeignException feignException = mock(FeignException.class); + when(feignException.status()).thenReturn(500); + when(feignException.getMessage()).thenReturn("clone failed"); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenThrow(feignException); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.cloneCloudStackVolume(request)); + } } @Test @@ -821,10 +1372,84 @@ void testSetOntapStorage() { } @Test - void testUpdateCloudStackVolume_ReturnsNull() { + void testUpdateCloudStackVolume_AppliesQosPolicy() { + Lun lun = new Lun(); + lun.setUuid("lun-uuid-123"); + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_0_to_5000_iops_svm1"); + qosPolicy.setUuid("qos-uuid"); + 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); + ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); + verify(sanFeignClient).updateLun(eq(authHeader), eq("lun-uuid-123"), lunCaptor.capture()); + assertEquals("cs_0_to_5000_iops_svm1", lunCaptor.getValue().getQosPolicy().getName()); + assertEquals("qos-uuid", lunCaptor.getValue().getQosPolicy().getUuid()); + } + } + + @Test + void testUpdateCloudStackVolume_UnexpectedException_IsWrapped() { + Lun lun = new Lun(); + lun.setUuid("lun-uuid-123"); + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + 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))) + .thenThrow(new IllegalStateException("decoder failed")); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.updateCloudStackVolume(request)); + assertTrue(ex.getMessage().contains("Failed to update LUN")); + assertTrue(ex.getCause() instanceof IllegalStateException); + } + } + + @Test + void testUpdateCloudStackVolume_NoQosPolicy_SkipsPatch() { + Lun lun = new Lun(); + lun.setUuid("lun-uuid-123"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + CloudStackVolume result = unifiedSANStrategy.updateCloudStackVolume(request); - assertNull(result); + + assertSame(request, result); + verify(sanFeignClient, never()).updateLun(any(), any(), any()); + } + + @Test + void testUpdateCloudStackVolume_InvalidRequest_ThrowsException() { + CloudStackVolume lunWithoutUuid = new CloudStackVolume(); + lunWithoutUuid.setLun(new Lun()); + + assertInvalidIscsiVolumeUpdate(null); + assertInvalidIscsiVolumeUpdate(new CloudStackVolume()); + assertInvalidIscsiVolumeUpdate(lunWithoutUuid); + + verify(sanFeignClient, never()).updateLun(any(), any(), any()); + } + + private void assertInvalidIscsiVolumeUpdate(CloudStackVolume request) { + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.updateCloudStackVolume(request)); + assertEquals("Invalid iSCSI volume request", ex.getMessage()); } @Test @@ -1419,6 +2044,85 @@ void testCreateCloudStackVolume_EmptyResponse_ThrowsException() { } } + + @Test + void testCreateCloudStackVolume_IncompleteLunMissingName_ThrowsException() { + Lun lun = new Lun(); + lun.setName("/vol/vol1/lun1"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + Lun incomplete = new Lun(); + incomplete.setUuid("lun-uuid-123"); + // name intentionally null + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of(incomplete)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))) + .thenReturn(response); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createCloudStackVolume(request)); + assertTrue(ex.getMessage().contains("incomplete LUN")); + } + } + + @Test + void testCreateCloudStackVolume_IncompleteLunMissingUuid_ThrowsException() { + Lun lun = new Lun(); + lun.setName("/vol/vol1/lun1"); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + Lun incomplete = new Lun(); + incomplete.setName("/vol/vol1/lun1"); + // uuid intentionally null + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of(incomplete)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))) + .thenReturn(response); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.createCloudStackVolume(request)); + } + } + + @Test + void testCloneCloudStackVolume_IncompleteLunMissingUuid_ThrowsException() { + Lun.Source source = new Lun.Source(); + source.setUuid("source-lun-uuid"); + Lun.Clone clone = new Lun.Clone(); + clone.setSource(source); + Lun lun = new Lun(); + lun.setName("/vol/vol1/cloned"); + lun.setClone(clone); + + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + Lun incomplete = new Lun(); + incomplete.setName("/vol/vol1/cloned"); + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of(incomplete)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))) + .thenReturn(response); + + assertThrows(CloudRuntimeException.class, + () -> unifiedSANStrategy.cloneCloudStackVolume(request)); + } + } + @Test void testCreateCloudStackVolume_NullResponse_ThrowsException() { Lun lun = new Lun(); @@ -1802,4 +2506,35 @@ void testEnsureLunMapped_ExistingMapping_ReturnsExistingNumber() { verify(sanFeignClient, never()).createLunMap(any(), anyBoolean(), any(LunMap.class)); } } + + @Test + void testCreateCloudStackVolume_PassesQosPolicyOnLunCreate() { + Lun lun = new Lun(); + lun.setName("/vol/vol1/lun1"); + VolumeQosPolicy qosPolicy = new VolumeQosPolicy(); + qosPolicy.setName("cs_100_to200_iops_svm1"); + lun.setQosPolicy(qosPolicy); + CloudStackVolume request = new CloudStackVolume(); + request.setLun(lun); + + Lun createdLun = new Lun(); + createdLun.setName("/vol/vol1/lun1"); + createdLun.setUuid("lun-uuid-123"); + OntapResponse response = new OntapResponse<>(); + response.setRecords(List.of(createdLun)); + + try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) { + utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password")) + .thenReturn(authHeader); + when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))) + .thenReturn(response); + + unifiedSANStrategy.createCloudStackVolume(request); + + ArgumentCaptor lunCaptor = ArgumentCaptor.forClass(Lun.class); + verify(sanFeignClient).createLun(eq(authHeader), eq(true), lunCaptor.capture()); + assertNotNull(lunCaptor.getValue().getQosPolicy()); + assertEquals("cs_100_to200_iops_svm1", lunCaptor.getValue().getQosPolicy().getName()); + } + } } diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/utils/OntapStorageUtilsTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/utils/OntapStorageUtilsTest.java index ebe7da25ed12..3dc863eb7913 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/utils/OntapStorageUtilsTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/utils/OntapStorageUtilsTest.java @@ -81,6 +81,30 @@ public void getIgroupName_truncates_whenOneCharOverMaxLength() { assertEquals(OntapStorageConstants.IGROUP_NAME_MAX_LENGTH, result.length()); } + @Test + public void toFlexVolRelativePath_stripsVolPrefix() { + assertEquals("lun1", OntapStorageUtils.toFlexVolRelativePath("/vol/vol1/lun1", "vol1")); + assertEquals("file-uuid", OntapStorageUtils.toFlexVolRelativePath("file-uuid", "vol1")); + assertEquals("file-uuid", OntapStorageUtils.toFlexVolRelativePath("/file-uuid", "vol1")); + } + + @Test + public void toLunCloneSourcePathInSnapshot_buildsSnapshotQualifiedPath() { + assertEquals("/vol/vol1/.snapshot/snap_cs200/source_lun", + OntapStorageUtils.toLunCloneSourcePathInSnapshot("/vol/vol1/source_lun", "vol1", "snap_cs200")); + assertEquals("/vol/vol1/.snapshot/snap_cs200/source_lun", + OntapStorageUtils.toLunCloneSourcePathInSnapshot("source_lun", "vol1", "snap_cs200")); + } + + @Test + public void toLunCloneSourcePathInSnapshot_rejectsBlankInputs() { + org.junit.jupiter.api.Assertions.assertThrows(com.cloud.exception.InvalidParameterValueException.class, + () -> OntapStorageUtils.toLunCloneSourcePathInSnapshot("/vol/vol1/lun", "vol1", null)); + org.junit.jupiter.api.Assertions.assertThrows(com.cloud.exception.InvalidParameterValueException.class, + () -> OntapStorageUtils.toLunCloneSourcePathInSnapshot("/vol/vol1/lun", "", "snap")); + org.junit.jupiter.api.Assertions.assertThrows(com.cloud.exception.InvalidParameterValueException.class, + () -> OntapStorageUtils.toLunCloneSourcePathInSnapshot("", "vol1", "snap")); + } @Test public void isOntapSnapshotNotFoundError_matchesEntryDoesNotExist() { diff --git a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java index 0702450d85c9..6907a08c53a7 100644 --- a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java +++ b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java @@ -884,7 +884,7 @@ public VolumeVO allocVolume(long ownerId, Long zoneId, Long diskOfferingId, Long minIops = 0L; maxIops = 0L; } else { - if (minIops == null || minIops <= 0) { + if (minIops == null || minIops < 0) { throw new InvalidParameterValueException("The min IOPS must be greater than 0."); } @@ -2321,7 +2321,7 @@ public Volume changeDiskOfferingForVolumeInternal(Long volumeId, Long newDiskOff if (volumeMigrateRequired) { logger.warn(String.format("Volume change offering operation succeeded for volume ID: %s but volume resize operation failed, so please try resize volume operation separately", volume.getUuid())); } else { - throw new CloudRuntimeException(String.format("Volume disk offering change operation failed for volume ID [%s] because the volume resize operation failed.", volume.getUuid())); + throw new CloudRuntimeException(String.format("Volume disk offering change operation failed for volume ID [%s] because the volume resize operation failed: %s", volume.getUuid(), e.getMessage())); } } } @@ -2365,7 +2365,7 @@ public Volume changeDiskOfferingForVolumeInternal(Long volumeId, Long newDiskOff if (volumeMigrateRequired) { logger.warn(String.format("Volume change offering operation succeeded for volume ID: %s but volume resize operation failed, so please try resize volume operation separately", volume.getUuid())); } else { - throw new CloudRuntimeException(String.format("Volume change offering operation failed for volume ID: %s due to resize volume operation failed", volume.getUuid())); + throw new CloudRuntimeException(String.format("Volume change offering operation failed for volume ID: %s due to resize volume operation failed: %s", volume.getUuid(), e.getMessage()), e); } } } diff --git a/ui/src/views/infra/AddPrimaryStorage.vue b/ui/src/views/infra/AddPrimaryStorage.vue index eeb3ebdd22c6..ec155d6dcd72 100644 --- a/ui/src/views/infra/AddPrimaryStorage.vue +++ b/ui/src/views/infra/AddPrimaryStorage.vue @@ -301,6 +301,12 @@ + + + +
diff --git a/ui/src/views/infra/zone/ZoneWizardAddResources.vue b/ui/src/views/infra/zone/ZoneWizardAddResources.vue index 9bd9c6d37aef..faf9a10679f6 100644 --- a/ui/src/views/infra/zone/ZoneWizardAddResources.vue +++ b/ui/src/views/infra/zone/ZoneWizardAddResources.vue @@ -611,7 +611,7 @@ export default { title: 'label.capacityiops', key: 'capacityIops', hidden: { - provider: ['DefaultPrimary', 'PowerFlex', 'Linstor', 'NetApp ONTAP'] + provider: ['DefaultPrimary', 'PowerFlex', 'Linstor'] } }, { diff --git a/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue b/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue index f21201572dff..9402fe99a9c9 100644 --- a/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue +++ b/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue @@ -1619,6 +1619,9 @@ export default { if (this.prefillContent.capacityBytes && this.prefillContent.capacityBytes.length > 0) { params.capacityBytes = this.prefillContent.capacityBytes.split(',').join('') } + if (this.prefillContent.capacityIops && this.prefillContent.capacityIops.length > 0) { + params.capacityIops = this.prefillContent.capacityIops.split(',').join('') + } } params.tags = this.prefillContent?.primaryStorageTags || '' diff --git a/ui/src/views/storage/ChangeOfferingForVolume.vue b/ui/src/views/storage/ChangeOfferingForVolume.vue index 66a3cf1442e4..29d00dfe512e 100644 --- a/ui/src/views/storage/ChangeOfferingForVolume.vue +++ b/ui/src/views/storage/ChangeOfferingForVolume.vue @@ -158,7 +158,7 @@ export default { miniops: [{ type: 'number', validator: async (rule, value) => { - if (value && (isNaN(value) || value <= 0)) { + if (value && (isNaN(value) || value < 0)) { return Promise.reject(this.$t('message.error.number')) } return Promise.resolve() diff --git a/ui/src/views/storage/CreateVolume.vue b/ui/src/views/storage/CreateVolume.vue index ae203bdfa9b8..073ff204bba1 100644 --- a/ui/src/views/storage/CreateVolume.vue +++ b/ui/src/views/storage/CreateVolume.vue @@ -311,7 +311,7 @@ export default { size: [{ required: true, message: this.$t('message.error.custom.disk.size') }], miniops: [{ validator: async (rule, value) => { - if (value && (isNaN(value) || value <= 0)) { + if (value && (isNaN(value) || value < 0)) { return Promise.reject(this.$t('message.error.number')) } return Promise.resolve()