From f42552baac9c420c47554506cac6b9a08f66d9a9 Mon Sep 17 00:00:00 2001 From: "Jain, Rajiv" Date: Thu, 19 Feb 2026 19:53:33 +0530 Subject: [PATCH 1/7] CSTACKEX-18_2: NFS3 snapshot changes --- .../driver/OntapPrimaryDatastoreDriver.java | 170 +++++++++++++++++- .../storage/feign/client/NASFeignClient.java | 7 + .../storage/feign/model/FileClone.java | 51 ++++++ .../storage/feign/model/VolumeConcise.java | 43 +++++ .../storage/service/StorageStrategy.java | 15 +- .../storage/service/UnifiedNASStrategy.java | 91 +++++++++- .../storage/service/UnifiedSANStrategy.java | 5 + .../service/model/CloudStackVolume.java | 29 +++ .../cloudstack/storage/utils/Constants.java | 6 + .../storage/service/StorageStrategyTest.java | 5 + 10 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileClone.java create mode 100644 plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeConcise.java 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 9ab57dc60a62..016b526d24f4 100755 --- 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 @@ -29,6 +29,8 @@ import com.cloud.storage.Volume; import com.cloud.storage.VolumeVO; import com.cloud.storage.ScopeType; +import com.cloud.storage.dao.SnapshotDetailsDao; +import com.cloud.storage.dao.SnapshotDetailsVO; import com.cloud.storage.dao.VolumeDao; import com.cloud.storage.dao.VolumeDetailsDao; import com.cloud.utils.Pair; @@ -47,9 +49,11 @@ import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.framework.async.AsyncCompletionCallback; import org.apache.cloudstack.storage.command.CommandResult; +import org.apache.cloudstack.storage.command.CreateObjectAnswer; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.feign.model.FileInfo; import org.apache.cloudstack.storage.feign.model.Lun; import org.apache.cloudstack.storage.service.SANStrategy; import org.apache.cloudstack.storage.service.StorageStrategy; @@ -57,8 +61,10 @@ import org.apache.cloudstack.storage.service.model.AccessGroup; import org.apache.cloudstack.storage.service.model.CloudStackVolume; import org.apache.cloudstack.storage.service.model.ProtocolType; +import org.apache.cloudstack.storage.to.SnapshotObjectTO; import org.apache.cloudstack.storage.utils.Constants; import org.apache.cloudstack.storage.utils.Utility; +import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -80,14 +86,16 @@ public class OntapPrimaryDatastoreDriver implements PrimaryDataStoreDriver { @Inject private VMInstanceDao vmDao; @Inject private VolumeDao volumeDao; @Inject private VolumeDetailsDao volumeDetailsDao; + @Inject private SnapshotDetailsDao snapshotDetailsDao; + @Override public Map getCapabilities() { s_logger.trace("OntapPrimaryDatastoreDriver: getCapabilities: Called"); Map mapCapabilities = new HashMap<>(); // RAW managed initial implementation: snapshot features not yet supported // TODO Set it to false once we start supporting snapshot feature - mapCapabilities.put(DataStoreCapabilities.STORAGE_SYSTEM_SNAPSHOT.toString(), Boolean.FALSE.toString()); - mapCapabilities.put(DataStoreCapabilities.CAN_CREATE_VOLUME_FROM_SNAPSHOT.toString(), Boolean.FALSE.toString()); + mapCapabilities.put(DataStoreCapabilities.STORAGE_SYSTEM_SNAPSHOT.toString(), Boolean.TRUE.toString()); + mapCapabilities.put(DataStoreCapabilities.CAN_CREATE_VOLUME_FROM_SNAPSHOT.toString(), Boolean.TRUE.toString()); return mapCapabilities; } @@ -525,6 +533,81 @@ public long getUsedIops(StoragePool storagePool) { @Override public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback callback) { + CreateCmdResult result; + + try { + VolumeInfo volumeInfo = snapshot.getBaseVolume(); + + VolumeVO volumeVO = volumeDao.findById(volumeInfo.getId()); + if(volumeVO == null) { + throw new CloudRuntimeException("takeSnapshot: VolumeVO not found for id: " + volumeInfo.getId()); + } + + /** we are keeping file path at volumeVO.getPath() */ + + StoragePoolVO storagePool = storagePoolDao.findById(volumeVO.getPoolId()); + if(storagePool == null) { + s_logger.error("takeSnapshot : Storage Pool not found for id: " + volumeVO.getPoolId()); + throw new CloudRuntimeException("takeSnapshot : Storage Pool not found for id: " + volumeVO.getPoolId()); + } + Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(volumeVO.getPoolId()); + StorageStrategy storageStrategy = Utility.getStrategyByStoragePoolDetails(poolDetails); + + Map cloudStackVolumeRequestMap = new HashMap<>(); + cloudStackVolumeRequestMap.put(Constants.VOLUME_UUID, poolDetails.get(Constants.VOLUME_UUID)); + cloudStackVolumeRequestMap.put(Constants.FILE_PATH, volumeVO.getPath()); + CloudStackVolume cloudStackVolume = storageStrategy.getCloudStackVolume(cloudStackVolumeRequestMap); + if (cloudStackVolume == null || cloudStackVolume.getFile() == null) { + throw new CloudRuntimeException("takeSnapshot: Failed to get source file to take snapshot"); + } + long capacityBytes = storagePool.getCapacityBytes(); + + long usedBytes = getUsedBytes(storagePool); + long fileSize = cloudStackVolume.getFile().getSize(); + + usedBytes += fileSize; + + if (usedBytes > capacityBytes) { + throw new CloudRuntimeException("Insufficient space remains in this primary storage to take a snapshot"); + } + + storagePool.setUsedBytes(usedBytes); + + SnapshotObjectTO snapshotObjectTo = (SnapshotObjectTO)snapshot.getTO(); + + String fileSnapshotName = volumeInfo.getName() + "-" + snapshot.getUuid(); + + int maxSnapshotNameLength = 64; + int trimRequired = fileSnapshotName.length() - maxSnapshotNameLength; + + if (trimRequired > 0) { + fileSnapshotName = StringUtils.left(volumeInfo.getName(), (volumeInfo.getName().length() - trimRequired)) + "-" + snapshot.getUuid(); + } + + CloudStackVolume snapCloudStackVolumeRequest = snapshotCloudStackVolumeRequestByProtocol(poolDetails, volumeVO.getPath(), fileSnapshotName); + CloudStackVolume cloneCloudStackVolume = storageStrategy.snapshotCloudStackVolume(snapCloudStackVolumeRequest); + + updateSnapshotDetails(snapshot.getId(), volumeInfo.getId(), poolDetails.get(Constants.VOLUME_UUID), cloneCloudStackVolume.getFile().getPath(), volumeVO.getPoolId(), fileSize); + + snapshotObjectTo.setPath(Constants.ONTAP_SNAP_ID +"="+cloneCloudStackVolume.getFile().getPath()); + + /** Update size for the storage-pool including snapshot size */ + storagePoolDao.update(volumeVO.getPoolId(), storagePool); + + CreateObjectAnswer createObjectAnswer = new CreateObjectAnswer(snapshotObjectTo); + + result = new CreateCmdResult(null, createObjectAnswer); + + result.setResult(null); + } + catch (Exception ex) { + s_logger.error("takeSnapshot: Failed due to ", ex); + result = new CreateCmdResult(null, new CreateObjectAnswer(ex.toString())); + + result.setResult(ex.toString()); + } + + callback.complete(result); } @Override @@ -622,4 +705,87 @@ private CloudStackVolume createDeleteCloudStackVolumeRequest(StoragePool storage return cloudStackVolumeDeleteRequest; } + + private CloudStackVolume getCloudStackVolumeRequestByProtocol(Map details, String filePath) { + CloudStackVolume cloudStackVolumeRequest = null; + ProtocolType protocolType = null; + String protocol = null; + + try { + protocol = details.get(Constants.PROTOCOL); + protocolType = ProtocolType.valueOf(protocol); + } catch (IllegalArgumentException e) { + throw new CloudRuntimeException("getCloudStackVolumeRequestByProtocol: Protocol: "+ protocol +" is not valid"); + } + switch (protocolType) { + case NFS3: + cloudStackVolumeRequest = new CloudStackVolume(); + FileInfo fileInfo = new FileInfo(); + fileInfo.setPath(filePath); + cloudStackVolumeRequest.setFile(fileInfo); + String volumeUuid = details.get(Constants.VOLUME_UUID); + cloudStackVolumeRequest.setFlexVolumeUuid(volumeUuid); + break; + default: + throw new CloudRuntimeException("createCloudStackVolumeRequestByProtocol: Unsupported protocol " + protocol); + } + return cloudStackVolumeRequest; + } + + private CloudStackVolume snapshotCloudStackVolumeRequestByProtocol(Map details, + String sourcePath, + String destinationPath) { + CloudStackVolume cloudStackVolumeRequest = null; + ProtocolType protocolType = null; + String protocol = null; + + try { + protocol = details.get(Constants.PROTOCOL); + protocolType = ProtocolType.valueOf(protocol); + } catch (IllegalArgumentException e) { + throw new CloudRuntimeException("getCloudStackVolumeRequestByProtocol: Protocol: "+ protocol +" is not valid"); + } + switch (protocolType) { + case NFS3: + cloudStackVolumeRequest = new CloudStackVolume(); + FileInfo fileInfo = new FileInfo(); + fileInfo.setPath(sourcePath); + cloudStackVolumeRequest.setFile(fileInfo); + String volumeUuid = details.get(Constants.VOLUME_UUID); + cloudStackVolumeRequest.setFlexVolumeUuid(volumeUuid); + cloudStackVolumeRequest.setDestinationPath(destinationPath); + break; + default: + throw new CloudRuntimeException("createCloudStackVolumeRequestByProtocol: Unsupported protocol " + protocol); + + } + return cloudStackVolumeRequest; + } + + /** + * + * @param csSnapshotId: generated snapshot id from cloudstack + * @param csVolumeId: Source CS volume id + * @param ontapVolumeUuid: storage flexvolume id + * @param ontapNewSnapshot: generated snapshot id from ONTAP + * @param storagePoolId: primary storage pool id + * @param ontapSnapSize: Size of snapshot CS volume(LUN/file) + */ + private void updateSnapshotDetails(long csSnapshotId, long csVolumeId, String ontapVolumeUuid, String ontapNewSnapshot, long storagePoolId, long ontapSnapSize) { + SnapshotDetailsVO snapshotDetail = new SnapshotDetailsVO(csSnapshotId, Constants.SRC_CS_VOLUME_ID, String.valueOf(csVolumeId), false); + snapshotDetailsDao.persist(snapshotDetail); + + snapshotDetail = new SnapshotDetailsVO(csSnapshotId, Constants.BASE_ONTAP_FV_ID, String.valueOf(ontapVolumeUuid), false); + snapshotDetailsDao.persist(snapshotDetail); + + snapshotDetail = new SnapshotDetailsVO(csSnapshotId, Constants.ONTAP_SNAP_ID, String.valueOf(ontapNewSnapshot), false); + snapshotDetailsDao.persist(snapshotDetail); + + snapshotDetail = new SnapshotDetailsVO(csSnapshotId, Constants.PRIMARY_POOL_ID, String.valueOf(storagePoolId), false); + snapshotDetailsDao.persist(snapshotDetail); + + snapshotDetail = new SnapshotDetailsVO(csSnapshotId, Constants.ONTAP_SNAP_SIZE, String.valueOf(ontapSnapSize), false); + snapshotDetailsDao.persist(snapshotDetail); + } + } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java index f48f83dc28de..8d4df6d8c4f1 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java @@ -21,7 +21,9 @@ import feign.QueryMap; import org.apache.cloudstack.storage.feign.model.ExportPolicy; +import org.apache.cloudstack.storage.feign.model.FileClone; import org.apache.cloudstack.storage.feign.model.FileInfo; +import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import feign.Headers; import feign.Param; @@ -58,6 +60,11 @@ void createFile(@Param("authHeader") String authHeader, @Param("path") String filePath, FileInfo file); + @RequestLine("POST /api/storage/volumes/{volumeUuid}/files/{path}") + @Headers({"Authorization: {authHeader}"}) + JobResponse cloneFile(@Param("authHeader") String authHeader, + FileClone fileClone); + // Export Policy Operations @RequestLine("POST /api/protocols/nfs/export-policies") @Headers({"Authorization: {authHeader}"}) diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileClone.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileClone.java new file mode 100644 index 000000000000..a117ec6e6a0b --- /dev/null +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileClone.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.storage.feign.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class FileClone { + @JsonProperty("source_path") + private String sourcePath; + @JsonProperty("destination_path") + private String destinationPath; + @JsonProperty("volume") + private VolumeConcise volume; + public VolumeConcise getVolume() { + return volume; + } + public void setVolume(VolumeConcise volume) { + this.volume = volume; + } + public String getSourcePath() { + return sourcePath; + } + public void setSourcePath(String sourcePath) { + this.sourcePath = sourcePath; + } + public String getDestinationPath() { + return destinationPath; + } + public void setDestinationPath(String destinationPath) {} +} diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeConcise.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeConcise.java new file mode 100644 index 000000000000..eaa5b2ed2ae9 --- /dev/null +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeConcise.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.storage.feign.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class VolumeConcise { + @JsonProperty("uuid") + private String uuid; + @JsonProperty("name") + private String name; + public String getUuid() { + return uuid; + } + public void setUuid(String uuid) { + this.uuid = uuid; + } + public String getName() { + return name; + } + public void setName(String name) {} +} 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 d9f98dcf7cb1..74270ffc4c9d 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 @@ -506,6 +506,19 @@ public String getNetworkInterface() { */ abstract public CloudStackVolume getCloudStackVolume(Map cloudStackVolumeMap); + /** + * Method encapsulates the behavior based on the opted protocol in subclasses. + * it is going to mimic + * snapshotLun for iSCSI, FC protocols + * snapshotFile for NFS3.0 and NFS4.1 protocols + * + * + * @param cloudstackVolume the source CloudStack volume + * @return the retrieved snapshot CloudStackVolume object + */ + public abstract CloudStackVolume snapshotCloudStackVolume(CloudStackVolume cloudstackVolume); + + /** * Method encapsulates the behavior based on the opted protocol in subclasses * createiGroup for iSCSI and FC protocols @@ -569,7 +582,7 @@ public String getNetworkInterface() { */ abstract public Map getLogicalAccess(Map values); - private Boolean jobPollForSuccess(String jobUUID, int maxRetries, int sleepTimeInSecs) { + protected Boolean jobPollForSuccess(String jobUUID, int maxRetries, int sleepTimeInSecs) { //Create URI for GET Job API int jobRetryCount = 0; Job jobResp = null; 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 c2aa4e462d2f..9af8acc7edeb 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 @@ -39,12 +39,14 @@ import org.apache.cloudstack.storage.feign.client.VolumeFeignClient; import org.apache.cloudstack.storage.feign.model.ExportPolicy; import org.apache.cloudstack.storage.feign.model.ExportRule; +import org.apache.cloudstack.storage.feign.model.FileClone; import org.apache.cloudstack.storage.feign.model.FileInfo; import org.apache.cloudstack.storage.feign.model.Job; import org.apache.cloudstack.storage.feign.model.Nas; import org.apache.cloudstack.storage.feign.model.OntapStorage; import org.apache.cloudstack.storage.feign.model.Svm; import org.apache.cloudstack.storage.feign.model.Volume; +import org.apache.cloudstack.storage.feign.model.VolumeConcise; import org.apache.cloudstack.storage.feign.model.response.JobResponse; import org.apache.cloudstack.storage.feign.model.response.OntapResponse; import org.apache.cloudstack.storage.service.model.AccessGroup; @@ -134,7 +136,72 @@ public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) { @Override public CloudStackVolume getCloudStackVolume(Map cloudStackVolumeMap) { - return null; + s_logger.info("getCloudStackVolume: Get cloudstack volume " + cloudStackVolumeMap); + CloudStackVolume cloudStackVolume = null; + FileInfo fileInfo = getFile(cloudStackVolumeMap.get(Constants.VOLUME_UUID),cloudStackVolumeMap.get(Constants.FILE_PATH)); + + if(fileInfo != null){ + cloudStackVolume = new CloudStackVolume(); + cloudStackVolume.setFlexVolumeUuid(cloudStackVolumeMap.get(Constants.VOLUME_UUID)); + cloudStackVolume.setFile(fileInfo); + } else { + s_logger.warn("getCloudStackVolume: File not found for volume UUID: {} and file path: {}", cloudStackVolumeMap.get(Constants.VOLUME_UUID), cloudStackVolumeMap.get(Constants.FILE_PATH)); + } + + return cloudStackVolume; + } + + @Override + public CloudStackVolume snapshotCloudStackVolume(CloudStackVolume cloudstackVolumeArg) { + s_logger.info("snapshotCloudStackVolume: Get cloudstack volume " + cloudstackVolumeArg); + CloudStackVolume cloudStackVolume = null; + String authHeader = Utility.generateAuthHeader(storage.getUsername(), storage.getPassword()); + JobResponse jobResponse = null; + + FileClone fileClone = new FileClone(); + VolumeConcise volumeConcise = new VolumeConcise(); + volumeConcise.setUuid(cloudstackVolumeArg.getFlexVolumeUuid()); + fileClone.setVolume(volumeConcise); + + fileClone.setSourcePath(cloudstackVolumeArg.getFile().getPath()); + fileClone.setDestinationPath(cloudstackVolumeArg.getDestinationPath()); + + try { + /** Clone file call to storage */ + jobResponse = nasFeignClient.cloneFile(authHeader, fileClone); + if (jobResponse == null || jobResponse.getJob() == null) { + throw new CloudRuntimeException("Failed to initiate file clone" + cloudstackVolumeArg.getFile().getPath()); + } + String jobUUID = jobResponse.getJob().getUuid(); + + /** Create URI for GET Job API */ + Boolean jobSucceeded = jobPollForSuccess(jobUUID,3,2); + if (!jobSucceeded) { + s_logger.error("File clone failed: " + cloudstackVolumeArg.getFile().getPath()); + throw new CloudRuntimeException("File clone failed: " + cloudstackVolumeArg.getFile().getPath()); + } + s_logger.info("File clone job completed successfully for file: " + cloudstackVolumeArg.getFile().getPath()); + + } catch (FeignException e) { + s_logger.error("Failed to clone file response: " + cloudstackVolumeArg.getFile().getPath(), e); + throw new CloudRuntimeException("File not found: " + e.getMessage()); + } catch (Exception e) { + s_logger.error("Exception to get file: {}", cloudstackVolumeArg.getFile().getPath(), e); + throw new CloudRuntimeException("Failed to get the file: " + e.getMessage()); + } + + FileInfo clonedFileInfo = null; + try { + /** Get cloned file call from storage */ + clonedFileInfo = getFile(cloudstackVolumeArg.getFlexVolumeUuid(), cloudstackVolumeArg.getDestinationPath()); + } catch (Exception e) { + s_logger.error("Exception to get cloned file: {}", cloudstackVolumeArg.getDestinationPath(), e); + throw new CloudRuntimeException("Failed to get the cloned file: " + e.getMessage()); + } + cloudStackVolume = new CloudStackVolume(); + cloudStackVolume.setFlexVolumeUuid(cloudstackVolumeArg.getFlexVolumeUuid()); + cloudStackVolume.setFile(clonedFileInfo); + return cloudStackVolume; } @Override @@ -548,4 +615,26 @@ private Answer deleteVolumeOnKVMHost(DataObject volumeInfo) { return new Answer(null, false, e.toString()); } } + + private FileInfo getFile(String volumeUuid, String filePath) { + s_logger.info("Get File: {} for volume: {}", filePath, volumeUuid); + + String authHeader = Utility.generateAuthHeader(storage.getUsername(), storage.getPassword()); + OntapResponse fileResponse = null; + try { + fileResponse = nasFeignClient.getFileResponse(authHeader, volumeUuid, filePath); + if (fileResponse == null || fileResponse.getRecords().isEmpty()) { + throw new CloudRuntimeException("File " + filePath + " not not found on ONTAP. " + + "Received successful response but file does not exist."); + } + } catch (FeignException e) { + s_logger.error("Failed to get file response: " + filePath, e); + throw new CloudRuntimeException("File not found: " + e.getMessage()); + } catch (Exception e) { + s_logger.error("Exception to get file: {}", filePath, e); + throw new CloudRuntimeException("Failed to get the file: " + e.getMessage()); + } + s_logger.info("File retrieved successfully with name {}", filePath); + return fileResponse.getRecords().get(0); + } } 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 c42e5cb6f516..1fd591aea2d9 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 @@ -202,6 +202,11 @@ public CloudStackVolume getCloudStackVolume(Map values) { } } + @Override + public CloudStackVolume snapshotCloudStackVolume(CloudStackVolume cloudstackVolume) { + return null; + } + @Override public AccessGroup createAccessGroup(AccessGroup accessGroup) { s_logger.info("createAccessGroup : Create Igroup"); 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 6c51e4630800..3edf02000cf2 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 @@ -25,9 +25,28 @@ public class CloudStackVolume { + /** + * Filed used for request: + * a. snapshot workflows will get source file details from it. + */ private FileInfo file; + + /** + * Filed used for request: + * a. snapshot workflows will get source LUN details from it. + */ private Lun lun; private String datastoreId; + /** + * FlexVolume UUID on which this cloudstack volume is created. + * a. Field is eligible for unified storage only. + * b. It will be null for the disaggregated storage. + */ + private String flexVolumeUuid; + /** + * Field serves for snapshot workflows + */ + private String destinationPath; private DataObject volumeInfo; // This is needed as we need DataObject to be passed to agent to create volume public FileInfo getFile() { return file; @@ -56,4 +75,14 @@ public DataObject getVolumeInfo() { public void setVolumeInfo(DataObject volumeInfo) { this.volumeInfo = volumeInfo; } + public String getFlexVolumeUuid() { + return flexVolumeUuid; + } + public void setFlexVolumeUuid(String flexVolumeUuid) { + this.flexVolumeUuid = flexVolumeUuid; + } + + public String getDestinationPath() { return this.destinationPath; } + public void setDestinationPath(String destinationPath) { this.destinationPath = destinationPath; } + } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/Constants.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/Constants.java index 5e8729ad1917..2474ad2598b6 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/Constants.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/Constants.java @@ -90,4 +90,10 @@ public class Constants { public static final String IGROUP_DOT_UUID = "igroup.uuid"; public static final String UNDERSCORE = "_"; public static final String CS = "cs"; + public static final String SRC_CS_VOLUME_ID = "src_cs_volume_id"; + public static final String BASE_ONTAP_FV_ID = "base_ontap_fv_id"; + public static final String ONTAP_SNAP_ID = "ontap_snap_id"; + public static final String PRIMARY_POOL_ID = "primary_pool_id"; + public static final String ONTAP_SNAP_SIZE = "ontap_snap_size"; + public static final String FILE_PATH = "file_path"; } 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 467c01b2c995..f606cc7c04e6 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 @@ -146,6 +146,11 @@ public CloudStackVolume getCloudStackVolume( return null; } + @Override + public CloudStackVolume snapshotCloudStackVolume(CloudStackVolume cloudstackVolume) { + return null; + } + @Override public AccessGroup createAccessGroup( org.apache.cloudstack.storage.service.model.AccessGroup accessGroup) { From 8894248c47c52150cae9c0427fc94da91b5ce15a Mon Sep 17 00:00:00 2001 From: "Jain, Rajiv" Date: Thu, 19 Feb 2026 21:10:52 +0530 Subject: [PATCH 2/7] CSTACK-18_2: fixing junit dependent changes --- .../storage/driver/OntapPrimaryDatastoreDriverTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d050d379563c..9318516d82b0 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 @@ -135,8 +135,8 @@ void testGetCapabilities() { Map capabilities = driver.getCapabilities(); assertNotNull(capabilities); - assertEquals(Boolean.FALSE.toString(), capabilities.get("STORAGE_SYSTEM_SNAPSHOT")); - assertEquals(Boolean.FALSE.toString(), capabilities.get("CAN_CREATE_VOLUME_FROM_SNAPSHOT")); + assertEquals(Boolean.TRUE.toString(), capabilities.get("STORAGE_SYSTEM_SNAPSHOT")); + assertEquals(Boolean.TRUE.toString(), capabilities.get("CAN_CREATE_VOLUME_FROM_SNAPSHOT")); } @Test From 3f0019a0042b63af85a8e987a8a21780dea22402 Mon Sep 17 00:00:00 2001 From: "Jain, Rajiv" Date: Fri, 20 Feb 2026 11:33:20 +0530 Subject: [PATCH 3/7] STACK-18_2: fixes --- .../driver/OntapPrimaryDatastoreDriver.java | 4 ++-- .../storage/feign/client/NASFeignClient.java | 4 ++-- .../storage/feign/model/FileClone.java | 4 +++- .../cloudstack/storage/feign/model/FileInfo.java | 16 ---------------- 4 files changed, 7 insertions(+), 21 deletions(-) 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 016b526d24f4..c36c467ff806 100755 --- 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 @@ -532,7 +532,7 @@ public long getUsedIops(StoragePool storagePool) { @Override public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback callback) { - + s_logger.error("temp takeSnapshot : entered with snapshot id: " + snapshot.getId() + " and name: " + snapshot.getName()); CreateCmdResult result; try { @@ -561,7 +561,7 @@ public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback getFileResponse(@Param("authHeader") String authHeader, @Param("volumeUuid") String volumeUUID, @@ -60,7 +60,7 @@ void createFile(@Param("authHeader") String authHeader, @Param("path") String filePath, FileInfo file); - @RequestLine("POST /api/storage/volumes/{volumeUuid}/files/{path}") + @RequestLine("POST /api/storage/file/clone") @Headers({"Authorization: {authHeader}"}) JobResponse cloneFile(@Param("authHeader") String authHeader, FileClone fileClone); diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileClone.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileClone.java index a117ec6e6a0b..99bb0d99a28d 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileClone.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileClone.java @@ -47,5 +47,7 @@ public void setSourcePath(String sourcePath) { public String getDestinationPath() { return destinationPath; } - public void setDestinationPath(String destinationPath) {} + public void setDestinationPath(String destinationPath) { + this.destinationPath = destinationPath; + } } diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java index 181620268932..a5dd24a3a286 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java @@ -25,7 +25,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; -import java.time.OffsetDateTime; import java.util.Objects; /** @@ -36,8 +35,6 @@ public class FileInfo { @JsonProperty("bytes_used") private Long bytesUsed = null; - @JsonProperty("creation_time") - private OffsetDateTime creationTime = null; @JsonProperty("fill_enabled") private Boolean fillEnabled = null; @JsonProperty("is_empty") @@ -46,8 +43,6 @@ public class FileInfo { private Boolean isSnapshot = null; @JsonProperty("is_vm_aligned") private Boolean isVmAligned = null; - @JsonProperty("modified_time") - private OffsetDateTime modifiedTime = null; @JsonProperty("name") private String name = null; @JsonProperty("overwrite_enabled") @@ -110,10 +105,6 @@ public Long getBytesUsed() { return bytesUsed; } - public OffsetDateTime getCreationTime() { - return creationTime; - } - public FileInfo fillEnabled(Boolean fillEnabled) { this.fillEnabled = fillEnabled; return this; @@ -149,11 +140,6 @@ public Boolean isIsVmAligned() { return isVmAligned; } - - public OffsetDateTime getModifiedTime() { - return modifiedTime; - } - public FileInfo name(String name) { this.name = name; return this; @@ -266,12 +252,10 @@ public String toString() { StringBuilder sb = new StringBuilder(); sb.append("class FileInfo {\n"); sb.append(" bytesUsed: ").append(toIndentedString(bytesUsed)).append("\n"); - sb.append(" creationTime: ").append(toIndentedString(creationTime)).append("\n"); sb.append(" fillEnabled: ").append(toIndentedString(fillEnabled)).append("\n"); sb.append(" isEmpty: ").append(toIndentedString(isEmpty)).append("\n"); sb.append(" isSnapshot: ").append(toIndentedString(isSnapshot)).append("\n"); sb.append(" isVmAligned: ").append(toIndentedString(isVmAligned)).append("\n"); - sb.append(" modifiedTime: ").append(toIndentedString(modifiedTime)).append("\n"); sb.append(" name: ").append(toIndentedString(name)).append("\n"); sb.append(" overwriteEnabled: ").append(toIndentedString(overwriteEnabled)).append("\n"); sb.append(" path: ").append(toIndentedString(path)).append("\n"); From 9b79f4689d2c6cb9a138ae656299eb339ccb2de7 Mon Sep 17 00:00:00 2001 From: "Jain, Rajiv" Date: Fri, 20 Feb 2026 14:12:55 +0530 Subject: [PATCH 4/7] CSTACKEX-18_2: adding VM snapshot logic --- plugins/storage/volume/ontap/pom.xml | 10 + .../vmsnapshot/OntapVMSnapshotStrategy.java | 382 ++++++++ .../spring-storage-volume-ontap-context.xml | 3 + .../OntapVMSnapshotStrategyTest.java | 833 ++++++++++++++++++ 4 files changed, 1228 insertions(+) create mode 100644 plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java create mode 100644 plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java diff --git a/plugins/storage/volume/ontap/pom.xml b/plugins/storage/volume/ontap/pom.xml index 0a7f43bde6c9..3dddf318189c 100644 --- a/plugins/storage/volume/ontap/pom.xml +++ b/plugins/storage/volume/ontap/pom.xml @@ -84,6 +84,16 @@ cloud-engine-storage-volume ${project.version} + + org.apache.cloudstack + cloud-engine-storage-snapshot + ${project.version} + + + org.apache.cloudstack + cloud-server + ${project.version} + io.swagger swagger-annotations diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java new file mode 100644 index 000000000000..0f3d3e9f2966 --- /dev/null +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java @@ -0,0 +1,382 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.vmsnapshot; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; +import org.apache.cloudstack.engine.subsystem.api.storage.StrategyPriority; +import org.apache.cloudstack.engine.subsystem.api.storage.VMSnapshotOptions; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.storage.utils.Constants; +import org.apache.commons.collections.CollectionUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.agent.api.CreateVMSnapshotAnswer; +import com.cloud.agent.api.CreateVMSnapshotCommand; +import com.cloud.agent.api.FreezeThawVMAnswer; +import com.cloud.agent.api.FreezeThawVMCommand; +import com.cloud.agent.api.VMSnapshotTO; +import com.cloud.event.EventTypes; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.GuestOSVO; +import com.cloud.storage.VolumeVO; +import com.cloud.uservm.UserVm; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.fsm.NoTransitionException; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.snapshot.VMSnapshot; +import com.cloud.vm.snapshot.VMSnapshotDetailsVO; +import com.cloud.vm.snapshot.VMSnapshotVO; + +/** + * VM Snapshot strategy for NetApp ONTAP managed storage. + * + *

This strategy handles VM-level (instance) snapshots for VMs whose volumes + * reside on ONTAP managed primary storage using the NFS protocol. It uses the + * QEMU guest agent to freeze/thaw the VM file systems for consistency, and + * delegates per-volume snapshot creation to the existing CloudStack snapshot + * framework which routes to {@code StorageSystemSnapshotStrategy} → + * {@code OntapPrimaryDatastoreDriver.takeSnapshot()} (ONTAP file clone).

+ * + *

Flow:

+ *
    + *
  1. Freeze the VM via QEMU guest agent ({@code fsfreeze})
  2. + *
  3. For each attached volume, create a storage-level snapshot (ONTAP file clone)
  4. + *
  5. Thaw the VM
  6. + *
  7. Record VM snapshot ↔ volume snapshot mappings in {@code vm_snapshot_details}
  8. + *
+ * + *

Strategy Selection:

+ *

Returns {@code StrategyPriority.HIGHEST} when:

+ *
    + *
  • Hypervisor is KVM
  • + *
  • Snapshot type is Disk-only (no memory)
  • + *
  • All VM volumes are on ONTAP managed NFS primary storage
  • + *
+ */ +public class OntapVMSnapshotStrategy extends StorageVMSnapshotStrategy { + + private static final Logger logger = LogManager.getLogger(OntapVMSnapshotStrategy.class); + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + return super.configure(name, params); + } + + // ────────────────────────────────────────────────────────────────────────── + // Strategy Selection + // ────────────────────────────────────────────────────────────────────────── + + @Override + public StrategyPriority canHandle(VMSnapshot vmSnapshot) { + VMSnapshotVO vmSnapshotVO = (VMSnapshotVO) vmSnapshot; + + // For existing (non-Allocated) snapshots, check if we created them + if (!VMSnapshot.State.Allocated.equals(vmSnapshotVO.getState())) { + List vmSnapshotDetails = vmSnapshotDetailsDao.findDetails(vmSnapshot.getId(), STORAGE_SNAPSHOT); + if (CollectionUtils.isEmpty(vmSnapshotDetails)) { + return StrategyPriority.CANT_HANDLE; + } + // Verify the volumes are still on ONTAP storage + if (allVolumesOnOntapManagedStorage(vmSnapshot.getVmId())) { + return StrategyPriority.HIGHEST; + } + return StrategyPriority.CANT_HANDLE; + } + + // For new snapshots, check if Disk-only and all volumes on ONTAP + if (vmSnapshotVO.getType() != VMSnapshot.Type.Disk) { + logger.debug("ONTAP VM snapshot strategy cannot handle memory snapshots for VM [{}]", vmSnapshot.getVmId()); + return StrategyPriority.CANT_HANDLE; + } + + if (allVolumesOnOntapManagedStorage(vmSnapshot.getVmId())) { + return StrategyPriority.HIGHEST; + } + + return StrategyPriority.CANT_HANDLE; + } + + @Override + public StrategyPriority canHandle(Long vmId, Long rootPoolId, boolean snapshotMemory) { + if (snapshotMemory) { + logger.debug("ONTAP VM snapshot strategy cannot handle memory snapshots for VM [{}]", vmId); + return StrategyPriority.CANT_HANDLE; + } + + if (allVolumesOnOntapManagedStorage(vmId)) { + return StrategyPriority.HIGHEST; + } + + return StrategyPriority.CANT_HANDLE; + } + + /** + * Checks whether all volumes of a VM reside on ONTAP managed primary storage. + */ + private boolean allVolumesOnOntapManagedStorage(long vmId) { + UserVm userVm = userVmDao.findById(vmId); + if (userVm == null) { + logger.debug("VM with id [{}] not found", vmId); + return false; + } + + if (!Hypervisor.HypervisorType.KVM.equals(userVm.getHypervisorType())) { + logger.debug("ONTAP VM snapshot strategy only supports KVM hypervisor, VM [{}] uses [{}]", + vmId, userVm.getHypervisorType()); + return false; + } + + if (!VirtualMachine.State.Running.equals(userVm.getState())) { + logger.debug("ONTAP VM snapshot strategy requires a running VM, VM [{}] is in state [{}]", + vmId, userVm.getState()); + return false; + } + + List volumes = volumeDao.findByInstance(vmId); + if (volumes == null || volumes.isEmpty()) { + logger.debug("No volumes found for VM [{}]", vmId); + return false; + } + + for (VolumeVO volume : volumes) { + if (volume.getPoolId() == null) { + return false; + } + StoragePoolVO pool = storagePool.findById(volume.getPoolId()); + if (pool == null) { + return false; + } + if (!pool.isManaged()) { + logger.debug("Volume [{}] is on non-managed storage pool [{}], not ONTAP", + volume.getId(), pool.getName()); + return false; + } + if (!Constants.ONTAP_PLUGIN_NAME.equals(pool.getStorageProviderName())) { + logger.debug("Volume [{}] is on managed pool [{}] with provider [{}], not ONTAP", + volume.getId(), pool.getName(), pool.getStorageProviderName()); + return false; + } + } + + logger.debug("All volumes of VM [{}] are on ONTAP managed storage, this strategy can handle", vmId); + return true; + } + + // ────────────────────────────────────────────────────────────────────────── + // Take VM Snapshot + // ────────────────────────────────────────────────────────────────────────── + + /** + * Takes a VM-level snapshot by freezing the VM, creating per-volume snapshots + * on ONTAP storage (file clones), and then thawing the VM. + * + *

The quiesce option is always {@code true} for ONTAP to ensure filesystem + * consistency across all volumes. The QEMU guest agent must be installed and + * running inside the guest VM.

+ */ + @Override + public VMSnapshot takeVMSnapshot(VMSnapshot vmSnapshot) { + Long hostId = vmSnapshotHelper.pickRunningHost(vmSnapshot.getVmId()); + UserVm userVm = userVmDao.findById(vmSnapshot.getVmId()); + VMSnapshotVO vmSnapshotVO = (VMSnapshotVO) vmSnapshot; + + CreateVMSnapshotAnswer answer = null; + FreezeThawVMAnswer freezeAnswer = null; + FreezeThawVMCommand thawCmd = null; + FreezeThawVMAnswer thawAnswer = null; + List forRollback = new ArrayList<>(); + long startFreeze = 0; + + try { + vmSnapshotHelper.vmSnapshotStateTransitTo(vmSnapshotVO, VMSnapshot.Event.CreateRequested); + } catch (NoTransitionException e) { + throw new CloudRuntimeException(e.getMessage()); + } + + boolean result = false; + try { + GuestOSVO guestOS = guestOSDao.findById(userVm.getGuestOSId()); + List volumeTOs = vmSnapshotHelper.getVolumeTOList(userVm.getId()); + + long prev_chain_size = 0; + long virtual_size = 0; + + // Build snapshot parent chain + VMSnapshotTO current = null; + VMSnapshotVO currentSnapshot = vmSnapshotDao.findCurrentSnapshotByVmId(userVm.getId()); + if (currentSnapshot != null) { + current = vmSnapshotHelper.getSnapshotWithParents(currentSnapshot); + } + + // For ONTAP managed NFS, always quiesce the VM for filesystem consistency + boolean quiescevm = true; + VMSnapshotOptions options = vmSnapshotVO.getOptions(); + if (options != null && !options.needQuiesceVM()) { + logger.info("Quiesce option was set to false, but overriding to true for ONTAP managed storage " + + "to ensure filesystem consistency across all volumes"); + } + + VMSnapshotTO target = new VMSnapshotTO(vmSnapshot.getId(), vmSnapshot.getName(), + vmSnapshot.getType(), null, vmSnapshot.getDescription(), false, current, quiescevm); + + if (current == null) { + vmSnapshotVO.setParent(null); + } else { + vmSnapshotVO.setParent(current.getId()); + } + + CreateVMSnapshotCommand ccmd = new CreateVMSnapshotCommand( + userVm.getInstanceName(), userVm.getUuid(), target, volumeTOs, guestOS.getDisplayName()); + + logger.info("Creating ONTAP VM Snapshot for VM [{}] with quiesce=true", userVm.getInstanceName()); + + // Prepare volume info list + List volumeInfos = new ArrayList<>(); + for (VolumeObjectTO volumeObjectTO : volumeTOs) { + volumeInfos.add(volumeDataFactory.getVolume(volumeObjectTO.getId())); + virtual_size += volumeObjectTO.getSize(); + VolumeVO volumeVO = volumeDao.findById(volumeObjectTO.getId()); + prev_chain_size += volumeVO.getVmSnapshotChainSize() == null ? 0 : volumeVO.getVmSnapshotChainSize(); + } + + // ── Step 1: Freeze the VM ── + FreezeThawVMCommand freezeCommand = new FreezeThawVMCommand(userVm.getInstanceName()); + freezeCommand.setOption(FreezeThawVMCommand.FREEZE); + freezeAnswer = (FreezeThawVMAnswer) agentMgr.send(hostId, freezeCommand); + startFreeze = System.nanoTime(); + + thawCmd = new FreezeThawVMCommand(userVm.getInstanceName()); + thawCmd.setOption(FreezeThawVMCommand.THAW); + + if (freezeAnswer == null || !freezeAnswer.getResult()) { + String detail = (freezeAnswer != null) ? freezeAnswer.getDetails() : "no response from agent"; + throw new CloudRuntimeException("Could not freeze VM [" + userVm.getInstanceName() + + "] for ONTAP snapshot. Ensure qemu-guest-agent is installed and running. Details: " + detail); + } + + logger.info("VM [{}] frozen successfully via QEMU guest agent", userVm.getInstanceName()); + + // ── Step 2: Create per-volume snapshots (ONTAP file clones) ── + try { + for (VolumeInfo vol : volumeInfos) { + long startSnapshot = System.nanoTime(); + + SnapshotInfo snapInfo = createDiskSnapshot(vmSnapshot, forRollback, vol); + + if (snapInfo == null) { + throw new CloudRuntimeException("Could not take ONTAP snapshot for volume id=" + vol.getId()); + } + + logger.info("ONTAP snapshot for volume [{}] (id={}) completed in {} ms", + vol.getName(), vol.getId(), + TimeUnit.MILLISECONDS.convert(System.nanoTime() - startSnapshot, TimeUnit.NANOSECONDS)); + } + } finally { + // ── Step 3: Thaw the VM (always, even on error) ── + try { + thawAnswer = (FreezeThawVMAnswer) agentMgr.send(hostId, thawCmd); + if (thawAnswer != null && thawAnswer.getResult()) { + logger.info("VM [{}] thawed successfully. Total freeze duration: {} ms", + userVm.getInstanceName(), + TimeUnit.MILLISECONDS.convert(System.nanoTime() - startFreeze, TimeUnit.NANOSECONDS)); + } else { + logger.warn("Failed to thaw VM [{}]: {}", userVm.getInstanceName(), + (thawAnswer != null) ? thawAnswer.getDetails() : "no response"); + } + } catch (Exception thawEx) { + logger.error("Exception while thawing VM [{}]: {}", userVm.getInstanceName(), thawEx.getMessage(), thawEx); + } + } + + // ── Step 4: Finalize ── + answer = new CreateVMSnapshotAnswer(ccmd, true, ""); + answer.setVolumeTOs(volumeTOs); + + processAnswer(vmSnapshotVO, userVm, answer, null); + logger.info("ONTAP VM Snapshot [{}] created successfully for VM [{}]", + vmSnapshot.getName(), userVm.getInstanceName()); + + long new_chain_size = 0; + for (VolumeObjectTO volumeTo : answer.getVolumeTOs()) { + publishUsageEvent(EventTypes.EVENT_VM_SNAPSHOT_CREATE, vmSnapshot, userVm, volumeTo); + new_chain_size += volumeTo.getSize(); + } + publishUsageEvent(EventTypes.EVENT_VM_SNAPSHOT_ON_PRIMARY, vmSnapshot, userVm, + new_chain_size - prev_chain_size, virtual_size); + + result = true; + return vmSnapshot; + + } catch (OperationTimedoutException e) { + logger.error("ONTAP VM Snapshot [{}] timed out: {}", vmSnapshot.getName(), e.getMessage()); + throw new CloudRuntimeException("Creating Instance Snapshot: " + vmSnapshot.getName() + " timed out: " + e.getMessage()); + } catch (AgentUnavailableException e) { + logger.error("ONTAP VM Snapshot [{}] failed, agent unavailable: {}", vmSnapshot.getName(), e.getMessage()); + throw new CloudRuntimeException("Creating Instance Snapshot: " + vmSnapshot.getName() + " failed: " + e.getMessage()); + } catch (CloudRuntimeException e) { + throw e; + } finally { + if (!result) { + // Rollback all disk snapshots created so far + for (SnapshotInfo snapshotInfo : forRollback) { + try { + rollbackDiskSnapshot(snapshotInfo); + } catch (Exception rollbackEx) { + logger.error("Failed to rollback snapshot [{}]: {}", snapshotInfo.getId(), rollbackEx.getMessage()); + } + } + + // Ensure VM is thawed if we haven't done so + if (thawAnswer == null && freezeAnswer != null && freezeAnswer.getResult()) { + try { + logger.info("Thawing VM [{}] during error cleanup", userVm.getInstanceName()); + thawAnswer = (FreezeThawVMAnswer) agentMgr.send(hostId, thawCmd); + } catch (Exception ex) { + logger.error("Could not thaw VM during cleanup: {}", ex.getMessage()); + } + } + + // Clean up VM snapshot details and transition state + try { + List vmSnapshotDetails = vmSnapshotDetailsDao.listDetails(vmSnapshot.getId()); + for (VMSnapshotDetailsVO detail : vmSnapshotDetails) { + if (STORAGE_SNAPSHOT.equals(detail.getName())) { + vmSnapshotDetailsDao.remove(detail.getId()); + } + } + vmSnapshotHelper.vmSnapshotStateTransitTo(vmSnapshot, VMSnapshot.Event.OperationFailed); + } catch (NoTransitionException e1) { + logger.error("Cannot set VM Snapshot state to OperationFailed: {}", e1.getMessage()); + } + } + } + } +} diff --git a/plugins/storage/volume/ontap/src/main/resources/META-INF/cloudstack/storage-volume-ontap/spring-storage-volume-ontap-context.xml b/plugins/storage/volume/ontap/src/main/resources/META-INF/cloudstack/storage-volume-ontap/spring-storage-volume-ontap-context.xml index 6ab9c46fcf9d..bb907871469c 100644 --- a/plugins/storage/volume/ontap/src/main/resources/META-INF/cloudstack/storage-volume-ontap/spring-storage-volume-ontap-context.xml +++ b/plugins/storage/volume/ontap/src/main/resources/META-INF/cloudstack/storage-volume-ontap/spring-storage-volume-ontap-context.xml @@ -30,4 +30,7 @@ + + diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java new file mode 100644 index 000000000000..b755c5db0e55 --- /dev/null +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java @@ -0,0 +1,833 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cloudstack.storage.vmsnapshot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo; +import org.apache.cloudstack.engine.subsystem.api.storage.StrategyPriority; +import org.apache.cloudstack.engine.subsystem.api.storage.VMSnapshotOptions; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.storage.utils.Constants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.FreezeThawVMAnswer; +import com.cloud.agent.api.FreezeThawVMCommand; +import com.cloud.agent.api.VMSnapshotTO; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.GuestOSVO; +import com.cloud.storage.VolumeVO; +import com.cloud.storage.dao.GuestOSDao; +import com.cloud.storage.dao.VolumeDao; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.fsm.NoTransitionException; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.snapshot.VMSnapshot; +import com.cloud.vm.snapshot.VMSnapshotDetailsVO; +import com.cloud.vm.snapshot.VMSnapshotVO; +import com.cloud.vm.snapshot.dao.VMSnapshotDao; +import com.cloud.vm.snapshot.dao.VMSnapshotDetailsDao; + +/** + * Unit tests for {@link OntapVMSnapshotStrategy}. + * + *

Tests cover: + *

    + *
  • canHandle(VMSnapshot) — various conditions for Allocated and non-Allocated states
  • + *
  • canHandle(Long vmId, Long rootPoolId, boolean snapshotMemory) — allocation-phase checks
  • + *
  • takeVMSnapshot — success path with freeze/thaw and per-volume snapshot
  • + *
  • takeVMSnapshot — failure scenarios (freeze failure, disk snapshot failure, agent errors)
  • + *
  • Quiesce override behavior (always true for ONTAP)
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class OntapVMSnapshotStrategyTest { + + private static final long VM_ID = 100L; + private static final long HOST_ID = 10L; + private static final long SNAPSHOT_ID = 200L; + private static final long VOLUME_ID_1 = 301L; + private static final long VOLUME_ID_2 = 302L; + private static final long POOL_ID_1 = 401L; + private static final long POOL_ID_2 = 402L; + private static final long GUEST_OS_ID = 50L; + private static final String VM_INSTANCE_NAME = "i-2-100-VM"; + private static final String VM_UUID = "vm-uuid-123"; + + @Spy + private OntapVMSnapshotStrategy strategy; + + @Mock + private UserVmDao userVmDao; + @Mock + private VolumeDao volumeDao; + @Mock + private PrimaryDataStoreDao storagePool; + @Mock + private VMSnapshotDetailsDao vmSnapshotDetailsDao; + @Mock + private VMSnapshotHelper vmSnapshotHelper; + @Mock + private VMSnapshotDao vmSnapshotDao; + @Mock + private AgentManager agentMgr; + @Mock + private GuestOSDao guestOSDao; + @Mock + private VolumeDataFactory volumeDataFactory; + + @BeforeEach + void setUp() throws Exception { + // Inject mocks into the inherited fields via reflection + // DefaultVMSnapshotStrategy fields + setField(strategy, DefaultVMSnapshotStrategy.class, "vmSnapshotHelper", vmSnapshotHelper); + setField(strategy, DefaultVMSnapshotStrategy.class, "guestOSDao", guestOSDao); + setField(strategy, DefaultVMSnapshotStrategy.class, "userVmDao", userVmDao); + setField(strategy, DefaultVMSnapshotStrategy.class, "vmSnapshotDao", vmSnapshotDao); + setField(strategy, DefaultVMSnapshotStrategy.class, "agentMgr", agentMgr); + setField(strategy, DefaultVMSnapshotStrategy.class, "volumeDao", volumeDao); + + // StorageVMSnapshotStrategy fields + setField(strategy, StorageVMSnapshotStrategy.class, "storagePool", storagePool); + setField(strategy, StorageVMSnapshotStrategy.class, "vmSnapshotDetailsDao", vmSnapshotDetailsDao); + setField(strategy, StorageVMSnapshotStrategy.class, "volumeDataFactory", volumeDataFactory); + } + + // ────────────────────────────────────────────────────────────────────────── + // Helper: inject field via reflection into a specific declaring class + // ────────────────────────────────────────────────────────────────────────── + + private void setField(Object target, Class declaringClass, String fieldName, Object value) throws Exception { + Field field = declaringClass.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + // ────────────────────────────────────────────────────────────────────────── + // Helper: create common mocks + // ────────────────────────────────────────────────────────────────────────── + + private UserVmVO createMockUserVm(Hypervisor.HypervisorType hypervisorType, VirtualMachine.State state) { + UserVmVO userVm = mock(UserVmVO.class); + when(userVm.getHypervisorType()).thenReturn(hypervisorType); + when(userVm.getState()).thenReturn(state); + return userVm; + } + + private VolumeVO createMockVolume(long volumeId, long poolId) { + VolumeVO volume = mock(VolumeVO.class); + when(volume.getId()).thenReturn(volumeId); + when(volume.getPoolId()).thenReturn(poolId); + return volume; + } + + private StoragePoolVO createOntapManagedPool(long poolId) { + StoragePoolVO pool = mock(StoragePoolVO.class); + when(pool.isManaged()).thenReturn(true); + when(pool.getStorageProviderName()).thenReturn(Constants.ONTAP_PLUGIN_NAME); + return pool; + } + + private VMSnapshotVO createMockVmSnapshot(VMSnapshot.State state, VMSnapshot.Type type) { + VMSnapshotVO vmSnapshot = mock(VMSnapshotVO.class); + when(vmSnapshot.getId()).thenReturn(SNAPSHOT_ID); + when(vmSnapshot.getVmId()).thenReturn(VM_ID); + when(vmSnapshot.getState()).thenReturn(state); + when(vmSnapshot.getType()).thenReturn(type); + return vmSnapshot; + } + + private void setupAllVolumesOnOntap() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + VolumeVO vol1 = createMockVolume(VOLUME_ID_1, POOL_ID_1); + VolumeVO vol2 = createMockVolume(VOLUME_ID_2, POOL_ID_2); + when(volumeDao.findByInstance(VM_ID)).thenReturn(Arrays.asList(vol1, vol2)); + + StoragePoolVO pool1 = createOntapManagedPool(POOL_ID_1); + StoragePoolVO pool2 = createOntapManagedPool(POOL_ID_2); + when(storagePool.findById(POOL_ID_1)).thenReturn(pool1); + when(storagePool.findById(POOL_ID_2)).thenReturn(pool2); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Tests: canHandle(VMSnapshot) + // ══════════════════════════════════════════════════════════════════════════ + + @Test + void testCanHandle_AllocatedDiskType_AllVolumesOnOntap_ReturnsHighest() { + setupAllVolumesOnOntap(); + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.HIGHEST, result); + } + + @Test + void testCanHandle_AllocatedDiskAndMemoryType_ReturnsCantHandle() { + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.DiskAndMemory); + when(vmSnapshot.getVmId()).thenReturn(VM_ID); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_AllocatedDiskType_VmNotFound_ReturnsCantHandle() { + when(userVmDao.findById(VM_ID)).thenReturn(null); + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_AllocatedDiskType_VmxenHypervisor_ReturnsCantHandle() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.XenServer, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_AllocatedDiskType_VmNotRunning_ReturnsCantHandle() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Stopped); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_AllocatedDiskType_NoVolumes_ReturnsCantHandle() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + when(volumeDao.findByInstance(VM_ID)).thenReturn(Collections.emptyList()); + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_AllocatedDiskType_VolumeOnNonManagedPool_ReturnsCantHandle() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + VolumeVO vol = createMockVolume(VOLUME_ID_1, POOL_ID_1); + when(volumeDao.findByInstance(VM_ID)).thenReturn(Collections.singletonList(vol)); + + StoragePoolVO pool = mock(StoragePoolVO.class); + when(pool.isManaged()).thenReturn(false); + when(pool.getName()).thenReturn("non-managed-pool"); + when(storagePool.findById(POOL_ID_1)).thenReturn(pool); + + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_AllocatedDiskType_VolumeOnNonOntapManagedPool_ReturnsCantHandle() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + VolumeVO vol = createMockVolume(VOLUME_ID_1, POOL_ID_1); + when(volumeDao.findByInstance(VM_ID)).thenReturn(Collections.singletonList(vol)); + + StoragePoolVO pool = mock(StoragePoolVO.class); + when(pool.isManaged()).thenReturn(true); + when(pool.getStorageProviderName()).thenReturn("SolidFire"); + when(pool.getName()).thenReturn("solidfire-pool"); + when(storagePool.findById(POOL_ID_1)).thenReturn(pool); + + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_AllocatedDiskType_VolumeWithNullPoolId_ReturnsCantHandle() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + VolumeVO vol = mock(VolumeVO.class); + when(vol.getPoolId()).thenReturn(null); + when(volumeDao.findByInstance(VM_ID)).thenReturn(Collections.singletonList(vol)); + + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_AllocatedDiskType_PoolNotFound_ReturnsCantHandle() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + VolumeVO vol = createMockVolume(VOLUME_ID_1, POOL_ID_1); + when(volumeDao.findByInstance(VM_ID)).thenReturn(Collections.singletonList(vol)); + when(storagePool.findById(POOL_ID_1)).thenReturn(null); + + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_NonAllocated_HasStorageSnapshotDetails_AllOnOntap_ReturnsHighest() { + setupAllVolumesOnOntap(); + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Ready, VMSnapshot.Type.Disk); + + List details = new ArrayList<>(); + details.add(new VMSnapshotDetailsVO(SNAPSHOT_ID, "kvmStorageSnapshot", "123", true)); + when(vmSnapshotDetailsDao.findDetails(SNAPSHOT_ID, "kvmStorageSnapshot")).thenReturn(details); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.HIGHEST, result); + } + + @Test + void testCanHandle_NonAllocated_NoStorageSnapshotDetails_ReturnsCantHandle() { + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Ready, VMSnapshot.Type.Disk); + when(vmSnapshotDetailsDao.findDetails(SNAPSHOT_ID, "kvmStorageSnapshot")).thenReturn(Collections.emptyList()); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_NonAllocated_HasDetails_NotOnOntap_ReturnsCantHandle() { + // VM has details but volumes are now on non-ONTAP storage + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + VolumeVO vol = createMockVolume(VOLUME_ID_1, POOL_ID_1); + when(volumeDao.findByInstance(VM_ID)).thenReturn(Collections.singletonList(vol)); + + StoragePoolVO pool = mock(StoragePoolVO.class); + when(pool.isManaged()).thenReturn(false); + when(pool.getName()).thenReturn("other-pool"); + when(storagePool.findById(POOL_ID_1)).thenReturn(pool); + + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Ready, VMSnapshot.Type.Disk); + List details = new ArrayList<>(); + details.add(new VMSnapshotDetailsVO(SNAPSHOT_ID, "kvmStorageSnapshot", "123", true)); + when(vmSnapshotDetailsDao.findDetails(SNAPSHOT_ID, "kvmStorageSnapshot")).thenReturn(details); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandle_MixedPools_OneOntapOneNot_ReturnsCantHandle() { + UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Running); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + VolumeVO vol1 = createMockVolume(VOLUME_ID_1, POOL_ID_1); + VolumeVO vol2 = createMockVolume(VOLUME_ID_2, POOL_ID_2); + when(volumeDao.findByInstance(VM_ID)).thenReturn(Arrays.asList(vol1, vol2)); + + StoragePoolVO ontapPool = createOntapManagedPool(POOL_ID_1); + StoragePoolVO otherPool = mock(StoragePoolVO.class); + when(otherPool.isManaged()).thenReturn(true); + when(otherPool.getStorageProviderName()).thenReturn("SolidFire"); + when(otherPool.getName()).thenReturn("sf-pool"); + when(storagePool.findById(POOL_ID_1)).thenReturn(ontapPool); + when(storagePool.findById(POOL_ID_2)).thenReturn(otherPool); + + VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk); + + StrategyPriority result = strategy.canHandle(vmSnapshot); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Tests: canHandle(Long vmId, Long rootPoolId, boolean snapshotMemory) + // ══════════════════════════════════════════════════════════════════════════ + + @Test + void testCanHandleByVmId_MemorySnapshot_ReturnsCantHandle() { + StrategyPriority result = strategy.canHandle(VM_ID, POOL_ID_1, true); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + @Test + void testCanHandleByVmId_DiskOnly_AllOnOntap_ReturnsHighest() { + setupAllVolumesOnOntap(); + + StrategyPriority result = strategy.canHandle(VM_ID, POOL_ID_1, false); + + assertEquals(StrategyPriority.HIGHEST, result); + } + + @Test + void testCanHandleByVmId_DiskOnly_NotOnOntap_ReturnsCantHandle() { + when(userVmDao.findById(VM_ID)).thenReturn(null); + + StrategyPriority result = strategy.canHandle(VM_ID, POOL_ID_1, false); + + assertEquals(StrategyPriority.CANT_HANDLE, result); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Tests: takeVMSnapshot — Success + // ══════════════════════════════════════════════════════════════════════════ + + @Test + void testTakeVMSnapshot_Success_SingleVolume() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + + VolumeObjectTO volumeTO = mock(VolumeObjectTO.class); + when(volumeTO.getId()).thenReturn(VOLUME_ID_1); + when(volumeTO.getSize()).thenReturn(10737418240L); // 10GB + List volumeTOs = Collections.singletonList(volumeTO); + when(vmSnapshotHelper.getVolumeTOList(VM_ID)).thenReturn(volumeTOs); + + VolumeVO volumeVO = mock(VolumeVO.class); + when(volumeVO.getVmSnapshotChainSize()).thenReturn(null); + when(volumeDao.findById(VOLUME_ID_1)).thenReturn(volumeVO); + + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getId()).thenReturn(VOLUME_ID_1); + when(volumeInfo.getName()).thenReturn("vol-1"); + when(volumeDataFactory.getVolume(VOLUME_ID_1)).thenReturn(volumeInfo); + + // Freeze success + FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class); + when(freezeAnswer.getResult()).thenReturn(true); + // Thaw success + FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class); + when(thawAnswer.getResult()).thenReturn(true); + + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenReturn(freezeAnswer) + .thenReturn(thawAnswer); + + // createDiskSnapshot success + SnapshotInfo snapshotInfo = mock(SnapshotInfo.class); + doReturn(snapshotInfo).when(strategy).createDiskSnapshot(any(), any(), any()); + + // processAnswer - no-op + doNothing().when(strategy).processAnswer(any(), any(), any(), any()); + // publishUsageEvent - no-op + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), any(VolumeObjectTO.class)); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), anyLong(), anyLong()); + + VMSnapshot result = strategy.takeVMSnapshot(vmSnapshot); + + assertNotNull(result); + assertEquals(vmSnapshot, result); + + // Verify freeze and thaw were both called + verify(agentMgr, times(2)).send(eq(HOST_ID), any(FreezeThawVMCommand.class)); + // Verify disk snapshot was taken + verify(strategy).createDiskSnapshot(any(), any(), eq(volumeInfo)); + } + + @Test + void testTakeVMSnapshot_Success_MultipleVolumes() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + + VolumeObjectTO volumeTO1 = mock(VolumeObjectTO.class); + when(volumeTO1.getId()).thenReturn(VOLUME_ID_1); + when(volumeTO1.getSize()).thenReturn(10737418240L); + VolumeObjectTO volumeTO2 = mock(VolumeObjectTO.class); + when(volumeTO2.getId()).thenReturn(VOLUME_ID_2); + when(volumeTO2.getSize()).thenReturn(21474836480L); + + List volumeTOs = Arrays.asList(volumeTO1, volumeTO2); + when(vmSnapshotHelper.getVolumeTOList(VM_ID)).thenReturn(volumeTOs); + + VolumeVO volumeVO1 = mock(VolumeVO.class); + when(volumeVO1.getVmSnapshotChainSize()).thenReturn(0L); + VolumeVO volumeVO2 = mock(VolumeVO.class); + when(volumeVO2.getVmSnapshotChainSize()).thenReturn(0L); + when(volumeDao.findById(VOLUME_ID_1)).thenReturn(volumeVO1); + when(volumeDao.findById(VOLUME_ID_2)).thenReturn(volumeVO2); + + VolumeInfo volInfo1 = mock(VolumeInfo.class); + when(volInfo1.getId()).thenReturn(VOLUME_ID_1); + when(volInfo1.getName()).thenReturn("vol-1"); + VolumeInfo volInfo2 = mock(VolumeInfo.class); + when(volInfo2.getId()).thenReturn(VOLUME_ID_2); + when(volInfo2.getName()).thenReturn("vol-2"); + when(volumeDataFactory.getVolume(VOLUME_ID_1)).thenReturn(volInfo1); + when(volumeDataFactory.getVolume(VOLUME_ID_2)).thenReturn(volInfo2); + + FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class); + when(freezeAnswer.getResult()).thenReturn(true); + FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class); + when(thawAnswer.getResult()).thenReturn(true); + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenReturn(freezeAnswer) + .thenReturn(thawAnswer); + + SnapshotInfo snapInfo1 = mock(SnapshotInfo.class); + SnapshotInfo snapInfo2 = mock(SnapshotInfo.class); + doReturn(snapInfo1).doReturn(snapInfo2).when(strategy).createDiskSnapshot(any(), any(), any()); + + doNothing().when(strategy).processAnswer(any(), any(), any(), any()); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), any(VolumeObjectTO.class)); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), anyLong(), anyLong()); + + VMSnapshot result = strategy.takeVMSnapshot(vmSnapshot); + + assertNotNull(result); + // Verify both volumes were snapshotted + verify(strategy, times(2)).createDiskSnapshot(any(), any(), any()); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Tests: takeVMSnapshot — Failure Scenarios + // ══════════════════════════════════════════════════════════════════════════ + + @Test + void testTakeVMSnapshot_FreezeFailure_ThrowsException() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + // Freeze failure + FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class); + when(freezeAnswer.getResult()).thenReturn(false); + when(freezeAnswer.getDetails()).thenReturn("qemu-guest-agent not responding"); + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))).thenReturn(freezeAnswer); + + // Cleanup mocks for finally block + when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); + doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> strategy.takeVMSnapshot(vmSnapshot)); + + assertEquals(true, ex.getMessage().contains("Could not freeze VM")); + assertEquals(true, ex.getMessage().contains("qemu-guest-agent")); + // Verify no disk snapshots were attempted + verify(strategy, never()).createDiskSnapshot(any(), any(), any()); + } + + @Test + void testTakeVMSnapshot_FreezeReturnsNull_ThrowsException() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + // Freeze returns null + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))).thenReturn(null); + + when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); + doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + + assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot)); + } + + @Test + void testTakeVMSnapshot_DiskSnapshotFails_RollbackAndThaw() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + // Freeze success + FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class); + when(freezeAnswer.getResult()).thenReturn(true); + FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class); + when(thawAnswer.getResult()).thenReturn(true); + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenReturn(freezeAnswer) + .thenReturn(thawAnswer); + + // createDiskSnapshot returns null (failure) + doReturn(null).when(strategy).createDiskSnapshot(any(), any(), any()); + + // Cleanup mocks + when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); + doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + + assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot)); + + // Verify thaw was called (once in the try-finally for disk snapshots) + verify(agentMgr, times(2)).send(eq(HOST_ID), any(FreezeThawVMCommand.class)); + } + + @Test + void testTakeVMSnapshot_AgentUnavailable_ThrowsCloudRuntimeException() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenThrow(new AgentUnavailableException(HOST_ID)); + + when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); + doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> strategy.takeVMSnapshot(vmSnapshot)); + assertEquals(true, ex.getMessage().contains("failed")); + } + + @Test + void testTakeVMSnapshot_OperationTimeout_ThrowsCloudRuntimeException() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenThrow(new OperationTimedoutException(null, 0, 0, 0, false)); + + when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); + doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + + CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, + () -> strategy.takeVMSnapshot(vmSnapshot)); + assertEquals(true, ex.getMessage().contains("timed out")); + } + + @Test + void testTakeVMSnapshot_StateTransitionFails_ThrowsCloudRuntimeException() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + when(vmSnapshotHelper.pickRunningHost(VM_ID)).thenReturn(HOST_ID); + UserVmVO userVm = mock(UserVmVO.class); + when(userVm.getGuestOSId()).thenReturn(GUEST_OS_ID); + when(userVm.getId()).thenReturn(VM_ID); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + // State transition fails + doThrow(new NoTransitionException("Cannot transition")).when(vmSnapshotHelper) + .vmSnapshotStateTransitTo(vmSnapshot, VMSnapshot.Event.CreateRequested); + + assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot)); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Tests: Quiesce Override + // ══════════════════════════════════════════════════════════════════════════ + + @Test + void testTakeVMSnapshot_QuiesceOverriddenToTrue() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + // Explicitly set quiesce to false + VMSnapshotOptions options = mock(VMSnapshotOptions.class); + when(options.needQuiesceVM()).thenReturn(false); + when(vmSnapshot.getOptions()).thenReturn(options); + + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class); + when(freezeAnswer.getResult()).thenReturn(true); + FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class); + when(thawAnswer.getResult()).thenReturn(true); + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenReturn(freezeAnswer) + .thenReturn(thawAnswer); + + SnapshotInfo snapshotInfo = mock(SnapshotInfo.class); + doReturn(snapshotInfo).when(strategy).createDiskSnapshot(any(), any(), any()); + doNothing().when(strategy).processAnswer(any(), any(), any(), any()); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), any(VolumeObjectTO.class)); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), anyLong(), anyLong()); + + VMSnapshot result = strategy.takeVMSnapshot(vmSnapshot); + + // Snapshot should succeed even with quiesce=false, because ONTAP overrides to true + assertNotNull(result); + // The freeze command is always sent (quiesce=true) + verify(agentMgr, times(2)).send(eq(HOST_ID), any(FreezeThawVMCommand.class)); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Tests: Parent snapshot chain + // ══════════════════════════════════════════════════════════════════════════ + + @Test + void testTakeVMSnapshot_WithParentSnapshot() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + // Has a current (parent) snapshot + VMSnapshotVO currentSnapshot = mock(VMSnapshotVO.class); + when(vmSnapshotDao.findCurrentSnapshotByVmId(VM_ID)).thenReturn(currentSnapshot); + VMSnapshotTO parentTO = mock(VMSnapshotTO.class); + when(parentTO.getId()).thenReturn(199L); + when(vmSnapshotHelper.getSnapshotWithParents(currentSnapshot)).thenReturn(parentTO); + + FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class); + when(freezeAnswer.getResult()).thenReturn(true); + FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class); + when(thawAnswer.getResult()).thenReturn(true); + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenReturn(freezeAnswer) + .thenReturn(thawAnswer); + + SnapshotInfo snapshotInfo = mock(SnapshotInfo.class); + doReturn(snapshotInfo).when(strategy).createDiskSnapshot(any(), any(), any()); + doNothing().when(strategy).processAnswer(any(), any(), any(), any()); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), any(VolumeObjectTO.class)); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), anyLong(), anyLong()); + + VMSnapshot result = strategy.takeVMSnapshot(vmSnapshot); + + assertNotNull(result); + // Verify parent was set on the VM snapshot + verify(vmSnapshot).setParent(199L); + } + + @Test + void testTakeVMSnapshot_WithNoParentSnapshot() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + // No current snapshot + when(vmSnapshotDao.findCurrentSnapshotByVmId(VM_ID)).thenReturn(null); + + FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class); + when(freezeAnswer.getResult()).thenReturn(true); + FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class); + when(thawAnswer.getResult()).thenReturn(true); + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenReturn(freezeAnswer) + .thenReturn(thawAnswer); + + SnapshotInfo snapshotInfo = mock(SnapshotInfo.class); + doReturn(snapshotInfo).when(strategy).createDiskSnapshot(any(), any(), any()); + doNothing().when(strategy).processAnswer(any(), any(), any(), any()); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), any(VolumeObjectTO.class)); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), anyLong(), anyLong()); + + VMSnapshot result = strategy.takeVMSnapshot(vmSnapshot); + + assertNotNull(result); + verify(vmSnapshot).setParent(null); + } + + // ────────────────────────────────────────────────────────────────────────── + // Helper: Set up common mocks for takeVMSnapshot tests + // ────────────────────────────────────────────────────────────────────────── + + private VMSnapshotVO createTakeSnapshotVmSnapshot() { + VMSnapshotVO vmSnapshot = mock(VMSnapshotVO.class); + when(vmSnapshot.getId()).thenReturn(SNAPSHOT_ID); + when(vmSnapshot.getVmId()).thenReturn(VM_ID); + when(vmSnapshot.getName()).thenReturn("vm-snap-1"); + when(vmSnapshot.getType()).thenReturn(VMSnapshot.Type.Disk); + when(vmSnapshot.getDescription()).thenReturn("Test ONTAP VM Snapshot"); + when(vmSnapshot.getOptions()).thenReturn(new VMSnapshotOptions(true)); + return vmSnapshot; + } + + private UserVmVO setupTakeSnapshotCommon(VMSnapshotVO vmSnapshot) throws Exception { + when(vmSnapshotHelper.pickRunningHost(VM_ID)).thenReturn(HOST_ID); + + UserVmVO userVm = mock(UserVmVO.class); + when(userVm.getId()).thenReturn(VM_ID); + when(userVm.getGuestOSId()).thenReturn(GUEST_OS_ID); + when(userVm.getInstanceName()).thenReturn(VM_INSTANCE_NAME); + when(userVm.getUuid()).thenReturn(VM_UUID); + when(userVmDao.findById(VM_ID)).thenReturn(userVm); + + GuestOSVO guestOS = mock(GuestOSVO.class); + when(guestOS.getDisplayName()).thenReturn("CentOS 8"); + when(guestOSDao.findById(GUEST_OS_ID)).thenReturn(guestOS); + + when(vmSnapshotDao.findCurrentSnapshotByVmId(VM_ID)).thenReturn(null); + + doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(vmSnapshot, VMSnapshot.Event.CreateRequested); + + return userVm; + } + + private void setupSingleVolumeForTakeSnapshot() { + VolumeObjectTO volumeTO = mock(VolumeObjectTO.class); + when(volumeTO.getId()).thenReturn(VOLUME_ID_1); + when(volumeTO.getSize()).thenReturn(10737418240L); + List volumeTOs = Collections.singletonList(volumeTO); + when(vmSnapshotHelper.getVolumeTOList(VM_ID)).thenReturn(volumeTOs); + + VolumeVO volumeVO = mock(VolumeVO.class); + when(volumeVO.getVmSnapshotChainSize()).thenReturn(null); + when(volumeDao.findById(VOLUME_ID_1)).thenReturn(volumeVO); + + VolumeInfo volumeInfo = mock(VolumeInfo.class); + when(volumeInfo.getId()).thenReturn(VOLUME_ID_1); + when(volumeInfo.getName()).thenReturn("vol-1"); + when(volumeDataFactory.getVolume(VOLUME_ID_1)).thenReturn(volumeInfo); + } +} From 7a0d61e1187be3a67e213b215d6da96b292d22e2 Mon Sep 17 00:00:00 2001 From: "Jain, Rajiv" Date: Fri, 20 Feb 2026 14:30:46 +0530 Subject: [PATCH 5/7] CSTACKEX-18_2: fix junit issues --- .../OntapVMSnapshotStrategyTest.java | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java index b755c5db0e55..c0aaf40557eb 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java @@ -27,6 +27,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -54,6 +55,8 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import com.cloud.agent.AgentManager; import com.cloud.agent.api.FreezeThawVMAnswer; @@ -90,6 +93,7 @@ * */ @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class OntapVMSnapshotStrategyTest { private static final long VM_ID = 100L; @@ -182,7 +186,7 @@ private VMSnapshotVO createMockVmSnapshot(VMSnapshot.State state, VMSnapshot.Typ when(vmSnapshot.getId()).thenReturn(SNAPSHOT_ID); when(vmSnapshot.getVmId()).thenReturn(VM_ID); when(vmSnapshot.getState()).thenReturn(state); - when(vmSnapshot.getType()).thenReturn(type); + lenient().when(vmSnapshot.getType()).thenReturn(type); return vmSnapshot; } @@ -570,7 +574,7 @@ void testTakeVMSnapshot_FreezeFailure_ThrowsException() throws Exception { // Cleanup mocks for finally block when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); - doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot)); @@ -591,7 +595,7 @@ void testTakeVMSnapshot_FreezeReturnsNull_ThrowsException() throws Exception { when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))).thenReturn(null); when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); - doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot)); } @@ -616,7 +620,7 @@ void testTakeVMSnapshot_DiskSnapshotFails_RollbackAndThaw() throws Exception { // Cleanup mocks when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); - doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot)); @@ -634,7 +638,7 @@ void testTakeVMSnapshot_AgentUnavailable_ThrowsCloudRuntimeException() throws Ex .thenThrow(new AgentUnavailableException(HOST_ID)); when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); - doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot)); @@ -651,7 +655,7 @@ void testTakeVMSnapshot_OperationTimeout_ThrowsCloudRuntimeException() throws Ex .thenThrow(new OperationTimedoutException(null, 0, 0, 0, false)); when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList()); - doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); + doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed)); CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot)); @@ -663,8 +667,6 @@ void testTakeVMSnapshot_StateTransitionFails_ThrowsCloudRuntimeException() throw VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); when(vmSnapshotHelper.pickRunningHost(VM_ID)).thenReturn(HOST_ID); UserVmVO userVm = mock(UserVmVO.class); - when(userVm.getGuestOSId()).thenReturn(GUEST_OS_ID); - when(userVm.getId()).thenReturn(VM_ID); when(userVmDao.findById(VM_ID)).thenReturn(userVm); // State transition fails @@ -786,10 +788,10 @@ private VMSnapshotVO createTakeSnapshotVmSnapshot() { VMSnapshotVO vmSnapshot = mock(VMSnapshotVO.class); when(vmSnapshot.getId()).thenReturn(SNAPSHOT_ID); when(vmSnapshot.getVmId()).thenReturn(VM_ID); - when(vmSnapshot.getName()).thenReturn("vm-snap-1"); - when(vmSnapshot.getType()).thenReturn(VMSnapshot.Type.Disk); - when(vmSnapshot.getDescription()).thenReturn("Test ONTAP VM Snapshot"); - when(vmSnapshot.getOptions()).thenReturn(new VMSnapshotOptions(true)); + lenient().when(vmSnapshot.getName()).thenReturn("vm-snap-1"); + lenient().when(vmSnapshot.getType()).thenReturn(VMSnapshot.Type.Disk); + lenient().when(vmSnapshot.getDescription()).thenReturn("Test ONTAP VM Snapshot"); + lenient().when(vmSnapshot.getOptions()).thenReturn(new VMSnapshotOptions(true)); return vmSnapshot; } @@ -809,7 +811,7 @@ private UserVmVO setupTakeSnapshotCommon(VMSnapshotVO vmSnapshot) throws Excepti when(vmSnapshotDao.findCurrentSnapshotByVmId(VM_ID)).thenReturn(null); - doNothing().when(vmSnapshotHelper).vmSnapshotStateTransitTo(vmSnapshot, VMSnapshot.Event.CreateRequested); + doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(vmSnapshot, VMSnapshot.Event.CreateRequested); return userVm; } From 7c3419e5e548fc62241bc325a238516c6d0564dc Mon Sep 17 00:00:00 2001 From: "Jain, Rajiv" Date: Sat, 21 Feb 2026 14:21:51 +0530 Subject: [PATCH 6/7] CSTACKEX-18_2: fixes for vm snapshot workflow --- .../vmsnapshot/OntapVMSnapshotStrategy.java | 28 ++++++++++++++++ .../OntapVMSnapshotStrategyTest.java | 33 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java index 0f3d3e9f2966..160f1d259382 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java @@ -190,6 +190,34 @@ private boolean allVolumesOnOntapManagedStorage(long vmId) { return true; } + // ────────────────────────────────────────────────────────────────────────── + // Per-Volume Snapshot (quiesce override) + // ────────────────────────────────────────────────────────────────────────── + + /** + * Creates a per-volume disk snapshot as part of a VM snapshot operation. + * + *

Overrides the parent to ensure {@code quiescevm} is always {@code false} + * in the per-volume snapshot payload. ONTAP handles quiescing at the VM level + * via QEMU guest agent freeze/thaw in {@link #takeVMSnapshot}, so the + * individual volume snapshot must not request quiescing again. Without this + * override, {@link org.apache.cloudstack.storage.snapshot.DefaultSnapshotStrategy#takeSnapshot} + * would reject the request with "can't handle quiescevm equal true for volume snapshot" + * when the user selects the quiesce option in the UI.

+ */ + @Override + protected SnapshotInfo createDiskSnapshot(VMSnapshot vmSnapshot, List forRollback, VolumeInfo vol) { + // Temporarily override the quiesce option to false for the per-volume snapshot + VMSnapshotVO vmSnapshotVO = (VMSnapshotVO) vmSnapshot; + VMSnapshotOptions originalOptions = vmSnapshotVO.getOptions(); + try { + vmSnapshotVO.setOptions(new VMSnapshotOptions(false)); + return super.createDiskSnapshot(vmSnapshot, forRollback, vol); + } finally { + vmSnapshotVO.setOptions(originalOptions); + } + } + // ────────────────────────────────────────────────────────────────────────── // Take VM Snapshot // ────────────────────────────────────────────────────────────────────────── diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java index c0aaf40557eb..7aa32f6c83bd 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java @@ -713,6 +713,39 @@ void testTakeVMSnapshot_QuiesceOverriddenToTrue() throws Exception { verify(agentMgr, times(2)).send(eq(HOST_ID), any(FreezeThawVMCommand.class)); } + @Test + void testTakeVMSnapshot_WithQuiesceTrue_SucceedsWithoutPayloadRejection() throws Exception { + VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot(); + // Explicitly set quiesce to TRUE — this is the scenario that was failing + VMSnapshotOptions options = new VMSnapshotOptions(true); + when(vmSnapshot.getOptions()).thenReturn(options); + + setupTakeSnapshotCommon(vmSnapshot); + setupSingleVolumeForTakeSnapshot(); + + FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class); + when(freezeAnswer.getResult()).thenReturn(true); + FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class); + when(thawAnswer.getResult()).thenReturn(true); + when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class))) + .thenReturn(freezeAnswer) + .thenReturn(thawAnswer); + + SnapshotInfo snapshotInfo = mock(SnapshotInfo.class); + doReturn(snapshotInfo).when(strategy).createDiskSnapshot(any(), any(), any()); + doNothing().when(strategy).processAnswer(any(), any(), any(), any()); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), any(VolumeObjectTO.class)); + doNothing().when(strategy).publishUsageEvent(any(String.class), any(VMSnapshot.class), any(), anyLong(), anyLong()); + + VMSnapshot result = strategy.takeVMSnapshot(vmSnapshot); + + // Snapshot should succeed with quiesce=true because ONTAP overrides quiesce + // to false in the per-volume createDiskSnapshot payload (freeze/thaw is at VM level) + assertNotNull(result); + verify(agentMgr, times(2)).send(eq(HOST_ID), any(FreezeThawVMCommand.class)); + verify(strategy).createDiskSnapshot(any(), any(), any()); + } + // ══════════════════════════════════════════════════════════════════════════ // Tests: Parent snapshot chain // ══════════════════════════════════════════════════════════════════════════ From d2b6a27368d38a265886f784f10897a3369ea35b Mon Sep 17 00:00:00 2001 From: "Jain, Rajiv" Date: Sat, 21 Feb 2026 16:23:25 +0530 Subject: [PATCH 7/7] CSTACKEX-18_2: fixing the behaviour for the VM level snapshot when quiecing is enabled and memory-disk is opted out --- ...KvmFileBasedStorageVmSnapshotStrategy.java | 5 ++- ...reateDiskOnlyVMSnapshotCommandWrapper.java | 41 ++++++++++++------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/KvmFileBasedStorageVmSnapshotStrategy.java b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/KvmFileBasedStorageVmSnapshotStrategy.java index 003065e394f5..ff32607ca6c4 100644 --- a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/KvmFileBasedStorageVmSnapshotStrategy.java +++ b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/KvmFileBasedStorageVmSnapshotStrategy.java @@ -503,8 +503,9 @@ protected VMSnapshot takeVmSnapshotInternal(VMSnapshot vmSnapshot, Map volSizeAndNewPath = mapVolumeToSnapshotSizeAndNewVolumePath.get(volumeObjectTO.getUuid()); - PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) volumeObjectTO.getDataStore(); - KVMStoragePool kvmStoragePool = storagePoolMgr.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); - - if (volSizeAndNewPath == null) { - continue; - } - try { - Files.deleteIfExists(Path.of(kvmStoragePool.getLocalPathFor(volSizeAndNewPath.second()))); - } catch (IOException ex) { - logger.warn("Tried to delete leftover snapshot at [{}] failed.", volSizeAndNewPath.second(), ex); - } - } + cleanupLeftoverDeltas(volumeObjectTos, mapVolumeToSnapshotSizeAndNewVolumePath, storagePoolMgr); return new Answer(cmd, e); + } catch (Exception e) { + logger.error("Unexpected exception while creating disk-only VM snapshot for VM [{}]. Deleting leftover deltas.", vmName, e); + cleanupLeftoverDeltas(volumeObjectTos, mapVolumeToSnapshotSizeAndNewVolumePath, storagePoolMgr); + return new CreateDiskOnlyVmSnapshotAnswer(cmd, false, + String.format("Creation of disk-only VM snapshot for VM [%s] failed due to %s.", vmName, e.getMessage()), null); } return new CreateDiskOnlyVmSnapshotAnswer(cmd, true, null, mapVolumeToSnapshotSizeAndNewVolumePath); @@ -192,6 +188,23 @@ protected Pair>> createSnapshotXmlAndNewV return new Pair<>(snapshotXml, volumeObjectToNewPathMap); } + protected void cleanupLeftoverDeltas(List volumeObjectTos, Map> mapVolumeToSnapshotSizeAndNewVolumePath, KVMStoragePoolManager storagePoolMgr) { + for (VolumeObjectTO volumeObjectTO : volumeObjectTos) { + Pair volSizeAndNewPath = mapVolumeToSnapshotSizeAndNewVolumePath.get(volumeObjectTO.getUuid()); + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) volumeObjectTO.getDataStore(); + KVMStoragePool kvmStoragePool = storagePoolMgr.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + + if (volSizeAndNewPath == null) { + continue; + } + try { + Files.deleteIfExists(Path.of(kvmStoragePool.getLocalPathFor(volSizeAndNewPath.second()))); + } catch (IOException ex) { + logger.warn("Tried to delete leftover snapshot at [{}] failed.", volSizeAndNewPath.second(), ex); + } + } + } + protected long getFileSize(String path) { return new File(path).length(); }