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 613bcd6d1b91..0e2ed66eb4de 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 ed942b438e16..b1d8dba6bf9d 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java
@@ -27,6 +27,7 @@
import com.cloud.host.Host;
import com.cloud.host.HostVO;
import com.cloud.hypervisor.Hypervisor.HypervisorType;
+import com.cloud.storage.DataStoreRole;
import com.cloud.storage.Storage;
import com.cloud.storage.StoragePool;
import com.cloud.storage.Volume;
@@ -58,6 +59,7 @@
import org.apache.commons.lang3.StringUtils;
import org.apache.cloudstack.framework.async.AsyncCompletionCallback;
import org.apache.cloudstack.storage.command.CommandResult;
+import org.apache.cloudstack.storage.command.CopyCmdAnswer;
import org.apache.cloudstack.storage.command.CreateObjectAnswer;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
@@ -87,6 +89,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -116,6 +119,11 @@ public Map getCapabilities() {
// 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;
}
@@ -170,37 +178,41 @@ 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: a clone of the cached template when the
- // orchestrator asked for one, otherwise a blank LUN (iSCSI) or qcow2 file (NFS).
+ /*
+ * 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: MIN_IOPS/MAX_IOPS may be on snapshot_details; apply is TODO below.
+ *
+ * 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 = cloneOfTemplateId != null
- ? cloneCloudStackVolumeFromTemplate(storagePool, volInfo, details, cloneOfTemplateId)
- : createCloudStackVolume(storagePool, volInfo, details);
-
- 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 = clonedCloudStackVolume.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());
+ CloudStackVolume clonedCloudStackVolume;
+ if (cloneOfSnapshotId != null) {
+ clonedCloudStackVolume = cloneCloudStackVolumeFromSnapshot(
+ storagePool, volInfo, details, cloneOfSnapshotId);
+ // TODO(CSTACKEX-306): apply persisted MIN_IOPS / MAX_IOPS from snapshot_details
+ // onto this CloudStack volume (and ONTAP QoS if applicable) after successful clone.
+ } 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);
@@ -222,6 +234,41 @@ 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.
*/
@@ -327,6 +374,104 @@ private Long getTemplateIdForCloning(long volumeId) {
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).
+ */
+ private CloudStackVolume cloneCloudStackVolumeFromSnapshot(StoragePoolVO storagePool, VolumeInfo volumeInfo,
+ Map details, long csSnapshotId) {
+ 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);
+
+ logger.info("cloneCloudStackVolumeFromSnapshot: Cloning from CS snapshot [{}] (ONTAP snap [{}], path [{}]) "
+ + "for volume [{}] on pool [{}] protocol [{}]",
+ csSnapshotId, snapshotName, volumePath, volumeInfo.getId(), storagePool.getId(), poolProtocol);
+
+ CloudStackVolume request = isIscsi(details)
+ ? createCloneLunFromSnapshotRequest(storagePool, details, volumeInfo, volumePath, snapshotName)
+ : 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());
+ storageStrategy.resizeCloudStackVolume(cloned, requestedSize);
+ }
+
+ return cloned;
+ }
+
+ 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) {
@@ -473,6 +618,7 @@ public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallbac
CloudStackVolume cloudStackVolumeRequest = createDeleteCloudStackVolumeRequest(storagePool, details, volumeInfo);
storageStrategy.deleteCloudStackVolume(cloudStackVolumeRequest);
logger.info("deleteAsync: Volume deleted: " + volumeInfo.getId());
+ removeTemporarySnapshotCopyRecord(volumeInfo.getId());
commandResult.setResult(null);
commandResult.setSuccess(true);
} else if (data.getType() == DataObjectType.TEMPLATE) {
@@ -491,7 +637,10 @@ 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);
+ }
}
}
@@ -582,17 +731,143 @@ 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());
+ 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);
}
@Override
@@ -652,6 +927,7 @@ 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 {
@@ -665,6 +941,22 @@ 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);
@@ -1122,9 +1414,22 @@ public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback snapshotDetails = new LinkedHashMap<>();
+ snapshotDetails.put(OntapStorageConstants.SRC_CS_VOLUME_ID, String.valueOf(volumeInfo.getId()));
+ snapshotDetails.put(OntapStorageConstants.BASE_ONTAP_FV_ID, flexVolUuid);
+ snapshotDetails.put(OntapStorageConstants.ONTAP_SNAP_ID, ontapSnapshotUuid);
+ snapshotDetails.put(OntapStorageConstants.ONTAP_SNAP_NAME, snapshotName);
+ snapshotDetails.put(OntapStorageConstants.VOLUME_PATH, volumePath);
+ snapshotDetails.put(OntapStorageConstants.PRIMARY_POOL_ID, String.valueOf(volumeVO.getPoolId()));
+ snapshotDetails.put(OntapStorageConstants.PROTOCOL, protocol);
+ // iSCSI only — needed for LUN restore / identity; null (skipped) for NFS.
+ snapshotDetails.put(OntapStorageConstants.LUN_DOT_UUID, lunUuid);
+ // TODO(CSTACKEX-306): on create-volume-from-snapshot, read these and apply to the new volume.
+ putPositiveIopsDetail(snapshotDetails, OntapStorageConstants.MIN_IOPS, volumeVO.getMinIops());
+ putPositiveIopsDetail(snapshotDetails, OntapStorageConstants.MAX_IOPS, volumeVO.getMaxIops());
+ updateSnapshotDetails(snapshot.getId(), snapshotDetails);
CreateObjectAnswer createObjectAnswer = new CreateObjectAnswer(snapshotObjectTo);
result = new CreateCmdResult(null, createObjectAnswer);
@@ -1472,6 +1777,65 @@ private CloudStackVolume createCloneFileRequest(StoragePoolVO storagePool, Volum
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) {
+ 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);
+
+ CloudStackVolume request = new CloudStackVolume();
+ request.setLun(lunRequest);
+ 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
// ──────────────────────────────────────────────────────────────────────────
@@ -1514,61 +1878,43 @@ private Storage.ImageFormat getImageFormat(StoragePoolVO storagePool) {
}
/**
- * 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, 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 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 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/model/FileCloneRequest.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileCloneRequest.java
index a9f2a106e9a8..cefff9fd282f 100644
--- 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
@@ -46,6 +46,15 @@ public class FileCloneRequest {
@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() {
}
@@ -55,6 +64,14 @@ public FileCloneRequest(String flexVolUuid, String flexVolName, String sourcePat
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;
}
@@ -87,6 +104,14 @@ 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 {
@@ -122,10 +147,37 @@ public void setName(String 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 + "}";
+ + ", destinationPath=" + destinationPath
+ + ", snapshot=" + (snapshot != null ? snapshot.getName() : null) + "}";
}
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/StorageStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/StorageStrategy.java
index e482301967f1..f279a31e9e42 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
@@ -65,7 +65,6 @@
import feign.FeignException;
import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo;
-
/**
* Storage Strategy represents the communication path for all the ONTAP storage options
*
@@ -823,6 +822,14 @@ abstract public CloudStackVolume createTemplateCache(StoragePoolVO storagePool,
* 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
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java
index 4a9f45f7301e..393716274e86 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
@@ -140,7 +140,8 @@ 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.
+ * {@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) {
@@ -157,14 +158,16 @@ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume)
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");
+ throw new CloudRuntimeException("Failed to clone file, FlexVolume uuid is missing from pool poolDetails");
}
String sourcePath = cloudstackVolume.getFile().getPath();
String destinationPath = cloudstackVolume.getDestinationPath();
+ String snapshotName = cloudstackVolume.getSnapshotName();
- logger.info("cloneCloudStackVolume: Cloning file [{}] to [{}] in FlexVol [{}]", sourcePath, destinationPath, flexVolName);
+ logger.info("cloneCloudStackVolume: Cloning file [{}] to [{}] in FlexVol [{}] from snapshot [{}]",
+ sourcePath, destinationPath, flexVolName, snapshotName);
try {
- FileCloneRequest request = new FileCloneRequest(flexVolUuid, flexVolName, sourcePath, destinationPath);
+ FileCloneRequest request = new FileCloneRequest(flexVolUuid, flexVolName, sourcePath, destinationPath, snapshotName);
JobResponse jobResponse = nasFeignClient.cloneFile(getAuthHeader(), request);
pollJobIfPresent(jobResponse, "clone file [" + sourcePath + "] to [" + destinationPath + "]");
@@ -177,6 +180,7 @@ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume 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: {}",
@@ -266,7 +270,7 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
logger.info("createAccessGroup: ExportPolicy created: {}, now attaching this policy to storage pool volume", createdPolicy.getName());
// attach export policy to volume of storage pool
assignExportPolicyToVolume(volumeUUID,createdPolicy.getName());
- // save the export policy details in storage pool details
+ // save the export policy poolDetails in storage pool poolDetails
storagePoolDetailsDao.addDetail(accessGroup.getStoragePoolId(), OntapStorageConstants.EXPORT_POLICY_ID, String.valueOf(createdPolicy.getId()), true);
storagePoolDetailsDao.addDetail(accessGroup.getStoragePoolId(), OntapStorageConstants.EXPORT_POLICY_NAME, createdPolicy.getName(), true);
logger.info("Successfully assigned exportPolicy {} to volume {}", policyRequest.getName(), volumeName);
@@ -327,7 +331,7 @@ public AccessGroup updateAccessGroup(AccessGroup accessGroup) {
Map details = storagePoolDetailsDao.listDetailsKeyPairs(accessGroup.getStoragePoolId());
if (details == null || details.isEmpty()) {
- throw new CloudRuntimeException("No storage pool details found for storagePoolId: " + accessGroup.getStoragePoolId());
+ throw new CloudRuntimeException("No storage pool poolDetails found for storagePoolId: " + accessGroup.getStoragePoolId());
}
String exportPolicyId = details.get(OntapStorageConstants.EXPORT_POLICY_ID);
if (exportPolicyId == null || exportPolicyId.isEmpty()) {
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java
index b9e32b081e4d..29148a748b97 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
@@ -110,7 +110,9 @@ public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, TemplateI
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() + "]");
+ + "]; 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);
@@ -260,7 +262,6 @@ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume)
}
}
-
/**
* 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.
@@ -340,10 +341,10 @@ public CloudStackVolume getCloudStackVolume(Map values) {
return null;
}
logger.error("FeignException occurred while fetching Lun, Status: {}, Exception: {}", e.status(), e.getMessage());
- throw new CloudRuntimeException("Failed to fetch Lun details: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Lun poolDetails: " + e.getMessage());
} catch (Exception e) {
logger.error("Exception occurred while fetching Lun, Exception: {}", e.getMessage());
- throw new CloudRuntimeException("Failed to fetch Lun details: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Lun poolDetails: " + e.getMessage());
}
}
@@ -354,9 +355,9 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
logger.error("createAccessGroup: Igroup creation failed. Invalid request: {}", accessGroup);
throw new CloudRuntimeException("Failed to create Igroup, invalid request");
}
- // Get StoragePool details
+ // Get StoragePool poolDetails
if (accessGroup.getStoragePoolId() == null) {
- throw new CloudRuntimeException("Failed to create Igroup, invalid datastore details in the request");
+ throw new CloudRuntimeException("Failed to create Igroup, invalid datastore poolDetails in the request");
}
if (accessGroup.getHostsToConnect() == null || accessGroup.getHostsToConnect().isEmpty()) {
throw new CloudRuntimeException("Failed to create Igroup, no hosts to connect provided in the request");
@@ -365,7 +366,7 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
String igroupName = null;
try {
Map dataStoreDetails = storagePoolDetailsDao.listDetailsKeyPairs(accessGroup.getStoragePoolId());
- logger.trace("createAccessGroup: Successfully fetched datastore details.");
+ logger.trace("createAccessGroup: Successfully fetched datastore poolDetails.");
// Generate Igroup request
Igroup igroupRequest = new Igroup();
@@ -441,9 +442,9 @@ public void deleteAccessGroup(AccessGroup accessGroup) {
logger.error("deleteAccessGroup: Igroup deletion failed. Invalid request: {}", accessGroup);
throw new CloudRuntimeException("Failed to delete Igroup, invalid request");
}
- // Get StoragePool details
+ // Get StoragePool poolDetails
if (accessGroup.getStoragePoolId() == null) {
- throw new CloudRuntimeException("Failed to delete Igroup, invalid datastore details in the request");
+ throw new CloudRuntimeException("Failed to delete Igroup, invalid datastore poolDetails in the request");
}
try {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
@@ -550,10 +551,10 @@ public AccessGroup getAccessGroup(Map values) {
return null;
}
logger.error("FeignException occurred while fetching Igroup, Status: {}, Exception: {}", e.status(), e.getMessage());
- throw new CloudRuntimeException("Failed to fetch Igroup details: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Igroup poolDetails: " + e.getMessage());
} catch (Exception e) {
logger.error("Exception occurred while fetching Igroup, Exception: {}", e.getMessage());
- throw new CloudRuntimeException("Failed to fetch Igroup details: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Igroup poolDetails: " + e.getMessage());
}
}
@@ -599,7 +600,7 @@ public String enableLogicalAccess(Map values) {
throw feignEx;
}
}
- // Get the LunMap details
+ // Get the LunMap poolDetails
OntapResponse lunMapResponse = null;
try {
lunMapResponse = sanFeignClient.getLunMapResponse(authHeader,
@@ -614,12 +615,12 @@ public String enableLogicalAccess(Map values) {
lunNumber = lunMapResponse.getRecords().get(0).getLogicalUnitNumber().toString();
} else {
- logger.error("enableLogicalAccess: Failed to fetch LunMap details for Lun: {} and igroup: {}. LunMap response is null or empty.", lunName, igroupName);
- throw new CloudRuntimeException("Failed to fetch LunMap details for Lun: " + lunName + " and igroup: " + igroupName);
+ logger.error("enableLogicalAccess: Failed to fetch LunMap poolDetails for Lun: {} and igroup: {}. LunMap response is null or empty.", lunName, igroupName);
+ throw new CloudRuntimeException("Failed to fetch LunMap poolDetails for Lun: " + lunName + " and igroup: " + igroupName);
}
} catch (Exception e) {
- logger.error("enableLogicalAccess: Failed to fetch LunMap details for Lun: {} and igroup: {}, Exception: {}", lunName, igroupName, e);
- throw new CloudRuntimeException("Failed to fetch LunMap details for Lun: " + lunName + " and igroup: " + igroupName);
+ logger.error("enableLogicalAccess: Failed to fetch LunMap poolDetails for Lun: {} and igroup: {}, Exception: {}", lunName, igroupName, e);
+ throw new CloudRuntimeException("Failed to fetch LunMap poolDetails for Lun: " + lunName + " and igroup: " + igroupName);
}
logger.trace("enableLogicalAccess: LunMap created successfully, LunMap: {}", lunMapResponse.getRecords().get(0));
} catch (Exception e) {
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 4ac49c95dfa1..421b8ff028e6 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
@@ -94,6 +94,12 @@ public class OntapStorageConstants {
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";
@@ -115,6 +121,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-";
@@ -148,6 +161,26 @@ public class OntapStorageConstants {
*/
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_";
+
// ASUP (AutoSupport) / EMS telemetry
public static final String ADVANCED_CONFIG_KEY_CATEGORY = "Advanced";
public static final String ASUP_CATEGORY = "provisioning";
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 e2b419ea46f0..5155f9f868f6 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
@@ -138,6 +138,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 db1806c8473e..103eb9fc9772 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,19 +22,27 @@
import com.cloud.host.Host;
import com.cloud.host.HostVO;
import com.cloud.hypervisor.Hypervisor;
+import com.cloud.storage.DataStoreRole;
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.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;
@@ -43,6 +51,7 @@
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
import org.apache.cloudstack.storage.feign.model.Igroup;
+import org.apache.cloudstack.storage.feign.model.FileInfo;
import org.apache.cloudstack.storage.feign.model.Lun;
import org.apache.cloudstack.storage.service.UnifiedNASStrategy;
import org.apache.cloudstack.storage.service.UnifiedSANStrategy;
@@ -63,6 +72,7 @@
import java.util.HashMap;
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;
@@ -72,6 +82,7 @@
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;
@@ -101,6 +112,12 @@ class OntapPrimaryDatastoreDriverTest {
@Mock
private VolumeDetailsDao volumeDetailsDao;
+ @Mock
+ private SnapshotDetailsDao snapshotDetailsDao;
+
+ @Mock
+ private SnapshotDao snapshotDao;
+
@Mock
private VMTemplatePoolDao vmTemplatePoolDao;
@@ -160,6 +177,8 @@ void testGetCapabilities() {
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
@@ -370,6 +389,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());
}
}
@@ -542,6 +563,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();
}
}
@@ -948,6 +970,306 @@ void testCreateAsync_VolumeClonedFromTemplate_GrowsWhenOfferingIsLarger() {
}
}
+ @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());
+ 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_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_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.
*/
@@ -968,6 +1290,9 @@ private void stubVolumeCloneFromTemplate(long templateSize, long volumeSize) {
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));
@@ -1400,6 +1725,7 @@ void testCreateAsync_VolumeClonedFromTemplate_MissingSpoolRef_Fails() {
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);
@@ -1415,4 +1741,318 @@ void testCreateAsync_VolumeClonedFromTemplate_MissingSpoolRef_Fails() {
verify(sanStrategy, never()).cloneCloudStackVolume(any());
}
}
+
+ 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());
+
+ 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());
+ }
+ }
+
+ @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 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 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));
+
+ 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 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/service/UnifiedNASStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java
index dd90363af045..354625bf8db7 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
@@ -1015,6 +1015,43 @@ public void testCloneCloudStackVolume_Success() {
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
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedSANStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedSANStrategyTest.java
index 700b63d15575..fa4d7a3a7c4f 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
@@ -75,6 +75,9 @@ class UnifiedSANStrategyTest {
@Mock
private SANFeignClient sanFeignClient;
+ @Mock
+ private org.apache.cloudstack.storage.feign.client.NASFeignClient nasFeignClient;
+
@Mock
private OntapStorage ontapStorage;
@@ -105,6 +108,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);
@@ -1006,6 +1013,40 @@ void testCloneCloudStackVolume_MissingSource_ThrowsException() {
() -> unifiedSANStrategy.cloneCloudStackVolume(request));
}
+ @Test
+ 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(lun);
+
+ 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 testResizeCloudStackVolume_ValidRequest_PatchesSize() {
Lun lun = new Lun();
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..1fa61966af88 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
@@ -93,4 +93,29 @@ public void isOntapSnapshotNotFoundError_rejectsUnrelatedErrors() {
assertFalse(OntapStorageUtils.isOntapObjectNotFoundError(
new CloudRuntimeException("Job failed with error: permission denied")));
}
+
+ @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"));
+ }
}