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):

+ * + * + *

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, AsyncCompletionCallbackUsed 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):

+ * + * + * @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")); + } }