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..f1e6618913e3 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
@@ -170,12 +170,39 @@ 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).
- Long cloneOfTemplateId = getTemplateIdForCloning(volInfo.getId());
- CloudStackVolume clonedCloudStackVolume = cloneOfTemplateId != null
- ? cloneCloudStackVolumeFromTemplate(storagePool, volInfo, details, cloneOfTemplateId)
- : createCloudStackVolume(storagePool, volInfo, details);
+ /*
+ * 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 restore never accidentally falls through to blank create.
+ */
+ Long cloneOfSnapshotId = getSnapshotIdForCloning(volInfo.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 {
+ Long cloneOfTemplateId = getTemplateIdForCloning(volInfo.getId());
+ clonedCloudStackVolume = cloneOfTemplateId != null
+ ? cloneCloudStackVolumeFromTemplate(
+ storagePool, volInfo, details, cloneOfTemplateId)
+ : createCloudStackVolume(storagePool, volInfo, details);
+ }
volumeVO.setPoolType(storagePool.getPoolType());
volumeVO.setPoolId(storagePool.getId());
@@ -327,6 +354,102 @@ 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 restore-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}. Cross-pool restore 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() + "] (cross-pool restore is not supported in v1)");
+ }
+ 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 cloned = storageStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, volumePath, snapshotName);
+ 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) {
@@ -1122,9 +1245,11 @@ public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallbackVolume-snapshot delete reads {@code base_ontap_fv_id} and {@code ontap_snap_id} here
* during {@link #deleteCloudStackVolumeSnapshot}; missing rows prevent ONTAP cleanup.
*
+ * 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 csVolumeId Source CloudStack volume ID
* @param flexVolUuid ONTAP FlexVolume UUID
@@ -1530,45 +1657,48 @@ private Storage.ImageFormat getImageFormat(StoragePoolVO storagePool) {
* @param storagePoolId Primary storage pool ID
* @param protocol Storage protocol (NFS3 or ISCSI)
* @param lunUuid LUN UUID (only for iSCSI, null for NFS)
+ * @param minIops Source volume min IOPS if configured; null/<=0 skipped
+ * @param maxIops Source volume max IOPS if configured; null/<=0 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);
+ String lunUuid, Long minIops, Long maxIops) {
+ persistSnapshotDetail(csSnapshotId, OntapStorageConstants.SRC_CS_VOLUME_ID, String.valueOf(csVolumeId));
+ persistSnapshotDetail(csSnapshotId, OntapStorageConstants.BASE_ONTAP_FV_ID, flexVolUuid);
+ persistSnapshotDetail(csSnapshotId, OntapStorageConstants.ONTAP_SNAP_ID, ontapSnapshotUuid);
+ persistSnapshotDetail(csSnapshotId, OntapStorageConstants.ONTAP_SNAP_NAME, snapshotName);
+ persistSnapshotDetail(csSnapshotId, OntapStorageConstants.VOLUME_PATH, volumePath);
+ persistSnapshotDetail(csSnapshotId, OntapStorageConstants.PRIMARY_POOL_ID, String.valueOf(storagePoolId));
+ persistSnapshotDetail(csSnapshotId, OntapStorageConstants.PROTOCOL, protocol);
+ // iSCSI only — needed for LUN restore / identity; omitted for NFS.
+ persistSnapshotDetail(csSnapshotId, OntapStorageConstants.LUN_DOT_UUID, lunUuid);
+ // Optional: only when the source volume has IOPS configured (no-op otherwise).
+ // TODO(CSTACKEX-306): on create-volume-from-snapshot, read these and apply to the new volume.
+ persistSnapshotIopsDetail(csSnapshotId, OntapStorageConstants.MIN_IOPS, minIops);
+ persistSnapshotIopsDetail(csSnapshotId, OntapStorageConstants.MAX_IOPS, maxIops);
+ }
- snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.PROTOCOL, protocol, false);
- snapshotDetailsDao.persist(snapshotDetail);
+ /**
+ * 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));
+ }
- // 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);
+ /**
+ * Persists a snapshot IOPS detail when {@code iops} is non-null and positive; otherwise no-op.
+ */
+ private void persistSnapshotIopsDetail(long csSnapshotId, String detailName, Long iops) {
+ if (iops == null || iops <= 0) {
+ return;
}
+ persistSnapshotDetail(csSnapshotId, detailName, String.valueOf(iops));
+ logger.debug("updateSnapshotDetails: persisted {}={} for snapshot [{}]", detailName, iops, csSnapshotId);
}
}
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..a6b0a56279da 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,6 +65,7 @@
import feign.FeignException;
import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo;
+import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
/**
* Storage Strategy represents the communication path for all the ONTAP storage options
@@ -829,6 +830,32 @@ abstract public CloudStackVolume createTemplateCache(StoragePoolVO storagePool,
*/
abstract public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume);
+ /**
+ * Creates a new file/LUN in the same FlexVolume by cloning from a FlexVolume snapshot.
+ *
+ * Product scope (v1): same primary pool / FlexVol only. Cross-pool restore is
+ * descoped — operators may later {@code migrateVolume} if another pool is required.
+ *
+ * ONTAP backends (protocol-specific; each subclass builds its own request):
+ *
+ * - NAS (NFS3) — {@code POST /api/storage/file/clone} with {@code snapshot.name}
+ * - SAN (iSCSI) — {@code POST /api/storage/luns} with {@code clone.source.name} =
+ * {@code /vol/<fv>/.snapshot/<snap>/<lun>}
+ *
+ *
+ * @param storagePool target CloudStack primary pool (same FlexVol as the snapshot)
+ * @param details pool details (SVM, FlexVol name/uuid, protocol, …)
+ * @param volumeInfo destination CloudStack volume being created
+ * @param sourceVolumePath snapshotted object path from {@code snapshot_details.VOLUME_PATH}
+ * @param snapshotName ONTAP FlexVol snapshot name from {@code snapshot_details}
+ * @return created CloudStackVolume with protocol-specific identity (LUN uuid or file path)
+ */
+ abstract public CloudStackVolume cloneCloudStackVolumeFromSnapshot(StoragePoolVO storagePool,
+ Map details,
+ VolumeInfo volumeInfo,
+ String sourceVolumePath,
+ String snapshotName);
+
/**
* Grows an existing backend object to {@code sizeInBytes}.
*
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java
index 4a9f45f7301e..e8cc2093397f 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
@@ -31,6 +31,7 @@
import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint;
import org.apache.cloudstack.engine.subsystem.api.storage.EndPointSelector;
import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo;
+import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
import org.apache.cloudstack.storage.command.CreateObjectCommand;
import org.apache.cloudstack.storage.command.DeleteCommand;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
@@ -188,6 +189,76 @@ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume)
}
}
+ /**
+ * Creates a new qcow2 (or other file) in the FlexVol by cloning from a FlexVolume snapshot
+ * via {@code POST /api/storage/file/clone} with {@code snapshot.name}.
+ *
+ * Builds the NFS request and executes it (mirrors {@link #createTemplateCache}).
+ * SAN uses the LUN REST clone path instead.
+ *
+ * Combinations covered here:
+ *
+ * - DATA or ROOT snapshot → new data volume (ROOT restore is never bootable as a volume;
+ * CloudStack still uses this path for {@code createVolume(snapshotid)}; bootable ROOT
+ * recovery remains {@code createTemplate} → deploy)
+ * - Same pool / FlexVol only (v1)
+ * - IOPS from snapshot_details are not applied here — see driver TODO
+ *
+ */
+ @Override
+ public CloudStackVolume cloneCloudStackVolumeFromSnapshot(StoragePoolVO storagePool, Map details,
+ VolumeInfo volumeInfo, String sourceVolumePath,
+ String snapshotName) {
+ if (storagePool == null || details == null || volumeInfo == null) {
+ throw new CloudRuntimeException("Failed to clone file from snapshot, invalid request");
+ }
+ if (sourceVolumePath == null || sourceVolumePath.isEmpty()) {
+ throw new CloudRuntimeException("Failed to clone file from snapshot, source path is required");
+ }
+ if (snapshotName == null || snapshotName.isEmpty()) {
+ throw new CloudRuntimeException("Failed to clone file from snapshot, snapshot name is required");
+ }
+
+ 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 from snapshot, FlexVolume uuid is missing from pool details");
+ }
+
+ String sourcePath = OntapStorageUtils.toFlexVolRelativePath(sourceVolumePath, flexVolName);
+ String destinationPath = OntapStorageUtils.toFlexVolRelativePath(volumeInfo.getUuid(), flexVolName);
+
+ logger.info("cloneCloudStackVolumeFromSnapshot [NFS]: Cloning file [{}] -> [{}] from snapshot [{}] on FlexVol [{}]",
+ sourcePath, destinationPath, snapshotName, flexVolName);
+ try {
+ FileCloneRequest request = new FileCloneRequest(flexVolUuid, flexVolName, sourcePath, destinationPath, snapshotName);
+ JobResponse jobResponse = nasFeignClient.cloneFile(getAuthHeader(), request);
+ pollJobIfPresent(jobResponse, "clone file from snapshot [" + snapshotName + "] [" + sourcePath
+ + "] to [" + destinationPath + "]");
+
+ updateCloudStackVolumeMetadata(String.valueOf(storagePool.getId()), volumeInfo);
+
+ FileInfo clonedFile = new FileInfo();
+ clonedFile.setPath(destinationPath);
+
+ CloudStackVolume clonedCloudStackVolume = new CloudStackVolume();
+ clonedCloudStackVolume.setFile(clonedFile);
+ clonedCloudStackVolume.setDatastoreId(String.valueOf(storagePool.getId()));
+ clonedCloudStackVolume.setVolumeInfo(volumeInfo);
+ clonedCloudStackVolume.setSnapshotName(snapshotName);
+ return clonedCloudStackVolume;
+ } catch (FeignException e) {
+ logger.error("FeignException while cloning file from snapshot [{}], Status: {}, Exception: {}",
+ snapshotName, e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to clone file from snapshot: " + e.getMessage());
+ } catch (CloudRuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ logger.error("Exception while cloning file from snapshot [{}]: {}", snapshotName, e.getMessage());
+ throw new CloudRuntimeException("Failed to clone file from snapshot: " + e.getMessage());
+ }
+ }
+
/**
* Grows the cloned qcow2 to the requested size via a host-side {@code qemu-img resize}.
*/
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..180190b55a9a 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
@@ -23,6 +23,7 @@
import com.cloud.utils.exception.CloudRuntimeException;
import feign.FeignException;
import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo;
+import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
import org.apache.cloudstack.storage.feign.model.Igroup;
@@ -260,6 +261,102 @@ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume)
}
}
+ /**
+ * Creates a new LUN by cloning from a FlexVolume snapshot via the LUN REST API
+ * ({@code POST /api/storage/luns} with {@code clone.source.name} pointing into
+ * {@code /vol/<fv>/.snapshot/<snap>/...}).
+ *
+ * Builds the protocol-specific request and executes it (mirrors {@link #createTemplateCache}).
+ * NFS uses file-clone in {@link UnifiedNASStrategy} instead.
+ *
+ * Combinations covered here:
+ *
+ * - DATA or ROOT snapshot → new data volume on the same OntapiSCSI pool
+ * - Same pool / FlexVol only (v1); cross-pool is not implemented
+ * - In-place revert remains {@link #revertSnapshotForCloudStackVolume}; this method always
+ * creates a new LUN
+ * - IOPS from snapshot_details are not applied here — see driver TODO
+ *
+ */
+ @Override
+ public CloudStackVolume cloneCloudStackVolumeFromSnapshot(StoragePoolVO storagePool, Map details,
+ VolumeInfo volumeInfo, String sourceVolumePath,
+ String snapshotName) {
+ if (storagePool == null || details == null || volumeInfo == null) {
+ throw new CloudRuntimeException("Failed to clone Lun from snapshot, invalid request");
+ }
+ if (sourceVolumePath == null || sourceVolumePath.isEmpty()) {
+ throw new CloudRuntimeException("Failed to clone Lun from snapshot, source LUN path is required");
+ }
+ if (snapshotName == null || snapshotName.isEmpty()) {
+ throw new CloudRuntimeException("Failed to clone Lun from snapshot, snapshot name is required");
+ }
+
+ Lun lunRequest = buildCloneLunFromSnapshotRequest(storagePool, details, volumeInfo, sourceVolumePath, snapshotName);
+ logger.info("cloneCloudStackVolumeFromSnapshot [iSCSI]: Cloning LUN [{}] from snapshot source [{}]",
+ lunRequest.getName(), lunRequest.getClone().getSource().getName());
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ OntapResponse clonedLun = sanFeignClient.createLun(authHeader, true, lunRequest);
+ if (clonedLun == null || CollectionUtils.isEmpty(clonedLun.getRecords())) {
+ logger.error("cloneCloudStackVolumeFromSnapshot: LUN clone returned no records for Lun {}",
+ lunRequest.getName());
+ throw new CloudRuntimeException("Failed to clone Lun from snapshot: " + lunRequest.getName());
+ }
+ Lun lun = clonedLun.getRecords().get(0);
+ validateCreatedLun(lun, lunRequest.getName(), "cloneCloudStackVolumeFromSnapshot");
+ logger.debug("cloneCloudStackVolumeFromSnapshot: LUN cloned successfully. Lun: {}", lun);
+
+ CloudStackVolume clonedCloudStackVolume = new CloudStackVolume();
+ clonedCloudStackVolume.setLun(lun);
+ return clonedCloudStackVolume;
+ } catch (FeignException e) {
+ logger.error("FeignException while cloning LUN from snapshot, Status: {}, Exception: {}",
+ e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to clone Lun from snapshot: " + e.getMessage());
+ } catch (CloudRuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ logger.error("Exception while cloning LUN from snapshot: {}", e.getMessage());
+ throw new CloudRuntimeException("Failed to clone Lun from snapshot: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Builds {@code POST /api/storage/luns} clone request with snapshot-qualified source name
+ * {@code /vol/<flexVol>/.snapshot/<snap>/<lun>} (name required; uuid cannot
+ * identify a snapshot-resident LUN).
+ */
+ private Lun buildCloneLunFromSnapshotRequest(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();
+ }
+ String snapshotSourceName = OntapStorageUtils.toLunCloneSourcePathInSnapshot(
+ sourceVolumePath, flexVolName, snapshotName);
+
+ Svm svm = new Svm();
+ svm.setName(details.get(OntapStorageConstants.SVM_NAME));
+
+ Lun.Source source = new Lun.Source();
+ source.setName(snapshotSourceName);
+ 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);
+ return lunRequest;
+ }
/**
* Ensures ONTAP returned a usable LUN identity from create/clone. Callers in the datastore
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..27e3dd5d3660 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,14 @@ 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";
+
// 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..714c9c760115 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
@@ -24,9 +24,13 @@
import com.cloud.hypervisor.Hypervisor;
import com.cloud.storage.ScopeType;
import com.cloud.storage.Storage;
+import com.cloud.storage.SnapshotVO;
import com.cloud.storage.VMTemplateStoragePoolVO;
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;
@@ -43,6 +47,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;
@@ -101,6 +106,12 @@ class OntapPrimaryDatastoreDriverTest {
@Mock
private VolumeDetailsDao volumeDetailsDao;
+ @Mock
+ private SnapshotDetailsDao snapshotDetailsDao;
+
+ @Mock
+ private SnapshotDao snapshotDao;
+
@Mock
private VMTemplatePoolDao vmTemplatePoolDao;
@@ -948,6 +959,293 @@ 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.cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString()))
+ .thenReturn(cloned);
+
+ driver.createAsync(dataStore, volumeInfo, createCallback);
+
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class);
+ verify(createCallback).complete(resultCaptor.capture());
+ assertTrue(resultCaptor.getValue().isSuccess());
+
+ verify(sanStrategy).cloneCloudStackVolumeFromSnapshot(
+ eq(storagePool), any(), eq(volumeInfo), eq("/vol/vol1/source_lun"), eq("snap_cs200"));
+ verify(sanStrategy, never()).cloneCloudStackVolume(any());
+ 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.cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString()))
+ .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.cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString()))
+ .thenReturn(cloned);
+
+ driver.createAsync(dataStore, volumeInfo, createCallback);
+
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class);
+ verify(createCallback).complete(resultCaptor.capture());
+ assertTrue(resultCaptor.getValue().isSuccess());
+
+ verify(nasStrategy).cloneCloudStackVolumeFromSnapshot(
+ eq(storagePool), any(), eq(volumeInfo), eq("source-file-uuid"), eq("snap_cs200"));
+ 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()).cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString());
+ }
+ }
+
+ @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()).cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString());
+ }
+ }
+
+ @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()).cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString());
+ }
+ }
+
+ @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.cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString()))
+ .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.cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString()))
+ .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: snapshot id is resolved first; template is only consulted when snapshot is absent.
+ 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.cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString()))
+ .thenReturn(cloned);
+
+ driver.createAsync(dataStore, volumeInfo, createCallback);
+
+ verify(sanStrategy).cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString());
+ verify(volumeDetailsDao, never()).findDetail(100L, OntapStorageConstants.CLONE_OF_TEMPLATE);
+ verify(sanStrategy, never()).cloneCloudStackVolume(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.cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString()))
+ .thenReturn(cloned);
+
+ driver.createAsync(dataStore, volumeInfo, createCallback);
+
+ verify(nasStrategy).resizeCloudStackVolume(eq(cloned), eq(21474836480L));
+ verify(sanStrategy, never()).cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString());
+ }
+ }
+
+ @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.cloneCloudStackVolumeFromSnapshot(any(), any(), any(), anyString(), anyString()))
+ .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 +1266,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 +1701,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);
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java
index 2c516544cd49..ca8c52809391 100644
--- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java
@@ -26,6 +26,8 @@
import java.util.List;
import java.util.Map;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
import org.apache.cloudstack.storage.feign.client.AggregateFeignClient;
import org.apache.cloudstack.storage.feign.client.ClusterFeignClient;
import org.apache.cloudstack.storage.feign.client.JobFeignClient;
@@ -167,6 +169,15 @@ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume)
return null;
}
+ @Override
+ public CloudStackVolume cloneCloudStackVolumeFromSnapshot(StoragePoolVO storagePool,
+ Map details,
+ VolumeInfo volumeInfo,
+ String sourceVolumePath,
+ String snapshotName) {
+ return null;
+ }
+
@Override
public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes) {
}
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..a9f2b40aec7e 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
@@ -1017,6 +1017,130 @@ public void testCloneCloudStackVolume_Success() {
assertEquals("flexvol-uuid-1", captor.getValue().getVolume().getUuid());
}
+ @Test
+ public void testCloneCloudStackVolumeFromSnapshot_Success() {
+ VolumeObject volumeObject = mock(VolumeObject.class);
+ VolumeVO volumeVO = mock(VolumeVO.class);
+ StoragePoolVO storagePool = mock(StoragePoolVO.class);
+ when(volumeObject.getId()).thenReturn(100L);
+ when(volumeObject.getUuid()).thenReturn("new-volume-uuid");
+ when(storagePool.getId()).thenReturn(1L);
+ when(volumeDao.findById(100L)).thenReturn(volumeVO);
+ when(volumeDao.update(anyLong(), any(VolumeVO.class))).thenReturn(true);
+
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.SVM_NAME, "svm1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1");
+ details.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-1");
+
+ when(nasFeignClient.cloneFile(anyString(), any(FileCloneRequest.class))).thenReturn(new JobResponse());
+
+ CloudStackVolume result = strategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeObject, "source-file-uuid", "snap_cs200");
+
+ assertNotNull(result);
+ assertEquals("new-volume-uuid", result.getFile().getPath());
+ assertEquals("snap_cs200", result.getSnapshotName());
+ ArgumentCaptor captor = ArgumentCaptor.forClass(FileCloneRequest.class);
+ verify(nasFeignClient).cloneFile(anyString(), captor.capture());
+ assertEquals("source-file-uuid", captor.getValue().getSourcePath());
+ assertEquals("new-volume-uuid", captor.getValue().getDestinationPath());
+ assertNotNull(captor.getValue().getSnapshot());
+ assertEquals("snap_cs200", captor.getValue().getSnapshot().getName());
+ }
+
+ @Test
+ public void testCloneCloudStackVolumeFromSnapshot_MissingSnapshotName_Throws() {
+ StoragePoolVO storagePool = mock(StoragePoolVO.class);
+ VolumeObject volumeObject = mock(VolumeObject.class);
+ when(volumeObject.getUuid()).thenReturn("new-volume-uuid");
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1");
+
+ assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeObject, "source-file-uuid", null));
+ verify(nasFeignClient, never()).cloneFile(anyString(), any(FileCloneRequest.class));
+ }
+
+ @Test
+ public void testCloneCloudStackVolumeFromSnapshot_MissingSourcePath_Throws() {
+ StoragePoolVO storagePool = mock(StoragePoolVO.class);
+ VolumeObject volumeObject = mock(VolumeObject.class);
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1");
+
+ assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeObject, null, "snap_cs200"));
+ verify(nasFeignClient, never()).cloneFile(anyString(), any(FileCloneRequest.class));
+ }
+
+ @Test
+ public void testCloneCloudStackVolumeFromSnapshot_MissingFlexVolUuid_Throws() {
+ StoragePoolVO storagePool = mock(StoragePoolVO.class);
+ VolumeObject volumeObject = mock(VolumeObject.class);
+ when(volumeObject.getUuid()).thenReturn("new-volume-uuid");
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1");
+
+ assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeObject, "source-file-uuid", "snap_cs200"));
+ verify(nasFeignClient, never()).cloneFile(anyString(), any(FileCloneRequest.class));
+ }
+
+ @Test
+ public void testCloneCloudStackVolumeFromSnapshot_NullArgs_Throws() {
+ assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolumeFromSnapshot(
+ null, new HashMap<>(), mock(VolumeObject.class), "src", "snap"));
+ verify(nasFeignClient, never()).cloneFile(anyString(), any(FileCloneRequest.class));
+ }
+
+ @Test
+ public void testCloneCloudStackVolumeFromSnapshot_AbsoluteSourcePath_StrippedToRelative() {
+ VolumeObject volumeObject = mock(VolumeObject.class);
+ VolumeVO volumeVO = mock(VolumeVO.class);
+ StoragePoolVO storagePool = mock(StoragePoolVO.class);
+ when(volumeObject.getId()).thenReturn(100L);
+ when(volumeObject.getUuid()).thenReturn("new-volume-uuid");
+ when(storagePool.getId()).thenReturn(1L);
+ 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(nasFeignClient.cloneFile(anyString(), any(FileCloneRequest.class))).thenReturn(new JobResponse());
+
+ strategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeObject, "/vol/flexvol1/source-file-uuid", "snap_cs200");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(FileCloneRequest.class);
+ verify(nasFeignClient).cloneFile(anyString(), captor.capture());
+ assertEquals("source-file-uuid", captor.getValue().getSourcePath());
+ assertNotNull(captor.getValue().getSnapshot());
+ }
+
+ @Test
+ public void testCloneCloudStackVolumeFromSnapshot_FeignException_Throws() {
+ VolumeObject volumeObject = mock(VolumeObject.class);
+ StoragePoolVO storagePool = mock(StoragePoolVO.class);
+ when(volumeObject.getUuid()).thenReturn("new-volume-uuid");
+
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.VOLUME_NAME, "flexvol1");
+ details.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-1");
+
+ FeignException feignException = mock(FeignException.class);
+ when(feignException.status()).thenReturn(500);
+ when(feignException.getMessage()).thenReturn("clone failed");
+ when(nasFeignClient.cloneFile(anyString(), any(FileCloneRequest.class))).thenThrow(feignException);
+
+ assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeObject, "source-file-uuid", "snap_cs200"));
+ }
+
@Test
public void testCloneCloudStackVolume_InvalidRequest_ThrowsException() {
assertThrows(CloudRuntimeException.class, () -> strategy.cloneCloudStackVolume(null));
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..701cf3e16a17 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,212 @@ void testCloneCloudStackVolume_MissingSource_ThrowsException() {
() -> unifiedSANStrategy.cloneCloudStackVolume(request));
}
+ @Test
+ void testCloneCloudStackVolumeFromSnapshot_Success() {
+ org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool =
+ mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class);
+ when(storagePool.getName()).thenReturn("vol1");
+
+ org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo volumeInfo =
+ mock(org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo.class);
+ when(volumeInfo.getName()).thenReturn("new_lun");
+
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.SVM_NAME, "svm1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "vol1");
+
+ 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, org.mockito.Mockito.CALLS_REAL_METHODS)) {
+ utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password"))
+ .thenReturn(authHeader);
+
+ when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenReturn(response);
+
+ CloudStackVolume result = unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, "/vol/vol1/source_lun", "snap_cs200");
+
+ assertNotNull(result);
+ 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());
+ assertEquals("/vol/vol1/new_lun", lunCaptor.getValue().getName());
+ verify(nasFeignClient, never()).cloneFile(any(), any());
+ }
+ }
+
+ @Test
+ void testCloneCloudStackVolumeFromSnapshot_MissingSnapshotName_Throws() {
+ org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool =
+ mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class);
+ org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo volumeInfo =
+ mock(org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo.class);
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.SVM_NAME, "svm1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "vol1");
+
+ assertThrows(CloudRuntimeException.class,
+ () -> unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, "/vol/vol1/source_lun", null));
+ verify(sanFeignClient, never()).createLun(any(), anyBoolean(), any());
+ }
+
+ @Test
+ void testCloneCloudStackVolumeFromSnapshot_MissingSourcePath_Throws() {
+ org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool =
+ mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class);
+ org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo volumeInfo =
+ mock(org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo.class);
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.SVM_NAME, "svm1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "vol1");
+
+ assertThrows(CloudRuntimeException.class,
+ () -> unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, null, "snap_cs200"));
+ assertThrows(CloudRuntimeException.class,
+ () -> unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, "", "snap_cs200"));
+ verify(sanFeignClient, never()).createLun(any(), anyBoolean(), any());
+ }
+
+ @Test
+ void testCloneCloudStackVolumeFromSnapshot_NullArgs_Throws() {
+ assertThrows(CloudRuntimeException.class,
+ () -> unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ null, new HashMap<>(),
+ mock(org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo.class),
+ "/vol/vol1/source_lun", "snap"));
+ verify(sanFeignClient, never()).createLun(any(), anyBoolean(), any());
+ }
+
+ @Test
+ void testCloneCloudStackVolumeFromSnapshot_RelativeSourcePath_BuildsSnapshotQualifiedName() {
+ org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool =
+ mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class);
+ when(storagePool.getName()).thenReturn("vol1");
+
+ org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo volumeInfo =
+ mock(org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo.class);
+ when(volumeInfo.getName()).thenReturn("dest-lun");
+
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.SVM_NAME, "svm1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "vol1");
+
+ Lun createdLun = new Lun();
+ createdLun.setName("/vol/vol1/dest_lun");
+ createdLun.setUuid("new-lun-uuid");
+ OntapResponse response = new OntapResponse<>();
+ response.setRecords(List.of(createdLun));
+
+ try (MockedStatic utilityMock =
+ mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) {
+ utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password"))
+ .thenReturn(authHeader);
+ when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenReturn(response);
+
+ unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, "source_lun", "snap_cs200");
+
+ 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());
+ assertEquals("/vol/vol1/dest_lun", lunCaptor.getValue().getName());
+ }
+ }
+
+ @Test
+ void testCloneCloudStackVolumeFromSnapshot_EmptyRecords_Throws() {
+ org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool =
+ mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class);
+ when(storagePool.getName()).thenReturn("vol1");
+ org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo volumeInfo =
+ mock(org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo.class);
+ when(volumeInfo.getName()).thenReturn("new_lun");
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.SVM_NAME, "svm1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "vol1");
+
+ OntapResponse empty = new OntapResponse<>();
+ empty.setRecords(List.of());
+
+ try (MockedStatic utilityMock =
+ mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) {
+ utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password"))
+ .thenReturn(authHeader);
+ when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenReturn(empty);
+
+ assertThrows(CloudRuntimeException.class,
+ () -> unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, "/vol/vol1/source_lun", "snap_cs200"));
+ }
+ }
+
+ @Test
+ void testCloneCloudStackVolumeFromSnapshot_IncompleteLun_Throws() {
+ org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool =
+ mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class);
+ when(storagePool.getName()).thenReturn("vol1");
+ org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo volumeInfo =
+ mock(org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo.class);
+ when(volumeInfo.getName()).thenReturn("new_lun");
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.SVM_NAME, "svm1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "vol1");
+
+ Lun incomplete = new Lun();
+ incomplete.setName("/vol/vol1/new_lun");
+ OntapResponse response = new OntapResponse<>();
+ response.setRecords(List.of(incomplete));
+
+ try (MockedStatic utilityMock =
+ mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) {
+ utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password"))
+ .thenReturn(authHeader);
+ when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenReturn(response);
+
+ assertThrows(CloudRuntimeException.class,
+ () -> unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, "/vol/vol1/source_lun", "snap_cs200"));
+ }
+ }
+
+ @Test
+ void testCloneCloudStackVolumeFromSnapshot_FeignException_Throws() {
+ org.apache.cloudstack.storage.datastore.db.StoragePoolVO storagePool =
+ mock(org.apache.cloudstack.storage.datastore.db.StoragePoolVO.class);
+ when(storagePool.getName()).thenReturn("vol1");
+ org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo volumeInfo =
+ mock(org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo.class);
+ when(volumeInfo.getName()).thenReturn("new_lun");
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.SVM_NAME, "svm1");
+ details.put(OntapStorageConstants.VOLUME_NAME, "vol1");
+
+ FeignException feignException = mock(FeignException.class);
+ when(feignException.status()).thenReturn(500);
+ when(feignException.getMessage()).thenReturn("clone failed");
+
+ try (MockedStatic utilityMock =
+ mockStatic(OntapStorageUtils.class, org.mockito.Mockito.CALLS_REAL_METHODS)) {
+ utilityMock.when(() -> OntapStorageUtils.generateAuthHeader("admin", "password"))
+ .thenReturn(authHeader);
+ when(sanFeignClient.createLun(eq(authHeader), eq(true), any(Lun.class))).thenThrow(feignException);
+
+ assertThrows(CloudRuntimeException.class,
+ () -> unifiedSANStrategy.cloneCloudStackVolumeFromSnapshot(
+ storagePool, details, volumeInfo, "/vol/vol1/source_lun", "snap_cs200"));
+ }
+ }
+
@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"));
+ }
}