diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 689275cff115..375912667d63 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -17,7 +17,7 @@
/plugins/storage/volume/linstor @rp-
/plugins/storage/volume/storpool @slavkap
-/plugins/storage/volume/ontap @rajiv1 @sandeeplocharla @piyush5 @suryag
+/plugins/storage/volume/ontap @rajiv-jain-netapp @sandeeplocharla @piyush5netapp @suryag1201
.pre-commit-config.yaml @jbampton
/.github/linters/ @jbampton
diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/DataStoreProvider.java b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/DataStoreProvider.java
index 41c1d9407454..7ff206db3c99 100644
--- a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/DataStoreProvider.java
+++ b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/DataStoreProvider.java
@@ -29,6 +29,11 @@ public interface DataStoreProvider {
String SAMPLE_IMAGE = "Sample";
String SMB = "NFS";
String DEFAULT_PRIMARY = "DefaultPrimary";
+ /**
+ * Primary storage provider name for NetApp ONTAP ({@code OntapPrimaryDatastoreProvider#getName}).
+ * Single canonical value; use this instead of duplicating the string across modules.
+ */
+ String ONTAP_PLUGIN_NAME = "NetApp ONTAP";
enum DataStoreProviderType {
PRIMARY, IMAGE, ImageCache, OBJECT
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..655e0f961db5 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
@@ -51,6 +51,7 @@
import com.cloud.vm.snapshot.VMSnapshotVO;
import org.apache.cloudstack.backup.BackupOfferingVO;
import org.apache.cloudstack.backup.dao.BackupOfferingDao;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider;
import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine;
import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo;
import org.apache.cloudstack.engine.subsystem.api.storage.StrategyPriority;
@@ -325,8 +326,13 @@ public StrategyPriority canHandle(Long vmId, Long rootPoolId, boolean snapshotMe
List volumes = volumeDao.findByInstance(vmId);
for (VolumeVO volume : volumes) {
StoragePoolVO storagePoolVO = storagePool.findById(volume.getPoolId());
+ if (storagePoolVO.isManaged() && DataStoreProvider.ONTAP_PLUGIN_NAME.equals(storagePoolVO.getStorageProviderName())) {
+ logger.debug("{} as the VM has a volume on ONTAP managed storage pool [{}]. " +
+ "ONTAP managed storage has its own dedicated VM snapshot strategy.", cantHandleLog, storagePoolVO.getName());
+ return StrategyPriority.CANT_HANDLE;
+ }
if (!supportedStoragePoolTypes.contains(storagePoolVO.getPoolType())) {
- logger.debug(String.format("%s as the VM has a volume that is in a storage with unsupported type [%s].", cantHandleLog, storagePoolVO.getPoolType()));
+ logger.debug("{} as the VM has a volume that is in a storage with unsupported type [{}].", cantHandleLog, storagePoolVO.getPoolType());
return StrategyPriority.CANT_HANDLE;
}
List snapshots = snapshotDao.listByVolumeIdAndTypeNotInAndStateNotRemoved(volume.getId(), Snapshot.Type.GROUP);
@@ -503,8 +509,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();
}
diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/IscsiAdmStorageAdaptor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/IscsiAdmStorageAdaptor.java
index ba689d5107f7..495ae45a2896 100644
--- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/IscsiAdmStorageAdaptor.java
+++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/IscsiAdmStorageAdaptor.java
@@ -16,9 +16,14 @@
// under the License.
package com.cloud.hypervisor.kvm.storage;
+import java.io.File;
+import java.io.FileWriter;
+import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.nio.file.Files;
+import java.nio.file.Paths;
import org.apache.cloudstack.utils.qemu.QemuImg;
import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat;
@@ -95,14 +100,8 @@ public boolean connectPhysicalDisk(String volumeUuid, KVMStoragePool pool, Map/device/delete — the standard Linux kernel SCSI
+ * API for removing a single device without tearing down the entire iSCSI session.
+ * Once the kernel processes the delete, it also removes the by-path symlink.
+ *
+ * This is used instead of iscsiadm --logout when other LUNs on the same IQN are still
+ * active (ONTAP single-IQN-per-SVM model), since logout would tear down ALL LUNs.
+ */
+ private void removeStaleScsiDevice(String host, int port, String iqn, String lun) {
+ String byPath = getByPath(host, port, "/" + iqn + "/" + lun);
+ Path byPathLink = Paths.get(byPath);
+ if (!Files.exists(byPathLink)) {
+ logger.debug("by-path entry for LUN " + lun + " already gone, nothing to remove");
+ return;
+ }
+ try {
+ Path realDevice = byPathLink.toRealPath();
+ String devName = realDevice.getFileName().toString();
+ File deleteFile = new File("/sys/block/" + devName + "/device/delete");
+ if (!deleteFile.exists()) {
+ logger.warn("sysfs delete entry not found for device " + devName + " — cannot remove stale SCSI device");
+ return;
+ }
+ try (FileWriter fw = new FileWriter(deleteFile)) {
+ fw.write("1");
+ }
+ logger.info("Removed stale SCSI device " + devName + " for LUN /" + iqn + "/" + lun + " via sysfs");
+ } catch (Exception e) {
+ logger.warn("Failed to remove stale SCSI device for LUN /" + iqn + "/" + lun + ": " + e.getMessage());
+ }
+ }
+
private boolean disconnectPhysicalDisk(String host, int port, String iqn, String lun) {
- // use iscsiadm to log out of the iSCSI target and un-discover it
+ // Check if other LUNs on the same IQN target are still in use.
+ // ONTAP (and similar) uses a single IQN per SVM with multiple LUNs.
+ // Doing iscsiadm --logout tears down the ENTIRE target session,
+ // which would destroy access to ALL LUNs — not just the one being disconnected.
+ if (hasOtherActiveLuns(host, port, iqn, lun)) {
+ logger.info("Skipping iSCSI logout for /" + iqn + "/" + lun +
+ " — other LUNs on the same target are still active. Removing stale SCSI device for this LUN only.");
+ removeStaleScsiDevice(host, port, iqn, lun);
+ // After removing this LUN's device, re-check: if no other LUNs remain active,
+ // If it is the last one then must logout to clean up the iSCSI session entirely.
+ if (hasOtherActiveLuns(host, port, iqn, lun)) {
+ logger.info("Other LUNs still active after removing /" + iqn + "/" + lun + " — session kept alive.");
+ return true;
+ }
+ logger.info("No more active LUNs on target after removing /" + iqn + "/" + lun + " — proceeding with iSCSI logout.");
+ }
+
+ // No other LUNs active on this target — safe to logout and delete the node record.
// ex. sudo iscsiadm -m node -T iqn.2012-03.com.test:volume1 -p 192.168.233.10:3260 --logout
Script iScsiAdmCmd = new Script(true, "iscsiadm", 0, logger);
@@ -422,6 +571,19 @@ public KVMPhysicalDisk copyPhysicalDisk(KVMPhysicalDisk srcDisk, String destVolu
try {
QemuImg q = new QemuImg(timeout);
q.convert(srcFile, destFile);
+ // Below fix is required when vendor depends on host based copy rather than storage CAN_CREATE_VOLUME_FROM_VOLUME capability
+ // When host based template copy is triggered , small size template sits in RAM(depending on host memory and RAM) and copy is marked successful and by the time flush to storage is triggered
+ // disconnectPhysicalDisk would disconnect the lun , hence template staying in RAM is not copied to storage lun. Below does flushing of data to storage and marking
+ // copy as successful once flush is complete.
+ Script flushCmd = new Script(true, "blockdev", 0, logger);
+ flushCmd.add("--flushbufs", destDisk.getPath());
+ String flushResult = flushCmd.execute();
+ if (flushResult != null) {
+ logger.warn("iSCSI copyPhysicalDisk: blockdev --flushbufs returned: {}", flushResult);
+ }
+ Script syncCmd = new Script(true, "sync", 0, logger);
+ syncCmd.execute();
+ logger.info("iSCSI copyPhysicalDisk: flush/sync completed ");
} catch (QemuImgException | LibvirtException ex) {
String msg = "Failed to copy data from " + srcDisk.getPath() + " to " +
destDisk.getPath() + ". The error was the following: " + ex.getMessage();
diff --git a/plugins/storage/volume/ontap/pom.xml b/plugins/storage/volume/ontap/pom.xml
index 749d876911b8..7af43a2325ff 100644
--- a/plugins/storage/volume/ontap/pom.xml
+++ b/plugins/storage/volume/ontap/pom.xml
@@ -37,8 +37,7 @@
2.13.4
3.24.2
5.8.1
- 3.12.4
- 5.2.0
+ 1.15.11
@@ -104,21 +103,19 @@
org.mockito
mockito-core
- ${mockito.version}
+ ${cs.mockito.version}
test
org.mockito
mockito-junit-jupiter
- ${mockito-junit-jupiter.version}
+ ${cs.mockito.version}
test
-
-
- org.mockito
- mockito-inline
- ${mockito.version}
+ net.bytebuddy
+ byte-buddy-agent
+ ${byte-buddy-agent.version}
test
@@ -127,6 +124,12 @@
${assertj.version}
test
+
+ org.apache.cloudstack
+ cloud-engine-storage-snapshot
+ ${project.version}
+ compile
+
@@ -150,7 +153,6 @@
maven-surefire-plugin
${maven-surefire-plugin.version}
- false
**/*Test.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 305db1b1f2fa..738d31d6d4ee 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriver.java
@@ -18,13 +18,29 @@
*/
package org.apache.cloudstack.storage.driver;
+import org.apache.cloudstack.storage.utils.OntapStorageConstants;
+import com.cloud.agent.api.Answer;
+import com.cloud.agent.api.to.DataObjectType;
import com.cloud.agent.api.to.DataStoreTO;
import com.cloud.agent.api.to.DataTO;
+import com.cloud.exception.InvalidParameterValueException;
import com.cloud.host.Host;
+import com.cloud.host.HostVO;
+import com.cloud.hypervisor.Hypervisor.HypervisorType;
import com.cloud.storage.Storage;
import com.cloud.storage.StoragePool;
import com.cloud.storage.Volume;
+import com.cloud.storage.VolumeDetailVO;
+import com.cloud.storage.VolumeVO;
+import com.cloud.storage.ScopeType;
+import com.cloud.storage.SnapshotVO;
+import com.cloud.storage.dao.SnapshotDao;
+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;
+import com.cloud.utils.exception.CloudRuntimeException;
import org.apache.cloudstack.engine.subsystem.api.storage.ChapInfo;
import org.apache.cloudstack.engine.subsystem.api.storage.CopyCommandResult;
import org.apache.cloudstack.engine.subsystem.api.storage.CreateCmdResult;
@@ -37,23 +53,55 @@
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.client.SnapshotFeignClient;
+import org.apache.cloudstack.storage.feign.model.FlexVolSnapshot;
+import org.apache.cloudstack.storage.feign.model.Lun;
+import org.apache.cloudstack.storage.feign.model.response.JobResponse;
+import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
+import org.apache.cloudstack.storage.service.SANStrategy;
+import org.apache.cloudstack.storage.service.StorageStrategy;
+import org.apache.cloudstack.storage.service.UnifiedSANStrategy;
+import org.apache.cloudstack.storage.service.model.AccessGroup;
+import org.apache.cloudstack.storage.service.model.CloudStackVolume;
+import org.apache.cloudstack.storage.service.model.ProtocolType;
+import org.apache.cloudstack.storage.to.SnapshotObjectTO;
+import org.apache.cloudstack.storage.utils.OntapStorageUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.Nullable;
+import javax.inject.Inject;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+/**
+ * Primary datastore driver for NetApp ONTAP storage systems.
+ * Handles volume lifecycle operations for iSCSI and NFS protocols.
+ */
public class OntapPrimaryDatastoreDriver implements PrimaryDataStoreDriver {
private static final Logger logger = LogManager.getLogger(OntapPrimaryDatastoreDriver.class);
+ @Inject private StoragePoolDetailsDao storagePoolDetailsDao;
+ @Inject private PrimaryDataStoreDao storagePoolDao;
+ @Inject private VolumeDao volumeDao;
+ @Inject private VolumeDetailsDao volumeDetailsDao;
+ @Inject private SnapshotDetailsDao snapshotDetailsDao;
+ @Inject private SnapshotDao snapshotDao;
+
@Override
public Map getCapabilities() {
logger.trace("OntapPrimaryDatastoreDriver: getCapabilities: Called");
Map mapCapabilities = new HashMap<>();
- 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());
+ mapCapabilities.put(DataStoreCapabilities.CAN_REVERT_VOLUME_TO_SNAPSHOT.toString(), Boolean.TRUE.toString());
return mapCapabilities;
}
@@ -65,14 +113,259 @@ public DataTO getTO(DataObject data) {
@Override
public DataStoreTO getStoreTO(DataStore store) { return null; }
+ @Override
+ public boolean volumesRequireGrantAccessWhenUsed() {
+ logger.trace("volumesRequireGrantAccessWhenUsed invoked");
+ return true;
+ }
+
+ /**
+ * Creates a volume on the ONTAP storage system.
+ */
@Override
public void createAsync(DataStore dataStore, DataObject dataObject, AsyncCompletionCallback callback) {
- throw new UnsupportedOperationException("Create operation is not supported for ONTAP primary storage.");
+ CreateCmdResult createCmdResult = null;
+ String errMsg;
+
+ if (dataObject == null) {
+ throw new InvalidParameterValueException("dataObject should not be null");
+ }
+ if (dataStore == null) {
+ throw new InvalidParameterValueException("dataStore should not be null");
+ }
+ if (callback == null) {
+ throw new InvalidParameterValueException("callback should not be null");
+ }
+
+ try {
+ logger.info("Started for data store name [{}] and data object name [{}] of type [{}]",
+ dataStore.getName(), dataObject.getName(), dataObject.getType());
+
+ StoragePoolVO storagePool = storagePoolDao.findById(dataStore.getId());
+ if (storagePool == null) {
+ logger.error("createAsync: Storage Pool not found for id: " + dataStore.getId());
+ throw new CloudRuntimeException("Storage Pool not found for id: " + dataStore.getId());
+ }
+
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(dataStore.getId());
+
+ if (dataObject.getType() == DataObjectType.VOLUME) {
+ VolumeInfo volInfo = (VolumeInfo) dataObject;
+
+ // Update CloudStack volume record with storage pool association and protocol-specific details
+ VolumeVO volumeVO = volumeDao.findById(volInfo.getId());
+ if (volumeVO != null) {
+ // Create the backend storage object (LUN for iSCSI, no-op for NFS)
+ CloudStackVolume created = createCloudStackVolume(storagePool, volInfo, details);
+
+ volumeVO.setPoolType(storagePool.getPoolType());
+ volumeVO.setPoolId(storagePool.getId());
+ volumeVO.setFormat(getImageFormatByHypervisor(storagePool.getHypervisor()));
+ logger.info("createAsync: Volume format set to [{}] for hypervisor [{}]", volumeVO.getFormat(), storagePool.getHypervisor());
+
+ if (ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ String lunName = created != null && created.getLun() != null ? created.getLun().getName() : null;
+ if (lunName == null) {
+ throw new CloudRuntimeException("Missing LUN name for volume " + volInfo.getId());
+ }
+
+ // Persist LUN details for future operations (delete, grant/revoke access)
+ volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_UUID, created.getLun().getUuid(), false);
+ volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_NAME, lunName, false);
+ if (created.getLun().getUuid() != null) {
+ volumeVO.setFolder(created.getLun().getUuid());
+ }
+
+ logger.info("createAsync: Created LUN [{}] for volume [{}]. LUN mapping will occur during grantAccess() to per-host igroup.",
+ lunName, volumeVO.getId());
+ createCmdResult = new CreateCmdResult(lunName, new Answer(null, true, null));
+ } else if (ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ createCmdResult = new CreateCmdResult(volInfo.getUuid(), new Answer(null, true, null));
+ logger.info("createAsync: Managed NFS volume [{}] with path [{}] associated with pool {}",
+ volumeVO.getId(), volInfo.getUuid(), storagePool.getId());
+ }
+ volumeDao.update(volumeVO.getId(), volumeVO);
+ }
+ } else {
+ errMsg = "Invalid DataObjectType (" + dataObject.getType() + ") passed to createAsync";
+ logger.error(errMsg);
+ throw new CloudRuntimeException(errMsg);
+ }
+ } catch (Exception e) {
+ errMsg = e.getMessage();
+ logger.error("createAsync: Failed for dataObject name [{}]: {}", dataObject.getName(), errMsg);
+ createCmdResult = new CreateCmdResult(null, new Answer(null, false, errMsg));
+ createCmdResult.setResult(e.toString());
+ } finally {
+ if (createCmdResult != null && createCmdResult.isSuccess()) {
+ logger.info("createAsync: Operation completed successfully for {}", dataObject.getType());
+ }
+ callback.complete(createCmdResult);
+ }
}
+ /**
+ * Creates a volume on the ONTAP backend.
+ */
+ private CloudStackVolume createCloudStackVolume(StoragePoolVO storagePool, VolumeInfo volumeObject, Map details) {
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ CloudStackVolume cloudStackVolumeRequest = OntapStorageUtils.createCloudStackVolumeRequestByProtocol(storagePool, details, volumeObject);
+ return storageStrategy.createCloudStackVolume(cloudStackVolumeRequest);
+ }
+
+ /**
+ * Deletes a volume or snapshot from the ONTAP storage system.
+ *
+ * For volumes, deletes the backend storage object (LUN for iSCSI, file for NFS) via
+ * {@link StorageStrategy#deleteCloudStackVolume}.
+ *
+ * For volume snapshots, this driver is invoked by the standard CloudStack delete chain
+ * ({@code StorageSystemSnapshotStrategy} → {@code SnapshotServiceImpl.deleteSnapshot} →
+ * {@code deleteAsync}). It reads ONTAP metadata from {@code snapshot_details} and delegates
+ * the actual FlexVol snapshot delete to {@link StorageStrategy} (NFS or iSCSI implementation).
+ * ONTAP REST/delete-job logic must not live here — keep it in the storage-strategy layer.
+ */
@Override
public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallback callback) {
- throw new UnsupportedOperationException("Delete operation is not supported for ONTAP primary storage.");
+ CommandResult commandResult = new CommandResult();
+ try {
+ if (store == null || data == null) {
+ throw new CloudRuntimeException("store or data is null");
+ }
+
+ if (data.getType() == DataObjectType.VOLUME) {
+ StoragePoolVO storagePool = storagePoolDao.findById(store.getId());
+ if (storagePool == null) {
+ logger.error("deleteAsync: Storage Pool not found for id: " + store.getId());
+ throw new CloudRuntimeException("Storage Pool not found for id: " + store.getId());
+ }
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(store.getId());
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ logger.info("createCloudStackVolumeForTypeVolume: Connection to Ontap SVM [{}] successful, preparing CloudStackVolumeRequest", details.get(OntapStorageConstants.SVM_NAME));
+ VolumeInfo volumeInfo = (VolumeInfo) data;
+ CloudStackVolume cloudStackVolumeRequest = createDeleteCloudStackVolumeRequest(storagePool, details, volumeInfo);
+ storageStrategy.deleteCloudStackVolume(cloudStackVolumeRequest);
+ logger.info("deleteAsync: Volume deleted: " + volumeInfo.getId());
+ commandResult.setResult(null);
+ commandResult.setSuccess(true);
+ } else if (data.getType() == DataObjectType.SNAPSHOT) {
+ logger.info("deleteAsync: volume-snapshot delete for CloudStack snapshot [{}] on primary pool [{}] — "
+ + "delegating ONTAP FlexVol cleanup to StorageStrategy", data.getId(), store.getId());
+ deleteCloudStackVolumeSnapshot((SnapshotInfo) data, commandResult);
+ } else {
+ throw new CloudRuntimeException("Unsupported data object type: " + data.getType());
+ }
+ } catch (Exception e) {
+ logger.error("deleteAsync: Failed for data object [{}]: {}", data, e.getMessage());
+ commandResult.setSuccess(false);
+ commandResult.setResult(e.getMessage());
+ } finally {
+ callback.complete(commandResult);
+ }
+ }
+
+ /**
+ * Orchestrates CloudStack volume-snapshot delete on ONTAP.
+ *
+ * This method is intentionally thin: it resolves identifiers persisted during
+ * {@link #takeSnapshot} into {@code snapshot_details} and delegates to the protocol
+ * {@link StorageStrategy} selected from pool details (NFS → {@code UnifiedNASStrategy},
+ * iSCSI → {@code UnifiedSANStrategy}). Both protocols share the same FlexVol-level
+ * snapshot delete REST API.
+ *
+ * Required {@code snapshot_details} keys (see {@link OntapStorageConstants}):
+ *
+ * - {@code base_ontap_fv_id} — FlexVol UUID
+ * - {@code ontap_snap_id} — ONTAP snapshot UUID
+ * - {@code ontap_snap_name} — snapshot name (logging)
+ * - {@code primary_pool_id} — pool used to obtain credentials/protocol strategy
+ *
+ */
+ private void deleteCloudStackVolumeSnapshot(SnapshotInfo snapshotInfo, CommandResult commandResult) {
+ long snapshotId = snapshotInfo.getId();
+ logger.info("deleteCloudStackVolumeSnapshot: starting ONTAP delete for CloudStack volume snapshot [{}]", snapshotId);
+
+ try {
+ String flexVolUuid = getSnapshotDetail(snapshotId, OntapStorageConstants.BASE_ONTAP_FV_ID);
+ String ontapSnapshotUuid = getSnapshotDetail(snapshotId, OntapStorageConstants.ONTAP_SNAP_ID);
+ String snapshotName = getSnapshotDetail(snapshotId, OntapStorageConstants.ONTAP_SNAP_NAME);
+ String poolIdStr = getSnapshotDetail(snapshotId, OntapStorageConstants.PRIMARY_POOL_ID);
+
+ if (flexVolUuid == null || ontapSnapshotUuid == null) {
+ logger.warn("deleteCloudStackVolumeSnapshot: missing ONTAP identity for snapshot [{}] "
+ + "(flexVolUuid={}, ontapSnapshotUuid={}). Cannot call ONTAP delete; "
+ + "treating as no-op — verify snapshot_details were written during takeSnapshot",
+ snapshotId, flexVolUuid, ontapSnapshotUuid);
+ commandResult.setSuccess(true);
+ commandResult.setResult(null);
+ return;
+ }
+
+ long poolId = resolveSnapshotPoolId(poolIdStr, snapshotId);
+ Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(poolId);
+ String protocol = poolDetails.get(OntapStorageConstants.PROTOCOL);
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
+
+ logger.info("deleteCloudStackVolumeSnapshot: snapshot [{}] — protocol [{}], pool [{}], "
+ + "flexVol [{}], ontapSnapshot [{}] (name [{}])",
+ snapshotId, protocol, poolId, flexVolUuid, ontapSnapshotUuid, snapshotName);
+
+ storageStrategy.deleteFlexVolSnapshotForCloudStackVolume(flexVolUuid, ontapSnapshotUuid, snapshotName);
+
+ logger.info("deleteCloudStackVolumeSnapshot: completed ONTAP delete for CloudStack volume snapshot [{}]", snapshotId);
+ commandResult.setSuccess(true);
+ commandResult.setResult(null);
+ } catch (Exception e) {
+ if (isSnapshotNotFoundError(e)) {
+ logger.warn("deleteCloudStackVolumeSnapshot: ONTAP snapshot for CloudStack snapshot [{}] "
+ + "already absent (idempotent success): {}", snapshotId, e.getMessage());
+ commandResult.setSuccess(true);
+ commandResult.setResult(null);
+ return;
+ }
+ logger.error("deleteCloudStackVolumeSnapshot: ONTAP delete failed for CloudStack snapshot [{}]: {}",
+ snapshotId, e.getMessage(), e);
+ commandResult.setSuccess(false);
+ commandResult.setResult(e.getMessage());
+ }
+ }
+
+ /**
+ * Returns true when the exception indicates the ONTAP snapshot was already removed.
+ * Delete is idempotent: a missing backend snapshot is treated as success.
+ */
+ private boolean isSnapshotNotFoundError(Throwable error) {
+ if (error == null) {
+ return false;
+ }
+ String message = error.getMessage();
+ if (message != null) {
+ String lower = message.toLowerCase();
+ if (lower.contains("404") || lower.contains("not found") || lower.contains("does not exist")
+ || lower.contains("entry doesn't exist")) {
+ return true;
+ }
+ }
+ return isSnapshotNotFoundError(error.getCause());
+ }
+
+ private long resolveSnapshotPoolId(String poolIdStr, long snapshotId) {
+ if (poolIdStr != null && !poolIdStr.isEmpty()) {
+ return Long.parseLong(poolIdStr);
+ }
+ SnapshotVO snapshotVO = snapshotDao.findById(snapshotId);
+ if (snapshotVO == null) {
+ throw new CloudRuntimeException("Snapshot not found for snapshot [" + snapshotId + "]");
+ }
+ VolumeVO volumeVO = volumeDao.findByIdIncludingRemoved(snapshotVO.getVolumeId());
+ if (volumeVO == null) {
+ throw new CloudRuntimeException("CloudStack Volume not found for snapshot [" + snapshotId + "]");
+ }
+ Long poolId = volumeVO.getPoolId() != null ? volumeVO.getPoolId() : volumeVO.getLastPoolId();
+ if (poolId == null || poolId <= 0) {
+ throw new CloudRuntimeException("Cannot resolve storage pool for snapshot [" + snapshotId + "]");
+ }
+ return poolId;
}
@Override
@@ -98,14 +391,254 @@ public ChapInfo getChapInfo(DataObject dataObject) {
return null;
}
+ /**
+ * Grants a host access to a volume.
+ */
@Override
public boolean grantAccess(DataObject dataObject, Host host, DataStore dataStore) {
- return false;
+ try {
+ if (dataStore == null) {
+ throw new InvalidParameterValueException("dataStore should not be null");
+ }
+ if (dataObject == null) {
+ throw new InvalidParameterValueException("dataObject should not be null");
+ }
+ if (host == null) {
+ throw new InvalidParameterValueException("host should not be null");
+ }
+
+ StoragePoolVO storagePool = storagePoolDao.findById(dataStore.getId());
+ if (storagePool == null) {
+ logger.error("grantAccess: Storage Pool not found for id: " + dataStore.getId());
+ throw new CloudRuntimeException("Storage Pool not found for id: " + dataStore.getId());
+ }
+
+ // ONTAP managed storage only supports cluster and zone scoped pools
+ if (storagePool.getScope() != ScopeType.CLUSTER && storagePool.getScope() != ScopeType.ZONE) {
+ logger.error("grantAccess: Only Cluster and Zone scoped primary storage is supported for storage Pool: " + storagePool.getName());
+ throw new CloudRuntimeException("Only Cluster and Zone scoped primary storage is supported for Storage Pool: " + storagePool.getName());
+ }
+
+ if (dataObject.getType() == DataObjectType.VOLUME) {
+ VolumeVO volumeVO = volumeDao.findById(dataObject.getId());
+ if (volumeVO == null) {
+ logger.error("grantAccess: CloudStack Volume not found for id: " + dataObject.getId());
+ throw new CloudRuntimeException("CloudStack Volume not found for id: " + dataObject.getId());
+ }
+
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId());
+ String svmName = details.get(OntapStorageConstants.SVM_NAME);
+
+ if (ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ // Only retrieve LUN name for iSCSI volumes
+ grantAccessIscsi(host, volumeVO, details, svmName, storagePool);
+ } else if (ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ // For NFS, no access grant needed - file is accessible via mount
+ logger.debug("grantAccess: NFS volume [{}], no igroup mapping required", volumeVO.getUuid());
+ return true;
+ }
+ volumeVO.setPoolType(storagePool.getPoolType());
+ volumeVO.setPoolId(storagePool.getId());
+ volumeDao.update(volumeVO.getId(), volumeVO);
+ } else {
+ logger.error("Invalid DataObjectType (" + dataObject.getType() + ") passed to grantAccess");
+ throw new CloudRuntimeException("Invalid DataObjectType (" + dataObject.getType() + ") passed to grantAccess");
+ }
+ return true;
+ } catch (Exception e) {
+ logger.error("grantAccess: Failed for dataObject [{}]: {}", dataObject, e.getMessage());
+ throw new CloudRuntimeException("Failed with error: " + e.getMessage(), e);
+ }
}
+ private void grantAccessIscsi(Host host, VolumeVO volumeVO, Map details, String svmName, StoragePoolVO storagePool) {
+ String cloudStackVolumeName = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_NAME).getValue();
+ UnifiedSANStrategy sanStrategy = (UnifiedSANStrategy) OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
+
+ // Validate if Igroup exist ONTAP for this host as we may be using delete_on_unmap= true and igroup may be deleted by ONTAP automatically
+ Map getAccessGroupMap = Map.of(
+ OntapStorageConstants.NAME, accessGroupName,
+ OntapStorageConstants.SVM_DOT_NAME, svmName
+ );
+ AccessGroup accessGroup = sanStrategy.getAccessGroup(getAccessGroupMap);
+ if(accessGroup == null || accessGroup.getIgroup() == null) {
+ logger.info("grantAccess: Igroup {} does not exist for the host {} : Need to create Igroup for the host ", accessGroupName, host.getName());
+ // create the igroup for the host and perform lun-mapping
+ accessGroup = new AccessGroup();
+ List hosts = new ArrayList<>();
+ hosts.add((HostVO) host);
+ accessGroup.setHostsToConnect(hosts);
+ accessGroup.setStoragePoolId(storagePool.getId());
+ accessGroup = sanStrategy.createAccessGroup(accessGroup);
+ }else{
+ logger.info("grantAccess: Igroup {} already exist for the host {}: ", accessGroup.getIgroup().getName() , host.getName());
+ /* TODO Below cases will be covered later, for now they will be a pre-requisite on customer side
+ 1. Igroup exist with the same name but host initiator has been removed
+ 2. Igroup exist with the same name but host initiator has been changed may be due to new NIC or new adapter
+ In both cases we need to verify current host initiator is registered in the igroup before allowing access
+ Incase it is not , add it and proceed for lun-mapping
+ */
+ }
+ logger.info("grantAccess: Igroup {} is present now with initiators {} ", accessGroup.getIgroup().getName(), accessGroup.getIgroup().getInitiators());
+ // Create or retrieve existing LUN mapping
+ String lunNumber = sanStrategy.ensureLunMapped(svmName, cloudStackVolumeName, accessGroupName);
+
+ // Update volume path if changed (e.g., after migration or re-mapping)
+ String iscsiPath = OntapStorageConstants.SLASH + storagePool.getPath() + OntapStorageConstants.SLASH + lunNumber;
+ if (volumeVO.getPath() == null || !volumeVO.getPath().equals(iscsiPath)) {
+ volumeVO.set_iScsiName(iscsiPath);
+ volumeVO.setPath(iscsiPath);
+ }
+ }
+
+ /**
+ * Revokes a host's access to a volume.
+ */
@Override
public void revokeAccess(DataObject dataObject, Host host, DataStore dataStore) {
- throw new UnsupportedOperationException("Revoke access operation is not supported for ONTAP primary storage.");
+ try {
+ if (dataStore == null) {
+ throw new InvalidParameterValueException("dataStore should not be null");
+ }
+ if (dataObject == null) {
+ throw new InvalidParameterValueException("dataObject should not be null");
+ }
+ if (host == null) {
+ throw new InvalidParameterValueException("host should not be null");
+ }
+
+ StoragePoolVO storagePool = storagePoolDao.findById(dataStore.getId());
+ if (storagePool == null) {
+ logger.error("revokeAccess: Storage Pool not found for id: " + dataStore.getId());
+ throw new CloudRuntimeException("Storage Pool not found for id: " + dataStore.getId());
+ }
+
+ if (storagePool.getScope() != ScopeType.CLUSTER && storagePool.getScope() != ScopeType.ZONE) {
+ logger.error("revokeAccess: Only Cluster and Zone scoped primary storage is supported for storage Pool: " + storagePool.getName());
+ throw new CloudRuntimeException("Only Cluster and Zone scoped primary storage is supported for Storage Pool: " + storagePool.getName());
+ }
+
+ if (dataObject.getType() == DataObjectType.VOLUME) {
+ VolumeVO volumeVO = volumeDao.findById(dataObject.getId());
+ if (volumeVO == null) {
+ logger.error("revokeAccess: CloudStack Volume not found for id: " + dataObject.getId());
+ throw new CloudRuntimeException("CloudStack Volume not found for id: " + dataObject.getId());
+ }
+ revokeAccessForVolume(storagePool, volumeVO, host);
+ } else {
+ logger.error("revokeAccess: Invalid DataObjectType (" + dataObject.getType() + ") passed to revokeAccess");
+ throw new CloudRuntimeException("Invalid DataObjectType (" + dataObject.getType() + ") passed to revokeAccess");
+ }
+ } catch (Exception e) {
+ logger.error("revokeAccess: Failed for dataObject [{}]: {}", dataObject, e.getMessage());
+ throw new CloudRuntimeException("Failed with error: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Revokes volume access for the specified host.
+ */
+ private void revokeAccessForVolume(StoragePoolVO storagePool, VolumeVO volumeVO, Host host) {
+ logger.info("revokeAccessForVolume: Revoking access to volume [{}] for host [{}]", volumeVO.getName(), host.getName());
+
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId());
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ String svmName = details.get(OntapStorageConstants.SVM_NAME);
+
+ if (ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
+
+ // Retrieve LUN name from volume details; if missing, volume may not have been fully created
+ VolumeDetailVO lunDetail = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_NAME);
+ ValidateRevoke result = getValidateRevoke(volumeVO, host, lunDetail, storageStrategy, svmName, accessGroupName);
+ if (result == null) return;
+
+ // Remove the LUN mapping from the igroup
+ Map disableLogicalAccessMap = new HashMap<>();
+ disableLogicalAccessMap.put(OntapStorageConstants.LUN_DOT_UUID, result.cloudStackVolume.getLun().getUuid());
+ disableLogicalAccessMap.put(OntapStorageConstants.IGROUP_DOT_UUID, result.accessGroup.getIgroup().getUuid());
+ storageStrategy.disableLogicalAccess(disableLogicalAccessMap);
+
+ logger.info("revokeAccessForVolume: Successfully revoked access to LUN [{}] for host [{}]",
+ result.lunName, host.getName());
+ }
+ }
+
+ @Nullable
+ private ValidateRevoke getValidateRevoke(VolumeVO volumeVO, Host host, VolumeDetailVO lunDetail, StorageStrategy storageStrategy, String svmName, String accessGroupName) {
+ String lunName = lunDetail != null ? lunDetail.getValue() : null;
+ if (lunName == null) {
+ logger.warn("revokeAccessForVolume: No LUN name found for volume [{}]; skipping revoke", volumeVO.getId());
+ return null;
+ }
+
+ // Verify LUN still exists on ONTAP (may have been manually deleted)
+ CloudStackVolume cloudStackVolume = getCloudStackVolumeByName(storageStrategy, svmName, lunName);
+ if (cloudStackVolume == null || cloudStackVolume.getLun() == null || cloudStackVolume.getLun().getUuid() == null) {
+ logger.warn("revokeAccessForVolume: LUN for volume [{}] not found on ONTAP, skipping revoke", volumeVO.getId());
+ return null;
+ }
+
+ // Verify igroup still exists on ONTAP
+ AccessGroup accessGroup = getAccessGroupByName(storageStrategy, svmName, accessGroupName);
+ if (accessGroup == null || accessGroup.getIgroup() == null || accessGroup.getIgroup().getUuid() == null) {
+ logger.warn("revokeAccessForVolume: iGroup [{}] not found on ONTAP, skipping revoke", accessGroupName);
+ return null;
+ }
+
+ // Verify host initiator is in the igroup before attempting to remove mapping
+ SANStrategy sanStrategy = (UnifiedSANStrategy) storageStrategy;
+ if (!sanStrategy.validateInitiatorInAccessGroup(host.getStorageUrl(), svmName, accessGroup.getIgroup())) {
+ logger.warn("revokeAccessForVolume: Initiator [{}] is not in iGroup [{}], skipping revoke",
+ host.getStorageUrl(), accessGroupName);
+ return null;
+ }
+ return new ValidateRevoke(lunName, cloudStackVolume, accessGroup);
+ }
+
+ private static class ValidateRevoke {
+ public final String lunName;
+ public final CloudStackVolume cloudStackVolume;
+ public final AccessGroup accessGroup;
+
+ public ValidateRevoke(String lunName, CloudStackVolume cloudStackVolume, AccessGroup accessGroup) {
+ this.lunName = lunName;
+ this.cloudStackVolume = cloudStackVolume;
+ this.accessGroup = accessGroup;
+ }
+ }
+
+ /**
+ * Retrieves a volume from ONTAP by name.
+ */
+ private CloudStackVolume getCloudStackVolumeByName(StorageStrategy storageStrategy, String svmName, String cloudStackVolumeName) {
+ Map getCloudStackVolumeMap = new HashMap<>();
+ getCloudStackVolumeMap.put(OntapStorageConstants.NAME, cloudStackVolumeName);
+ getCloudStackVolumeMap.put(OntapStorageConstants.SVM_DOT_NAME, svmName);
+
+ CloudStackVolume cloudStackVolume = storageStrategy.getCloudStackVolume(getCloudStackVolumeMap);
+ if (cloudStackVolume == null || cloudStackVolume.getLun() == null || cloudStackVolume.getLun().getName() == null) {
+ logger.warn("getCloudStackVolumeByName: LUN [{}] not found on ONTAP", cloudStackVolumeName);
+ return null;
+ }
+ return cloudStackVolume;
+ }
+
+ /**
+ * Retrieves an access group from ONTAP by name.
+ */
+ private AccessGroup getAccessGroupByName(StorageStrategy storageStrategy, String svmName, String accessGroupName) {
+ Map getAccessGroupMap = new HashMap<>();
+ getAccessGroupMap.put(OntapStorageConstants.NAME, accessGroupName);
+ getAccessGroupMap.put(OntapStorageConstants.SVM_DOT_NAME, svmName);
+
+ AccessGroup accessGroup = storageStrategy.getAccessGroup(getAccessGroupMap);
+ if (accessGroup == null || accessGroup.getIgroup() == null || accessGroup.getIgroup().getName() == null) {
+ logger.warn("getAccessGroupByName: iGroup [{}] not found on ONTAP", accessGroupName);
+ return null;
+ }
+ return accessGroup;
}
@Override
@@ -128,11 +661,254 @@ public long getUsedIops(StoragePool storagePool) {
return 0;
}
+ /**
+ * Takes a snapshot by creating an ONTAP FlexVolume-level snapshot.
+ *
+ * This method creates a point-in-time, space-efficient snapshot of the entire
+ * FlexVolume containing the CloudStack volume. FlexVolume snapshots are atomic
+ * and capture all files/LUNs within the volume at the moment of creation.
+ *
+ * Both NFS and iSCSI protocols use the same FlexVolume snapshot approach:
+ *
+ * - NFS: The QCOW2 file is captured within the FlexVolume snapshot
+ * - iSCSI: The LUN is captured within the FlexVolume snapshot
+ *
+ *
+ *
+ * With {@code STORAGE_SYSTEM_SNAPSHOT=true}, {@code StorageSystemSnapshotStrategy}
+ * handles the workflow.
+ */
@Override
- public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback callback) {}
+ public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback callback) {
+ logger.info("OntapPrimaryDatastoreDriver.takeSnapshot: Creating FlexVolume snapshot for snapshot [{}]", snapshot.getId());
+ CreateCmdResult result;
+
+ try {
+ VolumeInfo volumeInfo = snapshot.getBaseVolume();
+
+ VolumeVO volumeVO = volumeDao.findById(volumeInfo.getId());
+ if (volumeVO == null) {
+ throw new CloudRuntimeException("CloudStack Volume not found for id: " + volumeInfo.getId());
+ }
+ StoragePoolVO storagePool = storagePoolDao.findById(volumeVO.getPoolId());
+ if (storagePool == null) {
+ logger.error("takeSnapshot: Storage Pool not found for id: {}", volumeVO.getPoolId());
+ throw new CloudRuntimeException("Storage Pool not found for id: " + volumeVO.getPoolId());
+ }
+
+ Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(volumeVO.getPoolId());
+ String protocol = poolDetails.get(OntapStorageConstants.PROTOCOL);
+ String flexVolUuid = poolDetails.get(OntapStorageConstants.VOLUME_UUID);
+
+ if (flexVolUuid == null || flexVolUuid.isEmpty()) {
+ throw new CloudRuntimeException("FlexVolume UUID not found in pool details for pool " + volumeVO.getPoolId());
+ }
+
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
+ SnapshotFeignClient snapshotClient = storageStrategy.getSnapshotFeignClient();
+ String authHeader = storageStrategy.getAuthHeader();
+
+ SnapshotObjectTO snapshotObjectTo = (SnapshotObjectTO) snapshot.getTO();
+
+ // Preserve CloudStack UI snapshot name with stable uniqueness suffix.
+ String snapshotName = buildSnapshotName(snapshot.getName(), snapshot.getId());
+
+ // Resolve the volume path for storing in snapshot details (for revert operation)
+ String volumePath = resolveVolumePathOnOntap(volumeVO, protocol, poolDetails);
+
+ // For iSCSI, retrieve LUN UUID for restore operations
+ String lunUuid = null;
+ if (ProtocolType.ISCSI.name().equalsIgnoreCase(protocol)) {
+ VolumeDetailVO lunDetail = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_UUID);
+ if (lunDetail == null || lunDetail.getValue() == null) {
+ throw new CloudRuntimeException("LUN UUID not found for iSCSI volume " + volumeVO.getId());
+ }
+ lunUuid = lunDetail.getValue();
+ }
+
+ // Create FlexVolume snapshot via ONTAP REST API
+ FlexVolSnapshot snapshotRequest = new FlexVolSnapshot(snapshotName,
+ "CloudStack volume snapshot for volume " + volumeInfo.getName());
+
+ logger.info("takeSnapshot: Creating ONTAP FlexVolume snapshot [{}] on FlexVol UUID [{}] for volume [{}]",
+ snapshotName, flexVolUuid, volumeVO.getId());
+
+ JobResponse jobResponse = snapshotClient.createSnapshot(authHeader, flexVolUuid, snapshotRequest);
+ if (jobResponse == null || jobResponse.getJob() == null) {
+ throw new CloudRuntimeException("Failed to initiate FlexVolume snapshot on FlexVol UUID [" + flexVolUuid + "]");
+ }
+
+ // Poll for job completion
+ Boolean jobSucceeded = storageStrategy.jobPollForSuccess(jobResponse.getJob().getUuid(), 30, 2000);
+ if (!jobSucceeded) {
+ throw new CloudRuntimeException("FlexVolume snapshot job failed on FlexVol UUID [" + flexVolUuid + "]");
+ }
+
+ // Retrieve the created snapshot UUID by name
+ String ontapSnapshotUuid = resolveSnapshotUuid(snapshotClient, authHeader, flexVolUuid, snapshotName);
+ if (ontapSnapshotUuid == null || ontapSnapshotUuid.isEmpty()) {
+ throw new CloudRuntimeException("Failed to resolve snapshot UUID for snapshot name [" + snapshotName + "]");
+ }
+
+ // Set snapshot path for CloudStack (format: snapshotName for identification)
+ snapshotObjectTo.setPath(OntapStorageConstants.ONTAP_SNAP_ID + "=" + ontapSnapshotUuid);
+
+ // Persist snapshot_details so deleteAsync can resolve ONTAP FlexVol/snapshot UUIDs
+ // (see deleteCloudStackVolumeSnapshot and StorageStrategy.deleteFlexVolSnapshotForCloudStackVolume)
+ updateSnapshotDetails(snapshot.getId(), volumeInfo.getId(), flexVolUuid,
+ ontapSnapshotUuid, snapshotName, volumePath, volumeVO.getPoolId(), protocol, lunUuid);
+
+ CreateObjectAnswer createObjectAnswer = new CreateObjectAnswer(snapshotObjectTo);
+ result = new CreateCmdResult(null, createObjectAnswer);
+ result.setResult(null);
+
+ logger.info("takeSnapshot: Successfully created FlexVolume snapshot [{}] (uuid={}) for volume [{}]",
+ snapshotName, ontapSnapshotUuid, volumeVO.getId());
+
+ } catch (Exception ex) {
+ logger.error("takeSnapshot: Failed due to ", ex);
+ result = new CreateCmdResult(null, new CreateObjectAnswer(ex.toString()));
+ result.setResult(ex.toString());
+ }
+
+ callback.complete(result);
+ }
+
+ /**
+ * Resolves the volume path on ONTAP for snapshot restore operations.
+ *
+ * @param volumeVO The CloudStack volume
+ * @param protocol Storage protocol (NFS3 or ISCSI)
+ * @param poolDetails Pool configuration details
+ * @return The ONTAP path (file path for NFS, LUN name for iSCSI)
+ */
+ private String resolveVolumePathOnOntap(VolumeVO volumeVO, String protocol, Map poolDetails) {
+ if (ProtocolType.NFS3.name().equalsIgnoreCase(protocol)) {
+ // For NFS, use the volume's file path
+ return volumeVO.getPath();
+ } else if (ProtocolType.ISCSI.name().equalsIgnoreCase(protocol)) {
+ // For iSCSI, retrieve the LUN name from volume details
+ VolumeDetailVO volumeDetails = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_NAME);
+
+ if(volumeDetails != null) {
+ String lunName = volumeDetails.getValue();
+ if (lunName == null) {
+ throw new CloudRuntimeException("No LUN name found for volume " + volumeVO.getId());
+ }
+ return lunName;
+ }
+ }
+ throw new CloudRuntimeException("Unsupported protocol " + protocol);
+ }
+
+ /**
+ * Resolves the ONTAP snapshot UUID by querying for the snapshot by name.
+ *
+ * @param snapshotClient The ONTAP snapshot Feign client
+ * @param authHeader Authorization header
+ * @param flexVolUuid FlexVolume UUID
+ * @param snapshotName Name of the snapshot to find
+ * @return The UUID of the snapshot, or null if not found
+ */
+ private String resolveSnapshotUuid(SnapshotFeignClient snapshotClient, String authHeader,
+ String flexVolUuid, String snapshotName) {
+ Map queryParams = new HashMap<>();
+ queryParams.put("name", snapshotName);
+ queryParams.put("fields", "uuid,name");
+
+ OntapResponse response = snapshotClient.getSnapshots(authHeader, flexVolUuid, queryParams);
+ if (response != null && response.getRecords() != null && !response.getRecords().isEmpty()) {
+ return response.getRecords().get(0).getUuid();
+ }
+ return null;
+ }
+
+ /**
+ * Reverts a volume to a snapshot using protocol-specific ONTAP restore APIs.
+ *
+ * This method delegates to the appropriate StorageStrategy to restore the
+ * specific file (NFS) or LUN (iSCSI) from the FlexVolume snapshot directly
+ * via ONTAP REST API, without involving the hypervisor agent.
+ *
+ * Both NFS and iSCSI delegate to CLI-based SFSR:
+ * {@code POST /api/private/cli/volume/snapshot/restore-file}
+ */
@Override
- public void revertSnapshot(SnapshotInfo snapshotOnImageStore, SnapshotInfo snapshotOnPrimaryStore, AsyncCompletionCallback callback) {}
+ public void revertSnapshot(SnapshotInfo snapshotOnImageStore, SnapshotInfo snapshotOnPrimaryStore,
+ AsyncCompletionCallback callback) {
+ logger.info("OntapPrimaryDatastoreDriver.revertSnapshot: Reverting snapshot [{}]",
+ snapshotOnImageStore.getId());
+
+ CommandResult result = new CommandResult();
+
+ try {
+ // Use the snapshot that has the ONTAP details stored
+ SnapshotInfo snapshot = snapshotOnPrimaryStore != null ? snapshotOnPrimaryStore : snapshotOnImageStore;
+ long snapshotId = snapshot.getId();
+
+ // Retrieve snapshot details stored during takeSnapshot
+ String flexVolUuid = getSnapshotDetail(snapshotId, OntapStorageConstants.BASE_ONTAP_FV_ID);
+ String ontapSnapshotUuid = getSnapshotDetail(snapshotId, OntapStorageConstants.ONTAP_SNAP_ID);
+ String snapshotName = getSnapshotDetail(snapshotId, OntapStorageConstants.ONTAP_SNAP_NAME);
+ String volumePath = getSnapshotDetail(snapshotId, OntapStorageConstants.VOLUME_PATH);
+ String poolIdStr = getSnapshotDetail(snapshotId, OntapStorageConstants.PRIMARY_POOL_ID);
+ String protocol = getSnapshotDetail(snapshotId, OntapStorageConstants.PROTOCOL);
+
+ if (flexVolUuid == null || snapshotName == null || volumePath == null || poolIdStr == null) {
+ throw new CloudRuntimeException("Missing required snapshot details for snapshot " + snapshotId +
+ " (flexVolUuid=" + flexVolUuid + ", snapshotName=" + snapshotName +
+ ", volumePath=" + volumePath + ", poolId=" + poolIdStr + ")");
+ }
+
+ long poolId = Long.parseLong(poolIdStr);
+ Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(poolId);
+
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
+
+ // Get the FlexVolume name (required for CLI-based restore API for all protocols)
+ String flexVolName = poolDetails.get(OntapStorageConstants.VOLUME_NAME);
+ if (flexVolName == null || flexVolName.isEmpty()) {
+ throw new CloudRuntimeException("FlexVolume name not found in pool details for pool " + poolId);
+ }
+
+ // Prepare protocol-specific parameters (lunUuid is only needed for backward compatibility)
+ String lunUuid = null;
+ if (ProtocolType.ISCSI.name().equalsIgnoreCase(protocol)) {
+ lunUuid = getSnapshotDetail(snapshotId, OntapStorageConstants.LUN_DOT_UUID);
+ }
+
+ // Delegate to strategy class for protocol-specific restore
+ JobResponse jobResponse = storageStrategy.revertSnapshotForCloudStackVolume(
+ snapshotName, flexVolUuid, ontapSnapshotUuid, volumePath, lunUuid, flexVolName);
+
+ storageStrategy.executeCliSfsrRestore(jobResponse, "revert snapshot [" + snapshotName + "]");
+
+ logger.info("revertSnapshot: Successfully restored {} [{}] from snapshot [{}]",
+ ProtocolType.ISCSI.name().equalsIgnoreCase(protocol) ? "LUN" : "file",
+ volumePath, snapshotName);
+
+ result.setResult(null); // Success
+
+ } catch (Exception ex) {
+ logger.error("revertSnapshot: Failed to revert snapshot {}", snapshotOnImageStore, ex);
+ result.setResult(ex.toString());
+ }
+
+ callback.complete(result);
+ }
+
+ /**
+ * Retrieves a snapshot detail value by key.
+ *
+ * @param snapshotId The CloudStack snapshot ID
+ * @param key The detail key
+ * @return The detail value, or null if not found
+ */
+ private String getSnapshotDetail(long snapshotId, String key) {
+ SnapshotDetailsVO detail = snapshotDetailsDao.findDetail(snapshotId, key);
+ return detail != null ? detail.getValue() : null;
+ }
@Override
public void handleQualityOfServiceForVolumeMigration(VolumeInfo volumeInfo, QualityOfServiceState qualityOfServiceState) {}
@@ -149,7 +925,7 @@ public Pair getStorageStats(StoragePool storagePool) {
@Override
public boolean canProvideVolumeStats() {
- return true;
+ return false; // Not yet implemented for RAW managed NFS
}
@Override
@@ -184,5 +960,115 @@ public boolean isStorageSupportHA(Storage.StoragePoolType type) {
}
@Override
- public void detachVolumeFromAllStorageNodes(Volume volume) {}
+ public void detachVolumeFromAllStorageNodes(Volume volume) {
+ }
+
+ private CloudStackVolume createDeleteCloudStackVolumeRequest(StoragePool storagePool, Map details, VolumeInfo volumeInfo) {
+ CloudStackVolume cloudStackVolumeDeleteRequest = null;
+
+ String protocol = details.get(OntapStorageConstants.PROTOCOL);
+ ProtocolType protocolType = ProtocolType.valueOf(protocol);
+ switch (protocolType) {
+ case NFS3:
+ cloudStackVolumeDeleteRequest = new CloudStackVolume();
+ cloudStackVolumeDeleteRequest.setDatastoreId(String.valueOf(storagePool.getId()));
+ cloudStackVolumeDeleteRequest.setVolumeInfo(volumeInfo);
+ break;
+ case ISCSI:
+ // Retrieve LUN identifiers stored during volume creation
+ String lunName = volumeDetailsDao.findDetail(volumeInfo.getId(), OntapStorageConstants.LUN_DOT_NAME).getValue();
+ String lunUUID = volumeDetailsDao.findDetail(volumeInfo.getId(), OntapStorageConstants.LUN_DOT_UUID).getValue();
+ if (lunName == null) {
+ throw new CloudRuntimeException("Missing LUN name for volume " + volumeInfo.getId());
+ }
+ cloudStackVolumeDeleteRequest = new CloudStackVolume();
+ Lun lun = new Lun();
+ lun.setName(lunName);
+ lun.setUuid(lunUUID);
+ cloudStackVolumeDeleteRequest.setLun(lun);
+ break;
+ default:
+ throw new CloudRuntimeException("Unsupported protocol " + protocol);
+
+ }
+ return cloudStackVolumeDeleteRequest;
+
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Snapshot Helper Methods
+ // ──────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Builds an ONTAP-safe snapshot name from the CloudStack UI name with uniqueness suffix.
+ */
+ private String buildSnapshotName(String cloudStackSnapshotName, long snapshotId) {
+ return OntapStorageUtils.buildOntapSnapshotName(cloudStackSnapshotName, OntapStorageConstants.CS + snapshotId);
+ }
+
+
+ private Storage.ImageFormat getImageFormatByHypervisor(HypervisorType hypervisorType) {
+ if (HypervisorType.KVM.equals(hypervisorType)) {
+ return Storage.ImageFormat.QCOW2;
+ }
+ throw new CloudRuntimeException("Unsupported hypervisor [" + hypervisorType + "] for ONTAP image format resolution");
+ }
+ /**
+ * Persists snapshot metadata in snapshot_details table.
+ *
+ * Persists ONTAP snapshot metadata in {@code snapshot_details} for revert and delete.
+ *
+ * Volume-snapshot delete reads {@code base_ontap_fv_id} and {@code ontap_snap_id} here
+ * during {@link #deleteCloudStackVolumeSnapshot}; missing rows prevent ONTAP cleanup.
+ *
+ * @param csSnapshotId CloudStack snapshot ID
+ * @param csVolumeId Source CloudStack volume ID
+ * @param flexVolUuid ONTAP FlexVolume UUID
+ * @param ontapSnapshotUuid ONTAP FlexVolume snapshot UUID
+ * @param snapshotName ONTAP snapshot name
+ * @param volumePath Path of the volume file/LUN within the FlexVolume (for restore)
+ * @param storagePoolId Primary storage pool ID
+ * @param protocol Storage protocol (NFS3 or ISCSI)
+ * @param lunUuid LUN UUID (only for iSCSI, null for NFS)
+ */
+ private void updateSnapshotDetails(long csSnapshotId, long csVolumeId, String flexVolUuid,
+ String ontapSnapshotUuid, String snapshotName,
+ String volumePath, long storagePoolId, String protocol,
+ String lunUuid) {
+ SnapshotDetailsVO snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
+ OntapStorageConstants.SRC_CS_VOLUME_ID, String.valueOf(csVolumeId), false);
+ snapshotDetailsDao.persist(snapshotDetail);
+
+ snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
+ OntapStorageConstants.BASE_ONTAP_FV_ID, flexVolUuid, false);
+ snapshotDetailsDao.persist(snapshotDetail);
+
+ snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
+ OntapStorageConstants.ONTAP_SNAP_ID, ontapSnapshotUuid, false);
+ snapshotDetailsDao.persist(snapshotDetail);
+
+ snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
+ OntapStorageConstants.ONTAP_SNAP_NAME, snapshotName, false);
+ snapshotDetailsDao.persist(snapshotDetail);
+
+ snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
+ OntapStorageConstants.VOLUME_PATH, volumePath, false);
+ snapshotDetailsDao.persist(snapshotDetail);
+
+ snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
+ OntapStorageConstants.PRIMARY_POOL_ID, String.valueOf(storagePoolId), false);
+ snapshotDetailsDao.persist(snapshotDetail);
+
+ snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
+ OntapStorageConstants.PROTOCOL, protocol, false);
+ snapshotDetailsDao.persist(snapshotDetail);
+
+ // Store LUN UUID for iSCSI volumes (required for LUN restore API)
+ if (lunUuid != null && !lunUuid.isEmpty()) {
+ snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
+ OntapStorageConstants.LUN_DOT_UUID, lunUuid, false);
+ snapshotDetailsDao.persist(snapshotDetail);
+ }
+ }
+
}
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..8cf21b94b2f1 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
@@ -32,7 +32,7 @@
public interface NASFeignClient {
// File Operations
- @RequestLine("GET /api/storage/volumes/{volumeUuid}/files/{path}")
+ @RequestLine("GET /api/storage/volumes/{volumeUuid}/files/{path}?return_metadata=true")
@Headers({"Authorization: {authHeader}"})
OntapResponse getFileResponse(@Param("authHeader") String authHeader,
@Param("volumeUuid") String volumeUUID,
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java
index 5cbba9d683d2..7281dc2ecbeb 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java
@@ -23,6 +23,8 @@
import org.apache.cloudstack.storage.feign.model.IscsiService;
import org.apache.cloudstack.storage.feign.model.Lun;
import org.apache.cloudstack.storage.feign.model.LunMap;
+import org.apache.cloudstack.storage.feign.model.LunRestoreRequest;
+import org.apache.cloudstack.storage.feign.model.response.JobResponse;
import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
import feign.Headers;
import feign.Param;
@@ -88,4 +90,24 @@ public interface SANFeignClient {
void deleteLunMap(@Param("authHeader") String authHeader,
@Param("lunUuid") String lunUUID,
@Param("igroupUuid") String igroupUUID);
+
+ // LUN Restore API
+ /**
+ * Restores a LUN from a FlexVolume snapshot.
+ *
+ * ONTAP REST: {@code POST /api/storage/luns/{lun.uuid}/restore}
+ *
+ * This API restores the LUN data from a specified snapshot to a destination path.
+ * The LUN must exist and the snapshot must contain the LUN data.
+ *
+ * @param authHeader Basic auth header
+ * @param lunUuid UUID of the LUN to restore
+ * @param request Request body with snapshot name and destination path
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("POST /api/storage/luns/{lunUuid}/restore")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse restoreLun(@Param("authHeader") String authHeader,
+ @Param("lunUuid") String lunUuid,
+ LunRestoreRequest request);
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SnapshotFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SnapshotFeignClient.java
new file mode 100644
index 000000000000..cb7375aead88
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SnapshotFeignClient.java
@@ -0,0 +1,278 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.feign.client;
+
+import feign.Headers;
+import feign.Param;
+import feign.QueryMap;
+import feign.RequestLine;
+import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest;
+import org.apache.cloudstack.storage.feign.model.ConsistencyGroup;
+import org.apache.cloudstack.storage.feign.model.ConsistencyGroupSnapshot;
+import org.apache.cloudstack.storage.feign.model.FlexVolSnapshot;
+import org.apache.cloudstack.storage.feign.model.SnapshotFileRestoreRequest;
+import org.apache.cloudstack.storage.feign.model.response.JobResponse;
+import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
+
+import java.util.Map;
+
+/**
+ * Feign client for ONTAP FlexVolume snapshot operations.
+ *
+ * Maps to the ONTAP REST API endpoint:
+ * {@code /api/storage/volumes/{volume_uuid}/snapshots}
+ *
+ * FlexVolume snapshots are point-in-time, space-efficient copies of an entire
+ * FlexVolume. Unlike file-level clones, a single FlexVolume snapshot atomically
+ * captures all files/LUNs within the volume, making it ideal for VM-level
+ * snapshots when multiple CloudStack disks reside on the same FlexVolume.
+ */
+public interface SnapshotFeignClient {
+
+ /**
+ * Creates a new snapshot for the specified FlexVolume.
+ *
+ * ONTAP REST: {@code POST /api/storage/volumes/{volume_uuid}/snapshots}
+ *
+ * @param authHeader Basic auth header
+ * @param volumeUuid UUID of the ONTAP FlexVolume
+ * @param snapshot Snapshot request body (at minimum, the {@code name} field)
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("POST /api/storage/volumes/{volumeUuid}/snapshots")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse createSnapshot(@Param("authHeader") String authHeader,
+ @Param("volumeUuid") String volumeUuid,
+ FlexVolSnapshot snapshot);
+
+ /**
+ * Lists snapshots for the specified FlexVolume.
+ *
+ * ONTAP REST: {@code GET /api/storage/volumes/{volume_uuid}/snapshots}
+ *
+ * @param authHeader Basic auth header
+ * @param volumeUuid UUID of the ONTAP FlexVolume
+ * @param queryParams Optional query parameters (e.g., {@code name}, {@code fields})
+ * @return Paginated response of FlexVolSnapshot records
+ */
+ @RequestLine("GET /api/storage/volumes/{volumeUuid}/snapshots")
+ @Headers({"Authorization: {authHeader}"})
+ OntapResponse getSnapshots(@Param("authHeader") String authHeader,
+ @Param("volumeUuid") String volumeUuid,
+ @QueryMap Map queryParams);
+
+ /**
+ * Retrieves a specific snapshot by UUID.
+ *
+ * ONTAP REST: {@code GET /api/storage/volumes/{volume_uuid}/snapshots/{uuid}}
+ *
+ * @param authHeader Basic auth header
+ * @param volumeUuid UUID of the ONTAP FlexVolume
+ * @param snapshotUuid UUID of the snapshot
+ * @return The FlexVolSnapshot object
+ */
+ @RequestLine("GET /api/storage/volumes/{volumeUuid}/snapshots/{snapshotUuid}")
+ @Headers({"Authorization: {authHeader}"})
+ FlexVolSnapshot getSnapshotByUuid(@Param("authHeader") String authHeader,
+ @Param("volumeUuid") String volumeUuid,
+ @Param("snapshotUuid") String snapshotUuid);
+
+ /**
+ * Deletes a specific snapshot.
+ *
+ * ONTAP REST: {@code DELETE /api/storage/volumes/{volume_uuid}/snapshots/{uuid}}
+ *
+ * @param authHeader Basic auth header
+ * @param volumeUuid UUID of the ONTAP FlexVolume
+ * @param snapshotUuid UUID of the snapshot to delete
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("DELETE /api/storage/volumes/{volumeUuid}/snapshots/{snapshotUuid}")
+ @Headers({"Authorization: {authHeader}"})
+ JobResponse deleteSnapshot(@Param("authHeader") String authHeader,
+ @Param("volumeUuid") String volumeUuid,
+ @Param("snapshotUuid") String snapshotUuid);
+
+ /**
+ * Restores a volume to a specific snapshot.
+ *
+ * ONTAP REST: {@code PATCH /api/storage/volumes/{volume_uuid}/snapshots/{uuid}}
+ * with body {@code {"restore": true}} triggers a snapshot restore operation.
+ *
+ * Note: This is a destructive operation — all data written after the
+ * snapshot was taken will be lost.
+ *
+ * @param authHeader Basic auth header
+ * @param volumeUuid UUID of the ONTAP FlexVolume
+ * @param snapshotUuid UUID of the snapshot to restore to
+ * @param body Request body, typically {@code {"restore": true}}
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("PATCH /api/storage/volumes/{volumeUuid}/snapshots/{snapshotUuid}?restore_to_snapshot=true")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse restoreSnapshot(@Param("authHeader") String authHeader,
+ @Param("volumeUuid") String volumeUuid,
+ @Param("snapshotUuid") String snapshotUuid);
+
+ /**
+ * Restores a single file or LUN from a FlexVolume snapshot.
+ *
+ * ONTAP REST:
+ * {@code POST /api/storage/volumes/{volume_uuid}/snapshots/{snapshot_uuid}/files/{file_path}/restore}
+ *
+ * This restores only the specified file/LUN from the snapshot to the
+ * given {@code destination_path}, without reverting the entire FlexVolume.
+ * Ideal when multiple VMs share the same FlexVolume.
+ *
+ * @param authHeader Basic auth header
+ * @param volumeUuid UUID of the ONTAP FlexVolume
+ * @param snapshotUuid UUID of the snapshot containing the file
+ * @param filePath path of the file within the snapshot (URL-encoded if needed)
+ * @param request request body with {@code destination_path}
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("POST /api/storage/volumes/{volumeUuid}/snapshots/{snapshotUuid}/files/{filePath}/restore")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse restoreFileFromSnapshot(@Param("authHeader") String authHeader,
+ @Param("volumeUuid") String volumeUuid,
+ @Param("snapshotUuid") String snapshotUuid,
+ @Param("filePath") String filePath,
+ SnapshotFileRestoreRequest request);
+
+ /**
+ * Restores a single file or LUN from a FlexVolume snapshot using the CLI native API.
+ *
+ * ONTAP REST (CLI passthrough):
+ * {@code POST /api/private/cli/volume/snapshot/restore-file}
+ *
+ * This CLI-based API is more reliable and works for both NFS files and iSCSI LUNs.
+ * The request body contains all required parameters: vserver, volume, snapshot, and path.
+ *
+ * Example payload:
+ *
+ * {
+ * "vserver": "vs0",
+ * "volume": "rajiv_ONTAP_SP1",
+ * "snapshot": "DATA-3-428726fe-7440-4b41-8d47-3f654e5d9814",
+ * "path": "/d266bb2c-d479-47ad-81c3-a070e8bb58c0"
+ * }
+ *
+ *
+ *
+ * @param authHeader Basic auth header
+ * @param request CLI snapshot restore request containing vserver, volume, snapshot, and path
+ * @return JobResponse containing the async job reference (if applicable)
+ */
+ @RequestLine("POST /api/private/cli/volume/snapshot/restore-file")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse restoreFileFromSnapshotCli(@Param("authHeader") String authHeader,
+ CliSnapshotRestoreRequest request);
+
+ /**
+ * Creates a consistency group.
+ *
+ * ONTAP REST: {@code POST /api/application/consistency-groups}
+ *
+ * @param authHeader Basic auth header
+ * @param request consistency group create request body
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("POST /api/application/consistency-groups")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse createConsistencyGroup(@Param("authHeader") String authHeader,
+ ConsistencyGroup request);
+
+ /**
+ * Lists consistency groups.
+ *
+ * ONTAP REST: {@code GET /api/application/consistency-groups}
+ *
+ * @param authHeader Basic auth header
+ * @param queryParams Optional query parameters
+ * @return Paginated consistency group records
+ */
+ @RequestLine("GET /api/application/consistency-groups")
+ @Headers({"Authorization: {authHeader}"})
+ OntapResponse getConsistencyGroups(@Param("authHeader") String authHeader,
+ @QueryMap Map queryParams);
+
+ /**
+ * Creates (starts) a consistency group snapshot.
+ *
+ * ONTAP REST: {@code POST /api/application/consistency-groups/{cgUuid}/snapshots}
+ *
+ * @param authHeader Basic auth header
+ * @param cgUuid consistency group UUID
+ * @param request snapshot start request body
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("POST /api/application/consistency-groups/{cgUuid}/snapshots")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse createConsistencyGroupSnapshot(@Param("authHeader") String authHeader,
+ @Param("cgUuid") String cgUuid,
+ ConsistencyGroupSnapshot request);
+
+ /**
+ * Lists snapshots for a consistency group.
+ *
+ * ONTAP REST: {@code GET /api/application/consistency-groups/{cgUuid}/snapshots}
+ *
+ * @param authHeader Basic auth header
+ * @param cgUuid consistency group UUID
+ * @param queryParams Optional query parameters
+ * @return Paginated consistency group snapshot records
+ */
+ @RequestLine("GET /api/application/consistency-groups/{cgUuid}/snapshots")
+ @Headers({"Authorization: {authHeader}"})
+ OntapResponse getConsistencyGroupSnapshots(@Param("authHeader") String authHeader,
+ @Param("cgUuid") String cgUuid,
+ @QueryMap Map queryParams);
+
+ /**
+ * Commits a started consistency group snapshot.
+ *
+ * ONTAP REST: {@code PATCH /api/application/consistency-groups/{cgUuid}/snapshots/{snapshotUuid}}
+ *
+ * @param authHeader Basic auth header
+ * @param cgUuid consistency group UUID
+ * @param snapshotUuid consistency group snapshot UUID
+ * @param request commit request body
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("PATCH /api/application/consistency-groups/{cgUuid}/snapshots/{snapshotUuid}")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse commitConsistencyGroupSnapshot(@Param("authHeader") String authHeader,
+ @Param("cgUuid") String cgUuid,
+ @Param("snapshotUuid") String snapshotUuid,
+ ConsistencyGroupSnapshot request);
+
+ /**
+ * Deletes a consistency group.
+ *
+ * ONTAP REST: {@code DELETE /api/application/consistency-groups/{cgUuid}}
+ *
+ * @param authHeader Basic auth header
+ * @param cgUuid consistency group UUID
+ * @return JobResponse containing the async job reference
+ */
+ @RequestLine("DELETE /api/application/consistency-groups/{cgUuid}")
+ @Headers({"Authorization: {authHeader}"})
+ JobResponse deleteConsistencyGroup(@Param("authHeader") String authHeader,
+ @Param("cgUuid") String cgUuid);
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/CliSnapshotRestoreRequest.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/CliSnapshotRestoreRequest.java
new file mode 100644
index 000000000000..be242523f534
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/CliSnapshotRestoreRequest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.feign.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Request body for the ONTAP CLI-based Snapshot File Restore API.
+ *
+ * ONTAP REST endpoint (CLI passthrough):
+ * {@code POST /api/private/cli/volume/snapshot/restore-file}
+ *
+ * This API restores a single file or LUN from a FlexVolume snapshot to a
+ * specified destination path using the CLI native implementation.
+ * It works for both NFS files and iSCSI LUNs.
+ *
+ * Example payload:
+ *
+ * {
+ * "vserver": "vs0",
+ * "volume": "rajiv_ONTAP_SP1",
+ * "snapshot": "DATA-3-428726fe-7440-4b41-8d47-3f654e5d9814",
+ * "path": "/d266bb2c-d479-47ad-81c3-a070e8bb58c0"
+ * }
+ *
+ *
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CliSnapshotRestoreRequest {
+
+ @JsonProperty("vserver")
+ private String vserver;
+
+ @JsonProperty("volume")
+ private String volume;
+
+ @JsonProperty("snapshot")
+ private String snapshot;
+
+ @JsonProperty("path")
+ private String path;
+
+ public CliSnapshotRestoreRequest() {
+ }
+
+ /**
+ * Creates a CLI snapshot restore request.
+ *
+ * @param vserver The SVM (vserver) name
+ * @param volume The FlexVolume name
+ * @param snapshot The snapshot name
+ * @param path The file/LUN path to restore (e.g., "/uuid.qcow2" or "/lun_name")
+ */
+ public CliSnapshotRestoreRequest(String vserver, String volume, String snapshot, String path) {
+ this.vserver = vserver;
+ this.volume = volume;
+ this.snapshot = snapshot;
+ this.path = path;
+ }
+
+ public String getVserver() {
+ return vserver;
+ }
+
+ public void setVserver(String vserver) {
+ this.vserver = vserver;
+ }
+
+ public String getVolume() {
+ return volume;
+ }
+
+ public void setVolume(String volume) {
+ this.volume = volume;
+ }
+
+ public String getSnapshot() {
+ return snapshot;
+ }
+
+ public void setSnapshot(String snapshot) {
+ this.snapshot = snapshot;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ @Override
+ public String toString() {
+ return "CliSnapshotRestoreRequest{" +
+ "vserver='" + vserver + '\'' +
+ ", volume='" + volume + '\'' +
+ ", snapshot='" + snapshot + '\'' +
+ ", path='" + path + '\'' +
+ '}';
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroup.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroup.java
new file mode 100644
index 000000000000..2c32a04d6b65
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroup.java
@@ -0,0 +1,99 @@
+/*
+ * 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;
+
+import java.util.List;
+
+/**
+ * Model representing an ONTAP application consistency group.
+ *
+ * Maps to the ONTAP REST API resource at
+ * {@code /api/application/consistency-groups}.
+ *
+ * @see
+ * ONTAP REST API - Create Consistency Group
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ConsistencyGroup {
+
+ @JsonProperty("uuid")
+ private String uuid;
+
+ @JsonProperty("name")
+ private String name;
+
+ @JsonProperty("svm")
+ private Svm svm;
+
+ @JsonProperty("volumes")
+ private List volumes;
+
+ public ConsistencyGroup() {
+ }
+
+ public ConsistencyGroup(String name) {
+ this.name = name;
+ }
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ public void setUuid(String uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Svm getSvm() {
+ return svm;
+ }
+
+ public void setSvm(Svm svm) {
+ this.svm = svm;
+ }
+
+ public List getVolumes() {
+ return volumes;
+ }
+
+ public void setVolumes(List volumes) {
+ this.volumes = volumes;
+ }
+
+ @Override
+ public String toString() {
+ return "ConsistencyGroup{" +
+ "uuid='" + uuid + '\'' +
+ ", name='" + name + '\'' +
+ ", volumes=" + (volumes != null ? volumes.size() : 0) +
+ '}';
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupSnapshot.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupSnapshot.java
new file mode 100644
index 000000000000..974745f02d4d
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupSnapshot.java
@@ -0,0 +1,149 @@
+/*
+ * 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;
+
+/**
+ * Model representing an ONTAP consistency group snapshot.
+ *
+ * Maps to the ONTAP REST API resource at
+ * {@code /api/application/consistency-groups/{consistency_group.uuid}/snapshots}.
+ *
+ * @see
+ * ONTAP REST API - Consistency Group Snapshots
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ConsistencyGroupSnapshot {
+
+ @JsonProperty("uuid")
+ private String uuid;
+
+ @JsonProperty("name")
+ private String name;
+
+ @JsonProperty("create_time")
+ private String createTime;
+
+ @JsonProperty("comment")
+ private String comment;
+
+ @JsonProperty("consistency_type")
+ private String consistencyType;
+
+ @JsonProperty("snapmirror_label")
+ private String snapmirrorLabel;
+
+ @JsonProperty("action")
+ private String action;
+
+ @JsonProperty("consistency_group")
+ private VolumeConcise consistencyGroup;
+
+ public ConsistencyGroupSnapshot() {
+ // default constructor for Jackson
+ }
+
+ public ConsistencyGroupSnapshot(String name) {
+ this.name = name;
+ }
+
+ public ConsistencyGroupSnapshot(String name, String action) {
+ this.name = name;
+ this.action = action;
+ }
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ public void setUuid(String uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getCreateTime() {
+ return createTime;
+ }
+
+ public void setCreateTime(String createTime) {
+ this.createTime = createTime;
+ }
+
+ public String getComment() {
+ return comment;
+ }
+
+ public void setComment(String comment) {
+ this.comment = comment;
+ }
+
+ public String getConsistencyType() {
+ return consistencyType;
+ }
+
+ public void setConsistencyType(String consistencyType) {
+ this.consistencyType = consistencyType;
+ }
+
+ public String getSnapmirrorLabel() {
+ return snapmirrorLabel;
+ }
+
+ public void setSnapmirrorLabel(String snapmirrorLabel) {
+ this.snapmirrorLabel = snapmirrorLabel;
+ }
+
+ public String getAction() {
+ return action;
+ }
+
+ public void setAction(String action) {
+ this.action = action;
+ }
+
+ public VolumeConcise getConsistencyGroup() {
+ return consistencyGroup;
+ }
+
+ public void setConsistencyGroup(VolumeConcise consistencyGroup) {
+ this.consistencyGroup = consistencyGroup;
+ }
+
+ @Override
+ public String toString() {
+ return "ConsistencyGroupSnapshot{" +
+ "uuid='" + uuid + '\'' +
+ ", name='" + name + '\'' +
+ ", createTime='" + createTime + '\'' +
+ ", comment='" + comment + '\'' +
+ ", consistencyType='" + consistencyType + '\'' +
+ '}';
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupVolume.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupVolume.java
new file mode 100644
index 000000000000..2a9cac5a6d99
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupVolume.java
@@ -0,0 +1,67 @@
+/*
+ * 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;
+
+/**
+ * Volume member reference for consistency group create/update requests.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ConsistencyGroupVolume {
+
+ @JsonProperty("uuid")
+ private String uuid;
+
+ @JsonProperty("name")
+ private String name;
+
+ @JsonProperty("provisioning_options")
+ private ConsistencyGroupVolumeProvisioningOptions provisioningOptions;
+
+ public ConsistencyGroupVolume() {
+ }
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ public void setUuid(String uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public ConsistencyGroupVolumeProvisioningOptions getProvisioningOptions() {
+ return provisioningOptions;
+ }
+
+ public void setProvisioningOptions(ConsistencyGroupVolumeProvisioningOptions provisioningOptions) {
+ this.provisioningOptions = provisioningOptions;
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupVolumeProvisioningOptions.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupVolumeProvisioningOptions.java
new file mode 100644
index 000000000000..0e1955a62ee7
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ConsistencyGroupVolumeProvisioningOptions.java
@@ -0,0 +1,49 @@
+/*
+ * 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;
+
+/**
+ * Provisioning options for a volume member of a consistency group.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ConsistencyGroupVolumeProvisioningOptions {
+
+ @JsonProperty("action")
+ private String action;
+
+ public ConsistencyGroupVolumeProvisioningOptions() {
+ }
+
+ public ConsistencyGroupVolumeProvisioningOptions(String action) {
+ this.action = action;
+ }
+
+ public String getAction() {
+ return action;
+ }
+
+ public void setAction(String action) {
+ this.action = action;
+ }
+}
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");
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FlexVolSnapshot.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FlexVolSnapshot.java
new file mode 100644
index 000000000000..af5d6f145520
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FlexVolSnapshot.java
@@ -0,0 +1,122 @@
+/*
+ * 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;
+
+/**
+ * Model representing an ONTAP FlexVolume-level snapshot.
+ *
+ * Maps to the ONTAP REST API resource at
+ * {@code /api/storage/volumes/{volume.uuid}/snapshots}.
+ *
+ * For creation, only the {@code name} field is required in the POST body.
+ * ONTAP returns the full representation including {@code uuid}, {@code name},
+ * and {@code create_time} on GET requests.
+ *
+ * @see
+ * ONTAP REST API - Volume Snapshots
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class FlexVolSnapshot {
+
+ @JsonProperty("uuid")
+ private String uuid;
+
+ @JsonProperty("name")
+ private String name;
+
+ @JsonProperty("create_time")
+ private String createTime;
+
+ @JsonProperty("comment")
+ private String comment;
+
+ /** Concise reference to the parent volume (returned in GET responses). */
+ @JsonProperty("volume")
+ private VolumeConcise volume;
+
+ public FlexVolSnapshot() {
+ // default constructor for Jackson
+ }
+
+ public FlexVolSnapshot(String name) {
+ this.name = name;
+ }
+
+ public FlexVolSnapshot(String name, String comment) {
+ this.name = name;
+ this.comment = comment;
+ }
+
+ // ── Getters / Setters ────────────────────────────────────────────────────
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ public void setUuid(String uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getCreateTime() {
+ return createTime;
+ }
+
+ public void setCreateTime(String createTime) {
+ this.createTime = createTime;
+ }
+
+ public String getComment() {
+ return comment;
+ }
+
+ public void setComment(String comment) {
+ this.comment = comment;
+ }
+
+ public VolumeConcise getVolume() {
+ return volume;
+ }
+
+ public void setVolume(VolumeConcise volume) {
+ this.volume = volume;
+ }
+
+ @Override
+ public String toString() {
+ return "FlexVolSnapshot{" +
+ "uuid='" + uuid + '\'' +
+ ", name='" + name + '\'' +
+ ", createTime='" + createTime + '\'' +
+ ", comment='" + comment + '\'' +
+ '}';
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Igroup.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Igroup.java
index 3596a802f941..a81e0a69d030 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Igroup.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Igroup.java
@@ -19,9 +19,11 @@
package org.apache.cloudstack.storage.feign.model;
+import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
import java.util.List;
import java.util.Objects;
@@ -71,6 +73,7 @@ public enum OsTypeEnum {
this.value = value;
}
+ @JsonValue
public String getValue() {
return value;
}
@@ -80,9 +83,11 @@ public String toString() {
return String.valueOf(value);
}
+ @JsonCreator
public static OsTypeEnum fromValue(String text) {
+ if (text == null) return null;
for (OsTypeEnum b : OsTypeEnum.values()) {
- if (String.valueOf(b.value).equals(text)) {
+ if (text.equalsIgnoreCase(b.value)) {
return b;
}
}
@@ -122,6 +127,7 @@ public enum ProtocolEnum {
this.value = value;
}
+ @JsonValue
public String getValue() {
return value;
}
@@ -131,9 +137,11 @@ public String toString() {
return String.valueOf(value);
}
+ @JsonCreator
public static ProtocolEnum fromValue(String text) {
+ if (text == null) return null;
for (ProtocolEnum b : ProtocolEnum.values()) {
- if (String.valueOf(b.value).equals(text)) {
+ if (text.equalsIgnoreCase(b.value)) {
return b;
}
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/LunRestoreRequest.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/LunRestoreRequest.java
new file mode 100644
index 000000000000..c645e4a5a16f
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/LunRestoreRequest.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.feign.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Request body for the ONTAP LUN Restore API.
+ *
+ * ONTAP REST endpoint:
+ * {@code POST /api/storage/luns/{lun.uuid}/restore}
+ *
+ * This API restores a LUN from a FlexVolume snapshot to a specified
+ * destination path. Unlike file restore, this is LUN-specific.
+ *
+ * Example payload:
+ *
+ * {
+ * "snapshot": {
+ * "name": "snapshot_name"
+ * },
+ * "destination": {
+ * "path": "/vol/volume_name/lun_name"
+ * }
+ * }
+ *
+ *
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class LunRestoreRequest {
+
+ @JsonProperty("snapshot")
+ private SnapshotRef snapshot;
+
+ @JsonProperty("destination")
+ private Destination destination;
+
+ public LunRestoreRequest() {
+ }
+
+ public LunRestoreRequest(String snapshotName, String destinationPath) {
+ this.snapshot = new SnapshotRef(snapshotName);
+ this.destination = new Destination(destinationPath);
+ }
+
+ public SnapshotRef getSnapshot() {
+ return snapshot;
+ }
+
+ public void setSnapshot(SnapshotRef snapshot) {
+ this.snapshot = snapshot;
+ }
+
+ public Destination getDestination() {
+ return destination;
+ }
+
+ public void setDestination(Destination destination) {
+ this.destination = destination;
+ }
+
+ /**
+ * Nested class for snapshot reference.
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class SnapshotRef {
+
+ @JsonProperty("name")
+ private String name;
+
+ public SnapshotRef() {
+ }
+
+ public SnapshotRef(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+
+ /**
+ * Nested class for destination path.
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Destination {
+
+ @JsonProperty("path")
+ private String path;
+
+ public Destination() {
+ }
+
+ public Destination(String path) {
+ this.path = path;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/OntapStorage.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/OntapStorage.java
index 8b450331b50a..a42cd02912b3 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/OntapStorage.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/OntapStorage.java
@@ -24,20 +24,18 @@
public class OntapStorage {
private final String username;
private final String password;
- private final String managementLIF;
+ private final String storageIP;
private final String svmName;
private final Long size;
private final ProtocolType protocolType;
- private final Boolean isDisaggregated;
- public OntapStorage(String username, String password, String managementLIF, String svmName, Long size, ProtocolType protocolType, Boolean isDisaggregated) {
+ public OntapStorage(String username, String password, String storageIP, String svmName, Long size, ProtocolType protocolType) {
this.username = username;
this.password = password;
- this.managementLIF = managementLIF;
+ this.storageIP = storageIP;
this.svmName = svmName;
this.size = size;
this.protocolType = protocolType;
- this.isDisaggregated = isDisaggregated;
}
public String getUsername() {
@@ -48,13 +46,9 @@ public String getPassword() {
return password;
}
- public String getManagementLIF() {
- return managementLIF;
- }
+ public String getStorageIP() { return storageIP; }
- public String getSvmName() {
- return svmName;
- }
+ public String getSvmName() { return svmName; }
public Long getSize() {
return size;
@@ -63,8 +57,4 @@ public Long getSize() {
public ProtocolType getProtocol() {
return protocolType;
}
-
- public Boolean getIsDisaggregated() {
- return isDisaggregated;
- }
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/SnapshotFileRestoreRequest.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/SnapshotFileRestoreRequest.java
new file mode 100644
index 000000000000..1f02e0c07470
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/SnapshotFileRestoreRequest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.feign.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Request body for the ONTAP Snapshot File Restore API.
+ *
+ * ONTAP REST endpoint:
+ * {@code POST /api/storage/volumes/{volume.uuid}/snapshots/{snapshot.uuid}/files/{file.path}/restore}
+ *
+ * This API restores a single file or LUN from a FlexVolume snapshot to a
+ * specified destination path, without reverting the entire FlexVolume.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class SnapshotFileRestoreRequest {
+
+ @JsonProperty("destination_path")
+ private String destinationPath;
+
+ public SnapshotFileRestoreRequest() {
+ }
+
+ public SnapshotFileRestoreRequest(String destinationPath) {
+ this.destinationPath = destinationPath;
+ }
+
+ public String getDestinationPath() {
+ return 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/Svm.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Svm.java
index b1462c593863..b1422ef9d333 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Svm.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Svm.java
@@ -23,8 +23,10 @@
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSetter;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -36,13 +38,13 @@ public class Svm {
@JsonProperty("name")
private String name = null;
- @JsonProperty("iscsi.enabled")
+ @JsonIgnore
private Boolean iscsiEnabled = null;
- @JsonProperty("fcp.enabled")
+ @JsonIgnore
private Boolean fcpEnabled = null;
- @JsonProperty("nfs.enabled")
+ @JsonIgnore
private Boolean nfsEnabled = null;
@JsonProperty("aggregates")
@@ -73,28 +75,43 @@ public void setName(String name) {
this.name = name;
}
+ public Boolean getNfsEnabled() {
+ return Boolean.TRUE.equals(nfsEnabled);
+ }
+
+ public void setNfsEnabled(Boolean nfsEnabled) {
+ this.nfsEnabled = nfsEnabled;
+ }
+
+ @JsonSetter("nfs")
+ public void setNfs(Map nfs) {
+ this.nfsEnabled = nfs != null ? Boolean.TRUE.equals(nfs.get("enabled")) : false;
+ }
+
public Boolean getIscsiEnabled() {
- return iscsiEnabled;
+ return Boolean.TRUE.equals(iscsiEnabled);
}
public void setIscsiEnabled(Boolean iscsiEnabled) {
this.iscsiEnabled = iscsiEnabled;
}
+ @JsonSetter("iscsi")
+ public void setIscsi(Map iscsi) {
+ this.iscsiEnabled = iscsi != null ? Boolean.TRUE.equals(iscsi.get("enabled")) : false;
+ }
+
public Boolean getFcpEnabled() {
- return fcpEnabled;
+ return Boolean.TRUE.equals(fcpEnabled);
}
public void setFcpEnabled(Boolean fcpEnabled) {
this.fcpEnabled = fcpEnabled;
}
- public Boolean getNfsEnabled() {
- return nfsEnabled;
- }
-
- public void setNfsEnabled(Boolean nfsEnabled) {
- this.nfsEnabled = nfsEnabled;
+ @JsonSetter("fcp")
+ public void setFcp(Map fcp) {
+ this.fcpEnabled = fcp != null ? Boolean.TRUE.equals(fcp.get("enabled")) : false;
}
public List getAggregates() {
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Volume.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Volume.java
index e7c538d7cd04..736d70de2178 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Volume.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Volume.java
@@ -19,9 +19,11 @@
package org.apache.cloudstack.storage.feign.model;
+import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
import java.util.List;
import java.util.Objects;
@@ -50,6 +52,9 @@ public class Volume {
@JsonProperty("space")
private VolumeSpace space;
+ @JsonProperty("guarantee")
+ private Guarantee guarantee;
+
@JsonProperty("anti_ransomware")
private AntiRansomware antiRansomware;
@@ -112,6 +117,14 @@ public void setSpace(VolumeSpace space) {
this.space = space;
}
+ public Guarantee getGuarantee() {
+ return guarantee;
+ }
+
+ public void setGuarantee(Guarantee guarantee) {
+ this.guarantee = guarantee;
+ }
+
public AntiRansomware getAntiRansomware() {
return antiRansomware;
}
@@ -139,4 +152,66 @@ public boolean equals(Object o) {
public int hashCode() {
return Objects.hashCode(uuid);
}
+
+ public static class Guarantee {
+
+ /**
+ * ONTAP FlexVolume space guarantee (provisioning) type.
+ *
+ * - {@link #NONE} - thin provisioning (space is not reserved up front)
+ * - {@link #VOLUME} - thick provisioning (full volume size is reserved on the aggregate)
+ *
+ */
+ public enum TypeEnum {
+ NONE("none"),
+
+ VOLUME("volume");
+
+ private String value;
+
+ TypeEnum(String value) {
+ this.value = value;
+ }
+
+ @JsonValue
+ public String getValue() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return String.valueOf(value);
+ }
+
+ @JsonCreator
+ public static TypeEnum fromValue(String text) {
+ if (text == null) return null;
+ for (TypeEnum b : TypeEnum.values()) {
+ if (text.equalsIgnoreCase(b.value)) {
+ return b;
+ }
+ }
+ return null;
+ }
+ }
+
+ @JsonProperty("type")
+ private TypeEnum type;
+
+ public Guarantee() {
+ }
+
+ public Guarantee(TypeEnum type) {
+ this.type = type;
+ }
+
+ public TypeEnum getType() {
+ return type;
+ }
+
+ public void setType(TypeEnum type) {
+ this.type = type;
+ }
+ }
+
}
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..82cea1260e10
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeConcise.java
@@ -0,0 +1,50 @@
+/*
+ * 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) {
+ this.name = name;
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java
index 7a66c0a72fe2..a206eaa053df 100755
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java
@@ -19,7 +19,7 @@
package org.apache.cloudstack.storage.lifecycle;
-
+import org.apache.cloudstack.engine.subsystem.api.storage.Scope;
import com.cloud.agent.api.StoragePoolInfo;
import com.cloud.dc.ClusterVO;
import com.cloud.dc.dao.ClusterDao;
@@ -31,9 +31,9 @@
import com.cloud.storage.StorageManager;
import com.cloud.storage.StoragePool;
import com.cloud.storage.StoragePoolAutomation;
-import com.cloud.utils.StringUtils;
import com.cloud.utils.exception.CloudRuntimeException;
import com.google.common.base.Preconditions;
+import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.HostScope;
@@ -55,6 +55,7 @@
import org.apache.cloudstack.storage.utils.OntapStorageConstants;
import org.apache.cloudstack.storage.utils.OntapStorageUtils;
import org.apache.cloudstack.storage.volume.datastore.PrimaryDataStoreHelper;
+import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -79,12 +80,16 @@ public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycl
private static final long ONTAP_MIN_VOLUME_SIZE_IN_BYTES = 1677721600L;
+ /**
+ * Creates primary storage on NetApp storage
+ * @param dsInfos datastore information map
+ * @return DataStore instance
+ */
@Override
public DataStore initialize(Map dsInfos) {
if (dsInfos == null) {
throw new CloudRuntimeException("Datastore info map is null, cannot create primary storage");
}
- String url = (String) dsInfos.get("url");
Long zoneId = (Long) dsInfos.get("zoneId");
Long podId = (Long) dsInfos.get("podId");
Long clusterId = (Long) dsInfos.get("clusterId");
@@ -99,10 +104,11 @@ public DataStore initialize(Map dsInfos) {
", zoneId: " + zoneId + ", podId: " + podId + ", clusterId: " + clusterId);
logger.debug("Received capacityBytes from UI: " + capacityBytes);
+ // Additional details requested for ONTAP primary storage pool creation
@SuppressWarnings("unchecked")
Map details = (Map) dsInfos.get("details");
- capacityBytes = validateInitializeInputs(capacityBytes, podId, clusterId, zoneId, storagePoolName, providerName, managed, url, details);
+ capacityBytes = validateInitializeInputs(capacityBytes, podId, clusterId, zoneId, storagePoolName, providerName, managed, details);
PrimaryDataStoreParameters parameters = new PrimaryDataStoreParameters();
if (clusterId != null) {
@@ -115,29 +121,30 @@ public DataStore initialize(Map dsInfos) {
}
details.put(OntapStorageConstants.SIZE, capacityBytes.toString());
- details.putIfAbsent(OntapStorageConstants.IS_DISAGGREGATED, "false");
ProtocolType protocol = ProtocolType.valueOf(details.get(OntapStorageConstants.PROTOCOL));
-// long volumeSize = Long.parseLong(details.get(OntapStorageConstants.SIZE));
OntapStorage ontapStorage = new OntapStorage(
details.get(OntapStorageConstants.USERNAME),
details.get(OntapStorageConstants.PASSWORD),
- details.get(OntapStorageConstants.MANAGEMENT_LIF),
+ details.get(OntapStorageConstants.STORAGE_IP),
details.get(OntapStorageConstants.SVM_NAME),
capacityBytes,
- protocol,
- Boolean.parseBoolean(details.get(OntapStorageConstants.IS_DISAGGREGATED).toLowerCase()));
+ protocol);
StorageStrategy storageStrategy = StorageProviderFactory.getStrategy(ontapStorage);
boolean isValid = storageStrategy.connect();
if (isValid) {
+ // Get the DataLIF for data access
String dataLIF = storageStrategy.getNetworkInterface();
if (dataLIF == null || dataLIF.isEmpty()) {
throw new CloudRuntimeException("Failed to retrieve Data LIF from ONTAP, cannot create primary storage");
}
logger.info("Using Data LIF for storage access: " + dataLIF);
details.put(OntapStorageConstants.DATA_LIF, dataLIF);
+ if (storageStrategy.getResolvedSvmUuid() != null && !storageStrategy.getResolvedSvmUuid().isEmpty()) {
+ details.put(OntapStorageConstants.SVM_UUID, storageStrategy.getResolvedSvmUuid());
+ }
logger.info("Creating ONTAP volume '" + storagePoolName + "' with size: " + capacityBytes + " bytes (" +
(capacityBytes / (1024 * 1024 * 1024)) + " GB)");
try {
@@ -157,6 +164,7 @@ public DataStore initialize(Map dsInfos) {
throw new CloudRuntimeException("ONTAP details validation failed, cannot create primary storage");
}
+ // Determine storage pool type, path and port based on protocol
String path;
int port;
switch (protocol) {
@@ -164,7 +172,9 @@ public DataStore initialize(Map dsInfos) {
parameters.setType(Storage.StoragePoolType.NetworkFilesystem);
path = OntapStorageConstants.SLASH + storagePoolName;
port = OntapStorageConstants.NFS3_PORT;
- logger.info("Setting NFS path for storage pool: " + path + ", port: " + port);
+ // Force NFSv3 for ONTAP managed storage to avoid NFSv4 ID mapping issues
+ details.put(ApiConstants.NFS_MOUNT_OPTIONS, OntapStorageConstants.NFS3_MOUNT_OPTIONS_VER_3);
+ logger.info("Setting NFS path for storage pool: " + path + ", port: " + port + " with mount option: vers=3");
break;
case ISCSI:
parameters.setType(Storage.StoragePoolType.Iscsi);
@@ -196,9 +206,9 @@ public DataStore initialize(Map dsInfos) {
}
private long validateInitializeInputs(Long capacityBytes, Long podId, Long clusterId, Long zoneId,
- String storagePoolName, String providerName, boolean managed, String url, Map details) {
+ String storagePoolName, String providerName, boolean managed, Map details) {
- // Capacity validation
+ // Validate and set capacity
if (capacityBytes == null || capacityBytes <= 0) {
logger.warn("capacityBytes not provided or invalid (" + capacityBytes + "), using ONTAP minimum size: " + ONTAP_MIN_VOLUME_SIZE_IN_BYTES);
capacityBytes = ONTAP_MIN_VOLUME_SIZE_IN_BYTES;
@@ -207,60 +217,46 @@ private long validateInitializeInputs(Long capacityBytes, Long podId, Long clust
capacityBytes = ONTAP_MIN_VOLUME_SIZE_IN_BYTES;
}
- // Scope (pod/cluster/zone) validation
+ // Validate scope
if (podId == null ^ clusterId == null) {
throw new CloudRuntimeException("Cluster Id or Pod Id is null, cannot create primary storage");
}
- if (podId == null && clusterId == null) {
- if (zoneId != null) {
- logger.info("Both Pod Id and Cluster Id are null, Primary storage pool will be associated with a Zone");
- } else {
- throw new CloudRuntimeException("Pod Id, Cluster Id and Zone Id are all null, cannot create primary storage");
- }
+
+ if (podId == null && zoneId == null) {
+ throw new CloudRuntimeException("Pod Id, Cluster Id and Zone Id are all null, cannot create primary storage");
+ }
+
+ if (podId == null) {
+ logger.info("Both Pod Id and Cluster Id are null, Primary storage pool will be associated with a Zone");
}
- // Basic parameter validation
if (StringUtils.isBlank(storagePoolName)) {
throw new CloudRuntimeException("Storage pool name is null or empty, cannot create primary storage");
}
+
if (StringUtils.isBlank(providerName)) {
throw new CloudRuntimeException("Provider name is null or empty, cannot create primary storage");
}
+
logger.debug("ONTAP primary storage will be created as " + (managed ? "managed" : "unmanaged"));
if (!managed) {
throw new CloudRuntimeException("ONTAP primary storage must be managed");
}
- // Details key validation
+ //Required ONTAP detail keys
Set requiredKeys = Set.of(
OntapStorageConstants.USERNAME,
OntapStorageConstants.PASSWORD,
OntapStorageConstants.SVM_NAME,
OntapStorageConstants.PROTOCOL,
- OntapStorageConstants.MANAGEMENT_LIF
- );
- Set optionalKeys = Set.of(
- OntapStorageConstants.IS_DISAGGREGATED
+ OntapStorageConstants.STORAGE_IP
);
- Set allowedKeys = new java.util.HashSet<>(requiredKeys);
- allowedKeys.addAll(optionalKeys);
-
- if (StringUtils.isNotBlank(url)) {
- for (String segment : url.split(OntapStorageConstants.SEMICOLON)) {
- if (segment.isEmpty()) {
- continue;
- }
- String[] kv = segment.split(OntapStorageConstants.EQUALS, 2);
- if (kv.length == 2) {
- details.put(kv[0].trim(), kv[1].trim());
- }
- }
- }
+ // Validate existing entries (reject unexpected keys, empty values)
for (Map.Entry e : details.entrySet()) {
String key = e.getKey();
String val = e.getValue();
- if (!allowedKeys.contains(key)) {
+ if (!requiredKeys.contains(key)) {
throw new CloudRuntimeException("Unexpected ONTAP detail key in URL: " + key);
}
if (StringUtils.isBlank(val)) {
@@ -268,6 +264,7 @@ private long validateInitializeInputs(Long capacityBytes, Long podId, Long clust
}
}
+ // Detect missing required keys
Set providedKeys = new HashSet<>(details.keySet());
if (!providedKeys.containsAll(requiredKeys)) {
Set missing = new HashSet<>(requiredKeys);
@@ -282,16 +279,16 @@ private long validateInitializeInputs(Long capacityBytes, Long podId, Long clust
public boolean attachCluster(DataStore dataStore, ClusterScope scope) {
logger.debug("In attachCluster for ONTAP primary storage");
if (dataStore == null) {
- throw new InvalidParameterValueException("attachCluster: dataStore should not be null");
+ throw new InvalidParameterValueException("DataStore should not be null");
}
if (scope == null) {
- throw new InvalidParameterValueException("attachCluster: scope should not be null");
+ throw new InvalidParameterValueException("Scope should not be null");
}
List hostsIdentifier = new ArrayList<>();
StoragePoolVO storagePool = storagePoolDao.findById(dataStore.getId());
if (storagePool == null) {
logger.error("attachCluster : Storage Pool not found for id: " + dataStore.getId());
- throw new CloudRuntimeException("attachCluster : Storage Pool not found for id: " + dataStore.getId());
+ throw new CloudRuntimeException("Storage Pool not found for id: " + dataStore.getId());
}
PrimaryDataStoreInfo primaryStore = (PrimaryDataStoreInfo)dataStore;
List hostsToConnect = _resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(primaryStore);
@@ -306,21 +303,10 @@ public boolean attachCluster(DataStore dataStore, ClusterScope scope) {
logger.error(errMsg);
throw new CloudRuntimeException(errMsg);
}
-
logger.debug("attachCluster: Attaching the pool to each of the host in the cluster: {}", primaryStore.getClusterId());
- if (hostsIdentifier != null && hostsIdentifier.size() > 0) {
- try {
- AccessGroup accessGroupRequest = new AccessGroup();
- accessGroupRequest.setHostsToConnect(hostsToConnect);
- accessGroupRequest.setScope(scope);
- primaryStore.setDetails(details);
- accessGroupRequest.setPrimaryDataStoreInfo(primaryStore);
- strategy.createAccessGroup(accessGroupRequest);
- } catch (Exception e) {
- logger.error("attachCluster: Failed to create access group on storage system for cluster: " + primaryStore.getClusterId() + ". Exception: " + e.getMessage());
- throw new CloudRuntimeException("attachCluster: Failed to create access group on storage system for cluster: " + primaryStore.getClusterId() + ". Exception: " + e.getMessage());
- }
- }
+ // We need to create export policy at pool level and igroup at host level(in grantAccess)
+ createNfsAccessGroupIfNeeded(details, hostsIdentifier, hostsToConnect, scope, storagePool, strategy);
+
logger.debug("attachCluster: Attaching the pool to each of the host in the cluster: {}", primaryStore.getClusterId());
for (HostVO host : hostsToConnect) {
try {
@@ -343,20 +329,23 @@ public boolean attachHost(DataStore store, HostScope scope, StoragePoolInfo exis
public boolean attachZone(DataStore dataStore, ZoneScope scope, Hypervisor.HypervisorType hypervisorType) {
logger.debug("In attachZone for ONTAP primary storage");
if (dataStore == null) {
- throw new InvalidParameterValueException("attachZone: dataStore should not be null");
+ throw new InvalidParameterValueException("dataStore should not be null");
}
if (scope == null) {
- throw new InvalidParameterValueException("attachZone: scope should not be null");
+ throw new InvalidParameterValueException("scope should not be null");
}
List hostsIdentifier = new ArrayList<>();
StoragePoolVO storagePool = storagePoolDao.findById(dataStore.getId());
if (storagePool == null) {
logger.error("attachZone : Storage Pool not found for id: " + dataStore.getId());
- throw new CloudRuntimeException("attachZone : Storage Pool not found for id: " + dataStore.getId());
+ throw new CloudRuntimeException("Storage Pool not found for id: " + dataStore.getId());
+ }
+ if (!Hypervisor.HypervisorType.KVM.equals(hypervisorType)) {
+ logger.error("attachZone : ONTAP primary storage is supported only for KVM hypervisor");
+ throw new CloudRuntimeException("ONTAP primary storage is supported only for KVM hypervisor");
}
-
PrimaryDataStoreInfo primaryStore = (PrimaryDataStoreInfo)dataStore;
- List hostsToConnect = _resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(dataStore, scope.getScopeId(), Hypervisor.HypervisorType.KVM);
+ List hostsToConnect = _resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(dataStore, scope.getScopeId(), hypervisorType);
logger.debug(String.format("In createPool. Attaching the pool to each of the hosts in %s.", hostsToConnect));
Map details = storagePoolDetailsDao.listDetailsKeyPairs(primaryStore.getId());
@@ -369,19 +358,10 @@ public boolean attachZone(DataStore dataStore, ZoneScope scope, Hypervisor.Hyper
logger.error(errMsg);
throw new CloudRuntimeException(errMsg);
}
- if (hostsIdentifier != null && !hostsIdentifier.isEmpty()) {
- try {
- AccessGroup accessGroupRequest = new AccessGroup();
- accessGroupRequest.setHostsToConnect(hostsToConnect);
- accessGroupRequest.setScope(scope);
- primaryStore.setDetails(details);
- accessGroupRequest.setPrimaryDataStoreInfo(primaryStore);
- strategy.createAccessGroup(accessGroupRequest);
- } catch (Exception e) {
- logger.error("attachZone: Failed to create access group on storage system for zone with Exception: " + e.getMessage());
- throw new CloudRuntimeException("attachZone: Failed to create access group on storage system for zone with Exception: " + e.getMessage());
- }
- }
+
+ // We need to create export policy at pool level and igroup at host level
+ createNfsAccessGroupIfNeeded(details, hostsIdentifier, hostsToConnect, scope, storagePool, strategy);
+
for (HostVO host : hostsToConnect) {
try {
_storageMgr.connectHostToSharedPool(host, dataStore.getId());
@@ -390,7 +370,7 @@ public boolean attachZone(DataStore dataStore, ZoneScope scope, Hypervisor.Hyper
return false;
}
}
- _dataStoreHelper.attachZone(dataStore);
+ _dataStoreHelper.attachZone(dataStore, hypervisorType);
return true;
}
@@ -401,7 +381,8 @@ private boolean validateProtocolSupportAndFetchHostsIdentifier(List host
for (HostVO host : hosts) {
if (host == null || host.getStorageUrl() == null || host.getStorageUrl().trim().isEmpty()
|| !host.getStorageUrl().startsWith(protocolPrefix)) {
- return false;
+ // TODO we will inform customer through alert for excluded host because of protocol enabled on host
+ continue;
}
hostIdentifiers.add(host.getStorageUrl());
}
@@ -411,23 +392,49 @@ private boolean validateProtocolSupportAndFetchHostsIdentifier(List host
for (HostVO host : hosts) {
if (host != null) {
ip = host.getStorageIpAddress() != null ? host.getStorageIpAddress().trim() : "";
- if (ip.isEmpty()) {
- if (host.getPrivateIpAddress() == null || host.getPrivateIpAddress().trim().isEmpty()) {
- return false;
- }
- ip = host.getPrivateIpAddress().trim();
+ if (ip.isEmpty() && StringUtils.isBlank(host.getPrivateIpAddress() )) {
+ // TODO we will inform customer through alert for excluded host because of protocol enabled on host
+ continue;
+ } else {
+ ip = ip.isEmpty() ? host.getPrivateIpAddress().trim() : ip;
}
+ hostIdentifiers.add(ip);
}
- hostIdentifiers.add(ip);
}
break;
default:
- throw new CloudRuntimeException("validateProtocolSupportAndFetchHostsIdentifier : Unsupported protocol: " + protocolType.name());
+ throw new CloudRuntimeException("Unsupported protocol: " + protocolType.name());
}
logger.info("validateProtocolSupportAndFetchHostsIdentifier: All hosts support the protocol: " + protocolType.name());
return true;
}
+ /**
+ * Creates an NFS export policy (access group) on the ONTAP storage if the protocol is NFS3
+ * and there are eligible hosts. Skipped for iSCSI (igroups are created per-host in grantAccess).
+ */
+ private void createNfsAccessGroupIfNeeded(Map details, List hostsIdentifier,
+ List hostsToConnect, Scope scope,
+ StoragePoolVO storagePool, StorageStrategy strategy) {
+ if (!ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ return;
+ }
+ if (hostsIdentifier.isEmpty()) {
+ // No eligible hosts — export policy will be created later via HostListener when hosts come up
+ return;
+ }
+ try {
+ AccessGroup accessGroupRequest = new AccessGroup();
+ accessGroupRequest.setHostsToConnect(hostsToConnect);
+ accessGroupRequest.setScope(scope);
+ accessGroupRequest.setStoragePoolId(storagePool.getId());
+ strategy.createAccessGroup(accessGroupRequest);
+ } catch (Exception e) {
+ logger.error("Failed to create NFS access group on storage for pool {}: {}", storagePool.getName(), e.getMessage());
+ throw new CloudRuntimeException("Failed to create NFS access group on storage for pool " + storagePool.getName() + ": " + e.getMessage());
+ }
+ }
+
@Override
public boolean maintain(DataStore store) {
logger.info("Placing storage pool {} in maintenance mode", store);
@@ -453,13 +460,15 @@ public boolean deleteDataStore(DataStore store) {
logger.info("deleteDataStore: Starting deletion process for storage pool id: {}", store.getId());
long storagePoolId = store.getId();
+ // Get the StoragePool details
StoragePool storagePool = _storageMgr.getStoragePool(storagePoolId);
if (storagePool == null) {
logger.warn("deleteDataStore: Storage pool not found for id: {}, skipping deletion", storagePoolId);
- return true;
+ return true; // Return true since the entity doesn't exist
}
try {
+ // Fetch storage pool details
Map details = _datastoreDetailsDao.listDetailsKeyPairs(storagePoolId);
if (details == null || details.isEmpty()) {
logger.warn("deleteDataStore: No details found for storage pool id: {}, proceeding with CS entity deletion only", storagePoolId);
@@ -468,11 +477,14 @@ public boolean deleteDataStore(DataStore store) {
logger.info("deleteDataStore: Deleting access groups for storage pool '{}'", storagePool.getName());
+ // Get the storage strategy to interact with ONTAP
StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ // Cast DataStore to PrimaryDataStoreInfo to get full details
PrimaryDataStoreInfo primaryDataStoreInfo = (PrimaryDataStoreInfo) store;
primaryDataStoreInfo.setDetails(details);
+ // Call deleteStorageVolume to delete the underlying ONTAP volume
logger.info("deleteDataStore: Deleting ONTAP volume for storage pool '{}'", storagePool.getName());
Volume volume = new Volume();
volume.setUuid(details.get(OntapStorageConstants.VOLUME_UUID));
@@ -490,16 +502,19 @@ public boolean deleteDataStore(DataStore store) {
storagePoolId, e.getMessage(), e);
}
AccessGroup accessGroup = new AccessGroup();
- accessGroup.setPrimaryDataStoreInfo(primaryDataStoreInfo);
+ accessGroup.setStoragePoolId(storagePoolId);
+ // Delete access groups associated with this storage pool
storageStrategy.deleteAccessGroup(accessGroup);
logger.info("deleteDataStore: Successfully deleted access groups for storage pool '{}'", storagePool.getName());
} catch (Exception e) {
logger.error("deleteDataStore: Failed to delete access groups for storage pool id: {}. Error: {}",
storagePoolId, e.getMessage(), e);
+ // Continue with CloudStack entity deletion even if ONTAP cleanup fails
logger.warn("deleteDataStore: Proceeding with CloudStack entity deletion despite ONTAP cleanup failure");
}
+ // Delete the CloudStack primary data store entity
return _dataStoreHelper.deletePrimaryDataStore(store);
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/listener/OntapHostListener.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/listener/OntapHostListener.java
index a7c851dbe718..ecdd3efd2c5c 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/listener/OntapHostListener.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/listener/OntapHostListener.java
@@ -25,6 +25,7 @@
import com.cloud.agent.api.ModifyStoragePoolAnswer;
import com.cloud.agent.api.StoragePoolInfo;
import com.cloud.alert.AlertManager;
+import com.cloud.hypervisor.Hypervisor;
import com.cloud.storage.StoragePoolHostVO;
import com.cloud.storage.dao.StoragePoolHostDao;
import org.apache.logging.log4j.Logger;
@@ -37,9 +38,12 @@
import com.cloud.utils.exception.CloudRuntimeException;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.engine.subsystem.api.storage.HypervisorHostListener;
import com.cloud.host.dao.HostDao;
+import java.util.Map;
+
public class OntapHostListener implements HypervisorHostListener {
protected Logger logger = LogManager.getLogger(getClass());
@@ -53,6 +57,9 @@ public class OntapHostListener implements HypervisorHostListener {
private HostDao _hostDao;
@Inject
private StoragePoolHostDao storagePoolHostDao;
+ @Inject
+ private StoragePoolDetailsDao _storagePoolDetailsDao;
+
@Override
public boolean hostConnect(long hostId, long poolId) {
@@ -62,6 +69,10 @@ public boolean hostConnect(long hostId, long poolId) {
logger.error("host was not found with id : {}", hostId);
return false;
}
+ if (!host.getHypervisorType().equals(Hypervisor.HypervisorType.KVM)) {
+ logger.error("ONTAP plugin does not support {} type host currently ", host.getHypervisorType());
+ return false;
+ }
StoragePool pool = _storagePoolDao.findById(poolId);
if (pool == null) {
@@ -70,7 +81,12 @@ public boolean hostConnect(long hostId, long poolId) {
}
logger.info("Connecting host {} to ONTAP storage pool {}", host.getName(), pool.getName());
try {
- ModifyStoragePoolCommand cmd = new ModifyStoragePoolCommand(true, pool);
+ // Load storage pool details from database to pass mount options and other config to agent
+ Map detailsMap = _storagePoolDetailsDao.listDetailsKeyPairs(poolId);
+ // Create the ModifyStoragePoolCommand to send to the agent
+ // Note: Always send command even if database entry exists, because agent may have restarted
+ // and lost in-memory pool registration. The command handler is idempotent.
+ ModifyStoragePoolCommand cmd = new ModifyStoragePoolCommand(true, pool, detailsMap);
Answer answer = _agentMgr.easySend(hostId, cmd);
@@ -87,9 +103,12 @@ public boolean hostConnect(long hostId, long poolId) {
"Unable to establish a connection from agent to storage pool %s due to %s", pool, answer.getDetails()));
}
+ // Get the mount path from the answer
+
if (!(answer instanceof ModifyStoragePoolAnswer)) {
- logger.error("Received unexpected answer type {} for storage pool {}", answer.getClass().getName(), pool.getName());
- throw new CloudRuntimeException("Failed to connect to storage pool. Please check agent logs for details.");
+ throw new CloudRuntimeException(String.format(
+ "Unexpected answer type %s returned for modify storage pool command for pool %s on host %d",
+ answer.getClass().getName(), pool, hostId));
}
ModifyStoragePoolAnswer mspAnswer = (ModifyStoragePoolAnswer) answer;
@@ -101,6 +120,7 @@ public boolean hostConnect(long hostId, long poolId) {
String localPath = poolInfo.getLocalPath();
logger.info("Storage pool {} successfully mounted at: {}", pool.getName(), localPath);
+ // Update or create the storage_pool_host_ref entry with the correct local_path
StoragePoolHostVO storagePoolHost = storagePoolHostDao.findByPoolHost(poolId, hostId);
if (storagePoolHost == null) {
@@ -113,6 +133,7 @@ public boolean hostConnect(long hostId, long poolId) {
logger.info("Updated storage_pool_host_ref entry with local_path: {}", localPath);
}
+ // Update pool capacity/usage information
StoragePoolVO poolVO = _storagePoolDao.findById(poolId);
if (poolVO != null && poolInfo.getCapacityBytes() > 0) {
poolVO.setCapacityBytes(poolInfo.getCapacityBytes());
@@ -123,46 +144,47 @@ public boolean hostConnect(long hostId, long poolId) {
} catch (Exception e) {
logger.error("Exception while connecting host {} to storage pool {}", host.getName(), pool.getName(), e);
+ // CRITICAL: Don't throw exception - it crashes the agent and causes restart loops
+ // Return false to indicate failure without crashing
return false;
}
return true;
}
@Override
- public boolean hostDisconnected(Host host, StoragePool pool) {
- logger.info("Disconnect from host " + host.getId() + " from pool " + pool.getName());
+ public boolean hostDisconnected(long hostId, long poolId) {
+ logger.info("Disconnect from host " + hostId + " from pool " + poolId);
- Host hostToremove = _hostDao.findById(host.getId());
+ Host hostToremove = _hostDao.findById(hostId);
if (hostToremove == null) {
- logger.error("Failed to add host by HostListener as host was not found with id : {}", host.getId());
+ logger.error("Failed to add host by HostListener as host was not found with id : {}", hostId);
return false;
}
- logger.info("Disconnecting host {} from ONTAP storage pool {}", host.getName(), pool.getName());
+
+ StoragePool pool = _storagePoolDao.findById(poolId);
+ if (pool == null) {
+ logger.error("Failed to disconnect host - storage pool not found with id: {}", poolId);
+ return false;
+ }
+ logger.info("Disconnecting host {} from ONTAP storage pool {}", hostToremove.getName(), pool.getName());
try {
DeleteStoragePoolCommand cmd = new DeleteStoragePoolCommand(pool);
- long hostId = host.getId();
Answer answer = _agentMgr.easySend(hostId, cmd);
-
if (answer != null && answer.getResult()) {
- logger.info("Successfully disconnected host {} from ONTAP storage pool {}", host.getName(), pool.getName());
+ logger.info("Successfully disconnected host {} from ONTAP storage pool {}", hostToremove.getName(), pool.getName());
return true;
} else {
String errMsg = (answer != null) ? answer.getDetails() : "Unknown error";
- logger.warn("Failed to disconnect host {} from storage pool {}. Error: {}", host.getName(), pool.getName(), errMsg);
+ logger.warn("Failed to disconnect host {} from storage pool {}. Error: {}", hostToremove.getName(), pool.getName(), errMsg);
return false;
}
} catch (Exception e) {
- logger.error("Exception while disconnecting host {} from storage pool {}", host.getName(), pool.getName(), e);
+ logger.error("Exception while disconnecting host {} from storage pool {}", hostToremove.getName(), pool.getName(), e);
return false;
}
}
- @Override
- public boolean hostDisconnected(long hostId, long poolId) {
- return false;
- }
-
@Override
public boolean hostAboutToBeRemoved(long hostId) {
return false;
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/provider/OntapPrimaryDatastoreProvider.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/provider/OntapPrimaryDatastoreProvider.java
index 65d9e9622f04..1131cd1405c7 100755
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/provider/OntapPrimaryDatastoreProvider.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/provider/OntapPrimaryDatastoreProvider.java
@@ -21,6 +21,7 @@
import com.cloud.utils.component.ComponentContext;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreDriver;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreLifeCycle;
import org.apache.cloudstack.engine.subsystem.api.storage.HypervisorHostListener;
@@ -28,7 +29,6 @@
import org.apache.cloudstack.storage.driver.OntapPrimaryDatastoreDriver;
import org.apache.cloudstack.storage.lifecycle.OntapPrimaryDatastoreLifecycle;
import org.apache.cloudstack.storage.listener.OntapHostListener;
-import org.apache.cloudstack.storage.utils.OntapStorageConstants;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.stereotype.Component;
@@ -66,7 +66,7 @@ public HypervisorHostListener getHostListener() {
@Override
public String getName() {
logger.trace("OntapPrimaryDatastoreProvider: getName: Called");
- return OntapStorageConstants.ONTAP_PLUGIN_NAME;
+ return DataStoreProvider.ONTAP_PLUGIN_NAME;
}
@Override
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/provider/StorageProviderFactory.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/provider/StorageProviderFactory.java
index 5c0bf1af4454..935e1284de42 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/provider/StorageProviderFactory.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/provider/StorageProviderFactory.java
@@ -21,6 +21,9 @@
import com.cloud.utils.component.ComponentContext;
import com.cloud.utils.exception.CloudRuntimeException;
+
+import java.nio.charset.StandardCharsets;
+
import org.apache.cloudstack.storage.feign.model.OntapStorage;
import org.apache.cloudstack.storage.service.StorageStrategy;
import org.apache.cloudstack.storage.service.UnifiedNASStrategy;
@@ -36,23 +39,23 @@ public class StorageProviderFactory {
public static StorageStrategy getStrategy(OntapStorage ontapStorage) {
ProtocolType protocol = ontapStorage.getProtocol();
logger.info("Initializing StorageProviderFactory with protocol: " + protocol);
+ String decodedPassword = new String(java.util.Base64.getDecoder().decode(ontapStorage.getPassword()), StandardCharsets.UTF_8);
+ ontapStorage = new OntapStorage(
+ ontapStorage.getUsername(),
+ decodedPassword,
+ ontapStorage.getStorageIP(),
+ ontapStorage.getSvmName(),
+ ontapStorage.getSize(),
+ protocol);
switch (protocol) {
case NFS3:
- if (!ontapStorage.getIsDisaggregated()) {
- UnifiedNASStrategy unifiedNASStrategy = new UnifiedNASStrategy(ontapStorage);
- ComponentContext.inject(unifiedNASStrategy);
- unifiedNASStrategy.setOntapStorage(ontapStorage);
- return unifiedNASStrategy;
- }
- throw new CloudRuntimeException("Unsupported configuration: Disaggregated ONTAP is not supported.");
+ UnifiedNASStrategy unifiedNASStrategy = new UnifiedNASStrategy(ontapStorage);
+ ComponentContext.inject(unifiedNASStrategy);
+ return unifiedNASStrategy;
case ISCSI:
- if (!ontapStorage.getIsDisaggregated()) {
- UnifiedSANStrategy unifiedSANStrategy = new UnifiedSANStrategy(ontapStorage);
- ComponentContext.inject(unifiedSANStrategy);
- unifiedSANStrategy.setOntapStorage(ontapStorage);
- return unifiedSANStrategy;
- }
- throw new CloudRuntimeException("Unsupported configuration: Disaggregated ONTAP is not supported.");
+ UnifiedSANStrategy unifiedSANStrategy = new UnifiedSANStrategy(ontapStorage);
+ ComponentContext.inject(unifiedSANStrategy);
+ return unifiedSANStrategy;
default:
throw new CloudRuntimeException("Unsupported protocol: " + protocol);
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/SANStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/SANStrategy.java
index ce3b2806ef75..4b1bca00f95c 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/SANStrategy.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/SANStrategy.java
@@ -19,11 +19,54 @@
package org.apache.cloudstack.storage.service;
+import org.apache.cloudstack.storage.feign.model.Igroup;
+import org.apache.cloudstack.storage.feign.model.Initiator;
import org.apache.cloudstack.storage.feign.model.OntapStorage;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
public abstract class SANStrategy extends StorageStrategy {
+ private static final Logger s_logger = LogManager.getLogger(SANStrategy.class);
public SANStrategy(OntapStorage ontapStorage) {
super(ontapStorage);
}
+ /**
+ * Ensures the LUN is mapped to the specified access group (igroup).
+ * If a mapping already exists, returns the existing LUN number.
+ * If not, creates a new mapping and returns the assigned LUN number.
+ *
+ * @param svmName the SVM name
+ * @param lunName the LUN name
+ * @param accessGroupName the igroup name
+ * @return the logical unit number as a String
+ */
+ public abstract String ensureLunMapped(String svmName, String lunName, String accessGroupName);
+
+ /**
+ * Validates that the host initiator is present in the access group (igroup).
+ *
+ * @param hostInitiator the host initiator IQN
+ * @param svmName the SVM name
+ * @param igroup the igroup
+ * @return true if the initiator is found in the igroup, false otherwise
+ */
+ public boolean validateInitiatorInAccessGroup(String hostInitiator, String svmName, Igroup igroup) {
+ s_logger.info("validateInitiatorInAccessGroup: Validating initiator [{}] is in igroup [{}] on SVM [{}]", hostInitiator, igroup, svmName);
+
+ if (hostInitiator == null || hostInitiator.isEmpty()) {
+ s_logger.warn("validateInitiatorInAccessGroup: host initiator is null or empty");
+ return false;
+ }
+ if (igroup.getInitiators() != null) {
+ for (Initiator initiator : igroup.getInitiators()) {
+ if (initiator.getName().equalsIgnoreCase(hostInitiator)) {
+ s_logger.info("validateInitiatorInAccessGroup: Initiator [{}] validated successfully in igroup [{}]", hostInitiator, igroup);
+ return true;
+ }
+ }
+ }
+ s_logger.warn("validateInitiatorInAccessGroup: Initiator [{}] NOT found in igroup [{}]", hostInitiator, igroup);
+ return false;
+ }
}
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 2eb459c78919..d349305e5e43 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
@@ -17,7 +17,7 @@
* under the License.
*/
- package org.apache.cloudstack.storage.service;
+package org.apache.cloudstack.storage.service;
import com.cloud.utils.exception.CloudRuntimeException;
import feign.FeignException;
@@ -25,7 +25,9 @@
import org.apache.cloudstack.storage.feign.client.AggregateFeignClient;
import org.apache.cloudstack.storage.feign.client.JobFeignClient;
import org.apache.cloudstack.storage.feign.client.NetworkFeignClient;
+import org.apache.cloudstack.storage.feign.client.NASFeignClient;
import org.apache.cloudstack.storage.feign.client.SANFeignClient;
+import org.apache.cloudstack.storage.feign.client.SnapshotFeignClient;
import org.apache.cloudstack.storage.feign.client.SvmFeignClient;
import org.apache.cloudstack.storage.feign.client.VolumeFeignClient;
import org.apache.cloudstack.storage.feign.model.Aggregate;
@@ -51,25 +53,40 @@
import java.util.Map;
import java.util.Objects;
+/**
+ * Storage Strategy represents the communication path for all the ONTAP storage options
+ *
+ * ONTAP storage operation would vary based on
+ * Supported protocols: NFS3.0, NFS4.1, FC, iSCSI, Nvme/TCP and Nvme/FC
+ * Supported platform: Unified and Disaggregated
+ */
public abstract class StorageStrategy {
- private final FeignClientFactory feignClientFactory;
- private final AggregateFeignClient aggregateFeignClient;
- private final VolumeFeignClient volumeFeignClient;
- private final SvmFeignClient svmFeignClient;
- private final JobFeignClient jobFeignClient;
- private final NetworkFeignClient networkFeignClient;
- private final SANFeignClient sanFeignClient;
+ // Replace @Inject Feign clients with FeignClientFactory
+ protected FeignClientFactory feignClientFactory;
+ protected AggregateFeignClient aggregateFeignClient;
+ protected VolumeFeignClient volumeFeignClient;
+ protected SvmFeignClient svmFeignClient;
+ protected JobFeignClient jobFeignClient;
+ protected NetworkFeignClient networkFeignClient;
+ protected SANFeignClient sanFeignClient;
+ protected NASFeignClient nasFeignClient;
+ protected SnapshotFeignClient snapshotFeignClient;
protected OntapStorage storage;
+ /**
+ * Presents aggregate object for the unified storage, not eligible for disaggregated
+ */
private List aggregates;
+ private String resolvedSvmUuid;
private static final Logger logger = LogManager.getLogger(StorageStrategy.class);
public StorageStrategy(OntapStorage ontapStorage) {
storage = ontapStorage;
- String baseURL = OntapStorageConstants.HTTPS + storage.getManagementLIF();
+ String baseURL = OntapStorageConstants.HTTPS + storage.getStorageIP();
logger.info("Initializing StorageStrategy with base URL: " + baseURL);
+ // Initialize FeignClientFactory and create clients
this.feignClientFactory = new FeignClientFactory();
this.aggregateFeignClient = feignClientFactory.createClient(AggregateFeignClient.class, baseURL);
this.volumeFeignClient = feignClientFactory.createClient(VolumeFeignClient.class, baseURL);
@@ -77,75 +94,129 @@ public StorageStrategy(OntapStorage ontapStorage) {
this.jobFeignClient = feignClientFactory.createClient(JobFeignClient.class, baseURL);
this.networkFeignClient = feignClientFactory.createClient(NetworkFeignClient.class, baseURL);
this.sanFeignClient = feignClientFactory.createClient(SANFeignClient.class, baseURL);
+ this.nasFeignClient = feignClientFactory.createClient(NASFeignClient.class, baseURL);
+ this.snapshotFeignClient = feignClientFactory.createClient(SnapshotFeignClient.class, baseURL);
}
+ /**
+ * Validates ONTAP cluster reachability, credentials, SVM state, protocol, and aggregate capacity
+ * for new FlexVol creation (primary pool provisioning).
+ */
public boolean connect() {
- logger.info("Attempting to connect to ONTAP cluster at " + storage.getManagementLIF() + " and validate SVM " +
- storage.getSvmName() + ", protocol " + storage.getProtocol());
+ return connect(true);
+ }
+
+ /**
+ * Validates ONTAP cluster reachability and SVM/protocol settings.
+ *
+ * Aggregate free-space checks apply only when {@code validateAggregatesForVolumeCreation} is
+ * {@code true} (pool provisioning). Snapshot, delete, revert, and grant/revoke paths must use
+ * {@code false} — they operate on an existing FlexVol and must not compare aggregate space to
+ * the full pool capacity stored in pool details.
+ */
+ public boolean connect(boolean validateAggregatesForVolumeCreation) {
+ logger.info("Attempting to connect to ONTAP cluster at " + storage.getStorageIP() + " and validate SVM " +
+ storage.getSvmName() + ", protocol " + storage.getProtocol()
+ + (validateAggregatesForVolumeCreation ? " (with aggregate validation)" : " (operations only)"));
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
String svmName = storage.getSvmName();
try {
+ // Call the SVM API to check if the SVM exists
Svm svm = new Svm();
logger.info("Fetching the SVM details...");
Map queryParams = Map.of(OntapStorageConstants.NAME, svmName, OntapStorageConstants.FIELDS, OntapStorageConstants.AGGREGATES +
- OntapStorageConstants.COMMA + OntapStorageConstants.STATE);
+ OntapStorageConstants.COMMA + OntapStorageConstants.STATE +
+ OntapStorageConstants.COMMA + OntapStorageConstants.NFS_ENABLED + OntapStorageConstants.COMMA + OntapStorageConstants.ISCSI_ENABLED);
OntapResponse svms = svmFeignClient.getSvmResponse(queryParams, authHeader);
if (svms != null && svms.getRecords() != null && !svms.getRecords().isEmpty()) {
svm = svms.getRecords().get(0);
} else {
- logger.error("No SVM found on the ONTAP cluster by the name" + svmName + ".");
- return false;
+ logger.error("No SVM found on the ONTAP cluster by the name " + svmName + ".");
+ throw new CloudRuntimeException("No SVM found on the ONTAP cluster by the name " + svmName + ".");
}
logger.info("Validating SVM state and protocol settings...");
if (!Objects.equals(svm.getState(), OntapStorageConstants.RUNNING)) {
logger.error("SVM " + svmName + " is not in running state.");
- return false;
+ throw new CloudRuntimeException("SVM " + svmName + " is not in running state.");
}
- if (Objects.equals(storage.getProtocol(), OntapStorageConstants.NFS) && !svm.getNfsEnabled()) {
+ if (Objects.equals(storage.getProtocol(), ProtocolType.NFS3) && !svm.getNfsEnabled()) {
logger.error("NFS protocol is not enabled on SVM " + svmName);
- return false;
- } else if (Objects.equals(storage.getProtocol(), OntapStorageConstants.ISCSI) && !svm.getIscsiEnabled()) {
- logger.error("iSCSI protocol is not enabled on SVM " + svmName);
- return false;
- }
- List aggrs = svm.getAggregates();
- if (aggrs == null || aggrs.isEmpty()) {
- logger.error("No aggregates are assigned to SVM " + svmName);
- return false;
- }
- for (Aggregate aggr : aggrs) {
- logger.debug("Found aggregate: " + aggr.getName() + " with UUID: " + aggr.getUuid());
- Aggregate aggrResp = aggregateFeignClient.getAggregateByUUID(authHeader, aggr.getUuid());
- if (aggrResp == null) {
- logger.warn("Aggregate details response is null for aggregate " + aggr.getName() + ". Skipping.");
- break;
- }
- if (!Objects.equals(aggrResp.getState(), Aggregate.StateEnum.ONLINE)) {
- logger.warn("Aggregate " + aggr.getName() + " is not in online state. Skipping this aggregate.");
- continue;
- } else if (aggrResp.getSpace() == null || aggrResp.getAvailableBlockStorageSpace() == null ||
- aggrResp.getAvailableBlockStorageSpace() <= storage.getSize().doubleValue()) {
- logger.warn("Aggregate " + aggr.getName() + " does not have sufficient available space. Skipping this aggregate.");
- continue;
- }
- logger.info("Selected aggregate: " + aggr.getName() + " for volume operations.");
- this.aggregates = List.of(aggr);
- break;
+ throw new CloudRuntimeException("NFS protocol is not enabled on SVM " + svmName);
+ } else if (Objects.equals(storage.getProtocol(), ProtocolType.ISCSI) && !svm.getIscsiEnabled()) {
+ logger.error("ISCSI protocol is not enabled on SVM " + svmName);
+ throw new CloudRuntimeException("ISCSI protocol is not enabled on SVM " + svmName);
}
- if (this.aggregates == null || this.aggregates.isEmpty()) {
- logger.error("No suitable aggregates found on SVM " + svmName + " for volume creation.");
- return false;
+ this.resolvedSvmUuid = svm.getUuid();
+
+ if (validateAggregatesForVolumeCreation) {
+ validateAndSelectAggregatesForVolumeCreation(authHeader, svmName, svm.getAggregates());
+ } else {
+ logger.debug("Skipping aggregate capacity validation — not required for existing-volume operations");
}
logger.info("Successfully connected to ONTAP cluster and validated ONTAP details provided");
+ } catch (CloudRuntimeException e) {
+ throw e;
+ } catch (FeignException.Unauthorized e) {
+ String msg = "Authentication failed: Invalid credentials. Please verify the username and password.";
+ logger.error(msg, e);
+ throw new CloudRuntimeException(msg, e);
} catch (Exception e) {
logger.error("Failed to connect to ONTAP cluster: " + e.getMessage(), e);
- return false;
+ throw new CloudRuntimeException("Failed to connect to ONTAP cluster: " + e.getMessage(), e);
}
return true;
}
+ /**
+ * ONTAP SVM UUID resolved during the last successful {@link #connect(boolean)} call.
+ */
+ public String getResolvedSvmUuid() {
+ return resolvedSvmUuid;
+ }
+
+ private void validateAndSelectAggregatesForVolumeCreation(String authHeader, String svmName, List aggrs) {
+ if (aggrs == null || aggrs.isEmpty()) {
+ logger.error("No aggregates are assigned to SVM " + svmName);
+ throw new CloudRuntimeException("No aggregates are assigned to SVM " + svmName);
+ }
+ for (Aggregate aggr : aggrs) {
+ logger.debug("Found aggregate: " + aggr.getName() + " with UUID: " + aggr.getUuid());
+ Aggregate aggrResp = aggregateFeignClient.getAggregateByUUID(authHeader, aggr.getUuid());
+ if (aggrResp == null) {
+ logger.warn("Aggregate details response is null for aggregate " + aggr.getName() + ". Skipping.");
+ continue;
+ }
+ if (!Objects.equals(aggrResp.getState(), Aggregate.StateEnum.ONLINE)) {
+ logger.warn("Aggregate " + aggr.getName() + " is not in online state. Skipping this aggregate.");
+ continue;
+ } else if (aggrResp.getSpace() == null || aggrResp.getAvailableBlockStorageSpace() == null ||
+ aggrResp.getAvailableBlockStorageSpace() <= storage.getSize().doubleValue()) {
+ logger.warn("Aggregate " + aggr.getName() + " does not have sufficient available space. Skipping this aggregate.");
+ continue;
+ }
+ logger.info("Selected aggregate: " + aggr.getName() + " for volume operations.");
+ this.aggregates = List.of(aggr);
+ break;
+ }
+ if (this.aggregates == null || this.aggregates.isEmpty()) {
+ logger.error("No suitable aggregates found on SVM " + svmName + " for volume creation.");
+ throw new CloudRuntimeException("No suitable aggregates found on SVM " + svmName + " for volume creation.");
+ }
+ }
+
+ // Common methods like create/delete etc., should be here
+
+ /**
+ * Creates ONTAP Flex-Volume
+ * Eligible only for Unified ONTAP storage
+ * throw exception in case of disaggregated ONTAP storage
+ *
+ * @param volumeName the name of the volume to create
+ * @param size the size of the volume in bytes
+ * @return the created Volume object
+ */
public Volume createStorageVolume(String volumeName, Long size) {
logger.info("Creating volume: " + volumeName + " of size: " + size + " bytes");
@@ -160,6 +231,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ // Generate the Create Volume Request
Volume volumeRequest = new Volume();
Svm svm = new Svm();
svm.setName(svmName);
@@ -169,6 +241,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
volumeRequest.setName(volumeName);
volumeRequest.setSvm(svm);
+ // Pick the best aggregate for this specific request (largest available, online, and sufficient space).
long maxAvailableAggregateSpaceBytes = -1L;
Aggregate aggrChosen = null;
for (Aggregate aggr : aggregates) {
@@ -177,7 +250,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
if (aggrResp == null) {
logger.warn("Aggregate details response is null for aggregate " + aggr.getName() + ". Skipping.");
- break;
+ continue;
}
if (!Objects.equals(aggrResp.getState(), Aggregate.StateEnum.ONLINE)) {
@@ -193,7 +266,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
final long availableBytes = aggrResp.getAvailableBlockStorageSpace().longValue();
logger.debug("Aggregate " + aggr.getName() + " available bytes=" + availableBytes + ", requested=" + size);
- if (availableBytes <= size) {
+ if (availableBytes < size) {
logger.warn("Aggregate " + aggr.getName() + " does not have sufficient available space. Required=" +
size + " bytes, available=" + availableBytes + " bytes. Skipping this aggregate.");
continue;
@@ -217,6 +290,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
volumeRequest.setAggregates(List.of(aggr));
volumeRequest.setSize(size);
volumeRequest.setNas(nas);
+ volumeRequest.setGuarantee(new Volume.Guarantee(Volume.Guarantee.TypeEnum.NONE));
try {
JobResponse jobResponse = volumeFeignClient.createVolumeWithJob(authHeader, volumeRequest);
if (jobResponse == null || jobResponse.getJob() == null) {
@@ -224,7 +298,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
}
String jobUUID = jobResponse.getJob().getUuid();
- Boolean jobSucceeded = jobPollForSuccess(jobUUID);
+ Boolean jobSucceeded = jobPollForSuccess(jobUUID,10, 1000);
if (!jobSucceeded) {
logger.error("Volume creation job failed for volume: " + volumeName);
throw new CloudRuntimeException("Volume creation job failed for volume: " + volumeName);
@@ -234,6 +308,8 @@ public Volume createStorageVolume(String volumeName, Long size) {
logger.error("Exception while creating volume: ", e);
throw new CloudRuntimeException("Failed to create volume: " + e.getMessage());
}
+ // Verify if the Volume has been created and set the Volume object
+ // Call the VolumeFeignClient to get the created volume details
OntapResponse volumesResponse = volumeFeignClient.getAllVolumes(authHeader, Map.of(OntapStorageConstants.NAME, volumeName));
if (volumesResponse == null || volumesResponse.getRecords() == null || volumesResponse.getRecords().isEmpty()) {
logger.error("Volume " + volumeName + " not found after creation.");
@@ -281,16 +357,31 @@ public Volume createStorageVolume(String volumeName, Long size) {
}
}
+ /**
+ * Updates ONTAP Flex-Volume
+ * Eligible only for Unified ONTAP storage
+ * throw exception in case of disaggregated ONTAP storage
+ *
+ * @param volume the volume to update
+ * @return the updated Volume object
+ */
public Volume updateStorageVolume(Volume volume) {
return null;
}
+ /**
+ * Delete ONTAP Flex-Volume
+ * Eligible only for Unified ONTAP storage
+ * throw exception in case of disaggregated ONTAP storage
+ *
+ * @param volume the volume to delete
+ */
public void deleteStorageVolume(Volume volume) {
logger.info("Deleting ONTAP volume by name: " + volume.getName() + " and uuid: " + volume.getUuid());
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
try {
JobResponse jobResponse = volumeFeignClient.deleteVolume(authHeader, volume.getUuid());
- Boolean jobSucceeded = jobPollForSuccess(jobResponse.getJob().getUuid());
+ Boolean jobSucceeded = jobPollForSuccess(jobResponse.getJob().getUuid(), 10, 1000);
if (!jobSucceeded) {
logger.error("Volume deletion job failed for volume: " + volume.getName());
throw new CloudRuntimeException("Volume deletion job failed for volume: " + volume.getName());
@@ -303,10 +394,25 @@ public void deleteStorageVolume(Volume volume) {
logger.info("ONTAP volume deletion process completed for volume: " + volume.getName());
}
+ /**
+ * Gets ONTAP Flex-Volume
+ * Eligible only for Unified ONTAP storage
+ * throw exception in case of disaggregated ONTAP storage
+ *
+ * @param volume the volume to retrieve
+ * @return the retrieved Volume object
+ */
public Volume getStorageVolume(Volume volume) {
return null;
}
+ /**
+ * Get the storage path based on protocol.
+ * For iSCSI: Returns the iSCSI target IQN (e.g., iqn.1992-08.com.netapp:sn.xxx:vs.3)
+ * For NFS: Returns the mount path (to be implemented)
+ *
+ * @return the storage path as a String
+ */
public String getStoragePath() {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
String targetIqn = null;
@@ -336,6 +442,7 @@ public String getStoragePath() {
return targetIqn;
} else if (storage.getProtocol() == ProtocolType.NFS3) {
+ // TODO: Implement NFS path retrieval logic
} else {
throw new CloudRuntimeException("Unsupported protocol for path retrieval: " + storage.getProtocol());
}
@@ -347,6 +454,14 @@ public String getStoragePath() {
return targetIqn;
}
+
+
+ /**
+ * Get the network ip interface
+ *
+ * @return the network interface ip as a String
+ */
+
public String getNetworkInterface() {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
try {
@@ -371,6 +486,7 @@ public String getNetworkInterface() {
networkFeignClient.getNetworkIpInterfaces(authHeader, queryParams);
if (response != null && response.getRecords() != null && !response.getRecords().isEmpty()) {
IpInterface ipInterface = null;
+ // For simplicity, return the first interface's name (Of IPv4 type for NFS3)
if (storage.getProtocol() == ProtocolType.ISCSI) {
ipInterface = response.getRecords().get(0);
} else if (storage.getProtocol() == ProtocolType.NFS3) {
@@ -394,37 +510,193 @@ public String getNetworkInterface() {
}
}
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses.
+ * it is going to mimic
+ * createLun for iSCSI, FC protocols
+ * createFile for NFS3.0 and NFS4.1 protocols
+ * createNameSpace for Nvme/TCP and Nvme/FC protocol
+ *
+ * @param cloudstackVolume the CloudStack volume to create
+ * @return the created CloudStackVolume object
+ */
abstract public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume);
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses.
+ * it is going to mimic
+ * updateLun for iSCSI, FC protocols
+ * updateFile for NFS3.0 and NFS4.1 protocols
+ * updateNameSpace for Nvme/TCP and Nvme/FC protocol
+ *
+ * @param cloudstackVolume the CloudStack volume to update
+ * @return the updated CloudStackVolume object
+ */
abstract CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume);
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses.
+ * it is going to mimic
+ * deleteLun for iSCSI, FC protocols
+ * deleteFile for NFS3.0 and NFS4.1 protocols
+ * deleteNameSpace for Nvme/TCP and Nvme/FC protocol
+ *
+ * @param cloudstackVolume the CloudStack volume to delete
+ */
abstract public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume);
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses.
+ * it is going to mimic
+ * cloneLun for iSCSI, FC protocols
+ * cloneFile for NFS3.0 and NFS4.1 protocols
+ * cloneNameSpace for Nvme/TCP and Nvme/FC protocol
+ * @param cloudstackVolume the CloudStack volume to copy
+ */
abstract public void copyCloudStackVolume(CloudStackVolume cloudstackVolume);
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses.
+ * it is going to mimic
+ * getLun for iSCSI, FC protocols
+ * getFile for NFS3.0 and NFS4.1 protocols
+ * getNameSpace for Nvme/TCP and Nvme/FC protocol
+ * @param cloudStackVolumeMap the CloudStack volume to retrieve
+ * @return the retrieved CloudStackVolume object
+ */
abstract public CloudStackVolume getCloudStackVolume(Map cloudStackVolumeMap);
+ /**
+ * Reverts a CloudStack volume to a snapshot using ONTAP CLI-based Single File Snap Restore (SFSR).
+ *
+ * Both NFS and iSCSI use the CLI passthrough API:
+ * {@code POST /api/private/cli/volume/snapshot/restore-file}
+ *
+ * Callers should invoke {@link #executeCliSfsrRestore(JobResponse, String)} after this
+ * method returns to poll the async job when present, or treat a missing job as synchronous success.
+ *
+ * @param snapshotName The ONTAP FlexVolume snapshot name
+ * @param flexVolUuid The FlexVolume UUID containing the snapshot
+ * @param snapshotUuid The ONTAP snapshot UUID (used for NFS file restore)
+ * @param volumePath The path of the file/LUN within the FlexVolume
+ * @param lunUuid The LUN UUID (only for iSCSI, null for NFS)
+ * @param flexVolName The FlexVolume name (only for iSCSI, for constructing destination path)
+ * @return JobResponse for the async restore operation
+ */
+ public abstract JobResponse revertSnapshotForCloudStackVolume(String snapshotName, String flexVolUuid,
+ String snapshotUuid, String volumePath,
+ String lunUuid, String flexVolName);
+
+
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses
+ * createiGroup for iSCSI and FC protocols
+ * createExportPolicy for NFS 3.0 and NFS 4.1 protocols
+ * createSubsystem for Nvme/TCP and Nvme/FC protocols
+ * @param accessGroup the access group to create
+ * @return the created AccessGroup object
+ */
abstract public AccessGroup createAccessGroup(AccessGroup accessGroup);
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses
+ * deleteiGroup for iSCSI and FC protocols
+ * deleteExportPolicy for NFS 3.0 and NFS 4.1 protocols
+ * deleteSubsystem for Nvme/TCP and Nvme/FC protocols
+ * @param accessGroup the access group to delete
+ */
abstract public void deleteAccessGroup(AccessGroup accessGroup);
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses
+ * updateiGroup example add/remove-Iqn for iSCSI and FC protocols
+ * updateExportPolicy example add/remove-Rule for NFS 3.0 and NFS 4.1 protocols
+ * //TODO for Nvme/TCP and Nvme/FC protocols
+ * @param accessGroup the access group to update
+ * @return the updated AccessGroup object
+ */
abstract AccessGroup updateAccessGroup(AccessGroup accessGroup);
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses
+ * e.g., getIGroup for iSCSI and FC protocols
+ * e.g., getExportPolicy for NFS 3.0 and NFS 4.1 protocols
+ * //TODO for Nvme/TCP and Nvme/FC protocols
+ * @param values map to get access group values like name, svm name etc.
+ */
abstract public AccessGroup getAccessGroup(Map values);
- abstract public Map enableLogicalAccess(Map values);
-
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses
+ * lunMap for iSCSI and FC protocols
+ * //TODO for NFS 3.0 and NFS 4.1 protocols (e.g., export rule management)
+ * //TODO for Nvme/TCP and Nvme/FC protocols
+ * @param values map including SVM name, LUN name, and igroup name (for SAN) or equivalent for NAS
+ * @return String containing logical unit number for the new/existing mapping (SAN) or relevant info for NAS
+ */
+ abstract public String enableLogicalAccess(Map values);
+
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses
+ * lunUnmap for iSCSI and FC protocols
+ * @param values map including LUN UUID and iGroup UUID (for SAN) or equivalent for NAS
+ */
abstract public void disableLogicalAccess(Map values);
- abstract public Map getLogicalAccess(Map values);
+ /**
+ * Method encapsulates the behavior based on the opted protocol in subclasses
+ * lunMap lookup for iSCSI/FC protocols (GET-only, no side-effects)
+ * @param values map with SVM name, LUN name, and igroup name (for SAN) or equivalent for NAS
+ * @return String containing logical unit number if mapping exists; otherwise null
+ */
+ abstract public String getLogicalAccess(Map values);
+
+ // ── FlexVolume Snapshot accessors ────────────────────────────────────────
+
+ /**
+ * Returns the {@link SnapshotFeignClient} for ONTAP FlexVolume snapshot operations.
+ */
+ public SnapshotFeignClient getSnapshotFeignClient() {
+ return snapshotFeignClient;
+ }
+
+ /**
+ * Returns the {@link NASFeignClient} for ONTAP NAS file operations
+ * (including file clone for single-file SnapRestore).
+ */
+ public NASFeignClient getNasFeignClient() {
+ return nasFeignClient;
+ }
+
+ /**
+ * Generates the Basic-auth header for ONTAP REST calls.
+ */
+ public String getAuthHeader() {
+ return OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ }
+
+ /**
+ * Polls an ONTAP async job for successful completion.
+ *
+ * @param jobUUID UUID of the ONTAP job to poll
+ * @param maxRetries maximum number of poll attempts
+ * @param sleepTimeInMilliSecs sleep between poll attempts
+ * @return true if the job completed successfully
+ */
+ public Boolean jobPollForSuccess(String jobUUID, int maxRetries, int sleepTimeInMilliSecs) {
+ return jobPollUntilSuccess(jobUUID, maxRetries, sleepTimeInMilliSecs) != null;
+ }
- private Boolean jobPollForSuccess(String jobUUID) {
+ /**
+ * Polls an ONTAP async job until it succeeds and returns the completed job record.
+ */
+ public Job jobPollUntilSuccess(String jobUUID, int maxRetries, int sleepTimeInMilliSecs) {
int jobRetryCount = 0;
Job jobResp = null;
try {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
while (jobResp == null || !jobResp.getState().equals(OntapStorageConstants.JOB_SUCCESS)) {
- if (jobRetryCount >= OntapStorageConstants.JOB_MAX_RETRIES) {
+ if (jobRetryCount >= maxRetries) {
logger.error("Job did not complete within expected time.");
throw new CloudRuntimeException("Job did not complete within expected time.");
}
@@ -441,16 +713,113 @@ private Boolean jobPollForSuccess(String jobUUID) {
}
jobRetryCount++;
- Thread.sleep(OntapStorageConstants.CREATE_VOLUME_CHECK_SLEEP_TIME);
- }
- if (jobResp == null || !jobResp.getState().equals(OntapStorageConstants.JOB_SUCCESS)) {
- return false;
+ Thread.sleep(sleepTimeInMilliSecs);
}
+ return jobResp;
} catch (FeignException.FeignClientException e) {
throw new CloudRuntimeException("Failed to fetch job status: " + e.getMessage());
} catch (InterruptedException e) {
- throw new RuntimeException(e);
+ Thread.currentThread().interrupt();
+ throw new CloudRuntimeException("Interrupted while polling ONTAP job " + jobUUID, e);
}
- return true;
+ }
+
+ /**
+ * Polls an ONTAP async job when the API response includes a job reference.
+ *
+ * When no job is returned (common for CLI passthrough SFSR on synchronous completion),
+ * the operation is treated as successful after HTTP 2xx.
+ *
+ * @param response ONTAP job response (may be null or without a job)
+ * @param operationName label for logging and error messages
+ */
+ public void pollJobIfPresent(JobResponse response, String operationName) {
+ pollJobIfPresent(response, operationName,
+ OntapStorageConstants.ONTAP_CG_JOB_MAX_RETRIES,
+ OntapStorageConstants.ONTAP_CG_JOB_POLL_INTERVAL_MS);
+ }
+
+ /**
+ * Polls an ONTAP async job when present, using caller-supplied retry settings.
+ */
+ public void pollJobIfPresent(JobResponse response, String operationName,
+ int maxRetries, int pollIntervalMs) {
+ if (response == null || response.getJob() == null || response.getJob().getUuid() == null) {
+ logger.debug("pollJobIfPresent: No async job returned for operation [{}], continuing without polling",
+ operationName);
+ return;
+ }
+ jobPollForSuccess(response.getJob().getUuid(), maxRetries, pollIntervalMs);
+ }
+
+ /**
+ * Polls an ONTAP async job when present and returns the completed job (for extracting created resource UUIDs).
+ */
+ public Job pollJobIfPresentAndGetCompletedJob(JobResponse response, String operationName) {
+ return pollJobIfPresentAndGetCompletedJob(response, operationName,
+ OntapStorageConstants.ONTAP_CG_JOB_MAX_RETRIES,
+ OntapStorageConstants.ONTAP_CG_JOB_POLL_INTERVAL_MS);
+ }
+
+ public Job pollJobIfPresentAndGetCompletedJob(JobResponse response, String operationName,
+ int maxRetries, int pollIntervalMs) {
+ if (response == null || response.getJob() == null || response.getJob().getUuid() == null) {
+ logger.debug("pollJobIfPresentAndGetCompletedJob: No async job for operation [{}]", operationName);
+ return null;
+ }
+ return jobPollUntilSuccess(response.getJob().getUuid(), maxRetries, pollIntervalMs);
+ }
+
+ /**
+ * Completes CLI-based SFSR ({@code restore-file}) orchestration: poll job when returned,
+ * otherwise accept synchronous success.
+ */
+ public void executeCliSfsrRestore(JobResponse response, String operationName) {
+ pollJobIfPresent(response, operationName,
+ OntapStorageConstants.ONTAP_SFSR_JOB_MAX_RETRIES,
+ OntapStorageConstants.ONTAP_SFSR_JOB_POLL_INTERVAL_MS);
+ }
+
+ /**
+ * Deletes a FlexVolume snapshot on ONTAP for a CloudStack volume snapshot.
+ *
+ * ONTAP volume snapshots (NFS and iSCSI) are FlexVol-level snapshots created by
+ * {@code POST /storage/volumes/{uuid}/snapshots} during take. Delete uses the matching
+ * REST {@code DELETE /storage/volumes/{uuid}/snapshots/{snapshot.uuid}} API regardless
+ * of whether the CloudStack volume is a file (NFS) or LUN (iSCSI). Protocol-specific
+ * subclasses ({@code UnifiedNASStrategy}, {@code UnifiedSANStrategy}) inherit this
+ * implementation; revert/restore remains protocol-specific via SFSR CLI.
+ *
+ * Called from {@link org.apache.cloudstack.storage.driver.OntapPrimaryDatastoreDriver}
+ * during the standard delete chain — not from a separate ONTAP snapshot strategy.
+ *
+ * @param flexVolUuid ONTAP FlexVolume UUID
+ * @param snapshotUuid ONTAP FlexVolume snapshot UUID
+ * @param snapshotName ONTAP FlexVolume snapshot name (for logging)
+ */
+ public void deleteFlexVolSnapshotForCloudStackVolume(String flexVolUuid, String snapshotUuid, String snapshotName) {
+ if (flexVolUuid == null || flexVolUuid.isEmpty() || snapshotUuid == null || snapshotUuid.isEmpty()) {
+ throw new CloudRuntimeException("FlexVolume UUID and snapshot UUID are required to delete an ONTAP snapshot");
+ }
+
+ logger.info("deleteFlexVolSnapshotForCloudStackVolume: issuing ONTAP REST delete for snapshot [{}] "
+ + "(uuid={}) on FlexVol [{}]", snapshotName, snapshotUuid, flexVolUuid);
+
+ JobResponse jobResponse = snapshotFeignClient.deleteSnapshot(getAuthHeader(), flexVolUuid, snapshotUuid);
+
+ if (jobResponse == null || jobResponse.getJob() == null) {
+ logger.debug("deleteFlexVolSnapshotForCloudStackVolume: no async job returned for snapshot [{}] "
+ + "(uuid={}); treating HTTP success as completion", snapshotName, snapshotUuid);
+ } else {
+ logger.debug("deleteFlexVolSnapshotForCloudStackVolume: polling ONTAP delete job [{}] for snapshot [{}]",
+ jobResponse.getJob().getUuid(), snapshotName);
+ }
+
+ pollJobIfPresent(jobResponse, "delete FlexVol snapshot [" + snapshotName + "] uuid [" + snapshotUuid + "]",
+ OntapStorageConstants.ONTAP_SNAPSHOT_DELETE_JOB_MAX_RETRIES,
+ OntapStorageConstants.ONTAP_SNAPSHOT_DELETE_JOB_POLL_INTERVAL_MS);
+
+ logger.info("deleteFlexVolSnapshotForCloudStackVolume: ONTAP FlexVol snapshot [{}] (uuid={}) removed from [{}]",
+ snapshotName, snapshotUuid, flexVolUuid);
}
}
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 54dee01ac2b6..198957ca5db8 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedNASStrategy.java
@@ -19,19 +19,22 @@
package org.apache.cloudstack.storage.service;
+import com.cloud.agent.api.Answer;
import com.cloud.host.HostVO;
+import com.cloud.storage.Storage;
+import com.cloud.storage.VolumeVO;
import com.cloud.storage.dao.VolumeDao;
import com.cloud.utils.exception.CloudRuntimeException;
import feign.FeignException;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataObject;
+import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint;
import org.apache.cloudstack.engine.subsystem.api.storage.EndPointSelector;
-import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreInfo;
+import org.apache.cloudstack.storage.command.CreateObjectCommand;
+import org.apache.cloudstack.storage.command.DeleteCommand;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
-import org.apache.cloudstack.storage.feign.FeignClientFactory;
-import org.apache.cloudstack.storage.feign.client.JobFeignClient;
-import org.apache.cloudstack.storage.feign.client.NASFeignClient;
-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.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;
@@ -39,8 +42,10 @@
import org.apache.cloudstack.storage.feign.model.Volume;
import org.apache.cloudstack.storage.feign.model.response.JobResponse;
import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
+import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest;
import org.apache.cloudstack.storage.service.model.AccessGroup;
import org.apache.cloudstack.storage.service.model.CloudStackVolume;
+import org.apache.cloudstack.storage.volume.VolumeObject;
import org.apache.cloudstack.storage.utils.OntapStorageConstants;
import org.apache.cloudstack.storage.utils.OntapStorageUtils;
import org.apache.logging.log4j.LogManager;
@@ -52,23 +57,13 @@
import java.util.Map;
public class UnifiedNASStrategy extends NASStrategy {
-
private static final Logger logger = LogManager.getLogger(UnifiedNASStrategy.class);
- private final FeignClientFactory feignClientFactory;
- private final NASFeignClient nasFeignClient;
- private final VolumeFeignClient volumeFeignClient;
- private final JobFeignClient jobFeignClient;
@Inject private VolumeDao volumeDao;
@Inject private EndPointSelector epSelector;
@Inject private StoragePoolDetailsDao storagePoolDetailsDao;
public UnifiedNASStrategy(OntapStorage ontapStorage) {
super(ontapStorage);
- String baseURL = OntapStorageConstants.HTTPS + ontapStorage.getManagementLIF();
- this.feignClientFactory = new FeignClientFactory();
- this.nasFeignClient = feignClientFactory.createClient(NASFeignClient.class, baseURL);
- this.volumeFeignClient = feignClientFactory.createClient(VolumeFeignClient.class,baseURL );
- this.jobFeignClient = feignClientFactory.createClient(JobFeignClient.class, baseURL );
}
public void setOntapStorage(OntapStorage ontapStorage) {
@@ -77,7 +72,22 @@ public void setOntapStorage(OntapStorage ontapStorage) {
@Override
public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume) {
- return null;
+ logger.info("createCloudStackVolume: Create cloudstack volume " + cloudstackVolume);
+ try {
+ // Step 1: set cloudstack volume metadata
+ updateCloudStackVolumeMetadata(cloudstackVolume.getDatastoreId(), cloudstackVolume.getVolumeInfo());
+ // Step 2: Send command to KVM host to create qcow2 file using qemu-img
+ Answer answer = createVolumeOnKVMHost(cloudstackVolume.getVolumeInfo());
+ if (answer == null || !answer.getResult()) {
+ String errMsg = answer != null ? answer.getDetails() : "Failed to create qcow2 on KVM host";
+ logger.error("createCloudStackVolume: " + errMsg);
+ throw new CloudRuntimeException(errMsg);
+ }
+ return cloudstackVolume;
+ }catch (Exception e) {
+ logger.error("createCloudStackVolume: error occured " + e);
+ throw new CloudRuntimeException(e);
+ }
}
@Override
@@ -87,6 +97,19 @@ CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) {
@Override
public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ logger.info("deleteCloudStackVolume: Delete cloudstack volume " + cloudstackVolume);
+ try {
+ // Step 1: Send command to KVM host to delete qcow2 file using qemu-img
+ Answer answer = deleteVolumeOnKVMHost(cloudstackVolume.getVolumeInfo());
+ if (answer == null || !answer.getResult()) {
+ String errMsg = answer != null ? answer.getDetails() : "Failed to delete qcow2 on KVM host";
+ logger.error("deleteCloudStackVolume: " + errMsg);
+ throw new CloudRuntimeException(errMsg);
+ }
+ } catch (Exception e) {
+ logger.error("deleteCloudStackVolume: error occured " + e);
+ throw new CloudRuntimeException(e);
+ }
}
@Override
@@ -96,24 +119,38 @@ public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) {
@Override
public CloudStackVolume getCloudStackVolume(Map cloudStackVolumeMap) {
- return null;
+ logger.info("getCloudStackVolume: Get cloudstack volume " + cloudStackVolumeMap);
+ CloudStackVolume cloudStackVolume = null;
+ FileInfo fileInfo = getFile(cloudStackVolumeMap.get(OntapStorageConstants.VOLUME_UUID),cloudStackVolumeMap.get(OntapStorageConstants.FILE_PATH));
+
+ if (fileInfo != null) {
+ cloudStackVolume = new CloudStackVolume();
+ cloudStackVolume.setFlexVolumeUuid(cloudStackVolumeMap.get(OntapStorageConstants.VOLUME_UUID));
+ cloudStackVolume.setFile(fileInfo);
+ }
+
+ return cloudStackVolume;
}
@Override
public AccessGroup createAccessGroup(AccessGroup accessGroup) {
logger.info("createAccessGroup: Create access group {}: " , accessGroup);
- Map details = accessGroup.getPrimaryDataStoreInfo().getDetails();
+
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(accessGroup.getStoragePoolId());
String svmName = details.get(OntapStorageConstants.SVM_NAME);
String volumeUUID = details.get(OntapStorageConstants.VOLUME_UUID);
String volumeName = details.get(OntapStorageConstants.VOLUME_NAME);
+ // Create the export policy
ExportPolicy policyRequest = createExportPolicyRequest(accessGroup,svmName,volumeName);
try {
ExportPolicy createdPolicy = createExportPolicy(svmName, policyRequest);
- logger.info("ExportPolicy created: {}, now attaching this policy to storage pool volume", createdPolicy.getName());
+ logger.info("createAccessGroup: ExportPolicy created: {}, now attaching this policy to storage pool volume", createdPolicy.getName());
+ // attach export policy to volume of storage pool
assignExportPolicyToVolume(volumeUUID,createdPolicy.getName());
- storagePoolDetailsDao.addDetail(accessGroup.getPrimaryDataStoreInfo().getId(), OntapStorageConstants.EXPORT_POLICY_ID, String.valueOf(createdPolicy.getId()), true);
- storagePoolDetailsDao.addDetail(accessGroup.getPrimaryDataStoreInfo().getId(), OntapStorageConstants.EXPORT_POLICY_NAME, createdPolicy.getName(), true);
+ // save the export policy details in storage pool details
+ storagePoolDetailsDao.addDetail(accessGroup.getStoragePoolId(), OntapStorageConstants.EXPORT_POLICY_ID, String.valueOf(createdPolicy.getId()), true);
+ storagePoolDetailsDao.addDetail(accessGroup.getStoragePoolId(), OntapStorageConstants.EXPORT_POLICY_NAME, createdPolicy.getName(), true);
logger.info("Successfully assigned exportPolicy {} to volume {}", policyRequest.getName(), volumeName);
accessGroup.setPolicy(policyRequest);
return accessGroup;
@@ -128,23 +165,15 @@ public void deleteAccessGroup(AccessGroup accessGroup) {
logger.info("deleteAccessGroup: Deleting export policy");
if (accessGroup == null) {
- throw new CloudRuntimeException("deleteAccessGroup: Invalid accessGroup object - accessGroup is null");
+ throw new CloudRuntimeException("Invalid accessGroup object - accessGroup is null");
}
- PrimaryDataStoreInfo primaryDataStoreInfo = accessGroup.getPrimaryDataStoreInfo();
- if (primaryDataStoreInfo == null) {
- throw new CloudRuntimeException("deleteAccessGroup: PrimaryDataStoreInfo is null in accessGroup");
- }
- logger.info("deleteAccessGroup: Deleting export policy for the storage pool {}", primaryDataStoreInfo.getName());
try {
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(accessGroup.getStoragePoolId());
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
- String svmName = storage.getSvmName();
- String exportPolicyName = primaryDataStoreInfo.getDetails().get(OntapStorageConstants.EXPORT_POLICY_NAME);
- String exportPolicyId = primaryDataStoreInfo.getDetails().get(OntapStorageConstants.EXPORT_POLICY_ID);
- if (exportPolicyId == null || exportPolicyId.isEmpty()) {
- logger.warn("deleteAccessGroup: Export policy ID not found in storage pool details for storage pool {}. Cannot delete export policy.", primaryDataStoreInfo.getName());
- throw new CloudRuntimeException("Export policy ID not found for storage pool: " + primaryDataStoreInfo.getName());
- }
+ // Determine export policy attached to the storage pool
+ String exportPolicyName = details.get(OntapStorageConstants.EXPORT_POLICY_NAME);
+ String exportPolicyId = details.get(OntapStorageConstants.EXPORT_POLICY_ID);
try {
nasFeignClient.deleteExportPolicyById(authHeader,exportPolicyId);
@@ -152,6 +181,7 @@ public void deleteAccessGroup(AccessGroup accessGroup) {
} catch (Exception e) {
logger.error("deleteAccessGroup: Failed to delete export policy. Exception: {}", e.getMessage(), e);
throw new CloudRuntimeException("Failed to delete export policy: " + e.getMessage(), e);
+
}
} catch (Exception e) {
logger.error("deleteAccessGroup: Failed to delete export policy. Exception: {}", e.getMessage(), e);
@@ -170,7 +200,7 @@ public AccessGroup getAccessGroup(Map values) {
}
@Override
- public Map enableLogicalAccess(Map values) {
+ public String enableLogicalAccess(Map values) {
return null;
}
@@ -179,12 +209,12 @@ public void disableLogicalAccess(Map values) {
}
@Override
- public Map getLogicalAccess(Map values) {
+ public String getLogicalAccess(Map values) {
return null;
}
private ExportPolicy createExportPolicy(String svmName, ExportPolicy policy) {
- logger.info("Creating export policy: {} for SVM: {}", policy, svmName);
+ logger.info("createExportPolicy: Creating export policy: {} for SVM: {}", policy, svmName);
try {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
@@ -197,18 +227,18 @@ private ExportPolicy createExportPolicy(String svmName, ExportPolicy policy) {
throw new CloudRuntimeException("Export policy " + policy.getName() + " was not created on ONTAP. " +
"Received successful response but policy does not exist.");
}
- logger.info("Export policy created and verified successfully: " + policy.getName());
+ logger.info("createExportPolicy: Export policy created and verified successfully: " + policy.getName());
} catch (FeignException e) {
- logger.error("Failed to verify export policy creation: " + policy.getName(), e);
+ logger.error("createExportPolicy: Failed to verify export policy creation: " + policy.getName(), e);
throw new CloudRuntimeException("Export policy creation verification failed: " + e.getMessage());
}
- logger.info("Export policy created successfully with name {}", policy.getName());
+ logger.info("createExportPolicy: Export policy created successfully with name {}", policy.getName());
return policiesResponse.getRecords().get(0);
} catch (FeignException e) {
- logger.error("Failed to create export policy: {}", policy, e);
+ logger.error("createExportPolicy: Failed to create export policy: {}", policy, e);
throw new CloudRuntimeException("Failed to create export policy: " + e.getMessage());
} catch (Exception e) {
- logger.error("Exception while creating export policy: {}", policy, e);
+ logger.error("createExportPolicy: Exception while creating export policy: {}", policy, e);
throw new CloudRuntimeException("Failed to create export policy: " + e.getMessage());
}
}
@@ -231,6 +261,7 @@ private void assignExportPolicyToVolume(String volumeUuid, String policyName) {
throw new CloudRuntimeException("Failed to attach policy " + policyName + "to volume " + volumeUuid);
}
String jobUUID = jobResponse.getJob().getUuid();
+ //Create URI for GET Job API
int jobRetryCount = 0;
Job createVolumeJob = null;
while(createVolumeJob == null || !createVolumeJob.getState().equals(OntapStorageConstants.JOB_SUCCESS)) {
@@ -252,15 +283,15 @@ private void assignExportPolicyToVolume(String volumeUuid, String policyName) {
Thread.sleep(OntapStorageConstants.CREATE_VOLUME_CHECK_SLEEP_TIME);
}
} catch (Exception e) {
- logger.error("Exception while updating volume: ", e);
+ logger.error("assignExportPolicyToVolume: Exception while updating volume: ", e);
throw new CloudRuntimeException("Failed to update volume: " + e.getMessage());
}
- logger.info("Export policy successfully assigned to volume: {}", volumeUuid);
+ logger.info("assignExportPolicyToVolume: Export policy successfully assigned to volume: {}", volumeUuid);
} catch (FeignException e) {
- logger.error("Failed to assign export policy to volume: {}", volumeUuid, e);
+ logger.error("assignExportPolicyToVolume: Failed to assign export policy to volume: {}", volumeUuid, e);
throw new CloudRuntimeException("Failed to assign export policy: " + e.getMessage());
} catch (Exception e) {
- logger.error("Exception while assigning export policy to volume: {}", volumeUuid, e);
+ logger.error("assignExportPolicyToVolume: Exception while assigning export policy to volume: {}", volumeUuid, e);
throw new CloudRuntimeException("Failed to assign export policy: " + e.getMessage());
}
}
@@ -280,13 +311,13 @@ private ExportPolicy createExportPolicyRequest(AccessGroup accessGroup,String sv
String ip = (hostStorageIp != null && !hostStorageIp.isEmpty())
? hostStorageIp
: host.getPrivateIpAddress();
- String ipToUse = ip + "/31";
+ String ipToUse = ip + "/32";
ExportRule.ExportClient exportClient = new ExportRule.ExportClient();
exportClient.setMatch(ipToUse);
exportClients.add(exportClient);
}
exportRule.setClients(exportClients);
- exportRule.setProtocols(List.of(ExportRule.ProtocolsEnum.ANY));
+ exportRule.setProtocols(List.of(ExportRule.ProtocolsEnum.NFS3));
exportRule.setRoRule(List.of("sys"));
exportRule.setRwRule(List.of("sys"));
exportRule.setSuperuser(List.of("sys"));
@@ -300,4 +331,153 @@ private ExportPolicy createExportPolicyRequest(AccessGroup accessGroup,String sv
return exportPolicy;
}
+
+ private String updateCloudStackVolumeMetadata(String dataStoreId, DataObject volumeInfo) {
+ logger.info("updateCloudStackVolumeMetadata called with datastoreID: {} volumeInfo: {} ", dataStoreId, volumeInfo );
+ try {
+ VolumeObject volumeObject = (VolumeObject) volumeInfo;
+ long volumeId = volumeObject.getId();
+ logger.info("updateCloudStackVolumeMetadata: VolumeInfo ID from VolumeObject: {}", volumeId);
+ VolumeVO volume = volumeDao.findById(volumeId);
+ if (volume == null) {
+ throw new CloudRuntimeException("Volume not found with id: " + volumeId);
+ }
+ String volumeUuid = volumeInfo.getUuid();
+ volume.setPoolType(Storage.StoragePoolType.NetworkFilesystem);
+ volume.setPoolId(Long.parseLong(dataStoreId));
+ volume.setPath(volumeUuid); // Filename for qcow2 file
+ volumeDao.update(volume.getId(), volume);
+ logger.info("Updated volume path to {} for volume ID {}", volumeUuid, volumeId);
+ return volumeUuid;
+ } catch (Exception e){
+ logger.error("updateCloudStackVolumeMetadata: Exception while updating volumeInfo: {} in volume: {}", dataStoreId, volumeInfo.getUuid(), e);
+ throw new CloudRuntimeException("Exception while updating volumeInfo: " + e.getMessage());
+ }
+ }
+
+ private Answer createVolumeOnKVMHost(DataObject volumeInfo) {
+ logger.info("createVolumeOnKVMHost called with volumeInfo: {} ", volumeInfo);
+
+ try {
+ logger.info("createVolumeOnKVMHost: Sending CreateObjectCommand to KVM agent for volume: {}", volumeInfo.getUuid());
+ CreateObjectCommand cmd = new CreateObjectCommand(volumeInfo.getTO());
+ EndPoint ep = epSelector.select(volumeInfo);
+ if (ep == null) {
+ String errMsg = "No remote endpoint to send CreateObjectCommand, check if host is up";
+ logger.error(errMsg);
+ return new Answer(cmd, false, errMsg);
+ }
+ logger.info("createVolumeOnKVMHost: Sending command to endpoint: {}", ep.getHostAddr());
+ Answer answer = ep.sendMessage(cmd);
+ if (answer != null && answer.getResult()) {
+ logger.info("createVolumeOnKVMHost: Successfully created qcow2 file on KVM host");
+ } else {
+ logger.error("createVolumeOnKVMHost: Failed to create qcow2 file: {}",
+ answer != null ? answer.getDetails() : "null answer");
+ }
+ return answer;
+ } catch (Exception e) {
+ logger.error("createVolumeOnKVMHost: Exception sending CreateObjectCommand", e);
+ return new Answer(null, false, e.toString());
+ }
+ }
+
+ private Answer deleteVolumeOnKVMHost(DataObject volumeInfo) {
+ logger.info("deleteVolumeOnKVMHost called with volumeInfo: {} ", volumeInfo);
+
+ try {
+ logger.info("deleteVolumeOnKVMHost: Sending DeleteCommand to KVM agent for volume: {}", volumeInfo.getUuid());
+ DeleteCommand cmd = new DeleteCommand(volumeInfo.getTO());
+ EndPoint ep = epSelector.select(volumeInfo);
+ if (ep == null) {
+ String errMsg = "No remote endpoint to send DeleteCommand, check if host is up";
+ logger.error(errMsg);
+ return new Answer(cmd, false, errMsg);
+ }
+ logger.info("deleteVolumeOnKVMHost: Sending command to endpoint: {}", ep.getHostAddr());
+ Answer answer = ep.sendMessage(cmd);
+ if (answer != null && answer.getResult()) {
+ logger.info("deleteVolumeOnKVMHost: Successfully deleted qcow2 file on KVM host");
+ } else {
+ logger.error("deleteVolumeOnKVMHost: Failed to delete qcow2 file: {}",
+ answer != null ? answer.getDetails() : "null answer");
+ }
+ return answer;
+ } catch (Exception e) {
+ logger.error("deleteVolumeOnKVMHost: Exception sending DeleteCommand", e);
+ return new Answer(null, false, e.toString());
+ }
+ }
+
+ private FileInfo getFile(String volumeUuid, String filePath) {
+ logger.info("Get File: {} for volume: {}", filePath, volumeUuid);
+
+ String authHeader = OntapStorageUtils.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 found on ONTAP. " +
+ "Received successful response but file does not exist.");
+ }
+ } catch (FeignException e) {
+ logger.error("getFile: Failed to get file response: " + filePath, e);
+ throw new CloudRuntimeException("File not found: " + e.getMessage());
+ } catch (Exception e) {
+ logger.error("getFile: Exception to get file: {}", filePath, e);
+ throw new CloudRuntimeException("Failed to get the file: " + e.getMessage());
+ }
+ logger.info("getFile: File retrieved successfully with name {}", filePath);
+ return fileResponse.getRecords().get(0);
+ }
+
+ /**
+ * Reverts a file to a snapshot using the ONTAP CLI-based snapshot file restore API.
+ *
+ * ONTAP REST API (CLI passthrough):
+ * {@code POST /api/private/cli/volume/snapshot/restore-file}
+ *
+ * This method uses the CLI native API which is more reliable and works
+ * consistently for both NFS files and iSCSI LUNs.
+ *
+ * @param snapshotName The ONTAP FlexVolume snapshot name
+ * @param flexVolUuid The FlexVolume UUID (not used in CLI API, kept for interface consistency)
+ * @param snapshotUuid The ONTAP snapshot UUID (not used in CLI API, kept for interface consistency)
+ * @param volumePath The file path within the FlexVolume
+ * @param lunUuid Not used for NFS (null)
+ * @param flexVolName The FlexVolume name (required for CLI API)
+ * @return JobResponse for the async restore operation
+ */
+ @Override
+ public JobResponse revertSnapshotForCloudStackVolume(String snapshotName, String flexVolUuid,
+ String snapshotUuid, String volumePath,
+ String lunUuid, String flexVolName) {
+ logger.info("revertSnapshotForCloudStackVolume [NFS]: Restoring file [{}] from snapshot [{}] on FlexVol [{}]",
+ volumePath, snapshotName, flexVolName);
+
+ if (snapshotName == null || snapshotName.isEmpty()) {
+ throw new CloudRuntimeException("Snapshot name is required for NFS snapshot revert");
+ }
+ if (volumePath == null || volumePath.isEmpty()) {
+ throw new CloudRuntimeException("File path is required for NFS snapshot revert");
+ }
+ if (flexVolName == null || flexVolName.isEmpty()) {
+ throw new CloudRuntimeException("FlexVolume name is required for NFS snapshot revert");
+ }
+
+ String authHeader = getAuthHeader();
+ String svmName = storage.getSvmName();
+
+ // Prepare the file path for ONTAP CLI API (ensure it starts with "/")
+ String ontapFilePath = volumePath.startsWith("/") ? volumePath : "/" + volumePath;
+
+ // Create CLI snapshot restore request
+ CliSnapshotRestoreRequest restoreRequest = new CliSnapshotRestoreRequest(
+ svmName, flexVolName, snapshotName, ontapFilePath);
+
+ logger.info("revertSnapshotForCloudStackVolume: Calling CLI file restore API with vserver={}, volume={}, snapshot={}, path={}",
+ svmName, flexVolName, snapshotName, ontapFilePath);
+
+ return getSnapshotFeignClient().restoreFileFromSnapshotCli(authHeader, restoreRequest);
+ }
}
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 9814f3b9a93c..2b0e65f9f7ea 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
@@ -20,24 +20,27 @@
package org.apache.cloudstack.storage.service;
import com.cloud.host.HostVO;
-import com.cloud.hypervisor.Hypervisor;
import com.cloud.utils.exception.CloudRuntimeException;
-import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreInfo;
-import org.apache.cloudstack.storage.feign.FeignClientFactory;
-import org.apache.cloudstack.storage.feign.client.SANFeignClient;
+import feign.FeignException;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.feign.model.Igroup;
import org.apache.cloudstack.storage.feign.model.Initiator;
import org.apache.cloudstack.storage.feign.model.Svm;
import org.apache.cloudstack.storage.feign.model.OntapStorage;
+import org.apache.cloudstack.storage.feign.model.Lun;
+import org.apache.cloudstack.storage.feign.model.LunMap;
+import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest;
+import org.apache.cloudstack.storage.feign.model.response.JobResponse;
import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
import org.apache.cloudstack.storage.service.model.AccessGroup;
import org.apache.cloudstack.storage.service.model.CloudStackVolume;
import org.apache.cloudstack.storage.service.model.ProtocolType;
import org.apache.cloudstack.storage.utils.OntapStorageConstants;
import org.apache.cloudstack.storage.utils.OntapStorageUtils;
+import org.apache.commons.collections.CollectionUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-
+import javax.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -45,14 +48,12 @@
public class UnifiedSANStrategy extends SANStrategy {
private static final Logger logger = LogManager.getLogger(UnifiedSANStrategy.class);
- private final FeignClientFactory feignClientFactory;
- private final SANFeignClient sanFeignClient;
+ @Inject
+ private StoragePoolDetailsDao storagePoolDetailsDao;
public UnifiedSANStrategy(OntapStorage ontapStorage) {
super(ontapStorage);
- String baseURL = OntapStorageConstants.HTTPS + ontapStorage.getManagementLIF();
- this.feignClientFactory = new FeignClientFactory();
- this.sanFeignClient = feignClientFactory.createClient(SANFeignClient.class, baseURL);
+ String baseURL = OntapStorageConstants.HTTPS + ontapStorage.getStorageIP();
}
public void setOntapStorage(OntapStorage ontapStorage) {
@@ -61,7 +62,35 @@ public void setOntapStorage(OntapStorage ontapStorage) {
@Override
public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume) {
- return null;
+ logger.trace("createCloudStackVolume : Creating Lun with cloudstackVolume request {} ", cloudstackVolume);
+ if (cloudstackVolume == null || cloudstackVolume.getLun() == null) {
+ logger.error("createCloudStackVolume: LUN creation failed. Invalid request: {}", cloudstackVolume);
+ throw new CloudRuntimeException(" Failed to create Lun, invalid request");
+ }
+ try {
+ // Get AuthHeader
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ // Create URI for lun creation
+ //TODO: It is possible that Lun creation will take time and we may need to handle through async job.
+ OntapResponse createdLun = sanFeignClient.createLun(authHeader, true, cloudstackVolume.getLun());
+ if (createdLun == null || createdLun.getRecords() == null || createdLun.getRecords().size() == 0) {
+ logger.error("createCloudStackVolume: LUN creation failed for Lun {}", cloudstackVolume.getLun().getName());
+ throw new CloudRuntimeException("Failed to create Lun: " + cloudstackVolume.getLun().getName());
+ }
+ Lun lun = createdLun.getRecords().get(0);
+ logger.debug("createCloudStackVolume: LUN created successfully. Lun: {}", lun);
+
+ CloudStackVolume createdCloudStackVolume = new CloudStackVolume();
+ createdCloudStackVolume.setLun(lun);
+ return createdCloudStackVolume;
+ } catch (FeignException e) {
+ logger.error("FeignException occurred while creating LUN: {}, Status: {}, Exception: {}",
+ cloudstackVolume.getLun().getName(), e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage());
+ } catch (Exception e) {
+ logger.error("Exception occurred while creating LUN: {}, Exception: {}", cloudstackVolume.getLun().getName(), e.getMessage());
+ throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage());
+ }
}
@Override
@@ -70,47 +99,102 @@ CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) {
}
@Override
- public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) {}
+ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ if (cloudstackVolume == null || cloudstackVolume.getLun() == null) {
+ logger.error("deleteCloudStackVolume: Lun deletion failed. Invalid request: {}", cloudstackVolume);
+ throw new CloudRuntimeException(" Failed to delete Lun, invalid request");
+ }
+ logger.trace("deleteCloudStackVolume : Deleting Lun: {}", cloudstackVolume.getLun().getName());
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ Map queryParams = Map.of("allow_delete_while_mapped", "true");
+ try {
+ sanFeignClient.deleteLun(authHeader, cloudstackVolume.getLun().getUuid(), queryParams);
+ } catch (FeignException feignEx) {
+ if (feignEx.status() == 404) {
+ logger.warn("deleteCloudStackVolume: Lun {} does not exist (status 404), skipping deletion", cloudstackVolume.getLun().getName());
+ return;
+ }
+ throw feignEx;
+ }
+ logger.trace("deleteCloudStackVolume: Lun deleted successfully. LunName: {}", cloudstackVolume.getLun().getName());
+ } catch (Exception e) {
+ logger.error("Exception occurred while deleting Lun: {}, Exception: {}", cloudstackVolume.getLun().getName(), e.getMessage());
+ throw new CloudRuntimeException("Failed to delete Lun: " + e.getMessage());
+ }
+ }
@Override
public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) {}
@Override
public CloudStackVolume getCloudStackVolume(Map values) {
- return null;
+ logger.trace("getCloudStackVolume : fetching Lun with params {} ", values);
+ if (values == null || values.isEmpty()) {
+ logger.error("getCloudStackVolume: get Lun failed. Invalid request: {}", values);
+ throw new CloudRuntimeException(" get Lun Failed, invalid request");
+ }
+ String svmName = values.get(OntapStorageConstants.SVM_DOT_NAME);
+ String lunName = values.get(OntapStorageConstants.NAME);
+ if (svmName == null || lunName == null || svmName.isEmpty() || lunName.isEmpty()) {
+ logger.error("getCloudStackVolume: get Lun failed. Invalid svm:{} or Lun name: {}", svmName, lunName);
+ throw new CloudRuntimeException("Failed to get Lun, invalid request");
+ }
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ Map queryParams = Map.of(OntapStorageConstants.SVM_DOT_NAME, svmName, OntapStorageConstants.NAME, lunName);
+ OntapResponse lunResponse = sanFeignClient.getLunResponse(authHeader, queryParams);
+ if (lunResponse == null || lunResponse.getRecords() == null || lunResponse.getRecords().isEmpty()) {
+ logger.warn("getCloudStackVolume: Lun '{}' on SVM '{}' not found. Returning null.", lunName, svmName);
+ return null;
+ }
+ Lun lun = lunResponse.getRecords().get(0);
+ logger.debug("getCloudStackVolume: Lun Details : {}", lun);
+
+ CloudStackVolume cloudStackVolume = new CloudStackVolume();
+ cloudStackVolume.setLun(lun);
+ return cloudStackVolume;
+ } catch (FeignException e) {
+ if (e.status() == 404) {
+ logger.warn("getCloudStackVolume: Lun '{}' on SVM '{}' not found (status 404). Returning null.", lunName, svmName);
+ return null;
+ }
+ logger.error("FeignException occurred while fetching Lun, Status: {}, Exception: {}", e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Lun details: " + e.getMessage());
+ } catch (Exception e) {
+ logger.error("Exception occurred while fetching Lun, Exception: {}", e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Lun details: " + e.getMessage());
+ }
}
@Override
public AccessGroup createAccessGroup(AccessGroup accessGroup) {
- logger.info("createAccessGroup : Create Igroup");
- String igroupName = "unknown";
- logger.debug("createAccessGroup : Creating Igroup with access group request {} ", accessGroup);
+ logger.trace("createAccessGroup : Creating Igroup with access group request {} ", accessGroup);
if (accessGroup == null) {
logger.error("createAccessGroup: Igroup creation failed. Invalid request: {}", accessGroup);
- throw new CloudRuntimeException("createAccessGroup : Failed to create Igroup, invalid request");
+ throw new CloudRuntimeException("Failed to create Igroup, invalid request");
+ }
+ // Get StoragePool details
+ if (accessGroup.getStoragePoolId() == null) {
+ throw new CloudRuntimeException("Failed to create Igroup, invalid datastore details in the request");
+ }
+ if (accessGroup.getHostsToConnect() == null || accessGroup.getHostsToConnect().isEmpty()) {
+ throw new CloudRuntimeException("Failed to create Igroup, no hosts to connect provided in the request");
}
- try {
- if (accessGroup.getPrimaryDataStoreInfo() == null || accessGroup.getPrimaryDataStoreInfo().getDetails() == null
- || accessGroup.getPrimaryDataStoreInfo().getDetails().isEmpty()) {
- throw new CloudRuntimeException("createAccessGroup : Failed to create Igroup, invalid datastore details in the request");
- }
- Map dataStoreDetails = accessGroup.getPrimaryDataStoreInfo().getDetails();
- logger.debug("createAccessGroup: Successfully fetched datastore details.");
- String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ String igroupName = null;
+ try {
+ Map dataStoreDetails = storagePoolDetailsDao.listDetailsKeyPairs(accessGroup.getStoragePoolId());
+ logger.trace("createAccessGroup: Successfully fetched datastore details.");
+ // Generate Igroup request
Igroup igroupRequest = new Igroup();
- List hostsIdentifier = new ArrayList<>();
String svmName = dataStoreDetails.get(OntapStorageConstants.SVM_NAME);
- igroupName = OntapStorageUtils.getIgroupName(svmName, accessGroup.getScope().getScopeType(), accessGroup.getScope().getScopeId());
- Hypervisor.HypervisorType hypervisorType = accessGroup.getPrimaryDataStoreInfo().getHypervisor();
-
ProtocolType protocol = ProtocolType.valueOf(dataStoreDetails.get(OntapStorageConstants.PROTOCOL));
- if (accessGroup.getHostsToConnect() == null || accessGroup.getHostsToConnect().isEmpty()) {
- throw new CloudRuntimeException("createAccessGroup : Failed to create Igroup, no hosts to connect provided in the request");
- }
- if (!validateProtocolSupportAndFetchHostsIdentifier(accessGroup.getHostsToConnect(), protocol, hostsIdentifier)) {
- String errMsg = "createAccessGroup: Not all hosts in the " + accessGroup.getScope().getScopeType().toString() + " support the protocol: " + protocol.name();
+
+ // Check if all hosts support the protocol
+ if (!validateProtocolSupport(accessGroup.getHostsToConnect(), protocol)) {
+ String errMsg = "Not all hosts " + " support the protocol: " + protocol.name();
throw new CloudRuntimeException(errMsg);
}
@@ -119,50 +203,49 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
svm.setName(svmName);
igroupRequest.setSvm(svm);
}
+ // TODO: Defaulting to LINUX for zone scope for now, this has to be revisited when we support other hypervisors
+ igroupRequest.setOsType(Igroup.OsTypeEnum.Linux);
- if (igroupName != null && !igroupName.isEmpty()) {
+ for (HostVO host : accessGroup.getHostsToConnect()) {
+ igroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
igroupRequest.setName(igroupName);
- }
-
- igroupRequest.setOsType(Igroup.OsTypeEnum.Linux);
- if (hostsIdentifier != null && hostsIdentifier.size() > 0) {
List initiators = new ArrayList<>();
- for (String hostIdentifier : hostsIdentifier) {
- Initiator initiator = new Initiator();
- initiator.setName(hostIdentifier);
- initiators.add(initiator);
- }
+ Initiator initiator = new Initiator();
+ initiator.setName(host.getStorageUrl());// CloudStack has one iqn for one host
+ initiators.add(initiator);
igroupRequest.setInitiators(initiators);
+ igroupRequest.setDeleteOnUnmap(true);
}
- igroupRequest.setProtocol(Igroup.ProtocolEnum.valueOf("iscsi"));
+ igroupRequest.setProtocol(Igroup.ProtocolEnum.valueOf(OntapStorageConstants.ISCSI));
+ // Create Igroup
logger.debug("createAccessGroup: About to call sanFeignClient.createIgroup with igroupName: {}", igroupName);
AccessGroup createdAccessGroup = new AccessGroup();
OntapResponse createdIgroup = null;
try {
+ // Get AuthHeader
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
createdIgroup = sanFeignClient.createIgroup(authHeader, true, igroupRequest);
- } catch (Exception feignEx) {
- String errMsg = feignEx.getMessage();
- if (errMsg != null && errMsg.contains(("5374023"))) {
- logger.warn("createAccessGroup: Igroup with name {} already exists. Fetching existing Igroup.", igroupName);
+ } catch (FeignException feignEx) {
+ if (feignEx.status() == 409) {
+ logger.warn("createAccessGroup: Igroup with name {} already exists (status 409). Fetching existing Igroup.", igroupName);
+ // TODO: Currently we aren't doing anything with the returned AccessGroup object, so, haven't added code here to fetch the existing Igroup and set it in AccessGroup.
return createdAccessGroup;
}
- logger.error("createAccessGroup: Exception during Feign call: {}", feignEx.getMessage(), feignEx);
+ logger.error("createAccessGroup: FeignException during Igroup creation: Status: {}, Exception: {}", feignEx.status(), feignEx.getMessage(), feignEx);
throw feignEx;
}
- logger.debug("createAccessGroup: createdIgroup: {}", createdIgroup);
logger.debug("createAccessGroup: createdIgroup Records: {}", createdIgroup.getRecords());
- if (createdIgroup == null || createdIgroup.getRecords() == null || createdIgroup.getRecords().isEmpty()) {
+ if (createdIgroup.getRecords() == null || createdIgroup.getRecords().isEmpty()) {
logger.error("createAccessGroup: Igroup creation failed for Igroup Name {}", igroupName);
throw new CloudRuntimeException("Failed to create Igroup: " + igroupName);
}
Igroup igroup = createdIgroup.getRecords().get(0);
logger.debug("createAccessGroup: Successfully extracted igroup from response: {}", igroup);
- logger.info("createAccessGroup: Igroup created successfully. IgroupName: {}", igroup.getName());
createdAccessGroup.setIgroup(igroup);
- logger.debug("createAccessGroup: Returning createdAccessGroup");
+ logger.trace("createAccessGroup: Returning createdAccessGroup");
return createdAccessGroup;
} catch (Exception e) {
logger.error("Exception occurred while creating Igroup: {}, Exception: {}", igroupName, e.getMessage(), e);
@@ -172,87 +255,82 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
@Override
public void deleteAccessGroup(AccessGroup accessGroup) {
- logger.info("deleteAccessGroup: Deleting iGroup");
+ logger.trace("deleteAccessGroup: Deleting iGroup");
if (accessGroup == null) {
- throw new CloudRuntimeException("deleteAccessGroup: Invalid accessGroup object - accessGroup is null");
+ logger.error("deleteAccessGroup: Igroup deletion failed. Invalid request: {}", accessGroup);
+ throw new CloudRuntimeException("Failed to delete Igroup, invalid request");
}
-
- PrimaryDataStoreInfo primaryDataStoreInfo = accessGroup.getPrimaryDataStoreInfo();
- if (primaryDataStoreInfo == null) {
- throw new CloudRuntimeException("deleteAccessGroup: PrimaryDataStoreInfo is null in accessGroup");
+ // Get StoragePool details
+ if (accessGroup.getStoragePoolId() == null) {
+ throw new CloudRuntimeException("Failed to delete Igroup, invalid datastore details in the request");
}
-
try {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
-
String svmName = storage.getSvmName();
-
- String igroupName;
- if (primaryDataStoreInfo.getClusterId() != null) {
- igroupName = OntapStorageUtils.getIgroupName(svmName, com.cloud.storage.ScopeType.CLUSTER, primaryDataStoreInfo.getClusterId());
- logger.info("deleteAccessGroup: Deleting cluster-scoped iGroup '{}'", igroupName);
- } else {
- igroupName = OntapStorageUtils.getIgroupName(svmName, com.cloud.storage.ScopeType.ZONE, primaryDataStoreInfo.getDataCenterId());
- logger.info("deleteAccessGroup: Deleting zone-scoped iGroup '{}'", igroupName);
- }
-
- Map igroupParams = Map.of(
- OntapStorageConstants.SVM_DOT_NAME, svmName,
- OntapStorageConstants.NAME, igroupName
- );
-
- try {
- OntapResponse igroupResponse = sanFeignClient.getIgroupResponse(authHeader, igroupParams);
- if (igroupResponse == null || igroupResponse.getRecords() == null || igroupResponse.getRecords().isEmpty()) {
- logger.warn("deleteAccessGroup: iGroup '{}' not found, may have been already deleted", igroupName);
- return;
- }
-
- Igroup igroup = igroupResponse.getRecords().get(0);
- String igroupUuid = igroup.getUuid();
-
- if (igroupUuid == null || igroupUuid.isEmpty()) {
- throw new CloudRuntimeException("deleteAccessGroup: iGroup UUID is null or empty for iGroup: " + igroupName);
- }
-
- logger.info("deleteAccessGroup: Deleting iGroup '{}' with UUID '{}'", igroupName, igroupUuid);
-
- sanFeignClient.deleteIgroup(authHeader, igroupUuid);
-
- logger.info("deleteAccessGroup: Successfully deleted iGroup '{}'", igroupName);
-
- } catch (Exception e) {
- String errorMsg = e.getMessage();
- if (errorMsg != null && (errorMsg.contains("5374852") || errorMsg.contains("not found"))) {
- logger.warn("deleteAccessGroup: iGroup '{}' does not exist, skipping deletion", igroupName);
- } else {
- throw e;
+ //Get iGroup name per host
+ if(!CollectionUtils.isEmpty(accessGroup.getHostsToConnect())) {
+ for (HostVO host : accessGroup.getHostsToConnect()) {
+ String igroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
+ logger.info("deleteAccessGroup: iGroup name '{}'", igroupName);
+
+ // Get the iGroup to retrieve its UUID
+ Map igroupParams = Map.of(
+ OntapStorageConstants.SVM_DOT_NAME, svmName,
+ OntapStorageConstants.NAME, igroupName
+ );
+
+ try {
+ OntapResponse igroupResponse = sanFeignClient.getIgroupResponse(authHeader, igroupParams);
+ if (igroupResponse == null || igroupResponse.getRecords() == null || igroupResponse.getRecords().isEmpty()) {
+ logger.warn("deleteAccessGroup: iGroup '{}' not found, may have been already deleted", igroupName);
+ return;
+ }
+
+ Igroup igroup = igroupResponse.getRecords().get(0);
+ String igroupUuid = igroup.getUuid();
+
+ if (igroupUuid == null || igroupUuid.isEmpty()) {
+ throw new CloudRuntimeException("iGroup UUID is null or empty for iGroup: " + igroupName);
+ }
+
+ logger.info("deleteAccessGroup: Deleting iGroup '{}' with UUID '{}'", igroupName, igroupUuid);
+
+ // Delete the iGroup using the UUID
+ sanFeignClient.deleteIgroup(authHeader, igroupUuid);
+
+ logger.info("deleteAccessGroup: Successfully deleted iGroup '{}'", igroupName);
+
+ } catch (FeignException e) {
+ if (e.status() == 404) {
+ logger.warn("deleteAccessGroup: iGroup '{}' does not exist (status 404), skipping deletion", igroupName);
+ } else {
+ logger.error("deleteAccessGroup: FeignException occurred: Status: {}, Exception: {}", e.status(), e.getMessage(), e);
+ throw e;
+ }
+ } catch (Exception e) {
+ logger.error("deleteAccessGroup: Exception occurred: {}", e.getMessage(), e);
+ throw e;
+ }
}
}
-
+ } catch (FeignException e) {
+ logger.error("deleteAccessGroup: FeignException occurred while deleting iGroup. Status: {}, Exception: {}", e.status(), e.getMessage(), e);
+ throw new CloudRuntimeException("Failed to delete iGroup: " + e.getMessage(), e);
} catch (Exception e) {
logger.error("deleteAccessGroup: Failed to delete iGroup. Exception: {}", e.getMessage(), e);
throw new CloudRuntimeException("Failed to delete iGroup: " + e.getMessage(), e);
}
}
- private boolean validateProtocolSupportAndFetchHostsIdentifier(List hosts, ProtocolType protocolType, List hostIdentifiers) {
- switch (protocolType) {
- case ISCSI:
- String protocolPrefix = OntapStorageConstants.IQN;
- for (HostVO host : hosts) {
- if (host == null || host.getStorageUrl() == null || host.getStorageUrl().trim().isEmpty()
- || !host.getStorageUrl().startsWith(protocolPrefix)) {
- return false;
- }
- hostIdentifiers.add(host.getStorageUrl());
- }
- break;
- default:
- throw new CloudRuntimeException("validateProtocolSupportAndFetchHostsIdentifier : Unsupported protocol: " + protocolType.name());
+ private boolean validateProtocolSupport(List hosts, ProtocolType protocolType) {
+ String protocolPrefix = OntapStorageConstants.IQN;
+ for (HostVO host : hosts) {
+ if (host == null || host.getStorageUrl() == null || host.getStorageUrl().trim().isEmpty() || !host.getStorageUrl().startsWith(protocolPrefix)) {
+ return false;
+ }
}
- logger.info("validateProtocolSupportAndFetchHostsIdentifier: All hosts support the protocol: " + protocolType.name());
+ logger.trace("validateProtocolSupportAndFetchHostsIdentifier: All hosts support the protocol: " + protocolType.name());
return true;
}
@@ -261,18 +339,18 @@ public AccessGroup updateAccessGroup(AccessGroup accessGroup) {
return null;
}
+ @Override
public AccessGroup getAccessGroup(Map values) {
- logger.info("getAccessGroup : fetch Igroup");
- logger.debug("getAccessGroup : fetching Igroup with params {} ", values);
+ logger.trace("getAccessGroup : fetching Igroup with params {} ", values);
if (values == null || values.isEmpty()) {
logger.error("getAccessGroup: get Igroup failed. Invalid request: {}", values);
- throw new CloudRuntimeException("getAccessGroup : get Igroup Failed, invalid request");
+ throw new CloudRuntimeException("get Igroup Failed, invalid request");
}
String svmName = values.get(OntapStorageConstants.SVM_DOT_NAME);
String igroupName = values.get(OntapStorageConstants.NAME);
if (svmName == null || igroupName == null || svmName.isEmpty() || igroupName.isEmpty()) {
logger.error("getAccessGroup: get Igroup failed. Invalid svm:{} or igroup name: {}", svmName, igroupName);
- throw new CloudRuntimeException("getAccessGroup : Failed to get Igroup, invalid request");
+ throw new CloudRuntimeException("Failed to get Igroup, invalid request");
}
try {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
@@ -286,24 +364,231 @@ public AccessGroup getAccessGroup(Map values) {
AccessGroup accessGroup = new AccessGroup();
accessGroup.setIgroup(igroup);
return accessGroup;
- } catch (Exception e) {
- String errMsg = e.getMessage();
- if (errMsg != null && errMsg.contains("not found")) {
- logger.warn("getAccessGroup: Igroup '{}' not found on SVM '{}' ({}). Returning null.", igroupName, svmName, errMsg);
+ } catch (FeignException e) {
+ if (e.status() == 404) {
+ logger.warn("getAccessGroup: Igroup '{}' not found on SVM '{}' (status 404). Returning null.", igroupName, svmName);
return null;
}
- logger.error("Exception occurred while fetching Igroup, Exception: {}", errMsg);
- throw new CloudRuntimeException("Failed to fetch Igroup details: " + errMsg);
+ logger.error("FeignException occurred while fetching Igroup, Status: {}, Exception: {}", e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Igroup details: " + e.getMessage());
+ } catch (Exception e) {
+ logger.error("Exception occurred while fetching Igroup, Exception: {}", e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Igroup details: " + e.getMessage());
}
}
- public Map enableLogicalAccess(Map values) {
- return null;
+ @Override
+ public String enableLogicalAccess(Map values) {
+ logger.trace("enableLogicalAccess : Creating LunMap with values {} ", values);
+ String lunNumber = null;
+ if (values == null) {
+ logger.error("enableLogicalAccess: LunMap creation failed. Invalid request values: null");
+ throw new CloudRuntimeException("Failed to create LunMap, invalid request");
+ }
+ String svmName = values.get(OntapStorageConstants.SVM_DOT_NAME);
+ String lunName = values.get(OntapStorageConstants.LUN_DOT_NAME);
+ String igroupName = values.get(OntapStorageConstants.IGROUP_DOT_NAME);
+ if (svmName == null || lunName == null || igroupName == null || svmName.isEmpty() || lunName.isEmpty() || igroupName.isEmpty()) {
+ logger.error("enableLogicalAccess: LunMap creation failed. Invalid request values: {}", values);
+ throw new CloudRuntimeException("Failed to create LunMap, invalid request");
+ }
+ try {
+ // Get AuthHeader
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ // Create LunMap
+ LunMap lunMapRequest = new LunMap();
+ Svm svm = new Svm();
+ svm.setName(svmName);
+ lunMapRequest.setSvm(svm);
+ //Set Lun name
+ Lun lun = new Lun();
+ lun.setName(lunName);
+ lunMapRequest.setLun(lun);
+ //Set Igroup name
+ Igroup igroup = new Igroup();
+ igroup.setName(igroupName);
+ lunMapRequest.setIgroup(igroup);
+ try {
+ sanFeignClient.createLunMap(authHeader, true, lunMapRequest);
+ } catch (Exception feignEx) {
+ String errMsg = feignEx.getMessage();
+ if (errMsg != null && errMsg.contains(("LUN already mapped to this group"))) {
+ logger.warn("enableLogicalAccess: LunMap for Lun: {} and igroup: {} already exists.", lunName, igroupName);
+ } else {
+ logger.error("enableLogicalAccess: Exception during Feign call: {}", feignEx.getMessage(), feignEx);
+ throw feignEx;
+ }
+ }
+ // Get the LunMap details
+ OntapResponse lunMapResponse = null;
+ try {
+ lunMapResponse = sanFeignClient.getLunMapResponse(authHeader,
+ Map.of(
+ OntapStorageConstants.SVM_DOT_NAME, svmName,
+ OntapStorageConstants.LUN_DOT_NAME, lunName,
+ OntapStorageConstants.IGROUP_DOT_NAME, igroupName,
+ OntapStorageConstants.FIELDS, OntapStorageConstants.LOGICAL_UNIT_NUMBER
+ ));
+ if(lunMapResponse != null && lunMapResponse.getRecords() != null && !lunMapResponse.getRecords().isEmpty()
+ && lunMapResponse.getRecords().get(0).getLogicalUnitNumber() != null) {
+ lunNumber = lunMapResponse.getRecords().get(0).getLogicalUnitNumber().toString();
+
+ } else {
+ logger.error("enableLogicalAccess: Failed to fetch LunMap details for Lun: {} and igroup: {}. LunMap response is null or empty.", lunName, igroupName);
+ throw new CloudRuntimeException("Failed to fetch LunMap details for Lun: " + lunName + " and igroup: " + igroupName);
+ }
+ } catch (Exception e) {
+ logger.error("enableLogicalAccess: Failed to fetch LunMap details for Lun: {} and igroup: {}, Exception: {}", lunName, igroupName, e);
+ throw new CloudRuntimeException("Failed to fetch LunMap details for Lun: " + lunName + " and igroup: " + igroupName);
+ }
+ logger.trace("enableLogicalAccess: LunMap created successfully, LunMap: {}", lunMapResponse.getRecords().get(0));
+ } catch (Exception e) {
+ logger.error("Exception occurred while creating LunMap", e);
+ throw new CloudRuntimeException("Failed to create LunMap: " + e.getMessage());
+ }
+ return lunNumber;
}
- public void disableLogicalAccess(Map values) {}
+ public void disableLogicalAccess(Map values) {
+ logger.trace("disableLogicalAccess : Deleting LunMap with values {} ", values);
+ if (values == null) {
+ logger.error("disableLogicalAccess: LunMap deletion failed. Invalid request values: null");
+ throw new CloudRuntimeException(" Failed to delete LunMap, invalid request");
+ }
+ String lunUUID = values.get(OntapStorageConstants.LUN_DOT_UUID);
+ String igroupUUID = values.get(OntapStorageConstants.IGROUP_DOT_UUID);
+ if (lunUUID == null || igroupUUID == null || lunUUID.isEmpty() || igroupUUID.isEmpty()) {
+ logger.error("disableLogicalAccess: LunMap deletion failed. Invalid request values: {}", values);
+ throw new CloudRuntimeException(" Failed to delete LunMap, invalid request");
+ }
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ sanFeignClient.deleteLunMap(authHeader, lunUUID, igroupUUID);
+ logger.info("disableLogicalAccess: LunMap deleted successfully.");
+ } catch (FeignException e) {
+ if (e.status() == 404) {
+ logger.warn("disableLogicalAccess: LunMap with Lun UUID: {} and igroup UUID: {} does not exist, skipping deletion", lunUUID, igroupUUID);
+ return;
+ }
+ logger.error("FeignException occurred while deleting LunMap, Status: {}, Exception: {}", e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to delete LunMap: " + e.getMessage());
+ } catch (Exception e) {
+ logger.error("Exception occurred while deleting LunMap, Exception: {}", e.getMessage());
+ throw new CloudRuntimeException("Failed to delete LunMap: " + e.getMessage());
+ }
+ }
- public Map getLogicalAccess(Map values) {
- return null;
+ // GET-only helper: fetch LUN-map and return logical unit number if it exists; otherwise return null
+ @Override
+ public String getLogicalAccess(Map values) {
+ String lunNumber = null;
+ logger.trace("getLogicalAccess : Fetching LunMap with values {} ", values);
+ if (values == null) {
+ logger.error("getLogicalAccess: Invalid request values: null");
+ throw new CloudRuntimeException(" Invalid request");
+ }
+ String svmName = values.get(OntapStorageConstants.SVM_DOT_NAME);
+ String lunName = values.get(OntapStorageConstants.LUN_DOT_NAME);
+ String igroupName = values.get(OntapStorageConstants.IGROUP_DOT_NAME);
+ if (svmName == null || lunName == null || igroupName == null || svmName.isEmpty() || lunName.isEmpty() || igroupName.isEmpty()) {
+ logger.error("getLogicalAccess: Invalid request values: {}", values);
+ throw new CloudRuntimeException(" Invalid request");
+ }
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ OntapResponse lunMapResponse = sanFeignClient.getLunMapResponse(authHeader,
+ Map.of(
+ OntapStorageConstants.SVM_DOT_NAME, svmName,
+ OntapStorageConstants.LUN_DOT_NAME, lunName,
+ OntapStorageConstants.IGROUP_DOT_NAME, igroupName,
+ OntapStorageConstants.FIELDS, OntapStorageConstants.LOGICAL_UNIT_NUMBER
+ ));
+ if (lunMapResponse != null && lunMapResponse.getRecords() != null && !lunMapResponse.getRecords().isEmpty()) {
+ Integer lunLogicalUnitNum = lunMapResponse.getRecords().get(0).getLogicalUnitNumber();
+ lunNumber = lunLogicalUnitNum != null ? lunLogicalUnitNum.toString() : null;
+ }
+ } catch (Exception e) {
+ logger.warn("getLogicalAccess: LunMap not found for Lun: {} and igroup: {} ({}).", lunName, igroupName, e.getMessage());
+ }
+ return lunNumber;
+ }
+
+ @Override
+ public String ensureLunMapped(String svmName, String lunName, String accessGroupName) {
+ logger.trace("ensureLunMapped: Ensuring LUN [{}] is mapped to igroup [{}] on SVM [{}]", lunName, accessGroupName, svmName);
+
+ // Check existing map first
+ Map getMap = Map.of(
+ OntapStorageConstants.LUN_DOT_NAME, lunName,
+ OntapStorageConstants.SVM_DOT_NAME, svmName,
+ OntapStorageConstants.IGROUP_DOT_NAME, accessGroupName
+ );
+ String lunNumber = getLogicalAccess(getMap);
+ if (lunNumber != null) {
+ logger.info("ensureLunMapped: Existing LunMap found for LUN [{}] in igroup [{}] with LUN number [{}]", lunName, accessGroupName, lunNumber);
+ return lunNumber;
+ }
+
+ // Create if not exists
+ Map enableMap = Map.of(
+ OntapStorageConstants.LUN_DOT_NAME, lunName,
+ OntapStorageConstants.SVM_DOT_NAME, svmName,
+ OntapStorageConstants.IGROUP_DOT_NAME, accessGroupName
+ );
+ String response = enableLogicalAccess(enableMap);
+ if (response == null ) {
+ throw new CloudRuntimeException("Failed to map LUN [" + lunName + "] to iGroup [" + accessGroupName + "]");
+ }
+ logger.trace("ensureLunMapped: Successfully mapped LUN [{}] to igroup [{}] with LUN number [{}]", lunName, accessGroupName, response);
+ return response;
+ }
+ /**
+ * Reverts a LUN to a snapshot using the ONTAP CLI-based snapshot file restore API.
+ *
+ * ONTAP REST API (CLI passthrough):
+ * {@code POST /api/private/cli/volume/snapshot/restore-file}
+ *
+ * This method uses the CLI native API which is more reliable and works
+ * consistently for both NFS files and iSCSI LUNs.
+ *
+ * @param snapshotName The ONTAP FlexVolume snapshot name
+ * @param flexVolUuid The FlexVolume UUID (not used in CLI API, kept for interface consistency)
+ * @param snapshotUuid The ONTAP snapshot UUID (not used in CLI API, kept for interface consistency)
+ * @param volumePath The LUN name (used to construct the path)
+ * @param lunUuid The LUN UUID (not used in CLI API, kept for interface consistency)
+ * @param flexVolName The FlexVolume name (required for CLI API)
+ * @return JobResponse for the async restore operation
+ */
+ @Override
+ public JobResponse revertSnapshotForCloudStackVolume(String snapshotName, String flexVolUuid,
+ String snapshotUuid, String volumePath,
+ String lunUuid, String flexVolName) {
+ logger.trace("revertSnapshotForCloudStackVolume [iSCSI]: Restoring LUN [{}] from snapshot [{}] on FlexVol [{}]",
+ volumePath, snapshotName, flexVolName);
+
+ if (snapshotName == null || snapshotName.isEmpty()) {
+ throw new CloudRuntimeException("Snapshot name is required for iSCSI snapshot revert");
+ }
+ if (flexVolName == null || flexVolName.isEmpty()) {
+ throw new CloudRuntimeException("FlexVolume name is required for iSCSI snapshot revert");
+ }
+ if (volumePath == null || volumePath.isEmpty()) {
+ throw new CloudRuntimeException("LUN path is required for iSCSI snapshot revert");
+ }
+
+ String authHeader = getAuthHeader();
+ String svmName = storage.getSvmName();
+
+ // Prepare the LUN path for ONTAP CLI API (ensure it starts with "/")
+ String ontapLunPath = volumePath.startsWith("/") ? volumePath : "/" + volumePath;
+
+ // Create CLI snapshot restore request
+ CliSnapshotRestoreRequest restoreRequest = new CliSnapshotRestoreRequest(
+ svmName, flexVolName, snapshotName, ontapLunPath);
+
+ logger.trace("revertSnapshotForCloudStackVolume: Calling CLI file restore API with vserver={}, volume={}, snapshot={}, path={}",
+ svmName, flexVolName, snapshotName, ontapLunPath);
+
+ return getSnapshotFeignClient().restoreFileFromSnapshotCli(authHeader, restoreRequest);
}
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/model/AccessGroup.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/model/AccessGroup.java
index 9ff80e7cf8a9..9815724fc1aa 100755
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/model/AccessGroup.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/model/AccessGroup.java
@@ -20,7 +20,6 @@
package org.apache.cloudstack.storage.service.model;
import com.cloud.host.HostVO;
-import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreInfo;
import org.apache.cloudstack.engine.subsystem.api.storage.Scope;
import org.apache.cloudstack.storage.feign.model.ExportPolicy;
import org.apache.cloudstack.storage.feign.model.Igroup;
@@ -33,7 +32,7 @@ public class AccessGroup {
private ExportPolicy exportPolicy;
private List hostsToConnect;
- private PrimaryDataStoreInfo primaryDataStoreInfo;
+ private Long storagePoolId;
private Scope scope;
@@ -55,18 +54,23 @@ public void setPolicy(ExportPolicy policy) {
public List getHostsToConnect() {
return hostsToConnect;
}
+
public void setHostsToConnect(List hostsToConnect) {
this.hostsToConnect = hostsToConnect;
}
- public PrimaryDataStoreInfo getPrimaryDataStoreInfo() {
- return primaryDataStoreInfo;
+
+ public Long getStoragePoolId() {
+ return storagePoolId;
}
- public void setPrimaryDataStoreInfo(PrimaryDataStoreInfo primaryDataStoreInfo) {
- this.primaryDataStoreInfo = primaryDataStoreInfo;
+
+ public void setStoragePoolId(Long storagePoolId) {
+ this.storagePoolId = storagePoolId;
}
+
public Scope getScope() {
return scope;
}
+
public void setScope(Scope scope) {
this.scope = scope;
}
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..ab38e3045f51 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,10 +25,34 @@
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;
}
@@ -44,16 +68,37 @@ public Lun getLun() {
public void setLun(Lun lun) {
this.lun = lun;
}
+
public String getDatastoreId() {
return datastoreId;
}
+
public void setDatastoreId(String datastoreId) {
this.datastoreId = datastoreId;
}
+
public DataObject getVolumeInfo() {
return volumeInfo;
}
+
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/OntapStorageConstants.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java
index 0cf0a9b07e0f..da9f00331f90 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java
@@ -19,22 +19,28 @@
package org.apache.cloudstack.storage.utils;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider;
public class OntapStorageConstants {
- public static final String ONTAP_PLUGIN_NAME = "ONTAP";
+ /** Same as {@link DataStoreProvider#ONTAP_PLUGIN_NAME}; retained for existing ONTAP plugin call sites. */
+ public static final String ONTAP_PLUGIN_NAME = DataStoreProvider.ONTAP_PLUGIN_NAME;
public static final int NFS3_PORT = 2049;
public static final int ISCSI_PORT = 3260;
public static final String NFS = "nfs";
public static final String ISCSI = "iscsi";
+
+ public static final String NFS_ENABLED = "nfs.enabled";
+ public static final String ISCSI_ENABLED = "iscsi.enabled";
public static final String SIZE = "size";
public static final String PROTOCOL = "protocol";
public static final String SVM_NAME = "svmName";
+ public static final String SVM_UUID = "svmUUID";
public static final String USERNAME = "username";
public static final String PASSWORD = "password";
public static final String DATA_LIF = "dataLIF";
- public static final String MANAGEMENT_LIF = "managementLIF";
+ public static final String STORAGE_IP = "storageIP";
public static final String VOLUME_NAME = "volumeName";
public static final String VOLUME_UUID = "volumeUUID";
public static final String EXPORT_POLICY_ID = "exportPolicyId";
@@ -42,6 +48,7 @@ public class OntapStorageConstants {
public static final String IS_DISAGGREGATED = "isDisaggregated";
public static final String RUNNING = "running";
public static final String EXPORT = "export";
+ public static final String NFS3_MOUNT_OPTIONS_VER_3 = "vers=3";
public static final int ONTAP_PORT = 443;
@@ -65,6 +72,8 @@ public class OntapStorageConstants {
public static final String IP_ADDRESS = "ip.address";
public static final String SERVICES = "services";
public static final String RETURN_RECORDS = "return_records";
+ public static final String SVM = "svm";
+ public static final String VOLUMES = "volumes";
public static final int JOB_MAX_RETRIES = 100;
public static final int CREATE_VOLUME_CHECK_SLEEP_TIME = 2000;
@@ -90,4 +99,28 @@ public class OntapStorageConstants {
public static final String IGROUP_DOT_UUID = "igroup.uuid";
public static final String UNDERSCORE = "_";
public static final String CS = "cs";
+ public static final int IGROUP_NAME_MAX_LENGTH = 96;
+ 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 ONTAP_SNAP_NAME = "ontap_snap_name";
+ public static final String VOLUME_PATH = "volume_path";
+ public static final String PRIMARY_POOL_ID = "primary_pool_id";
+ public static final String ONTAP_SNAP_SIZE = "ontap_snap_size";
+ public static final String FILE_PATH = "file_path";
+ public static final int MAX_SNAPSHOT_NAME_LENGTH = 255;
+ public static final String ONTAP_TEMP_CG_PREFIX = "cs-temp-cg-";
+ /** ONTAP CG API: action required when referencing existing FlexVols in a consistency group. */
+ public static final String CG_VOLUME_PROVISIONING_ACTION_ADD = "add";
+ public static final int ONTAP_CG_JOB_MAX_RETRIES = 60;
+ public static final int ONTAP_CG_JOB_POLL_INTERVAL_MS = 2000;
+ public static final int ONTAP_CG_SNAPSHOT_RESOLVE_MAX_RETRIES = 30;
+ public static final int ONTAP_CG_SNAPSHOT_RESOLVE_POLL_INTERVAL_MS = 1000;
+ public static final int ONTAP_SFSR_JOB_MAX_RETRIES = 60;
+ public static final int ONTAP_SFSR_JOB_POLL_INTERVAL_MS = 2000;
+ public static final int ONTAP_SNAPSHOT_DELETE_JOB_MAX_RETRIES = 30;
+ public static final int ONTAP_SNAPSHOT_DELETE_JOB_POLL_INTERVAL_MS = 2000;
+
+ /** vm_snapshot_details key for ONTAP FlexVolume-level VM snapshots. */
+ public static final String ONTAP_FLEXVOL_SNAPSHOT = "ontapFlexVolSnapshot";
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java
index 0924cf3b9bb6..1ada832ea952 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java
@@ -19,12 +19,18 @@
package org.apache.cloudstack.storage.utils;
-import com.cloud.storage.ScopeType;
+import com.cloud.exception.InvalidParameterValueException;
import com.cloud.utils.StringUtils;
import com.cloud.utils.exception.CloudRuntimeException;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataObject;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.storage.feign.model.Lun;
+import org.apache.cloudstack.storage.feign.model.LunSpace;
import org.apache.cloudstack.storage.feign.model.OntapStorage;
+import org.apache.cloudstack.storage.feign.model.Svm;
import org.apache.cloudstack.storage.provider.StorageProviderFactory;
import org.apache.cloudstack.storage.service.StorageStrategy;
+import org.apache.cloudstack.storage.service.model.CloudStackVolume;
import org.apache.cloudstack.storage.service.model.ProtocolType;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -36,41 +42,201 @@
public class OntapStorageUtils {
private static final Logger logger = LogManager.getLogger(OntapStorageUtils.class);
-
private static final String BASIC = "Basic";
private static final String AUTH_HEADER_COLON = ":";
+ /**
+ * Method generates authentication headers using storage backend credentials passed as normal string
+ *
+ * @param username -->> username of the storage backend
+ * @param password -->> normal decoded password of the storage backend
+ * @return
+ */
public static String generateAuthHeader (String username, String password) {
byte[] encodedBytes = Base64Utils.encode((username + AUTH_HEADER_COLON + password).getBytes(StandardCharsets.UTF_8));
return BASIC + StringUtils.SPACE + new String(encodedBytes);
}
+ public static CloudStackVolume createCloudStackVolumeRequestByProtocol(StoragePoolVO storagePool, Map details, DataObject volumeObject) {
+ CloudStackVolume cloudStackVolumeRequest = null;
+
+ String protocol = details.get(OntapStorageConstants.PROTOCOL);
+ ProtocolType protocolType = ProtocolType.valueOf(protocol);
+ switch (protocolType) {
+ case NFS3:
+ cloudStackVolumeRequest = new CloudStackVolume();
+ cloudStackVolumeRequest.setDatastoreId(String.valueOf(storagePool.getId()));
+ cloudStackVolumeRequest.setVolumeInfo(volumeObject);
+ break;
+ case ISCSI:
+ Svm svm = new Svm();
+ svm.setName(details.get(OntapStorageConstants.SVM_NAME));
+ cloudStackVolumeRequest = new CloudStackVolume();
+ Lun lunRequest = new Lun();
+ lunRequest.setSvm(svm);
+
+ LunSpace lunSpace = new LunSpace();
+ lunSpace.setSize(volumeObject.getSize());
+ lunRequest.setSpace(lunSpace);
+ //Lun name is full path like in unified "/vol/VolumeName/LunName"
+ String lunName = volumeObject.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE);
+ if(!isValidName(lunName)) {
+ String errMsg = "createAsync: Invalid dataObject name [" + lunName + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long.";
+ throw new InvalidParameterValueException(errMsg);
+ }
+ String lunFullName = getLunName(storagePool.getName(), lunName);
+ lunRequest.setName(lunFullName);
+
+ String osType = getOSTypeFromHypervisor(storagePool.getHypervisor().name());
+ lunRequest.setOsType(Lun.OsTypeEnum.valueOf(osType));
+
+ cloudStackVolumeRequest.setLun(lunRequest);
+ break;
+ default:
+ throw new CloudRuntimeException("Unsupported protocol " + protocol);
+
+ }
+ return cloudStackVolumeRequest;
+ }
+
+ public static boolean isValidName(String name) {
+ // Check for null and length constraint first
+ if (name == null || name.length() > 200) {
+ return false;
+ }
+ // Regex: Starts with a letter, followed by letters, digits, or underscores
+ return name.matches(OntapStorageConstants.ONTAP_NAME_REGEX);
+ }
+
+ public static String getOSTypeFromHypervisor(String hypervisorType) {
+ switch (hypervisorType) {
+ case OntapStorageConstants.KVM:
+ return Lun.OsTypeEnum.LINUX.name();
+ default:
+ String errMsg = "getOSTypeFromHypervisor : Unsupported hypervisor type " + hypervisorType + " for ONTAP storage";
+ logger.error(errMsg);
+ throw new CloudRuntimeException(errMsg);
+ }
+ }
+
+ /**
+ * Returns a connected {@link StorageStrategy} for operations on an existing pool (snapshots,
+ * delete, revert, grant/revoke). Does not require aggregate free space for the full pool size.
+ */
public static StorageStrategy getStrategyByStoragePoolDetails(Map details) {
+ return getStrategyByStoragePoolDetails(details, false);
+ }
+
+ public static StorageStrategy getStrategyByStoragePoolDetails(Map details,
+ boolean validateAggregatesForVolumeCreation) {
if (details == null || details.isEmpty()) {
logger.error("getStrategyByStoragePoolDetails: Storage pool details are null or empty");
- throw new CloudRuntimeException("getStrategyByStoragePoolDetails: Storage pool details are null or empty");
+ throw new CloudRuntimeException("Storage pool details are null or empty");
}
String protocol = details.get(OntapStorageConstants.PROTOCOL);
OntapStorage ontapStorage = new OntapStorage(details.get(OntapStorageConstants.USERNAME), details.get(OntapStorageConstants.PASSWORD),
- details.get(OntapStorageConstants.MANAGEMENT_LIF), details.get(OntapStorageConstants.SVM_NAME), Long.parseLong(details.get(OntapStorageConstants.SIZE)),
- ProtocolType.valueOf(protocol),
- Boolean.parseBoolean(details.get(OntapStorageConstants.IS_DISAGGREGATED)));
+ details.get(OntapStorageConstants.STORAGE_IP), details.get(OntapStorageConstants.SVM_NAME), Long.parseLong(details.get(OntapStorageConstants.SIZE)),
+ ProtocolType.valueOf(protocol));
StorageStrategy storageStrategy = StorageProviderFactory.getStrategy(ontapStorage);
- boolean isValid = storageStrategy.connect();
+ boolean isValid = storageStrategy.connect(validateAggregatesForVolumeCreation);
if (isValid) {
logger.info("Connection to Ontap SVM [{}] successful", details.get(OntapStorageConstants.SVM_NAME));
return storageStrategy;
} else {
logger.error("getStrategyByStoragePoolDetails: Connection to Ontap SVM [" + details.get(OntapStorageConstants.SVM_NAME) + "] failed");
- throw new CloudRuntimeException("getStrategyByStoragePoolDetails: Connection to Ontap SVM [" + details.get(OntapStorageConstants.SVM_NAME) + "] failed");
+ throw new CloudRuntimeException("Connection to Ontap SVM [" + details.get(OntapStorageConstants.SVM_NAME) + "] failed");
}
}
- public static String getIgroupName(String svmName, ScopeType scopeType, Long scopeId) {
- return OntapStorageConstants.CS + OntapStorageConstants.UNDERSCORE + svmName + OntapStorageConstants.UNDERSCORE + scopeType.toString().toLowerCase() + OntapStorageConstants.UNDERSCORE + scopeId;
+ public static String getIgroupName(String svmName, String hostUuid) {
+ //Igroup name format: cs_hostUuid_svmName
+ String sanitizedHostUuid = hostUuid.replaceAll("[^a-zA-Z0-9_-]", "_");
+ String igroupName = OntapStorageConstants.CS + OntapStorageConstants.UNDERSCORE + sanitizedHostUuid + OntapStorageConstants.UNDERSCORE + svmName;
+ // ONTAP igroup names are limited to 96 characters; truncate if longer.
+ if (igroupName.length() > OntapStorageConstants.IGROUP_NAME_MAX_LENGTH) {
+ igroupName = igroupName.substring(0, OntapStorageConstants.IGROUP_NAME_MAX_LENGTH);
+ }
+ return igroupName;
}
public static String generateExportPolicyName(String svmName, String volumeName){
- return OntapStorageConstants.EXPORT + OntapStorageConstants.HYPHEN + svmName + OntapStorageConstants.HYPHEN + volumeName;
+ return OntapStorageConstants.CS + OntapStorageConstants.HYPHEN + svmName + OntapStorageConstants.HYPHEN + volumeName;
}
+
+ public static String getLunName(String volName, String lunName) {
+ //LUN name in ONTAP unified format: "/vol/VolumeName/LunName"
+ return OntapStorageConstants.VOLUME_PATH_PREFIX + volName + OntapStorageConstants.SLASH + lunName;
+ }
+
+ /**
+ * Builds an ONTAP-safe name token from user-provided snapshot text.
+ */
+ public static String getOntapSnapshotName(String cloudStackSnapshotName) {
+ if (cloudStackSnapshotName == null || cloudStackSnapshotName.trim().isEmpty()) {
+ throw new InvalidParameterValueException("Snapshot name cannot be null or blank");
+ }
+ String normalized = cloudStackSnapshotName.replaceAll("[^a-zA-Z0-9_]", "_");
+ if (normalized.isEmpty()) {
+ normalized = "snapshot";
+ }
+ if (!Character.isLetter(normalized.charAt(0))) {
+ normalized = "s_" + normalized;
+ }
+ if (normalized.length() > OntapStorageConstants.MAX_SNAPSHOT_NAME_LENGTH) {
+ normalized = normalized.substring(0, OntapStorageConstants.MAX_SNAPSHOT_NAME_LENGTH);
+ }
+ return normalized;
+ }
+
+ /**
+ * Builds an ONTAP-safe snapshot name that preserves the CloudStack UI snapshot name
+ * and appends a uniqueness suffix.
+ */
+ public static String buildOntapSnapshotName(String cloudStackSnapshotName, String uniquenessSuffix) {
+ String normalizedBase = (cloudStackSnapshotName == null || cloudStackSnapshotName.trim().isEmpty())
+ ? "snapshot"
+ : getOntapSnapshotName(cloudStackSnapshotName);
+ String suffix = (uniquenessSuffix == null || uniquenessSuffix.isEmpty())
+ ? ""
+ : "_" + uniquenessSuffix.replaceAll("[^a-zA-Z0-9_]", "_");
+ int maxLength = OntapStorageConstants.MAX_SNAPSHOT_NAME_LENGTH;
+ int maxBaseLength = maxLength - suffix.length();
+ if (maxBaseLength <= 0) {
+ return normalizedBase.substring(0, maxLength);
+ }
+ if (normalizedBase.length() > maxBaseLength) {
+ normalizedBase = normalizedBase.substring(0, maxBaseLength);
+ }
+ return normalizedBase + suffix;
+ }
+
+ /**
+ * Extracts a resource UUID from an ONTAP job description path.
+ *
+ * Example: {@code POST /api/application/consistency-groups/{cg}/snapshots/{uuid}}
+ * with {@code pathSegment} {@code "/snapshots/"} returns the snapshot UUID.
+ */
+ public static String extractUuidFromOntapJobDescription(String description, String pathSegment) {
+ if (description == null || pathSegment == null || pathSegment.isEmpty()) {
+ return null;
+ }
+ int idx = description.indexOf(pathSegment);
+ if (idx < 0) {
+ return null;
+ }
+ String remainder = description.substring(idx + pathSegment.length()).trim();
+ if (remainder.isEmpty()) {
+ return null;
+ }
+ int queryIdx = remainder.indexOf('?');
+ if (queryIdx >= 0) {
+ remainder = remainder.substring(0, queryIdx);
+ }
+ int slashIdx = remainder.indexOf('/');
+ if (slashIdx >= 0) {
+ remainder = remainder.substring(0, slashIdx);
+ }
+ return remainder.isEmpty() ? null : remainder;
+ }
+
}
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..702a3aa5e414
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java
@@ -0,0 +1,1309 @@
+/*
+ * 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.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import javax.inject.Inject;
+import javax.naming.ConfigurationException;
+
+import com.cloud.utils.StringUtils;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider;
+import org.apache.cloudstack.engine.subsystem.api.storage.StrategyPriority;
+import org.apache.cloudstack.engine.subsystem.api.storage.VMSnapshotOptions;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.storage.feign.client.SnapshotFeignClient;
+import org.apache.cloudstack.storage.feign.model.ConsistencyGroup;
+import org.apache.cloudstack.storage.feign.model.ConsistencyGroupSnapshot;
+import org.apache.cloudstack.storage.feign.model.ConsistencyGroupVolume;
+import org.apache.cloudstack.storage.feign.model.ConsistencyGroupVolumeProvisioningOptions;
+import org.apache.cloudstack.storage.feign.model.FlexVolSnapshot;
+import org.apache.cloudstack.storage.feign.model.Svm;
+import org.apache.cloudstack.storage.feign.model.Job;
+import org.apache.cloudstack.storage.feign.model.response.JobResponse;
+import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
+import org.apache.cloudstack.storage.service.StorageStrategy;
+import org.apache.cloudstack.storage.service.model.ProtocolType;
+import org.apache.cloudstack.storage.to.VolumeObjectTO;
+import org.apache.cloudstack.storage.utils.OntapStorageUtils;
+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.DeleteVMSnapshotAnswer;
+import com.cloud.agent.api.DeleteVMSnapshotCommand;
+import com.cloud.agent.api.FreezeThawVMAnswer;
+import com.cloud.agent.api.FreezeThawVMCommand;
+import com.cloud.agent.api.RevertToVMSnapshotAnswer;
+import com.cloud.agent.api.RevertToVMSnapshotCommand;
+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.VolumeDetailVO;
+import com.cloud.storage.VolumeVO;
+import com.cloud.storage.dao.VolumeDetailsDao;
+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;
+import org.apache.cloudstack.storage.utils.OntapStorageConstants;
+
+/**
+ * VM Snapshot strategy for NetApp ONTAP managed storage using temporary consistency-group orchestration.
+ *
+ * This strategy handles VM-level (instance) snapshots for VMs whose volumes
+ * reside on ONTAP managed primary storage. When VM volumes span multiple FlexVols,
+ * snapshot creation is coordinated through a temporary ONTAP consistency group (CG)
+ * and two-phase snapshot flow (start + commit). When all volumes share a single FlexVol,
+ * a direct FlexVol snapshot is used instead.
+ *
+ * Flow:
+ *
+ * - Group all VM volumes by their parent FlexVolume UUID
+ * - Freeze the VM via QEMU guest agent ({@code fsfreeze}) — if quiesce requested
+ * - If VM spans multiple FlexVolumes: create temporary CG, start + commit CG snapshot (two-phase)
+ * - If VM spans a single FlexVolume: create one FlexVol snapshot directly (no CG overhead)
+ * - Thaw the VM
+ * - Resolve FlexVolume → snapshot UUID mappings and persist in {@code vm_snapshot_details}
+ *
+ *
+ * Metadata in vm_snapshot_details:
+ * Each FlexVolume snapshot is stored as a detail row with:
+ *
+ * - name = {@value OntapStorageConstants#ONTAP_FLEXVOL_SNAPSHOT}
+ * - value = {@code "::::::::::"}
+ *
+ * One row is persisted per CloudStack volume (not per FlexVolume) so that the
+ * revert operation can restore individual files/LUNs using the ONTAP Snapshot
+ * File Restore API ({@code POST /api/storage/volumes/{vol}/snapshots/{snap}/files/{path}/restore}).
+ *
+ * Strategy Selection:
+ * Returns {@code StrategyPriority.HIGHEST} when:
+ *
+ * - Hypervisor is KVM
+ * - Snapshot type is Disk-only (no memory)
+ * - All VM volumes are on ONTAP managed primary storage
+ *
+ */
+public class OntapVMSnapshotStrategy extends StorageVMSnapshotStrategy {
+
+ private static final Logger logger = LogManager.getLogger(OntapVMSnapshotStrategy.class);
+
+ /** Separator used in the vm_snapshot_details value to delimit FlexVol UUID, snapshot UUID, snapshot name, and pool ID. */
+ static final String DETAIL_SEPARATOR = "::";
+
+ @Inject
+ private StoragePoolDetailsDao storagePoolDetailsDao;
+
+ @Inject
+ private VolumeDetailsDao volumeDetailsDao;
+
+ @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())) {
+ // Check for our FlexVolume snapshot details first
+ List flexVolDetails = vmSnapshotDetailsDao.findDetails(vmSnapshot.getId(), OntapStorageConstants.ONTAP_FLEXVOL_SNAPSHOT);
+ if (CollectionUtils.isNotEmpty(flexVolDetails)) {
+ // Verify the volumes are still on ONTAP storage
+ if (allVolumesOnOntapManagedStorage(vmSnapshot.getVmId())) {
+ return StrategyPriority.HIGHEST;
+ }
+ return StrategyPriority.CANT_HANDLE;
+ }
+ // Also check legacy STORAGE_SNAPSHOT details for backward compatibility
+ List legacyDetails = vmSnapshotDetailsDao.findDetails(vmSnapshot.getId(), STORAGE_SNAPSHOT);
+ if (CollectionUtils.isNotEmpty(legacyDetails) && allVolumesOnOntapManagedStorage(vmSnapshot.getVmId())) {
+ return StrategyPriority.HIGHEST;
+ }
+ return StrategyPriority.CANT_HANDLE;
+ }
+
+ // For new snapshots (Allocated state), check if we can handle this VM
+ // ONTAP only supports disk-only snapshots, not memory snapshots
+ if (allVolumesOnOntapManagedStorage(vmSnapshot.getVmId())) {
+ if (vmSnapshotVO.getType() == VMSnapshot.Type.DiskAndMemory) {
+ logger.debug("canHandle: Memory snapshots (DiskAndMemory) are not supported for VMs on ONTAP storage. VMSnapshot [{}]", vmSnapshot.getId());
+ return StrategyPriority.CANT_HANDLE;
+ }
+ return StrategyPriority.HIGHEST;
+ }
+
+ return StrategyPriority.CANT_HANDLE;
+ }
+
+ @Override
+ public StrategyPriority canHandle(Long vmId, Long rootPoolId, boolean snapshotMemory) {
+ // ONTAP FlexVolume snapshots only support disk-only (crash-consistent) snapshots.
+ // Memory snapshots (snapshotMemory=true) are not supported because:
+ // 1. ONTAP snapshots capture disk state only, not VM memory
+ // 2. Allowing memory snapshots would require falling back to libvirt snapshots,
+ // creating mixed snapshot chains that would cause issues during revert
+ // Return CANT_HANDLE so VMSnapshotManagerImpl can provide a clear error message.
+ if (snapshotMemory) {
+ logger.debug("canHandle: Memory snapshots (snapshotMemory=true) are not supported for VMs on ONTAP storage. 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.
+ */
+ boolean allVolumesOnOntapManagedStorage(long vmId) {
+ UserVm userVm = userVmDao.findById(vmId);
+ if (userVm == null) {
+ logger.debug("allVolumesOnOntapManagedStorage: VM with id [{}] not found", vmId);
+ return false;
+ }
+
+ if (!Hypervisor.HypervisorType.KVM.equals(userVm.getHypervisorType())) {
+ logger.debug("allVolumesOnOntapManagedStorage: ONTAP VM snapshot strategy only supports KVM hypervisor, VM [{}] uses [{}]",
+ vmId, userVm.getHypervisorType());
+ return false;
+ }
+
+ // ONTAP VM snapshots work for both Running and Stopped VMs.
+ // Running VMs may be frozen/thawed (if quiesce is requested).
+ // Stopped VMs don't need freeze/thaw - just take the FlexVol snapshot directly.
+ VirtualMachine.State vmState = userVm.getState();
+ if (!VirtualMachine.State.Running.equals(vmState) && !VirtualMachine.State.Stopped.equals(vmState)) {
+ logger.info("allVolumesOnOntapManagedStorage: ONTAP VM snapshot strategy requires VM to be Running or Stopped, VM [{}] is in state [{}], returning false",
+ vmId, vmState);
+ return false;
+ }
+
+ List volumes = volumeDao.findByInstance(vmId);
+ if (volumes == null || volumes.isEmpty()) {
+ logger.debug("allVolumesOnOntapManagedStorage: 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("allVolumesOnOntapManagedStorage: Volume [{}] is on non-managed storage pool [{}], not ONTAP",
+ volume.getId(), pool.getName());
+ return false;
+ }
+ if (!DataStoreProvider.ONTAP_PLUGIN_NAME.equals(pool.getStorageProviderName())) {
+ logger.debug("allVolumesOnOntapManagedStorage: Volume [{}] is on managed pool [{}] with provider [{}], not ONTAP",
+ volume.getId(), pool.getName(), pool.getStorageProviderName());
+ return false;
+ }
+ }
+
+ logger.debug("allVolumesOnOntapManagedStorage: All volumes of VM [{}] are on ONTAP managed storage, this strategy can handle", vmId);
+ return true;
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Take VM Snapshot (FlexVolume-level)
+ // ──────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Takes a VM-level snapshot by freezing the VM, creating ONTAP FlexVolume-level
+ * snapshot(s), and then thawing the VM.
+ *
+ * Volumes are grouped by their parent FlexVolume UUID (from storage pool details).
+ * When the VM spans more than one unique FlexVolume, a temporary ONTAP
+ * consistency group is used with two-phase snapshot semantics (start + commit) so
+ * all FlexVols are captured at the same point in time. When all VM volumes reside
+ * on a single FlexVolume, a direct per-FlexVol snapshot is taken instead —
+ * CG orchestration is unnecessary in that case.
+ *
+ * Memory Snapshots Not Supported: This strategy only supports disk-only
+ * (crash-consistent) snapshots. Memory snapshots (snapshotmemory=true) are rejected
+ * with a clear error message. This is because ONTAP FlexVolume snapshots capture disk
+ * state only, and allowing mixed snapshot chains (ONTAP disk + libvirt memory) would
+ * cause issues during revert operations.
+ *
+ * @throws CloudRuntimeException if memory snapshot is requested
+ */
+ @Override
+ public VMSnapshot takeVMSnapshot(VMSnapshot vmSnapshot) {
+ Long hostId = vmSnapshotHelper.pickRunningHost(vmSnapshot.getVmId());
+ UserVm userVm = userVmDao.findById(vmSnapshot.getVmId());
+ VMSnapshotVO vmSnapshotVO = (VMSnapshotVO) vmSnapshot;
+
+ // Transition to Creating state FIRST - this is required so that the finally block
+ // can properly transition to Error state via OperationFailed event if anything fails.
+ // (OperationFailed can only transition FROM Creating state, not from Allocated)
+ try {
+ vmSnapshotHelper.vmSnapshotStateTransitTo(vmSnapshotVO, VMSnapshot.Event.CreateRequested);
+ } catch (NoTransitionException e) {
+ throw new CloudRuntimeException(e.getMessage());
+ }
+
+ FreezeThawVMAnswer freezeAnswer = null;
+ FreezeThawVMCommand thawCmd = null;
+ FreezeThawVMAnswer thawAnswer = null;
+ long startFreeze = 0;
+
+ // Track which FlexVolume snapshots were created (for rollback and detail persistence)
+ List createdSnapshots = new ArrayList<>();
+
+ boolean result = false;
+ try {
+ GuestOSVO guestOS = guestOSDao.findById(userVm.getGuestOSId());
+ List volumeTOs = vmSnapshotHelper.getVolumeTOList(userVm.getId());
+
+ long prevChainSize = 0;
+ long virtualSize = 0;
+
+ // Build snapshot parent chain
+ VMSnapshotTO current = null;
+ VMSnapshotVO currentSnapshot = vmSnapshotDao.findCurrentSnapshotByVmId(userVm.getId());
+ if (currentSnapshot != null) {
+ current = vmSnapshotHelper.getSnapshotWithParents(currentSnapshot);
+ }
+
+ // Respect the user's quiesce option from the VM snapshot request
+ boolean quiesceVm = true; // default to true for safety
+ VMSnapshotOptions options = vmSnapshotVO.getOptions();
+ if (options != null) {
+ quiesceVm = options.needQuiesceVM();
+ }
+
+ // Check if VM is actually running - freeze/thaw only makes sense for running VMs
+ boolean vmIsRunning = VirtualMachine.State.Running.equals(userVm.getState());
+ boolean shouldFreezeThaw = quiesceVm && vmIsRunning;
+
+ if (!vmIsRunning) {
+ logger.info("takeVMSnapshot: VM [{}] is in state [{}] (not Running). Skipping freeze/thaw - " +
+ "FlexVolume snapshot will be taken directly.", userVm.getInstanceName(), userVm.getState());
+ } else if (quiesceVm) {
+ logger.info("takeVMSnapshot: Quiesce option is enabled for ONTAP VM Snapshot of VM [{}]. " +
+ "VM file systems will be frozen/thawed for application-consistent snapshots.", userVm.getInstanceName());
+ } else {
+ logger.info("takeVMSnapshot: Quiesce option is disabled for ONTAP VM Snapshot of VM [{}]. " +
+ "Snapshots will be crash-consistent only.", userVm.getInstanceName());
+ }
+
+ 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("takeVMSnapshot: Creating ONTAP VM snapshot for VM [{}] with quiesce={}",
+ userVm.getInstanceName(), quiesceVm);
+
+ // Prepare volume info list and calculate sizes
+ for (VolumeObjectTO volumeObjectTO : volumeTOs) {
+ virtualSize += volumeObjectTO.getSize();
+ VolumeVO volumeVO = volumeDao.findById(volumeObjectTO.getId());
+ prevChainSize += volumeVO.getVmSnapshotChainSize() == null ? 0 : volumeVO.getVmSnapshotChainSize();
+ }
+
+ // ── Group volumes by FlexVolume UUID ──
+ Map flexVolGroups = groupVolumesByFlexVol(volumeTOs);
+
+ logger.info("takeVMSnapshot: VM [{}] has {} volumes across {} unique FlexVolume(s)",
+ userVm.getInstanceName(), volumeTOs.size(), flexVolGroups.size());
+
+ // ── Step 1: Freeze the VM (only if quiescing is requested AND VM is running) ──
+ if (shouldFreezeThaw) {
+ 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("takeVMSnapshot: VM [{}] frozen successfully via QEMU guest agent", userVm.getInstanceName());
+ } else {
+ logger.info("takeVMSnapshot: Skipping VM freeze for VM [{}] (quiesce={}, vmIsRunning={})",
+ userVm.getInstanceName(), quiesceVm, vmIsRunning);
+ }
+
+ // ── Step 2: Create FlexVolume-level snapshot(s) ──
+ try {
+ String snapshotNameBase = buildSnapshotName(vmSnapshot);
+
+ // CG orchestration is only required when VM disks span multiple FlexVols.
+ // A single FlexVol already provides atomic capture for all volumes on that FlexVol.
+ if (flexVolGroups.size() > 1) {
+ logger.info("takeVMSnapshot: VM [{}] spans {} FlexVol(s); using temporary CG two-phase snapshot flow",
+ userVm.getInstanceName(), flexVolGroups.size());
+ createVmSnapshotsViaTemporaryCg(vmSnapshot, userVm, flexVolGroups, snapshotNameBase, createdSnapshots);
+ } else {
+ logger.info("takeVMSnapshot: VM [{}] spans a single FlexVol; using direct FlexVol snapshot flow",
+ userVm.getInstanceName());
+ createVmSnapshotsViaSingleFlexVol(vmSnapshot, userVm, flexVolGroups, snapshotNameBase, createdSnapshots);
+ }
+ } finally {
+ // ── Step 3: Thaw the VM (only if it was frozen, always even on error) ──
+ if (quiesceVm && freezeAnswer != null && freezeAnswer.getResult()) {
+ try {
+ thawAnswer = (FreezeThawVMAnswer) agentMgr.send(hostId, thawCmd);
+ if (thawAnswer != null && thawAnswer.getResult()) {
+ logger.info("takeVMSnapshot: VM [{}] thawed successfully. Total freeze duration: {} ms",
+ userVm.getInstanceName(),
+ TimeUnit.MILLISECONDS.convert(System.nanoTime() - startFreeze, TimeUnit.NANOSECONDS));
+ } else {
+ logger.warn("takeVMSnapshot: Failed to thaw VM [{}]: {}", userVm.getInstanceName(),
+ (thawAnswer != null) ? thawAnswer.getDetails() : "no response");
+ }
+ } catch (Exception thawEx) {
+ logger.error("takeVMSnapshot: Exception while thawing VM [{}]: {}", userVm.getInstanceName(), thawEx.getMessage(), thawEx);
+ }
+ }
+ }
+
+ // ── Step 4: Persist FlexVolume snapshot details (one row per CloudStack volume) ──
+ for (FlexVolSnapshotDetail detail : createdSnapshots) {
+ vmSnapshotDetailsDao.persist(new VMSnapshotDetailsVO(
+ vmSnapshot.getId(), OntapStorageConstants.ONTAP_FLEXVOL_SNAPSHOT, detail.toString(), true));
+ }
+
+ // ── Step 5: Finalize via parent processAnswer ──
+ CreateVMSnapshotAnswer answer = new CreateVMSnapshotAnswer(ccmd, true, "");
+ answer.setVolumeTOs(volumeTOs);
+
+ processAnswer(vmSnapshotVO, userVm, answer, null);
+ logger.info("takeVMSnapshot: ONTAP VM Snapshot [{}] created successfully for VM [{}] ({} detail row(s))",
+ vmSnapshot.getName(), userVm.getInstanceName(), createdSnapshots.size());
+
+ long newChainSize = 0;
+ for (VolumeObjectTO volumeTo : answer.getVolumeTOs()) {
+ publishUsageEvent(EventTypes.EVENT_VM_SNAPSHOT_CREATE, vmSnapshot, userVm, volumeTo);
+ newChainSize += volumeTo.getSize();
+ }
+ publishUsageEvent(EventTypes.EVENT_VM_SNAPSHOT_ON_PRIMARY, vmSnapshot, userVm,
+ newChainSize - prevChainSize, virtualSize);
+
+ result = true;
+ return vmSnapshot;
+
+ } catch (OperationTimedoutException e) {
+ logger.error("takeVMSnapshot: 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("takeVMSnapshot: ONTAP VM Snapshot [{}] failed, agent unavailable: {}", vmSnapshot.getName(), e.getMessage());
+ throw new CloudRuntimeException("Creating Instance Snapshot: " + vmSnapshot.getName() + " failed: " + e.getMessage());
+ } finally {
+ if (!result) {
+ // Rollback all FlexVolume snapshots created so far (deduplicate by FlexVol+Snapshot)
+ Map rolledBack = new HashMap<>();
+ for (FlexVolSnapshotDetail detail : createdSnapshots) {
+ String dedupeKey = detail.flexVolUuid + "::" + detail.snapshotUuid;
+ if (!rolledBack.containsKey(dedupeKey)) {
+ try {
+ rollbackFlexVolSnapshot(detail);
+ rolledBack.put(dedupeKey, Boolean.TRUE);
+ } catch (Exception rollbackEx) {
+ logger.error("takeVMSnapshot: Failed to rollback FlexVol snapshot [{}] on FlexVol [{}]: {}",
+ detail.snapshotUuid, detail.flexVolUuid, rollbackEx.getMessage());
+ }
+ }
+ }
+
+ // Ensure VM is thawed if we haven't done so
+ if (thawAnswer == null && freezeAnswer != null && freezeAnswer.getResult()) {
+ try {
+ logger.info("takeVMSnapshot: Thawing VM [{}] during error cleanup", userVm.getInstanceName());
+ thawAnswer = (FreezeThawVMAnswer) agentMgr.send(hostId, thawCmd);
+ } catch (Exception ex) {
+ logger.error("takeVMSnapshot: 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 (OntapStorageConstants.ONTAP_FLEXVOL_SNAPSHOT.equals(detail.getName())) {
+ vmSnapshotDetailsDao.remove(detail.getId());
+ }
+ }
+ vmSnapshotHelper.vmSnapshotStateTransitTo(vmSnapshot, VMSnapshot.Event.OperationFailed);
+ } catch (NoTransitionException e1) {
+ logger.error("takeVMSnapshot: Cannot set VM Snapshot state to OperationFailed: {}", e1.getMessage());
+ }
+ }
+ }
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Delete VM Snapshot
+ // ──────────────────────────────────────────────────────────────────────────
+
+ @Override
+ public boolean deleteVMSnapshot(VMSnapshot vmSnapshot) {
+ VMSnapshotVO vmSnapshotVO = (VMSnapshotVO) vmSnapshot;
+ UserVm userVm = userVmDao.findById(vmSnapshot.getVmId());
+
+ try {
+ vmSnapshotHelper.vmSnapshotStateTransitTo(vmSnapshotVO, VMSnapshot.Event.ExpungeRequested);
+ } catch (NoTransitionException e) {
+ throw new CloudRuntimeException(e.getMessage());
+ }
+
+ try {
+ List volumeTOs = vmSnapshotHelper.getVolumeTOList(userVm.getId());
+ String vmInstanceName = userVm.getInstanceName();
+ VMSnapshotTO parent = vmSnapshotHelper.getSnapshotWithParents(vmSnapshotVO).getParent();
+
+ VMSnapshotTO vmSnapshotTO = new VMSnapshotTO(vmSnapshotVO.getId(), vmSnapshotVO.getName(), vmSnapshotVO.getType(),
+ vmSnapshotVO.getCreated().getTime(), vmSnapshotVO.getDescription(), vmSnapshotVO.getCurrent(), parent, true);
+ GuestOSVO guestOS = guestOSDao.findById(userVm.getGuestOSId());
+ DeleteVMSnapshotCommand deleteSnapshotCommand = new DeleteVMSnapshotCommand(vmInstanceName, vmSnapshotTO,
+ volumeTOs, guestOS.getDisplayName());
+
+ // Check for FlexVolume snapshots (new approach)
+ List flexVolDetails = vmSnapshotDetailsDao.findDetails(vmSnapshot.getId(), OntapStorageConstants.ONTAP_FLEXVOL_SNAPSHOT);
+ if (CollectionUtils.isNotEmpty(flexVolDetails)) {
+ deleteFlexVolSnapshots(flexVolDetails);
+ }
+
+ // Also handle legacy STORAGE_SNAPSHOT details (backward compatibility)
+ List legacyDetails = vmSnapshotDetailsDao.findDetails(vmSnapshot.getId(), STORAGE_SNAPSHOT);
+ if (CollectionUtils.isNotEmpty(legacyDetails)) {
+ deleteDiskSnapshot(vmSnapshot);
+ }
+
+ processAnswer(vmSnapshotVO, userVm, new DeleteVMSnapshotAnswer(deleteSnapshotCommand, volumeTOs), null);
+ long fullChainSize = 0;
+ for (VolumeObjectTO volumeTo : volumeTOs) {
+ publishUsageEvent(EventTypes.EVENT_VM_SNAPSHOT_DELETE, vmSnapshot, userVm, volumeTo);
+ fullChainSize += volumeTo.getSize();
+ }
+ publishUsageEvent(EventTypes.EVENT_VM_SNAPSHOT_OFF_PRIMARY, vmSnapshot, userVm, fullChainSize, 0L);
+ return true;
+ } catch (CloudRuntimeException err) {
+ String errMsg = String.format("Delete of ONTAP VM Snapshot [%s] of VM [%s] failed: %s",
+ vmSnapshot.getName(), userVm.getInstanceName(), err.getMessage());
+ logger.error(errMsg, err);
+ throw new CloudRuntimeException(errMsg, err);
+ }
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Revert VM Snapshot
+ // ──────────────────────────────────────────────────────────────────────────
+
+ @Override
+ public boolean revertVMSnapshot(VMSnapshot vmSnapshot) {
+ VMSnapshotVO vmSnapshotVO = (VMSnapshotVO) vmSnapshot;
+ UserVm userVm = userVmDao.findById(vmSnapshot.getVmId());
+
+ try {
+ vmSnapshotHelper.vmSnapshotStateTransitTo(vmSnapshotVO, VMSnapshot.Event.RevertRequested);
+ } catch (NoTransitionException e) {
+ throw new CloudRuntimeException(e.getMessage());
+ }
+
+ boolean result = false;
+ try {
+ List volumeTOs = vmSnapshotHelper.getVolumeTOList(userVm.getId());
+ String vmInstanceName = userVm.getInstanceName();
+ VMSnapshotTO parent = vmSnapshotHelper.getSnapshotWithParents(vmSnapshotVO).getParent();
+
+ VMSnapshotTO vmSnapshotTO = new VMSnapshotTO(vmSnapshotVO.getId(), vmSnapshotVO.getName(), vmSnapshotVO.getType(),
+ vmSnapshotVO.getCreated().getTime(), vmSnapshotVO.getDescription(), vmSnapshotVO.getCurrent(), parent, true);
+ GuestOSVO guestOS = guestOSDao.findById(userVm.getGuestOSId());
+ RevertToVMSnapshotCommand revertToSnapshotCommand = new RevertToVMSnapshotCommand(vmInstanceName,
+ userVm.getUuid(), vmSnapshotTO, volumeTOs, guestOS.getDisplayName());
+
+ // Check for FlexVolume snapshots (new approach)
+ List flexVolDetails = vmSnapshotDetailsDao.findDetails(vmSnapshot.getId(), OntapStorageConstants.ONTAP_FLEXVOL_SNAPSHOT);
+ if (CollectionUtils.isNotEmpty(flexVolDetails)) {
+ revertFlexVolSnapshots(flexVolDetails);
+ }
+
+ // Also handle legacy STORAGE_SNAPSHOT details (backward compatibility)
+ List legacyDetails = vmSnapshotDetailsDao.findDetails(vmSnapshot.getId(), STORAGE_SNAPSHOT);
+ if (CollectionUtils.isNotEmpty(legacyDetails)) {
+ revertDiskSnapshot(vmSnapshot);
+ }
+
+ RevertToVMSnapshotAnswer answer = new RevertToVMSnapshotAnswer(revertToSnapshotCommand, true, "");
+ answer.setVolumeTOs(volumeTOs);
+ processAnswer(vmSnapshotVO, userVm, answer, null);
+ result = true;
+ } catch (CloudRuntimeException e) {
+ logger.error("revertVMSnapshot: Revert ONTAP VM Snapshot [{}] failed: {}", vmSnapshot.getName(), e.getMessage(), e);
+ throw new CloudRuntimeException("Revert ONTAP VM Snapshot ["+ vmSnapshot.getName() +"] failed.");
+ } finally {
+ if (!result) {
+ try {
+ vmSnapshotHelper.vmSnapshotStateTransitTo(vmSnapshot, VMSnapshot.Event.OperationFailed);
+ } catch (NoTransitionException e1) {
+ logger.error("Cannot set Instance Snapshot state due to: " + e1.getMessage());
+ }
+ }
+ }
+ return result;
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // FlexVolume Snapshot Helpers
+ // ──────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Groups volumes by their parent FlexVolume UUID using storage pool details.
+ *
+ * @param volumeTOs list of volume transfer objects
+ * @return map of FlexVolume UUID → group info (pool details, pool ID, volume IDs)
+ */
+ Map groupVolumesByFlexVol(List volumeTOs) {
+ Map groups = new HashMap<>();
+
+ for (VolumeObjectTO volumeTO : volumeTOs) {
+ VolumeVO volumeVO = volumeDao.findById(volumeTO.getId());
+ if (volumeVO == null || volumeVO.getPoolId() == null) {
+ throw new CloudRuntimeException("Volume [" + volumeTO.getId() + "] not found or has no pool assigned");
+ }
+
+ Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(volumeVO.getPoolId());
+ String flexVolUuid = poolDetails.get(OntapStorageConstants.VOLUME_UUID);
+ if (flexVolUuid == null || flexVolUuid.isEmpty()) {
+ throw new CloudRuntimeException("FlexVolume UUID not found in pool details for pool [" + volumeVO.getPoolId() + "]");
+ }
+
+ FlexVolGroupInfo group = groups.get(flexVolUuid);
+ if (group == null) {
+ group = new FlexVolGroupInfo(poolDetails, volumeVO.getPoolId());
+ groups.put(flexVolUuid, group);
+ }
+ group.volumeIds.add(volumeVO.getId());
+ }
+
+ return groups;
+ }
+
+ /**
+ * Creates VM snapshot artifacts via direct FlexVol snapshot API.
+ *
+ * Used when all VM volumes map to a single FlexVol. In that case a CG is not
+ * needed because one FlexVol snapshot already captures every disk atomically.
+ */
+ void createVmSnapshotsViaSingleFlexVol(VMSnapshot vmSnapshot, UserVm userVm,
+ Map flexVolGroups,
+ String snapshotNameBase,
+ List createdSnapshots) {
+ for (Map.Entry entry : flexVolGroups.entrySet()) {
+ String flexVolUuid = entry.getKey();
+ FlexVolGroupInfo groupInfo = entry.getValue();
+ long startSnapshot = System.nanoTime();
+
+ StorageStrategy storageStrategy = resolveStorageStrategy(groupInfo.poolDetails);
+ SnapshotFeignClient snapshotClient = storageStrategy.getSnapshotFeignClient();
+ String authHeader = storageStrategy.getAuthHeader();
+
+ FlexVolSnapshot snapshotRequest = new FlexVolSnapshot(snapshotNameBase,
+ "CloudStack VM snapshot " + vmSnapshot.getName() + " for VM " + userVm.getInstanceName());
+
+ logger.info("takeVMSnapshot: [FlexVol] Creating snapshot [{}] on FlexVol UUID [{}] covering {} volume(s)",
+ snapshotNameBase, flexVolUuid, groupInfo.volumeIds.size());
+
+ JobResponse jobResponse = snapshotClient.createSnapshot(authHeader, flexVolUuid, snapshotRequest);
+ if (jobResponse == null || jobResponse.getJob() == null) {
+ throw new CloudRuntimeException("Failed to initiate FlexVolume snapshot on FlexVol UUID [" + flexVolUuid + "]");
+ }
+
+ Boolean jobSucceeded = storageStrategy.jobPollForSuccess(jobResponse.getJob().getUuid(), 30, 2000);
+ if (!jobSucceeded) {
+ throw new CloudRuntimeException("FlexVolume snapshot job failed on FlexVol UUID [" + flexVolUuid + "]");
+ }
+
+ String snapshotUuid = resolveSnapshotUuid(snapshotClient, authHeader, flexVolUuid, snapshotNameBase);
+ String protocol = groupInfo.poolDetails.get(OntapStorageConstants.PROTOCOL);
+
+ for (Long volumeId : groupInfo.volumeIds) {
+ String volumePath = resolveVolumePathOnOntap(volumeId, protocol, groupInfo.poolDetails);
+ createdSnapshots.add(new FlexVolSnapshotDetail(
+ flexVolUuid, snapshotUuid, snapshotNameBase, volumePath, groupInfo.poolId, protocol));
+ }
+
+ logger.debug("takeVMSnapshot: [FlexVol] Snapshot [{}] (uuid={}) on FlexVol [{}] completed in {} ms. Covers volumes: {}",
+ snapshotNameBase, snapshotUuid, flexVolUuid,
+ TimeUnit.MILLISECONDS.convert(System.nanoTime() - startSnapshot, TimeUnit.NANOSECONDS),
+ groupInfo.volumeIds);
+ }
+ }
+
+ /**
+ * Creates VM snapshot artifacts via temporary consistency-group two-phase flow.
+ *
+ * Used when VM volumes span multiple FlexVols and require a consistent
+ * point-in-time capture across all participating FlexVolumes.
+ */
+ void createVmSnapshotsViaTemporaryCg(VMSnapshot vmSnapshot, UserVm userVm,
+ Map flexVolGroups,
+ String snapshotNameBase,
+ List createdSnapshots) {
+ String tempCgName = buildTemporaryConsistencyGroupName(vmSnapshot);
+ String tempCgUuid = null;
+ String cgSnapshotUuid = null;
+ long cgFlowStart = System.nanoTime();
+
+ // All volumes in a VM snapshot belong to ONTAP-managed pools and share the same ONTAP credentials.
+ // Use any one FlexVol group to obtain strategy/client objects for this operation.
+ FlexVolGroupInfo referenceGroup = flexVolGroups.values().iterator().next();
+ StorageStrategy storageStrategy = resolveStorageStrategy(referenceGroup.poolDetails);
+ SnapshotFeignClient snapshotClient = storageStrategy.getSnapshotFeignClient();
+ String authHeader = storageStrategy.getAuthHeader();
+
+ try {
+ logger.info("takeVMSnapshot: [CG:Create] Creating temporary consistency group [{}] for VM [{}] over {} FlexVol(s)",
+ tempCgName, userVm.getInstanceName(), flexVolGroups.size());
+ tempCgUuid = createTemporaryConsistencyGroup(snapshotClient, storageStrategy, authHeader, tempCgName,
+ resolveConsistencyGroupScope(flexVolGroups), flexVolGroups.keySet());
+
+ logger.info("takeVMSnapshot: [CG:Start] Starting phase-1 snapshot [{}] for temporary consistency group [{}]",
+ snapshotNameBase, tempCgUuid);
+ cgSnapshotUuid = resolveStartedConsistencyGroupSnapshotUuid(snapshotClient, storageStrategy,
+ authHeader, tempCgUuid, snapshotNameBase);
+
+ logger.info("takeVMSnapshot: [CG:Commit] Committing phase-2 snapshot [{}] (uuid={}) for temporary consistency group [{}]",
+ snapshotNameBase, cgSnapshotUuid, tempCgUuid);
+ commitConsistencyGroupSnapshot(snapshotClient, storageStrategy, authHeader, tempCgUuid, cgSnapshotUuid);
+
+ // Resolve per-FlexVol snapshot UUIDs and build one detail entry per CloudStack volume.
+ for (Map.Entry entry : flexVolGroups.entrySet()) {
+ String flexVolUuid = entry.getKey();
+ FlexVolGroupInfo groupInfo = entry.getValue();
+ String snapshotUuid = resolveSnapshotUuid(snapshotClient, authHeader, flexVolUuid, snapshotNameBase);
+ String protocol = groupInfo.poolDetails.get(OntapStorageConstants.PROTOCOL);
+
+ for (Long volumeId : groupInfo.volumeIds) {
+ String volumePath = resolveVolumePathOnOntap(volumeId, protocol, groupInfo.poolDetails);
+ createdSnapshots.add(new FlexVolSnapshotDetail(
+ flexVolUuid, snapshotUuid, snapshotNameBase, volumePath, groupInfo.poolId, protocol));
+ }
+
+ logger.debug("takeVMSnapshot: [CG:Resolve] Snapshot [{}] resolved to FlexVol snapshot uuid [{}] for FlexVol [{}], volumes={}",
+ snapshotNameBase, snapshotUuid, flexVolUuid, groupInfo.volumeIds);
+ }
+ } finally {
+ // CG is only a transaction boundary; remove it after commit/failure and keep snapshots intact.
+ if (tempCgUuid != null) {
+ try {
+ logger.info("takeVMSnapshot: [CG:Cleanup] Deleting temporary consistency group [{}]", tempCgUuid);
+ deleteTemporaryConsistencyGroup(snapshotClient, storageStrategy, authHeader, tempCgUuid);
+ } catch (Exception cleanupEx) {
+ logger.warn("takeVMSnapshot: Failed to delete temporary consistency group [{}]: {}",
+ tempCgUuid, cleanupEx.getMessage());
+ }
+ }
+ }
+
+ logger.info("takeVMSnapshot: Temporary consistency-group two-phase flow completed for VM [{}] in {} ms. CG snapshot uuid={}, detail rows={}",
+ userVm.getInstanceName(),
+ TimeUnit.MILLISECONDS.convert(System.nanoTime() - cgFlowStart, TimeUnit.NANOSECONDS),
+ cgSnapshotUuid, createdSnapshots.size());
+ }
+
+ /**
+ * Builds an ONTAP-safe snapshot name from the CloudStack VM snapshot UI name.
+ */
+ String buildSnapshotName(VMSnapshot vmSnapshot) {
+ return OntapStorageUtils.buildOntapSnapshotName(vmSnapshot.getName(), "vm" + vmSnapshot.getId());
+ }
+
+ /**
+ * Wrapper for static utility to simplify unit testing and keep call sites explicit.
+ */
+ StorageStrategy resolveStorageStrategy(Map poolDetails) {
+ return OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
+ }
+
+ /**
+ * Resolves the UUID of a newly created FlexVolume snapshot by name.
+ */
+ String resolveSnapshotUuid(SnapshotFeignClient client, String authHeader,
+ String flexVolUuid, String snapshotName) {
+ Map queryParams = new HashMap<>();
+ queryParams.put("name", snapshotName);
+ OntapResponse response = client.getSnapshots(authHeader, flexVolUuid, queryParams);
+ if (response == null || response.getRecords() == null || response.getRecords().isEmpty()) {
+ throw new CloudRuntimeException("Could not find FlexVolume snapshot [" + snapshotName +
+ "] on FlexVol [" + flexVolUuid + "] after creation");
+ }
+ return response.getRecords().get(0).getUuid();
+ }
+
+ /**
+ * Builds a deterministic temporary CG name for the VM snapshot transaction.
+ */
+ String buildTemporaryConsistencyGroupName(VMSnapshot vmSnapshot) {
+ return OntapStorageConstants.ONTAP_TEMP_CG_PREFIX + vmSnapshot.getId();
+ }
+
+ /**
+ * Validates and returns the ONTAP scope for a temporary consistency group.
+ *
+ * CG membership requires all FlexVols on the same ONTAP management endpoint and SVM.
+ * SVM name alone is not sufficient — different clusters may reuse names such as {@code vs0}.
+ * Identity uses {@code storageIP} plus {@code svmUUID} when persisted, otherwise
+ * {@code storageIP} plus {@code svmName} for legacy pools.
+ */
+ ConsistencyGroupScope resolveConsistencyGroupScope(Map flexVolGroups) {
+ ConsistencyGroupScope scope = null;
+ for (FlexVolGroupInfo group : flexVolGroups.values()) {
+ ConsistencyGroupScope candidate = consistencyGroupScopeFromPoolDetails(group.poolDetails, group.poolId);
+ if (scope == null) {
+ scope = candidate;
+ } else if (!scope.matches(candidate)) {
+ throw new CloudRuntimeException("ONTAP consistency groups require all VM volumes on the same "
+ + "ONTAP cluster and SVM. Found [" + scope + "] and [" + candidate + "]");
+ }
+ }
+ return scope;
+ }
+
+ ConsistencyGroupScope consistencyGroupScopeFromPoolDetails(Map poolDetails, long poolId) {
+ String storageIp = poolDetails.get(OntapStorageConstants.STORAGE_IP);
+ if (StringUtils.isBlank(storageIp)) {
+ throw new CloudRuntimeException("ONTAP storage management IP not found in pool details for pool ["
+ + poolId + "]");
+ }
+ String svmName = poolDetails.get(OntapStorageConstants.SVM_NAME);
+ if (StringUtils.isBlank(svmName)) {
+ throw new CloudRuntimeException("SVM name not found in pool details for pool [" + poolId + "]");
+ }
+ String svmUuid = poolDetails.get(OntapStorageConstants.SVM_UUID);
+ return new ConsistencyGroupScope(storageIp.trim(), svmName.trim(),
+ svmUuid != null && !svmUuid.trim().isEmpty() ? svmUuid.trim() : null);
+ }
+
+ /**
+ * Creates a temporary consistency group for the involved FlexVol UUIDs and returns its UUID.
+ */
+ String createTemporaryConsistencyGroup(SnapshotFeignClient client, StorageStrategy storageStrategy,
+ String authHeader, String cgName, ConsistencyGroupScope cgScope,
+ Set flexVolUuids) {
+ if (cgScope == null) {
+ throw new CloudRuntimeException("ONTAP consistency group scope is required to create CG [" + cgName + "]");
+ }
+
+ List volumeRefs = new ArrayList<>();
+ for (String flexVolUuid : flexVolUuids) {
+ ConsistencyGroupVolumeProvisioningOptions provisioningOptions =
+ new ConsistencyGroupVolumeProvisioningOptions(OntapStorageConstants.CG_VOLUME_PROVISIONING_ACTION_ADD);
+
+ ConsistencyGroupVolume volumeRef = new ConsistencyGroupVolume();
+ volumeRef.setUuid(flexVolUuid);
+ volumeRef.setProvisioningOptions(provisioningOptions);
+ volumeRefs.add(volumeRef);
+ }
+
+ ConsistencyGroup payload = new ConsistencyGroup();
+ payload.setName(cgName);
+ payload.setSvm(cgScope.toOntapSvm());
+ payload.setVolumes(volumeRefs);
+
+ JobResponse response = client.createConsistencyGroup(authHeader, payload);
+ storageStrategy.pollJobIfPresent(response, "create temporary consistency group " + cgName);
+
+ String cgUuid = resolveConsistencyGroupUuidByName(client, authHeader, cgName, cgScope);
+ if (cgUuid == null || cgUuid.isEmpty()) {
+ throw new CloudRuntimeException("Unable to resolve temporary consistency group UUID for [" + cgName + "]");
+ }
+ return cgUuid;
+ }
+
+ /**
+ * Starts phase-1 of the two-phase CG snapshot and returns the CG snapshot UUID when ONTAP exposes it in the job record.
+ */
+ String startConsistencyGroupSnapshot(SnapshotFeignClient client, StorageStrategy storageStrategy,
+ String authHeader, String cgUuid, String snapshotName) {
+ ConsistencyGroupSnapshot payload = new ConsistencyGroupSnapshot(snapshotName, "start");
+ JobResponse response = client.createConsistencyGroupSnapshot(authHeader, cgUuid, payload);
+ Job completedJob = storageStrategy.pollJobIfPresentAndGetCompletedJob(response,
+ "start CG snapshot " + snapshotName + " for " + cgUuid);
+ if (completedJob == null) {
+ return null;
+ }
+ String snapshotUuid = OntapStorageUtils.extractUuidFromOntapJobDescription(
+ completedJob.getDescription(), "/snapshots/");
+ if (snapshotUuid != null) {
+ logger.info("takeVMSnapshot: [CG:Start] Resolved CG snapshot UUID [{}] from ONTAP job for snapshot [{}]",
+ snapshotUuid, snapshotName);
+ }
+ return snapshotUuid;
+ }
+
+ /**
+ * Commits phase-2 of the started CG snapshot.
+ */
+ void commitConsistencyGroupSnapshot(SnapshotFeignClient client, StorageStrategy storageStrategy,
+ String authHeader, String cgUuid, String snapshotUuid) {
+ ConsistencyGroupSnapshot payload = new ConsistencyGroupSnapshot();
+ payload.setAction("commit");
+ JobResponse response = client.commitConsistencyGroupSnapshot(authHeader, cgUuid, snapshotUuid, payload);
+ storageStrategy.pollJobIfPresent(response, "commit CG snapshot " + snapshotUuid + " for " + cgUuid);
+ }
+
+ /**
+ * Deletes the temporary consistency group used as a transaction boundary.
+ */
+ void deleteTemporaryConsistencyGroup(SnapshotFeignClient client, StorageStrategy storageStrategy,
+ String authHeader, String cgUuid) {
+ JobResponse response = client.deleteConsistencyGroup(authHeader, cgUuid);
+ storageStrategy.pollJobIfPresent(response, "delete temporary consistency group " + cgUuid);
+ }
+
+ /**
+ * Resolves consistency group UUID by name within the given ONTAP cluster/SVM scope.
+ */
+ String resolveConsistencyGroupUuidByName(SnapshotFeignClient client, String authHeader,
+ String cgName, ConsistencyGroupScope cgScope) {
+ Map query = new HashMap<>();
+ query.put("name", cgName);
+ cgScope.applySvmQueryFilter(query);
+ query.put("fields", "uuid,name");
+ OntapResponse response = client.getConsistencyGroups(authHeader, query);
+ if (response == null || response.getRecords() == null || response.getRecords().isEmpty()) {
+ return null;
+ }
+ ConsistencyGroup consistencyGroup = response.getRecords().get(0);
+ return consistencyGroup != null ? consistencyGroup.getUuid() : null;
+ }
+
+ /**
+ * Resolves the started CG snapshot UUID after phase-1, using the job record when available and polling GET otherwise.
+ */
+ String resolveStartedConsistencyGroupSnapshotUuid(SnapshotFeignClient client, StorageStrategy storageStrategy,
+ String authHeader, String cgUuid, String snapshotName) {
+ String snapshotUuidFromJob = startConsistencyGroupSnapshot(client, storageStrategy, authHeader, cgUuid, snapshotName);
+ if (snapshotUuidFromJob != null && !snapshotUuidFromJob.isEmpty()) {
+ return snapshotUuidFromJob;
+ }
+ return resolveConsistencyGroupSnapshotUuid(client, storageStrategy, authHeader, cgUuid, snapshotName);
+ }
+
+ /**
+ * Resolves consistency group snapshot UUID by name with retries (ONTAP list can lag behind job success).
+ */
+ String resolveConsistencyGroupSnapshotUuid(SnapshotFeignClient client, StorageStrategy storageStrategy,
+ String authHeader, String cgUuid, String snapshotName) {
+ int maxRetries = OntapStorageConstants.ONTAP_CG_SNAPSHOT_RESOLVE_MAX_RETRIES;
+ int pollIntervalMs = OntapStorageConstants.ONTAP_CG_SNAPSHOT_RESOLVE_POLL_INTERVAL_MS;
+
+ for (int attempt = 1; attempt <= maxRetries; attempt++) {
+ String snapshotUuid = lookupConsistencyGroupSnapshotUuid(client, authHeader, cgUuid, snapshotName);
+ if (snapshotUuid != null) {
+ if (attempt > 1) {
+ logger.info("takeVMSnapshot: [CG:Resolve] CG snapshot [{}] resolved on attempt {}/{}",
+ snapshotName, attempt, maxRetries);
+ }
+ return snapshotUuid;
+ }
+ if (attempt < maxRetries) {
+ logger.debug("takeVMSnapshot: [CG:Resolve] CG snapshot [{}] not yet visible in CG [{}], retry {}/{}",
+ snapshotName, cgUuid, attempt, maxRetries);
+ try {
+ Thread.sleep(pollIntervalMs);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new CloudRuntimeException("Interrupted while resolving CG snapshot [" + snapshotName + "]", e);
+ }
+ }
+ }
+
+ throw new CloudRuntimeException("Unable to resolve consistency group snapshot UUID for snapshot [" +
+ snapshotName + "] in CG [" + cgUuid + "] after " + maxRetries + " attempts");
+ }
+
+ /**
+ * Single GET attempt: try to match by name,
+ * then fall back to listing all CG snapshots in this group (And it would be one
+ * always since workflow is keep deleting the CG).
+ */
+ String lookupConsistencyGroupSnapshotUuid(SnapshotFeignClient client, String authHeader,
+ String cgUuid, String snapshotName) {
+ Map query = new HashMap<>();
+ query.put("name", snapshotName);
+ query.put("fields", "uuid,name");
+ OntapResponse response = client.getConsistencyGroupSnapshots(authHeader, cgUuid, query);
+ String snapshotUuid = findConsistencyGroupSnapshotUuidInRecords(response, snapshotName);
+ if (snapshotUuid != null) {
+ return snapshotUuid;
+ }
+
+ Map listAllQuery = new HashMap<>();
+ listAllQuery.put("fields", "uuid,name");
+ OntapResponse allSnapshots = client.getConsistencyGroupSnapshots(authHeader, cgUuid, listAllQuery);
+ return findConsistencyGroupSnapshotUuidInRecords(allSnapshots, snapshotName);
+ }
+
+ private String findConsistencyGroupSnapshotUuidInRecords(OntapResponse response,
+ String snapshotName) {
+ if (response == null || response.getRecords() == null || response.getRecords().isEmpty()) {
+ return null;
+ }
+ for (ConsistencyGroupSnapshot record : response.getRecords()) {
+ if (record != null && snapshotName.equals(record.getName())) {
+ String uuid = record.getUuid();
+ if (uuid != null && !uuid.isEmpty()) {
+ return uuid;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Resolves the ONTAP-side path of a CloudStack volume within its FlexVolume.
+ *
+ *
+ * - For NFS volumes the path is the filename (e.g. {@code uuid.qcow2})
+ * retrieved via {@link VolumeVO#getPath()}.
+ * - For iSCSI volumes the path is the LUN name within the FlexVolume
+ * (e.g. {@code /vol/vol1/lun_name}) stored in volume_details.
+ *
+ *
+ * @param volumeId the CloudStack volume ID
+ * @param protocol the storage protocol (e.g. "NFS3", "ISCSI")
+ * @param poolDetails storage pool detail map (used for fall-back lookups)
+ * @return the volume path relative to the FlexVolume root
+ */
+ String resolveVolumePathOnOntap(Long volumeId, String protocol, Map poolDetails) {
+ if (ProtocolType.ISCSI.name().equalsIgnoreCase(protocol)) {
+ // iSCSI – the LUN's ONTAP name is stored as a volume detail
+ VolumeDetailVO lunDetail = volumeDetailsDao.findDetail(volumeId, OntapStorageConstants.LUN_DOT_NAME);
+ if (lunDetail == null || lunDetail.getValue() == null || lunDetail.getValue().isEmpty()) {
+ throw new CloudRuntimeException(
+ "LUN name (volume detail '" + OntapStorageConstants.LUN_DOT_NAME + "') not found for iSCSI volume [" + volumeId + "]");
+ }
+ return lunDetail.getValue();
+ } else {
+ // NFS – volumeVO.getPath() holds the file path (e.g. "uuid.qcow2")
+ VolumeVO vol = volumeDao.findById(volumeId);
+ if (vol == null || vol.getPath() == null || vol.getPath().isEmpty()) {
+ throw new CloudRuntimeException("Volume path not found for NFS volume [" + volumeId + "]");
+ }
+ return vol.getPath();
+ }
+ }
+
+ /**
+ * Rolls back (deletes) a FlexVolume snapshot that was created during a failed takeVMSnapshot.
+ */
+ void rollbackFlexVolSnapshot(FlexVolSnapshotDetail detail) {
+ try {
+ Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(detail.poolId);
+ StorageStrategy storageStrategy = resolveStorageStrategy(poolDetails);
+ SnapshotFeignClient client = storageStrategy.getSnapshotFeignClient();
+ String authHeader = storageStrategy.getAuthHeader();
+
+ logger.info("rollbackFlexVolSnapshot: Rolling back FlexVol snapshot [{}] (uuid={}) on FlexVol [{}]",
+ detail.snapshotName, detail.snapshotUuid, detail.flexVolUuid);
+
+ JobResponse jobResponse = client.deleteSnapshot(authHeader, detail.flexVolUuid, detail.snapshotUuid);
+ if (jobResponse != null && jobResponse.getJob() != null) {
+ storageStrategy.jobPollForSuccess(jobResponse.getJob().getUuid(), 10, 2000);
+ }
+ } catch (Exception e) {
+ logger.error("rollbackFlexVolSnapshot: Rollback of FlexVol snapshot failed: {}", e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Deletes all FlexVolume snapshots associated with a VM snapshot.
+ *
+ * Since there is one detail row per CloudStack volume, multiple rows may reference
+ * the same FlexVol + snapshot combination. This method deduplicates to delete each
+ * underlying ONTAP snapshot only once.
+ *
+ * Detail rows are removed only after the underlying ONTAP snapshot delete succeeds
+ * (or was already deleted for the same FlexVol+snapshot pair in this pass). If delete
+ * throws, the detail row is retained so a retry can still find the ONTAP snapshot.
+ */
+ void deleteFlexVolSnapshots(List flexVolDetails) {
+ Map deletedSnapshots = new HashMap<>();
+ CloudRuntimeException deleteFailure = null;
+
+ for (VMSnapshotDetailsVO detailVO : flexVolDetails) {
+ FlexVolSnapshotDetail detail = FlexVolSnapshotDetail.parse(detailVO.getValue());
+ String dedupeKey = detail.flexVolUuid + "::" + detail.snapshotUuid;
+
+ try {
+ if (!deletedSnapshots.containsKey(dedupeKey)) {
+ Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(detail.poolId);
+ StorageStrategy storageStrategy = resolveStorageStrategy(poolDetails);
+
+ logger.info("deleteFlexVolSnapshots: Deleting ONTAP FlexVol snapshot [{}] (uuid={}) on FlexVol [{}]",
+ detail.snapshotName, detail.snapshotUuid, detail.flexVolUuid);
+
+ storageStrategy.deleteFlexVolSnapshotForCloudStackVolume(
+ detail.flexVolUuid, detail.snapshotUuid, detail.snapshotName);
+
+ deletedSnapshots.put(dedupeKey, Boolean.TRUE);
+ logger.info("deleteFlexVolSnapshots: Deleted ONTAP FlexVol snapshot [{}] on FlexVol [{}]",
+ detail.snapshotName, detail.flexVolUuid);
+ }
+ } catch (Exception e) {
+ logger.error("deleteFlexVolSnapshots: Failed to delete ONTAP FlexVol snapshot [{}] (uuid={}) "
+ + "on FlexVol [{}] for detail [{}]: {}",
+ detail.snapshotName, detail.snapshotUuid, detail.flexVolUuid, detailVO.getId(), e.getMessage(), e);
+ if (deleteFailure == null) {
+ deleteFailure = e instanceof CloudRuntimeException
+ ? (CloudRuntimeException) e
+ : new CloudRuntimeException("Failed to delete ONTAP FlexVol snapshot: " + e.getMessage(), e);
+ }
+ } finally {
+ if (deletedSnapshots.containsKey(dedupeKey)) {
+ vmSnapshotDetailsDao.remove(detailVO.getId());
+ }
+ }
+ }
+
+ if (deleteFailure != null) {
+ throw deleteFailure;
+ }
+ }
+
+ /**
+ * Reverts all volumes of a VM snapshot using ONTAP CLI-based Snapshot File Restore.
+ *
+ * Instead of restoring the entire FlexVolume to a snapshot (which would affect
+ * other VMs/files on the same FlexVol), this method restores only the individual
+ * files or LUNs belonging to this VM using the dedicated ONTAP CLI snapshot file
+ * restore API:
+ *
+ * {@code POST /api/private/cli/volume/snapshot/restore-file}
+ *
+ * For each persisted detail row (one per CloudStack volume):
+ *
+ * - NFS: restores {@code } from the snapshot to the live volume
+ * - iSCSI: restores {@code } from the snapshot to the live volume
+ *
+ */
+ void revertFlexVolSnapshots(List flexVolDetails) {
+ for (VMSnapshotDetailsVO detailVO : flexVolDetails) {
+ FlexVolSnapshotDetail detail = FlexVolSnapshotDetail.parse(detailVO.getValue());
+
+ if (detail.volumePath == null || detail.volumePath.isEmpty()) {
+ // Legacy detail row without volumePath – cannot do single-file restore
+ logger.warn("revertFlexVolSnapshots: FlexVol snapshot detail for FlexVol [{}] has no volumePath (legacy format). " +
+ "Skipping single-file restore for this entry.", detail.flexVolUuid);
+ continue;
+ }
+
+ Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(detail.poolId);
+ StorageStrategy storageStrategy = resolveStorageStrategy(poolDetails);
+
+ String flexVolName = poolDetails.get(OntapStorageConstants.VOLUME_NAME);
+ if (flexVolName == null || flexVolName.isEmpty()) {
+ throw new CloudRuntimeException("FlexVolume name not found in pool details for pool [" + detail.poolId + "]");
+ }
+
+ String ontapFilePath = detail.volumePath.startsWith("/") ? detail.volumePath : "/" + detail.volumePath;
+
+ logger.info("revertFlexVolSnapshots: Restoring volume [{}] from FlexVol snapshot [{}] on FlexVol [{}] (protocol={})",
+ ontapFilePath, detail.snapshotName, flexVolName, detail.protocol);
+
+ JobResponse jobResponse = storageStrategy.revertSnapshotForCloudStackVolume(
+ detail.snapshotName, detail.flexVolUuid, detail.snapshotUuid,
+ detail.volumePath, null, flexVolName);
+
+ storageStrategy.executeCliSfsrRestore(jobResponse,
+ "VM snapshot file restore for path [" + ontapFilePath + "] from snapshot [" + detail.snapshotName + "]");
+
+ logger.info("revertFlexVolSnapshots: Successfully restored volume [{}] from snapshot [{}] on FlexVol [{}]",
+ ontapFilePath, detail.snapshotName, flexVolName);
+ }
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────
+ // Inner classes for grouping & detail tracking
+ // ──────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Groups information about volumes that share the same FlexVolume.
+ */
+ static class FlexVolGroupInfo {
+ final Map poolDetails;
+ final long poolId;
+ final List volumeIds = new ArrayList<>();
+
+ FlexVolGroupInfo(Map poolDetails, long poolId) {
+ this.poolDetails = poolDetails;
+ this.poolId = poolId;
+ }
+ }
+
+ /**
+ * Identifies the ONTAP cluster management endpoint and SVM for CG operations.
+ */
+ static class ConsistencyGroupScope {
+ final String storageIp;
+ final String svmName;
+ final String svmUuid;
+
+ ConsistencyGroupScope(String storageIp, String svmName, String svmUuid) {
+ this.storageIp = storageIp;
+ this.svmName = svmName;
+ this.svmUuid = svmUuid;
+ }
+
+ boolean matches(ConsistencyGroupScope other) {
+ return other != null && identityKey().equals(other.identityKey());
+ }
+
+ String identityKey() {
+ if (svmUuid != null && !svmUuid.isEmpty()) {
+ return storageIp + "|" + svmUuid;
+ }
+ return storageIp + "|" + svmName;
+ }
+
+ Map toOntapSvmReference() {
+ Svm svm = toOntapSvm();
+ Map svmRef = new LinkedHashMap<>();
+ if (svm.getUuid() != null && !svm.getUuid().isEmpty()) {
+ svmRef.put("uuid", svm.getUuid());
+ } else {
+ svmRef.put("name", svm.getName());
+ }
+ return svmRef;
+ }
+
+ Svm toOntapSvm() {
+ Svm svm = new Svm();
+ if (svmUuid != null && !svmUuid.isEmpty()) {
+ svm.setUuid(svmUuid);
+ } else {
+ svm.setName(svmName);
+ }
+ return svm;
+ }
+
+ void applySvmQueryFilter(Map query) {
+ if (svmUuid != null && !svmUuid.isEmpty()) {
+ query.put("svm.uuid", svmUuid);
+ } else {
+ query.put("svm.name", svmName);
+ }
+ }
+
+ @Override
+ public String toString() {
+ if (svmUuid != null && !svmUuid.isEmpty()) {
+ return "storageIP=" + storageIp + ", svmUUID=" + svmUuid + ", svmName=" + svmName;
+ }
+ return "storageIP=" + storageIp + ", svmName=" + svmName;
+ }
+ }
+
+ /**
+ * Holds the metadata for a single volume's FlexVolume snapshot entry (used during create and for
+ * serialization/deserialization to/from vm_snapshot_details).
+ *
+ * One row is persisted per CloudStack volume. Multiple volumes may share the same
+ * FlexVol snapshot (if they reside on the same FlexVolume).
+ *
+ * Serialized format: {@code "::::::::::"}
+ */
+ static class FlexVolSnapshotDetail {
+ final String flexVolUuid;
+ final String snapshotUuid;
+ final String snapshotName;
+ /** The ONTAP-side path of the file or LUN within the FlexVolume (e.g. "uuid.qcow2" for NFS, "/vol/vol1/lun1" for iSCSI). */
+ final String volumePath;
+ final long poolId;
+ /** Storage protocol: NFS3, ISCSI, etc. */
+ final String protocol;
+
+ FlexVolSnapshotDetail(String flexVolUuid, String snapshotUuid, String snapshotName,
+ String volumePath, long poolId, String protocol) {
+ this.flexVolUuid = flexVolUuid;
+ this.snapshotUuid = snapshotUuid;
+ this.snapshotName = snapshotName;
+ this.volumePath = volumePath;
+ this.poolId = poolId;
+ this.protocol = protocol;
+ }
+
+ /**
+ * Parses a vm_snapshot_details value string back into a FlexVolSnapshotDetail.
+ */
+ static FlexVolSnapshotDetail parse(String value) {
+ String[] parts = value.split(DETAIL_SEPARATOR);
+ if (parts.length == 4) {
+ // Legacy format without volumePath and protocol: flexVolUuid::snapshotUuid::snapshotName::poolId
+ return new FlexVolSnapshotDetail(parts[0], parts[1], parts[2], null, Long.parseLong(parts[3]), null);
+ }
+ if (parts.length != 6) {
+ throw new CloudRuntimeException("Invalid ONTAP FlexVol snapshot detail format: " + value);
+ }
+ return new FlexVolSnapshotDetail(parts[0], parts[1], parts[2], parts[3], Long.parseLong(parts[4]), parts[5]);
+ }
+
+ @Override
+ public String toString() {
+ return flexVolUuid + DETAIL_SEPARATOR + snapshotUuid + DETAIL_SEPARATOR + snapshotName +
+ DETAIL_SEPARATOR + volumePath + DETAIL_SEPARATOR + poolId + DETAIL_SEPARATOR + protocol;
+ }
+ }
+}
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/driver/OntapPrimaryDatastoreDriverTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java
new file mode 100644
index 000000000000..bad8168ba86d
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java
@@ -0,0 +1,576 @@
+/*
+ * 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.driver;
+
+import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.host.Host;
+import com.cloud.host.HostVO;
+import com.cloud.hypervisor.Hypervisor;
+import com.cloud.storage.ScopeType;
+import com.cloud.storage.Storage;
+import com.cloud.storage.VolumeVO;
+import com.cloud.storage.VolumeDetailVO;
+import com.cloud.storage.dao.VolumeDao;
+import com.cloud.storage.dao.VolumeDetailsDao;
+import com.cloud.utils.exception.CloudRuntimeException;
+import org.apache.cloudstack.engine.subsystem.api.storage.CreateCmdResult;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
+import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
+import org.apache.cloudstack.framework.async.AsyncCompletionCallback;
+import org.apache.cloudstack.storage.command.CommandResult;
+import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.storage.feign.model.Igroup;
+import org.apache.cloudstack.storage.feign.model.Lun;
+import org.apache.cloudstack.storage.service.UnifiedSANStrategy;
+import org.apache.cloudstack.storage.service.model.AccessGroup;
+import org.apache.cloudstack.storage.service.model.CloudStackVolume;
+import org.apache.cloudstack.storage.service.model.ProtocolType;
+import org.apache.cloudstack.storage.utils.OntapStorageConstants;
+import org.apache.cloudstack.storage.utils.OntapStorageUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.cloud.agent.api.to.DataObjectType.VOLUME;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class OntapPrimaryDatastoreDriverTest {
+
+ @Mock
+ private StoragePoolDetailsDao storagePoolDetailsDao;
+
+ @Mock
+ private PrimaryDataStoreDao storagePoolDao;
+
+ @Mock
+ private VolumeDao volumeDao;
+
+ @Mock
+ private VolumeDetailsDao volumeDetailsDao;
+
+ @Mock
+ private DataStore dataStore;
+
+ @Mock
+ private VolumeInfo volumeInfo;
+
+ @Mock
+ private StoragePoolVO storagePool;
+
+ @Mock
+ private VolumeVO volumeVO;
+
+ @Mock
+ private Host host;
+
+ @Mock
+ private UnifiedSANStrategy sanStrategy;
+
+ @Mock
+ private AsyncCompletionCallback createCallback;
+
+ @Mock
+ private AsyncCompletionCallback commandCallback;
+
+ @InjectMocks
+ private OntapPrimaryDatastoreDriver driver;
+
+ private Map storagePoolDetails;
+
+ @BeforeEach
+ void setUp() {
+ storagePoolDetails = new HashMap<>();
+ storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.ISCSI.name());
+ storagePoolDetails.put(OntapStorageConstants.SVM_NAME, "svm1");
+ }
+
+ @Test
+ void testGetCapabilities() {
+ Map capabilities = driver.getCapabilities();
+
+ assertNotNull(capabilities);
+ // With SIS clone approach, driver advertises storage system snapshot capability
+ // so StorageSystemSnapshotStrategy handles snapshot backup to secondary storage
+ assertEquals(Boolean.TRUE.toString(), capabilities.get("STORAGE_SYSTEM_SNAPSHOT"));
+ assertEquals(Boolean.TRUE.toString(), capabilities.get("CAN_CREATE_VOLUME_FROM_SNAPSHOT"));
+ assertEquals(Boolean.TRUE.toString(), capabilities.get("CAN_REVERT_VOLUME_TO_SNAPSHOT"));
+ }
+
+ @Test
+ void testCreateAsync_NullDataObject_ThrowsException() {
+ assertThrows(InvalidParameterValueException.class,
+ () -> driver.createAsync(dataStore, null, createCallback));
+ }
+
+ @Test
+ void testCreateAsync_NullDataStore_ThrowsException() {
+ assertThrows(InvalidParameterValueException.class,
+ () -> driver.createAsync(null, volumeInfo, createCallback));
+ }
+
+ @Test
+ void testCreateAsync_NullCallback_ThrowsException() {
+ assertThrows(InvalidParameterValueException.class,
+ () -> driver.createAsync(dataStore, volumeInfo, null));
+ }
+
+ @Test
+ void testCreateAsync_VolumeWithISCSI_Success() {
+ // Setup
+ when(dataStore.getId()).thenReturn(1L);
+ when(dataStore.getName()).thenReturn("ontap-pool");
+ when(volumeInfo.getType()).thenReturn(VOLUME);
+ when(volumeInfo.getId()).thenReturn(100L);
+ when(volumeInfo.getName()).thenReturn("test-volume");
+
+ when(storagePoolDao.findById(1L)).thenReturn(storagePool);
+ when(storagePool.getId()).thenReturn(1L);
+ when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
+ when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM);
+
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails);
+ when(volumeDao.findById(100L)).thenReturn(volumeVO);
+ when(volumeVO.getId()).thenReturn(100L);
+
+ Lun mockLun = new Lun();
+ mockLun.setName("/vol/vol1/lun1");
+ mockLun.setUuid("lun-uuid-123");
+ // Create request volume (returned by Utility.createCloudStackVolumeRequestByProtocol)
+ CloudStackVolume requestVolume = new CloudStackVolume();
+ requestVolume.setLun(mockLun);
+ // Create response volume (returned by sanStrategy.createCloudStackVolume)
+ CloudStackVolume responseVolume = new CloudStackVolume();
+ responseVolume.setLun(mockLun);
+
+ try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(sanStrategy);
+ utilityMock.when(() -> OntapStorageUtils.createCloudStackVolumeRequestByProtocol(
+ any(), any(), any())).thenReturn(requestVolume);
+ when(sanStrategy.createCloudStackVolume(any())).thenReturn(responseVolume);
+
+ // Execute
+ driver.createAsync(dataStore, volumeInfo, createCallback);
+
+ // Verify
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class);
+ verify(createCallback).complete(resultCaptor.capture());
+
+ CreateCmdResult result = resultCaptor.getValue();
+ assertNotNull(result);
+ assertTrue(result.isSuccess());
+
+ verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.LUN_DOT_UUID), eq("lun-uuid-123"), eq(false));
+ verify(volumeDetailsDao).addDetail(eq(100L), eq(OntapStorageConstants.LUN_DOT_NAME), eq("/vol/vol1/lun1"), eq(false));
+ verify(volumeVO).setFormat(Storage.ImageFormat.QCOW2);
+ verify(volumeDao).update(eq(100L), any(VolumeVO.class));
+ }
+ }
+
+ @Test
+ void testCreateAsync_VolumeWithNFS_Success() {
+ // Setup
+ storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name());
+
+ when(dataStore.getId()).thenReturn(1L);
+ when(dataStore.getName()).thenReturn("ontap-pool");
+ when(volumeInfo.getType()).thenReturn(VOLUME);
+ when(volumeInfo.getId()).thenReturn(100L);
+ when(volumeInfo.getName()).thenReturn("test-volume");
+
+ when(storagePoolDao.findById(1L)).thenReturn(storagePool);
+ when(storagePool.getId()).thenReturn(1L);
+ when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
+ when(storagePool.getHypervisor()).thenReturn(Hypervisor.HypervisorType.KVM);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails);
+ when(volumeDao.findById(100L)).thenReturn(volumeVO);
+ when(volumeVO.getId()).thenReturn(100L);
+
+ CloudStackVolume mockCloudStackVolume = new CloudStackVolume();
+
+ try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails))
+ .thenReturn(sanStrategy);
+ utilityMock.when(() -> OntapStorageUtils.createCloudStackVolumeRequestByProtocol(
+ any(), any(), any())).thenReturn(mockCloudStackVolume);
+
+ when(sanStrategy.createCloudStackVolume(any())).thenReturn(mockCloudStackVolume);
+
+ // Execute
+ driver.createAsync(dataStore, volumeInfo, createCallback);
+
+ // Verify
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CreateCmdResult.class);
+ verify(createCallback).complete(resultCaptor.capture());
+
+ CreateCmdResult result = resultCaptor.getValue();
+ assertNotNull(result);
+ assertTrue(result.isSuccess());
+ verify(volumeVO).setFormat(Storage.ImageFormat.QCOW2);
+ verify(volumeDao).update(eq(100L), any(VolumeVO.class));
+ }
+ }
+
+ @Test
+ void testDeleteAsync_NullStore_ThrowsException() {
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class);
+
+ driver.deleteAsync(null, volumeInfo, commandCallback);
+
+ verify(commandCallback).complete(resultCaptor.capture());
+ CommandResult result = resultCaptor.getValue();
+ assertFalse(result.isSuccess());
+ assertTrue(result.getResult().contains("store or data is null"));
+ }
+
+ @Test
+ void testDeleteAsync_ISCSIVolume_Success() {
+ // Setup
+ when(dataStore.getId()).thenReturn(1L);
+ when(volumeInfo.getType()).thenReturn(VOLUME);
+ when(volumeInfo.getId()).thenReturn(100L);
+
+ when(storagePoolDao.findById(1L)).thenReturn(storagePool);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails);
+
+ VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false);
+ VolumeDetailVO lunUuidDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_UUID, "lun-uuid-123", false);
+
+ when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail);
+ when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_UUID)).thenReturn(lunUuidDetail);
+
+ try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails))
+ .thenReturn(sanStrategy);
+
+ doNothing().when(sanStrategy).deleteCloudStackVolume(any());
+
+ // Execute
+ driver.deleteAsync(dataStore, volumeInfo, commandCallback);
+
+ // Verify
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class);
+ verify(commandCallback).complete(resultCaptor.capture());
+
+ CommandResult result = resultCaptor.getValue();
+ assertNotNull(result);
+ assertTrue(result.isSuccess());
+ verify(sanStrategy).deleteCloudStackVolume(any(CloudStackVolume.class));
+ }
+ }
+
+ @Test
+ void testDeleteAsync_NFSVolume_Success() {
+ // Setup
+ storagePoolDetails.put(OntapStorageConstants.PROTOCOL, ProtocolType.NFS3.name());
+
+ when(dataStore.getId()).thenReturn(1L);
+ when(volumeInfo.getType()).thenReturn(VOLUME);
+
+ when(storagePoolDao.findById(1L)).thenReturn(storagePool);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails);
+
+ // Execute
+ driver.deleteAsync(dataStore, volumeInfo, commandCallback);
+
+ // Verify
+ ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(CommandResult.class);
+ verify(commandCallback).complete(resultCaptor.capture());
+
+ CommandResult result = resultCaptor.getValue();
+ assertNotNull(result);
+ // NFS deletion doesn't fail, handled by hypervisor
+ }
+
+ @Test
+ void testGrantAccess_NullParameters_ThrowsException() {
+ assertThrows(CloudRuntimeException.class,
+ () -> driver.grantAccess(null, host, dataStore));
+
+ assertThrows(CloudRuntimeException.class,
+ () -> driver.grantAccess(volumeInfo, null, dataStore));
+
+ assertThrows(CloudRuntimeException.class,
+ () -> driver.grantAccess(volumeInfo, host, null));
+ }
+
+ @Test
+ void testGrantAccess_ClusterScope_Success() {
+ // Setup
+ when(dataStore.getId()).thenReturn(1L);
+ when(volumeInfo.getType()).thenReturn(VOLUME);
+ when(volumeInfo.getId()).thenReturn(100L);
+
+ when(storagePoolDao.findById(1L)).thenReturn(storagePool);
+ when(storagePool.getId()).thenReturn(1L);
+ when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER);
+ when(storagePool.getPath()).thenReturn("iqn.1992-08.com.netapp:sn.123456");
+ when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
+
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails);
+ when(volumeDao.findById(100L)).thenReturn(volumeVO);
+ when(volumeVO.getId()).thenReturn(100L);
+
+ when(host.getName()).thenReturn("host1");
+ when(host.getUuid()).thenReturn("host-uuid-1");
+
+ VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false);
+ when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail);
+
+ // Mock AccessGroup with existing igroup
+ AccessGroup existingAccessGroup = new AccessGroup();
+ Igroup existingIgroup = new Igroup();
+ existingIgroup.setName("igroup1");
+ existingAccessGroup.setIgroup(existingIgroup);
+
+ try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails))
+ .thenReturn(sanStrategy);
+ utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString()))
+ .thenReturn("igroup1");
+
+ when(sanStrategy.getAccessGroup(any())).thenReturn(existingAccessGroup);
+ when(sanStrategy.ensureLunMapped(anyString(), anyString(), anyString())).thenReturn("0");
+
+ // Execute
+ boolean result = driver.grantAccess(volumeInfo, host, dataStore);
+
+ // Verify
+ assertTrue(result);
+ verify(volumeDao).update(eq(100L), any(VolumeVO.class));
+ verify(sanStrategy).getAccessGroup(any());
+ verify(sanStrategy).ensureLunMapped(anyString(), anyString(), anyString());
+ verify(sanStrategy, never()).validateInitiatorInAccessGroup(anyString(), anyString(), any(Igroup.class));
+ }
+ }
+
+ @Test
+ void testGrantAccess_IgroupNotFound_CreatesNewIgroup() {
+ // Setup - use HostVO mock since production code casts Host to HostVO
+ HostVO hostVO = mock(HostVO.class);
+ when(hostVO.getName()).thenReturn("host1");
+ when(hostVO.getUuid()).thenReturn("host-uuid-1");
+
+ when(dataStore.getId()).thenReturn(1L);
+ when(volumeInfo.getType()).thenReturn(VOLUME);
+ when(volumeInfo.getId()).thenReturn(100L);
+
+ when(storagePoolDao.findById(1L)).thenReturn(storagePool);
+ when(storagePool.getId()).thenReturn(1L);
+ when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER);
+ when(storagePool.getPath()).thenReturn("iqn.1992-08.com.netapp:sn.123456");
+ when(storagePool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem);
+
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails);
+ when(volumeDao.findById(100L)).thenReturn(volumeVO);
+ when(volumeVO.getId()).thenReturn(100L);
+
+ VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false);
+ when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail);
+
+ // Mock getAccessGroup returning null (igroup doesn't exist)
+ AccessGroup createdAccessGroup = new AccessGroup();
+ Igroup createdIgroup = new Igroup();
+ createdIgroup.setName("igroup1");
+ createdAccessGroup.setIgroup(createdIgroup);
+
+ try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails))
+ .thenReturn(sanStrategy);
+ utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString()))
+ .thenReturn("igroup1");
+
+ when(sanStrategy.getAccessGroup(any())).thenReturn(null);
+ when(sanStrategy.createAccessGroup(any())).thenReturn(createdAccessGroup);
+ when(sanStrategy.ensureLunMapped(anyString(), anyString(), anyString())).thenReturn("0");
+
+ // Execute
+ boolean result = driver.grantAccess(volumeInfo, hostVO, dataStore);
+
+ // Verify
+ assertTrue(result);
+ verify(sanStrategy).getAccessGroup(any());
+ verify(sanStrategy).createAccessGroup(any());
+ verify(sanStrategy).ensureLunMapped(anyString(), anyString(), anyString());
+ verify(volumeDao).update(eq(100L), any(VolumeVO.class));
+ }
+ }
+
+ @Test
+ void testRevokeAccess_NFSVolume_SkipsRevoke() {
+ // Setup - NFS volumes have no LUN mapping, so revokeAccess is a no-op
+ when(dataStore.getId()).thenReturn(1L);
+ when(volumeInfo.getType()).thenReturn(VOLUME);
+ when(volumeInfo.getId()).thenReturn(100L);
+
+ when(volumeDao.findById(100L)).thenReturn(volumeVO);
+ when(volumeVO.getId()).thenReturn(100L);
+ when(volumeVO.getName()).thenReturn("test-volume");
+
+ when(storagePoolDao.findById(1L)).thenReturn(storagePool);
+ when(storagePool.getId()).thenReturn(1L);
+ when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails);
+ when(host.getName()).thenReturn("host1");
+
+ try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails))
+ .thenReturn(sanStrategy);
+
+ // Execute - NFS has no iSCSI protocol, so revokeAccessForVolume does nothing
+ driver.revokeAccess(volumeInfo, host, dataStore);
+
+ // Verify - no LUN unmap operations for NFS
+ verify(sanStrategy, never()).disableLogicalAccess(any());
+ }
+ }
+
+ @Test
+ void testRevokeAccess_ISCSIVolume_Success() {
+ // Setup
+ when(dataStore.getId()).thenReturn(1L);
+ when(volumeInfo.getType()).thenReturn(VOLUME);
+ when(volumeInfo.getId()).thenReturn(100L);
+
+ when(volumeDao.findById(100L)).thenReturn(volumeVO);
+ when(volumeVO.getId()).thenReturn(100L);
+ when(volumeVO.getName()).thenReturn("test-volume");
+
+ when(storagePoolDao.findById(1L)).thenReturn(storagePool);
+ when(storagePool.getId()).thenReturn(1L);
+ when(storagePool.getScope()).thenReturn(ScopeType.CLUSTER);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(storagePoolDetails);
+
+ when(host.getStorageUrl()).thenReturn("iqn.1993-08.org.debian:01:host1");
+ when(host.getName()).thenReturn("host1");
+ when(host.getUuid()).thenReturn("host-uuid-1");
+
+ VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false);
+ when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail);
+
+ Lun mockLun = new Lun();
+ mockLun.setName("/vol/vol1/lun1");
+ mockLun.setUuid("lun-uuid-123");
+ CloudStackVolume mockCloudStackVolume = new CloudStackVolume();
+ mockCloudStackVolume.setLun(mockLun);
+
+ org.apache.cloudstack.storage.feign.model.Igroup mockIgroup = mock(org.apache.cloudstack.storage.feign.model.Igroup.class);
+ when(mockIgroup.getName()).thenReturn("igroup1");
+ when(mockIgroup.getUuid()).thenReturn("igroup-uuid-123");
+ AccessGroup mockAccessGroup = new AccessGroup();
+ mockAccessGroup.setIgroup(mockIgroup);
+
+ try (MockedStatic utilityMock = mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(storagePoolDetails))
+ .thenReturn(sanStrategy);
+ utilityMock.when(() -> OntapStorageUtils.getIgroupName(anyString(), anyString()))
+ .thenReturn("igroup1");
+
+ // Mock the methods called by getCloudStackVolumeByName and getAccessGroupByName
+ when(sanStrategy.getCloudStackVolume(argThat(map ->
+ map != null &&
+ "/vol/vol1/lun1".equals(map.get("name")) &&
+ "svm1".equals(map.get("svm.name"))
+ ))).thenReturn(mockCloudStackVolume);
+
+ when(sanStrategy.getAccessGroup(argThat(map ->
+ map != null &&
+ "igroup1".equals(map.get("name")) &&
+ "svm1".equals(map.get("svm.name"))
+ ))).thenReturn(mockAccessGroup);
+
+ when(sanStrategy.validateInitiatorInAccessGroup(
+ eq("iqn.1993-08.org.debian:01:host1"),
+ eq("svm1"),
+ any(Igroup.class)
+ )).thenReturn(true);
+
+ doNothing().when(sanStrategy).disableLogicalAccess(argThat(map ->
+ map != null &&
+ "lun-uuid-123".equals(map.get("lun.uuid")) &&
+ "igroup-uuid-123".equals(map.get("igroup.uuid"))
+ ));
+
+ // Execute
+ driver.revokeAccess(volumeInfo, host, dataStore);
+
+ // Verify
+ verify(sanStrategy).getCloudStackVolume(any());
+ verify(sanStrategy).getAccessGroup(any());
+ verify(sanStrategy).validateInitiatorInAccessGroup(anyString(), anyString(), any(Igroup.class));
+ verify(sanStrategy).disableLogicalAccess(any());
+ }
+ }
+
+ @Test
+ void testCanHostAccessStoragePool_ReturnsTrue() {
+ assertTrue(driver.canHostAccessStoragePool(host, storagePool));
+ }
+
+ @Test
+ void testIsVmInfoNeeded_ReturnsTrue() {
+ assertTrue(driver.isVmInfoNeeded());
+ }
+
+ @Test
+ void testIsStorageSupportHA_ReturnsTrue() {
+ assertTrue(driver.isStorageSupportHA(Storage.StoragePoolType.NetworkFilesystem));
+ }
+
+ @Test
+ void testGetChapInfo_ReturnsNull() {
+ assertNull(driver.getChapInfo(volumeInfo));
+ }
+
+ @Test
+ void testCanProvideStorageStats_ReturnsFalse() {
+ assertFalse(driver.canProvideStorageStats());
+ }
+
+ @Test
+ void testCanProvideVolumeStats_ReturnsFalse() {
+ assertFalse(driver.canProvideVolumeStats());
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java
index 789615a9f43b..751b864ecfcc 100644
--- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java
@@ -18,6 +18,8 @@
*/
package org.apache.cloudstack.storage.lifecycle;
+import org.apache.cloudstack.storage.utils.OntapStorageConstants;
+import org.apache.cloudstack.storage.utils.OntapStorageUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,15 +34,35 @@
import com.cloud.dc.dao.ClusterDao;
import com.cloud.utils.exception.CloudRuntimeException;
import com.cloud.dc.ClusterVO;
+import com.cloud.host.HostVO;
+import com.cloud.resource.ResourceManager;
+import com.cloud.storage.StorageManager;
+import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
+import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreInfo;
+import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
+import org.apache.cloudstack.storage.service.model.AccessGroup;
+import com.cloud.hypervisor.Hypervisor;
import java.util.Map;
+import java.util.List;
+import java.util.ArrayList;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.withSettings;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.HashMap;
import org.apache.cloudstack.storage.provider.StorageProviderFactory;
import org.apache.cloudstack.storage.service.StorageStrategy;
import org.apache.cloudstack.storage.volume.datastore.PrimaryDataStoreHelper;
+import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
@ExtendWith(MockitoExtension.class)
@@ -58,8 +80,36 @@ public class OntapPrimaryDatastoreLifecycleTest {
@Mock
private PrimaryDataStoreHelper _dataStoreHelper;
+ @Mock
+ private ResourceManager _resourceMgr;
+
+ @Mock
+ private StorageManager _storageMgr;
+
+ @Mock
+ private StoragePoolDetailsDao storagePoolDetailsDao;
+
+ @Mock
+ private PrimaryDataStoreDao storagePoolDao;
+
+ // Mock object that implements both DataStore and PrimaryDataStoreInfo
+ // This is needed because attachCluster(DataStore) casts DataStore to PrimaryDataStoreInfo internally
+ private DataStore dataStore;
+
+ @Mock
+ private ClusterScope clusterScope;
+
+ @Mock
+ private ZoneScope zoneScope;
+
+ private List mockHosts;
+ private Map poolDetails;
+
@BeforeEach
void setUp() {
+ // Create a mock that implements both DataStore and PrimaryDataStoreInfo interfaces
+ dataStore = Mockito.mock(DataStore.class, withSettings()
+ .extraInterfaces(PrimaryDataStoreInfo.class));
ClusterVO clusterVO = new ClusterVO(1L, 1L, "clusterName");
clusterVO.setHypervisorType("KVM");
@@ -73,39 +123,49 @@ void setUp() {
volume.setName("testVolume");
when(storageStrategy.createStorageVolume(any(), any())).thenReturn(volume);
+ // Setup for attachCluster tests
+ // Configure dataStore mock with necessary methods (works for both DataStore and PrimaryDataStoreInfo)
+ when(dataStore.getId()).thenReturn(1L);
+ when(((PrimaryDataStoreInfo) dataStore).getClusterId()).thenReturn(1L);
+
+ // Mock the setDetails method to prevent NullPointerException
+ Mockito.doNothing().when(((PrimaryDataStoreInfo) dataStore)).setDetails(any());
+
+ // Mock storagePoolDao to return a valid StoragePoolVO
+ StoragePoolVO mockStoragePoolVO = new StoragePoolVO();
+ mockStoragePoolVO.setId(1L);
+ when(storagePoolDao.findById(1L)).thenReturn(mockStoragePoolVO);
+
+ mockHosts = new ArrayList<>();
+ HostVO host1 = new HostVO("host1-guid");
+ host1.setPrivateIpAddress("192.168.1.10");
+ host1.setStorageIpAddress("192.168.1.10");
+ host1.setClusterId(1L);
+ HostVO host2 = new HostVO("host2-guid");
+ host2.setPrivateIpAddress("192.168.1.11");
+ host2.setStorageIpAddress("192.168.1.11");
+ host2.setClusterId(1L);
+ mockHosts.add(host1);
+ mockHosts.add(host2);
+ poolDetails = new HashMap<>();
+ poolDetails.put("username", "admin");
+ poolDetails.put("password", "password");
+ poolDetails.put("svmName", "svm1");
+ poolDetails.put("protocol", "NFS3");
+ poolDetails.put("storageIP", "192.168.1.100");
}
@Test
public void testInitialize_positive() {
- Map dsInfos = new HashMap<>();
- dsInfos.put("username", "testUser");
- dsInfos.put("password", "testPassword");
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1");
- dsInfos.put("zoneId",1L);
- dsInfos.put("podId",1L);
- dsInfos.put("clusterId", 1L);
- dsInfos.put("name", "testStoragePool");
- dsInfos.put("providerName", "testProvider");
- dsInfos.put("capacityBytes",200000L);
- dsInfos.put("managed",true);
- dsInfos.put("tags", "testTag");
- dsInfos.put("isTagARule", false);
- dsInfos.put("details", new HashMap());
-
- try(MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
- storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
- ontapPrimaryDatastoreLifecycle.initialize(dsInfos);
- }
- }
-
- @Test
- public void testInitialize_positiveWithIsDisaggregated() {
+ HashMap detailsMap = new HashMap();
+ detailsMap.put(OntapStorageConstants.USERNAME, "testUser");
+ detailsMap.put(OntapStorageConstants.PASSWORD, "testPassword");
+ detailsMap.put(OntapStorageConstants.STORAGE_IP, "10.10.10.10");
+ detailsMap.put(OntapStorageConstants.SVM_NAME, "vs0");
+ detailsMap.put(OntapStorageConstants.PROTOCOL, "NFS3");
Map dsInfos = new HashMap<>();
- dsInfos.put("username", "testUser");
- dsInfos.put("password", "testPassword");
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1;isDisaggregated=false");
dsInfos.put("zoneId",1L);
dsInfos.put("podId",1L);
dsInfos.put("clusterId", 1L);
@@ -115,7 +175,7 @@ public void testInitialize_positiveWithIsDisaggregated() {
dsInfos.put("managed",true);
dsInfos.put("tags", "testTag");
dsInfos.put("isTagARule", false);
- dsInfos.put("details", new HashMap());
+ dsInfos.put("details", detailsMap);
try(MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
@@ -132,8 +192,14 @@ public void testInitialize_null_Arg() {
@Test
public void testInitialize_missingRequiredDetailKey() {
+
+ HashMap detailsMap = new HashMap();
+ detailsMap.put(OntapStorageConstants.USERNAME, "testUser");
+ detailsMap.put(OntapStorageConstants.PASSWORD, "testPassword");
+ detailsMap.put(OntapStorageConstants.STORAGE_IP, "10.10.10.10");
+ detailsMap.put(OntapStorageConstants.SVM_NAME, "vs0");
+
Map dsInfos = new HashMap<>();
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3");
dsInfos.put("zoneId",1L);
dsInfos.put("podId",1L);
dsInfos.put("clusterId", 1L);
@@ -143,7 +209,7 @@ public void testInitialize_missingRequiredDetailKey() {
dsInfos.put("managed",true);
dsInfos.put("tags", "testTag");
dsInfos.put("isTagARule", false);
- dsInfos.put("details", new HashMap());
+ dsInfos.put("details", detailsMap);
try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
@@ -154,8 +220,15 @@ public void testInitialize_missingRequiredDetailKey() {
@Test
public void testInitialize_invalidCapacityBytes() {
+
+ HashMap detailsMap = new HashMap();
+ detailsMap.put(OntapStorageConstants.USERNAME, "testUser");
+ detailsMap.put(OntapStorageConstants.PASSWORD, "testPassword");
+ detailsMap.put(OntapStorageConstants.STORAGE_IP, "10.10.10.10");
+ detailsMap.put(OntapStorageConstants.SVM_NAME, "vs0");
+ detailsMap.put(OntapStorageConstants.PROTOCOL, "NFS3");
+
Map dsInfos = new HashMap<>();
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1");
dsInfos.put("zoneId",1L);
dsInfos.put("podId",1L);
dsInfos.put("clusterId", 1L);
@@ -165,7 +238,7 @@ public void testInitialize_invalidCapacityBytes() {
dsInfos.put("managed",true);
dsInfos.put("tags", "testTag");
dsInfos.put("isTagARule", false);
- dsInfos.put("details", new HashMap());
+ dsInfos.put("details", detailsMap);
try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
@@ -176,7 +249,6 @@ public void testInitialize_invalidCapacityBytes() {
@Test
public void testInitialize_unmanagedStorage() {
Map dsInfos = new HashMap<>();
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1");
dsInfos.put("zoneId",1L);
dsInfos.put("podId",1L);
dsInfos.put("clusterId", 1L);
@@ -200,7 +272,6 @@ public void testInitialize_unmanagedStorage() {
@Test
public void testInitialize_nullStoragePoolName() {
Map dsInfos = new HashMap<>();
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1");
dsInfos.put("zoneId",1L);
dsInfos.put("podId",1L);
dsInfos.put("clusterId", 1L);
@@ -224,7 +295,6 @@ public void testInitialize_nullStoragePoolName() {
@Test
public void testInitialize_nullProviderName() {
Map dsInfos = new HashMap<>();
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1");
dsInfos.put("zoneId",1L);
dsInfos.put("podId",1L);
dsInfos.put("clusterId", 1L);
@@ -248,7 +318,6 @@ public void testInitialize_nullProviderName() {
@Test
public void testInitialize_nullPodAndClusterAndZone() {
Map dsInfos = new HashMap<>();
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1");
dsInfos.put("zoneId",null);
dsInfos.put("podId",null);
dsInfos.put("clusterId", null);
@@ -275,18 +344,24 @@ public void testInitialize_clusterNotKVM() {
clusterVO.setHypervisorType("XenServer");
when(_clusterDao.findById(2L)).thenReturn(clusterVO);
+ HashMap detailsMap = new HashMap<>();
+ detailsMap.put(OntapStorageConstants.USERNAME, "testUser");
+ detailsMap.put(OntapStorageConstants.PASSWORD, "testPassword");
+ detailsMap.put(OntapStorageConstants.STORAGE_IP, "10.10.10.10");
+ detailsMap.put(OntapStorageConstants.SVM_NAME, "vs0");
+ detailsMap.put(OntapStorageConstants.PROTOCOL, "NFS3");
+
Map dsInfos = new HashMap<>();
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1");
- dsInfos.put("zoneId",1L);
- dsInfos.put("podId",1L);
+ dsInfos.put("zoneId", 1L);
+ dsInfos.put("podId", 1L);
dsInfos.put("clusterId", 2L);
dsInfos.put("name", "testStoragePool");
dsInfos.put("providerName", "testProvider");
- dsInfos.put("capacityBytes",200000L);
- dsInfos.put("managed",true);
+ dsInfos.put("capacityBytes", 200000L);
+ dsInfos.put("managed", true);
dsInfos.put("tags", "testTag");
dsInfos.put("isTagARule", false);
- dsInfos.put("details", new HashMap());
+ dsInfos.put("details", detailsMap);
Exception ex = assertThrows(CloudRuntimeException.class, () -> {
try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
@@ -299,8 +374,16 @@ public void testInitialize_clusterNotKVM() {
@Test
public void testInitialize_unexpectedDetailKey() {
+
+ HashMap detailsMap = new HashMap();
+ detailsMap.put(OntapStorageConstants.USERNAME, "testUser");
+ detailsMap.put(OntapStorageConstants.PASSWORD, "testPassword");
+ detailsMap.put(OntapStorageConstants.STORAGE_IP, "10.10.10.10");
+ detailsMap.put(OntapStorageConstants.SVM_NAME, "vs0");
+ detailsMap.put(OntapStorageConstants.PROTOCOL, "NFS3");
+ detailsMap.put("unexpectedKey", "unexpectedValue");
+
Map dsInfos = new HashMap<>();
- dsInfos.put("url", "username=testUser;password=testPassword;svmName=testSVM;protocol=NFS3;managementLIF=192.168.1.1;unexpectedKey=unexpectedValue");
dsInfos.put("zoneId",1L);
dsInfos.put("podId",1L);
dsInfos.put("clusterId", 1L);
@@ -310,7 +393,7 @@ public void testInitialize_unexpectedDetailKey() {
dsInfos.put("managed",true);
dsInfos.put("tags", "testTag");
dsInfos.put("isTagARule", false);
- dsInfos.put("details", new HashMap());
+ dsInfos.put("details", detailsMap);
Exception ex = assertThrows(CloudRuntimeException.class, () -> {
try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
@@ -321,4 +404,454 @@ public void testInitialize_unexpectedDetailKey() {
assertTrue(ex.getMessage().contains("Unexpected ONTAP detail key in URL"));
}
+ // ========== attachCluster Tests ==========
+
+ @Test
+ public void testAttachCluster_positive() throws Exception {
+ // Setup
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any()))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+
+ // Mock successful host connections
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachCluster(
+ dataStore, clusterScope);
+
+ // Verify
+ assertTrue(result, "attachCluster should return true on success");
+ verify(_resourceMgr, times(1))
+ .getEligibleUpAndEnabledHostsInClusterForStorageConnection(any());
+ verify(storagePoolDetailsDao, times(1)).listDetailsKeyPairs(1L);
+ verify(storageStrategy, times(1)).createAccessGroup(any(AccessGroup.class));
+ verify(_storageMgr, times(2)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ verify(_dataStoreHelper, times(1)).attachCluster(any(DataStore.class));
+ }
+ }
+
+ @Test
+ public void testAttachCluster_withSingleHost() throws Exception {
+ // Setup - only one host in cluster
+ List singleHost = new ArrayList<>();
+ singleHost.add(mockHosts.get(0));
+
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any()))
+ .thenReturn(singleHost);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachCluster(
+ dataStore, clusterScope);
+
+ // Verify
+ assertTrue(result, "attachCluster should return true with single host");
+ verify(_storageMgr, times(1)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ verify(_dataStoreHelper, times(1)).attachCluster(any(DataStore.class));
+ }
+ }
+
+ @Test
+ public void testAttachCluster_withMultipleHosts() throws Exception {
+ // Setup - add more hosts
+ HostVO host3 = new HostVO("host3-guid");
+ host3.setPrivateIpAddress("192.168.1.12");
+ host3.setStorageIpAddress("192.168.1.12");
+ host3.setClusterId(1L);
+ mockHosts.add(host3);
+
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any()))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachCluster(
+ dataStore, clusterScope);
+
+ // Verify
+ assertTrue(result, "attachCluster should return true with multiple hosts");
+ verify(_storageMgr, times(3)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ verify(_dataStoreHelper, times(1)).attachCluster(any(DataStore.class));
+ }
+ }
+
+ @Test
+ public void testAttachCluster_hostConnectionFailure() throws Exception {
+ // Setup
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any()))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+
+ // Mock host connection failure for first host
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong()))
+ .thenThrow(new CloudRuntimeException("Connection failed"));
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachCluster(
+ dataStore, clusterScope);
+
+ // Verify
+ assertFalse(result, "attachCluster should return false on host connection failure");
+ verify(storageStrategy, times(1)).createAccessGroup(any(AccessGroup.class));
+ verify(_storageMgr, times(1)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ // _dataStoreHelper.attachCluster should NOT be called due to early return
+ verify(_dataStoreHelper, times(0)).attachCluster(any(DataStore.class));
+ }
+ }
+
+ @Test
+ public void testAttachCluster_emptyHostList() throws Exception {
+ // Setup - no hosts in cluster
+ List emptyHosts = new ArrayList<>();
+
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any()))
+ .thenReturn(emptyHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachCluster(
+ dataStore, clusterScope);
+
+ // Verify
+ assertTrue(result, "attachCluster should return true even with no hosts");
+ verify(_storageMgr, times(0)).connectHostToSharedPool(any(HostVO.class), anyLong());
+ verify(_dataStoreHelper, times(1)).attachCluster(any(DataStore.class));
+ }
+ }
+
+ @Test
+ public void testAttachCluster_secondHostConnectionFails() throws Exception {
+ // Setup
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any()))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+
+ // Mock: first host succeeds, second host fails
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong()))
+ .thenReturn(true)
+ .thenThrow(new CloudRuntimeException("Connection failed"));
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachCluster(
+ dataStore, clusterScope);
+
+ // Verify
+ assertFalse(result, "attachCluster should return false when any host connection fails");
+ verify(_storageMgr, times(2)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ verify(_dataStoreHelper, times(0)).attachCluster(any(DataStore.class));
+ }
+ }
+
+ @Test
+ public void testAttachCluster_createAccessGroupCalled() throws Exception {
+ // Setup
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any()))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachCluster(
+ dataStore, clusterScope);
+
+ // Verify - createAccessGroup is called with correct AccessGroup structure
+ assertTrue(result);
+ verify(storageStrategy, times(1)).createAccessGroup(any(AccessGroup.class));
+ }
+ }
+
+ // ========== attachZone Tests ==========
+
+ @Test
+ public void testAttachZone_positive() throws Exception {
+ // Setup
+ when(zoneScope.getScopeId()).thenReturn(1L);
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM)))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+
+ // Mock successful host connections
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.KVM);
+
+ // Verify
+ assertTrue(result, "attachZone should return true on success");
+ verify(_resourceMgr, times(1))
+ .getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM));
+ verify(storagePoolDetailsDao, times(1)).listDetailsKeyPairs(1L);
+ verify(storageStrategy, times(1)).createAccessGroup(any(AccessGroup.class));
+ verify(_storageMgr, times(2)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ verify(_dataStoreHelper, times(1)).attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM));
+ }
+ }
+
+ @Test
+ public void testAttachZone_withSingleHost() throws Exception {
+ // Setup - only one host in zone
+ List singleHost = new ArrayList<>();
+ singleHost.add(mockHosts.get(0));
+
+ when(zoneScope.getScopeId()).thenReturn(1L);
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM)))
+ .thenReturn(singleHost);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.KVM);
+
+ // Verify
+ assertTrue(result, "attachZone should return true with single host");
+ verify(_storageMgr, times(1)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ verify(_dataStoreHelper, times(1)).attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM));
+ }
+ }
+
+ @Test
+ public void testAttachZone_withMultipleHosts() throws Exception {
+ // Setup - add more hosts
+ HostVO host3 = new HostVO("host3-guid");
+ host3.setPrivateIpAddress("192.168.1.12");
+ host3.setStorageIpAddress("192.168.1.12");
+ host3.setClusterId(1L);
+ mockHosts.add(host3);
+
+ when(zoneScope.getScopeId()).thenReturn(1L);
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM)))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.KVM);
+
+ // Verify
+ assertTrue(result, "attachZone should return true with multiple hosts");
+ verify(_storageMgr, times(3)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ verify(_dataStoreHelper, times(1)).attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM));
+ }
+ }
+
+ @Test
+ public void testAttachZone_hostConnectionFailure() throws Exception {
+ // Setup
+ when(zoneScope.getScopeId()).thenReturn(1L);
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM)))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+
+ // Mock host connection failure for first host
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong()))
+ .thenThrow(new CloudRuntimeException("Connection failed"));
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.KVM);
+
+ // Verify
+ assertFalse(result, "attachZone should return false on host connection failure");
+ verify(storageStrategy, times(1)).createAccessGroup(any(AccessGroup.class));
+ verify(_storageMgr, times(1)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ // _dataStoreHelper.attachZone should NOT be called due to early return
+ verify(_dataStoreHelper, times(0)).attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM));
+ }
+ }
+
+ @Test
+ public void testAttachZone_emptyHostList() throws Exception {
+ // Setup - no hosts in zone
+ List emptyHosts = new ArrayList<>();
+
+ when(zoneScope.getScopeId()).thenReturn(1L);
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM)))
+ .thenReturn(emptyHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.KVM);
+
+ // Verify
+ assertTrue(result, "attachZone should return true even with no hosts");
+ verify(_storageMgr, times(0)).connectHostToSharedPool(any(HostVO.class), anyLong());
+ verify(_dataStoreHelper, times(1)).attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM));
+ }
+ }
+
+ @Test
+ public void testAttachZone_secondHostConnectionFails() throws Exception {
+ // Setup
+ when(zoneScope.getScopeId()).thenReturn(1L);
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM)))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+
+ // Mock: first host succeeds, second host fails
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong()))
+ .thenReturn(true)
+ .thenThrow(new CloudRuntimeException("Connection failed"));
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.KVM);
+
+ // Verify
+ assertFalse(result, "attachZone should return false when any host connection fails");
+ verify(_storageMgr, times(2)).connectHostToSharedPool(any(HostVO.class), eq(1L));
+ verify(_dataStoreHelper, times(0)).attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM));
+ }
+ }
+
+ @Test
+ public void testAttachZone_createAccessGroupCalled() throws Exception {
+ // Setup
+ when(zoneScope.getScopeId()).thenReturn(1L);
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM)))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ // Execute
+ boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.KVM);
+
+ // Verify - createAccessGroup is called with correct AccessGroup structure
+ assertTrue(result);
+ verify(storageStrategy, times(1)).createAccessGroup(any(AccessGroup.class));
+ }
+ }
+
+ @Test
+ public void testAttachZone_nonKvmHypervisorThrowsException() {
+ // Non-KVM hypervisor type must be rejected before any further processing
+ Exception ex = assertThrows(CloudRuntimeException.class, () ->
+ ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.VMware));
+
+ assertTrue(ex.getMessage().contains("ONTAP primary storage is supported only for KVM hypervisor"));
+ // update should never be reached
+ verify(storagePoolDao, times(0)).update(anyLong(), any(StoragePoolVO.class));
+ }
+
+ @Test
+ public void testAttachZone_nullHypervisorThrowsException() {
+ // null hypervisorType is not a valid case — only KVM is accepted
+ Exception ex = assertThrows(CloudRuntimeException.class, () ->
+ ontapPrimaryDatastoreLifecycle.attachZone(dataStore, zoneScope, null));
+
+ assertTrue(ex.getMessage().contains("ONTAP primary storage is supported only for KVM hypervisor"));
+ verify(storagePoolDao, times(0)).update(anyLong(), any(StoragePoolVO.class));
+ }
+
+ @Test
+ public void testAttachZone_kvmHypervisorSetsAndUpdatesPool() throws Exception {
+ // KVM hypervisorType should be set on the pool and persisted via storagePoolDao.update
+ when(zoneScope.getScopeId()).thenReturn(1L);
+ when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM)))
+ .thenReturn(mockHosts);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails);
+ when(_dataStoreHelper.attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM))).thenReturn(dataStore);
+
+ try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
+ .thenReturn(storageStrategy);
+ when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
+
+ boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
+ dataStore, zoneScope, Hypervisor.HypervisorType.KVM);
+
+ assertTrue(result, "attachZone should succeed for KVM hypervisor");
+ verify(_dataStoreHelper, times(1)).attachZone(any(DataStore.class), eq(Hypervisor.HypervisorType.KVM));
+ }
+ }
+
}
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/provider/OntapPrimaryDatastoreProviderTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/provider/OntapPrimaryDatastoreProviderTest.java
index 0d8ac53a1f98..d3fa21e4189c 100644
--- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/provider/OntapPrimaryDatastoreProviderTest.java
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/provider/OntapPrimaryDatastoreProviderTest.java
@@ -19,6 +19,7 @@
package org.apache.cloudstack.storage.provider;
import com.cloud.utils.component.ComponentContext;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreDriver;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreLifeCycle;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider.DataStoreProviderType;
@@ -26,7 +27,6 @@
import org.apache.cloudstack.storage.driver.OntapPrimaryDatastoreDriver;
import org.apache.cloudstack.storage.lifecycle.OntapPrimaryDatastoreLifecycle;
import org.apache.cloudstack.storage.listener.OntapHostListener;
-import org.apache.cloudstack.storage.utils.OntapStorageConstants;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -60,7 +60,7 @@ void setUp() {
@Test
public void testGetName() {
String name = provider.getName();
- assertEquals(OntapStorageConstants.ONTAP_PLUGIN_NAME, name);
+ assertEquals(DataStoreProvider.ONTAP_PLUGIN_NAME, name);
}
@Test
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
new file mode 100644
index 000000000000..6f09c3649757
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java
@@ -0,0 +1,932 @@
+/*
+ * 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.service;
+
+import com.cloud.utils.exception.CloudRuntimeException;
+import feign.FeignException;
+import org.apache.cloudstack.storage.feign.client.AggregateFeignClient;
+import org.apache.cloudstack.storage.feign.client.JobFeignClient;
+import org.apache.cloudstack.storage.feign.client.NetworkFeignClient;
+import org.apache.cloudstack.storage.feign.client.SANFeignClient;
+import org.apache.cloudstack.storage.feign.client.SnapshotFeignClient;
+import org.apache.cloudstack.storage.feign.client.SvmFeignClient;
+import org.apache.cloudstack.storage.feign.client.VolumeFeignClient;
+import org.apache.cloudstack.storage.feign.model.Aggregate;
+import org.apache.cloudstack.storage.feign.model.IpInterface;
+import org.apache.cloudstack.storage.feign.model.IscsiService;
+import org.apache.cloudstack.storage.feign.model.Job;
+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.response.JobResponse;
+import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
+import org.apache.cloudstack.storage.service.model.AccessGroup;
+import org.apache.cloudstack.storage.service.model.CloudStackVolume;
+import org.apache.cloudstack.storage.service.model.ProtocolType;
+import org.apache.cloudstack.storage.utils.OntapStorageConstants;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+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.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+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;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class StorageStrategyTest {
+
+ @Mock
+ private AggregateFeignClient aggregateFeignClient;
+
+ @Mock
+ private VolumeFeignClient volumeFeignClient;
+
+ @Mock
+ private SvmFeignClient svmFeignClient;
+
+ @Mock
+ private JobFeignClient jobFeignClient;
+
+ @Mock
+ private NetworkFeignClient networkFeignClient;
+
+ @Mock
+ private SANFeignClient sanFeignClient;
+
+ @Mock
+ private SnapshotFeignClient snapshotFeignClient;
+
+ private TestableStorageStrategy storageStrategy;
+
+ // Concrete implementation for testing abstract class
+ private static class TestableStorageStrategy extends StorageStrategy {
+ public TestableStorageStrategy(OntapStorage ontapStorage,
+ AggregateFeignClient aggregateFeignClient,
+ VolumeFeignClient volumeFeignClient,
+ SvmFeignClient svmFeignClient,
+ JobFeignClient jobFeignClient,
+ NetworkFeignClient networkFeignClient,
+ SANFeignClient sanFeignClient,
+ SnapshotFeignClient snapshotFeignClient) {
+ super(ontapStorage);
+ // Use reflection to replace the private Feign client fields with mocked ones
+ injectMockedClient("aggregateFeignClient", aggregateFeignClient);
+ injectMockedClient("volumeFeignClient", volumeFeignClient);
+ injectMockedClient("svmFeignClient", svmFeignClient);
+ injectMockedClient("jobFeignClient", jobFeignClient);
+ injectMockedClient("networkFeignClient", networkFeignClient);
+ injectMockedClient("sanFeignClient", sanFeignClient);
+ injectMockedClient("snapshotFeignClient", snapshotFeignClient);
+ }
+
+ private void injectMockedClient(String fieldName, Object mockedClient) {
+ try {
+ Field field = StorageStrategy.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(this, mockedClient);
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ throw new RuntimeException("Failed to inject mocked client: " + fieldName, e);
+ }
+ }
+
+ @Override
+ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ return null;
+ }
+
+ @Override
+ CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ return null;
+ }
+
+ @Override
+ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ }
+
+ @Override
+ public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) {
+
+ }
+
+ @Override
+ public CloudStackVolume getCloudStackVolume(Map cloudStackVolumeMap) {
+ return null;
+ }
+
+ @Override
+ public JobResponse revertSnapshotForCloudStackVolume(String snapshotName, String flexVolUuid, String snapshotUuid, String volumePath, String lunUuid, String flexVolName) {
+ return null;
+ }
+
+ @Override
+ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
+ return null;
+ }
+
+ @Override
+ public void deleteAccessGroup(AccessGroup accessGroup) {
+ }
+
+ @Override
+ AccessGroup updateAccessGroup(AccessGroup accessGroup) {
+ return null;
+ }
+
+ @Override
+ public AccessGroup getAccessGroup(Map values) {
+ return null;
+ }
+
+ @Override
+ public String enableLogicalAccess(Map values) {
+ return null;
+ }
+
+ @Override
+ public void disableLogicalAccess(Map values) {
+ }
+
+ @Override
+ public String getLogicalAccess(Map values) {
+ return null;
+ }
+ }
+
+ @BeforeEach
+ void setUp() {
+ // Create OntapStorage using constructor (immutable object)
+ OntapStorage ontapStorage = new OntapStorage("admin", "password", "192.168.1.100",
+ "svm1", 5000000000L, ProtocolType.NFS3);
+
+ // Note: In real implementation, StorageStrategy constructor creates Feign clients
+ // For testing, we'll need to mock the FeignClientFactory behavior
+ storageStrategy = new TestableStorageStrategy(ontapStorage,
+ aggregateFeignClient, volumeFeignClient, svmFeignClient,
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
+ }
+
+ // ========== connect() Tests ==========
+
+ @Test
+ public void testConnect_positive() {
+ // Setup
+ Svm svm = new Svm();
+ svm.setName("svm1");
+ svm.setState(OntapStorageConstants.RUNNING);
+ svm.setNfsEnabled(true);
+
+ Aggregate aggregate = new Aggregate();
+ aggregate.setName("aggr1");
+ aggregate.setUuid("aggr-uuid-1");
+ svm.setAggregates(List.of(aggregate));
+
+ OntapResponse svmResponse = new OntapResponse<>();
+ svmResponse.setRecords(List.of(svm));
+
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);
+ Aggregate aggregateDetail = mock(Aggregate.class);
+ when(aggregateDetail.getName()).thenReturn("aggr1");
+ when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1");
+ when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE);
+ when(aggregateDetail.getSpace()).thenReturn(mock(Aggregate.AggregateSpace.class));
+ when(aggregateDetail.getAvailableBlockStorageSpace()).thenReturn(10000000000.0);
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail);
+
+ // Execute
+ boolean result = storageStrategy.connect();
+
+ // Verify
+ assertTrue(result, "connect() should return true on success");
+ verify(svmFeignClient, times(1)).getSvmResponse(anyMap(), anyString());
+ }
+
+ @Test
+ public void testConnect_operationsOnly_skipsAggregateValidation() {
+ Svm svm = new Svm();
+ svm.setName("svm1");
+ svm.setState(OntapStorageConstants.RUNNING);
+ svm.setNfsEnabled(true);
+
+ Aggregate aggregate = new Aggregate();
+ aggregate.setName("aggr1");
+ aggregate.setUuid("aggr-uuid-1");
+ svm.setAggregates(List.of(aggregate));
+
+ OntapResponse svmResponse = new OntapResponse<>();
+ svmResponse.setRecords(List.of(svm));
+
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);
+
+ boolean result = storageStrategy.connect(false);
+
+ assertTrue(result);
+ verify(aggregateFeignClient, never()).getAggregateByUUID(anyString(), anyString());
+ }
+
+ @Test
+ public void testConnect_svmNotFound() {
+ // Setup
+ OntapResponse svmResponse = new OntapResponse<>();
+ svmResponse.setRecords(new ArrayList<>());
+
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);
+
+ // Execute & Verify
+ CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
+ assertTrue(ex.getMessage().contains("No SVM found"));
+ }
+
+ @Test
+ public void testConnect_svmNotRunning() {
+ // Setup
+ Svm svm = new Svm();
+ svm.setName("svm1");
+ svm.setState("stopped");
+ svm.setNfsEnabled(true);
+
+ OntapResponse svmResponse = new OntapResponse<>();
+ svmResponse.setRecords(List.of(svm));
+
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);
+
+ // Execute & Verify
+ CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
+ assertTrue(ex.getMessage().contains("not in running state"));
+ }
+
+ @Test
+ public void testConnect_nfsNotEnabled() {
+ // Setup
+ Svm svm = new Svm();
+ svm.setName("svm1");
+ svm.setState(OntapStorageConstants.RUNNING);
+ svm.setNfsEnabled(false);
+
+ Aggregate aggregate = new Aggregate();
+ aggregate.setName("aggr1");
+ aggregate.setUuid("aggr-uuid-1");
+ svm.setAggregates(List.of(aggregate));
+
+ OntapResponse svmResponse = new OntapResponse<>();
+ svmResponse.setRecords(List.of(svm));
+
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);
+
+ // Execute & Verify
+ CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
+ assertTrue(ex.getMessage().contains("NFS protocol is not enabled"));
+ }
+
+ @Test
+ public void testConnect_iscsiNotEnabled() {
+ // Setup - recreate with iSCSI protocol
+ OntapStorage iscsiStorage = new OntapStorage("admin", "password", "192.168.1.100",
+ "svm1", 5000000000L, ProtocolType.ISCSI);
+ storageStrategy = new TestableStorageStrategy(iscsiStorage,
+ aggregateFeignClient, volumeFeignClient, svmFeignClient,
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
+
+ Svm svm = new Svm();
+ svm.setName("svm1");
+ svm.setState(OntapStorageConstants.RUNNING);
+ svm.setIscsiEnabled(false);
+
+ Aggregate aggregate = new Aggregate();
+ aggregate.setName("aggr1");
+ aggregate.setUuid("aggr-uuid-1");
+ svm.setAggregates(List.of(aggregate));
+
+ OntapResponse svmResponse = new OntapResponse<>();
+ svmResponse.setRecords(List.of(svm));
+
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);
+
+ // Execute & Verify
+ CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
+ assertTrue(ex.getMessage().contains("ISCSI protocol is not enabled"));
+ }
+
+ @Test
+ public void testConnect_noAggregates() {
+ // Setup
+ Svm svm = new Svm();
+ svm.setName("svm1");
+ svm.setState(OntapStorageConstants.RUNNING);
+ svm.setNfsEnabled(true);
+ svm.setAggregates(new ArrayList<>());
+
+ OntapResponse svmResponse = new OntapResponse<>();
+ svmResponse.setRecords(List.of(svm));
+
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);
+
+ // Execute & Verify
+ CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
+ assertTrue(ex.getMessage().contains("No aggregates"));
+ }
+
+ @Test
+ public void testConnect_nullSvmResponse() {
+ // Setup
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(null);
+
+ // Execute & Verify
+ CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
+ assertTrue(ex.getMessage().contains("No SVM found"));
+ }
+
+ @Test
+ public void testConnect_invalidCredentials() {
+ // Setup - ONTAP rejects the supplied username/password with HTTP 401 Unauthorized.
+ when(svmFeignClient.getSvmResponse(anyMap(), anyString()))
+ .thenThrow(mock(FeignException.Unauthorized.class));
+
+ // Execute & Verify - connect() must surface a clear "invalid credentials" error.
+ CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
+ assertTrue(ex.getMessage().contains("Authentication failed: Invalid credentials"),
+ "Expected an authentication failure message but got: " + ex.getMessage());
+ assertTrue(ex.getMessage().contains("Please verify the username and password"),
+ "Expected the message to prompt verifying username/password but got: " + ex.getMessage());
+ }
+
+ // ========== createStorageVolume() Tests ==========
+
+ @Test
+ public void testCreateStorageVolume_positive() {
+ // Setup - First connect to populate aggregates
+ setupSuccessfulConnect();
+ storageStrategy.connect();
+
+ // Setup aggregate details
+ Aggregate aggregateDetail = mock(Aggregate.class);
+ when(aggregateDetail.getName()).thenReturn("aggr1");
+ when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1");
+ when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE);
+ when(aggregateDetail.getSpace()).thenReturn(mock(Aggregate.AggregateSpace.class)); // Mock non-null space
+ when(aggregateDetail.getAvailableBlockStorageSpace()).thenReturn(10000000000.0);
+
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1")))
+ .thenReturn(aggregateDetail);
+
+ // Setup job response
+ Job job = new Job();
+ job.setUuid("job-uuid-1");
+ JobResponse jobResponse = new JobResponse();
+ jobResponse.setJob(job);
+
+ when(volumeFeignClient.createVolumeWithJob(anyString(), any(Volume.class)))
+ .thenReturn(jobResponse);
+
+ // Setup job polling
+ Job completedJob = new Job();
+ completedJob.setUuid("job-uuid-1");
+ completedJob.setState(OntapStorageConstants.JOB_SUCCESS);
+ when(jobFeignClient.getJobByUUID(anyString(), eq("job-uuid-1")))
+ .thenReturn(completedJob);
+
+ // Setup volume retrieval after creation
+ Volume createdVolume = new Volume();
+ createdVolume.setName("test-volume");
+ createdVolume.setUuid("vol-uuid-1");
+ OntapResponse volumeResponse = new OntapResponse<>();
+ volumeResponse.setRecords(List.of(createdVolume));
+
+ when(volumeFeignClient.getAllVolumes(anyString(), anyMap()))
+ .thenReturn(volumeResponse);
+ when(volumeFeignClient.getVolume(anyString(), anyMap()))
+ .thenReturn(volumeResponse);
+
+ // Execute
+ Volume result = storageStrategy.createStorageVolume("test-volume", 5000000000L);
+
+ // Verify
+ assertNotNull(result);
+ assertEquals("test-volume", result.getName());
+ assertEquals("vol-uuid-1", result.getUuid());
+ verify(volumeFeignClient, times(1)).createVolumeWithJob(anyString(), any(Volume.class));
+ verify(jobFeignClient, atLeastOnce()).getJobByUUID(anyString(), eq("job-uuid-1"));
+ }
+
+ @Test
+ public void testCreateStorageVolume_invalidSize() {
+ // Setup
+ setupSuccessfulConnect();
+ storageStrategy.connect();
+
+ // Execute & Verify
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.createStorageVolume("test-volume", -1L));
+ assertTrue(ex.getMessage().contains("Invalid volume size"));
+ }
+
+ @Test
+ public void testCreateStorageVolume_nullSize() {
+ // Setup
+ setupSuccessfulConnect();
+ storageStrategy.connect();
+
+ // Execute & Verify
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.createStorageVolume("test-volume", null));
+ assertTrue(ex.getMessage().contains("Invalid volume size"));
+ }
+
+ @Test
+ public void testCreateStorageVolume_noAggregates() {
+ // Execute & Verify - without calling connect first
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.createStorageVolume("test-volume", 5000000000L));
+ assertTrue(ex.getMessage().contains("No aggregates available"));
+ }
+
+ @Test
+ public void testCreateStorageVolume_aggregateNotOnline() {
+ // Setup
+ setupSuccessfulConnect();
+ storageStrategy.connect();
+
+ Aggregate aggregateDetail = mock(Aggregate.class);
+ when(aggregateDetail.getName()).thenReturn("aggr1");
+ when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1");
+ when(aggregateDetail.getState()).thenReturn(null); // null state to simulate offline
+
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1")))
+ .thenReturn(aggregateDetail);
+
+ // Execute & Verify
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.createStorageVolume("test-volume", 5000000000L));
+ assertTrue(ex.getMessage().contains("No suitable aggregates found"));
+ }
+
+ @Test
+ public void testCreateStorageVolume_insufficientSpace() {
+ // Setup
+ setupSuccessfulConnect();
+ storageStrategy.connect();
+
+ Aggregate aggregateDetail = mock(Aggregate.class);
+ when(aggregateDetail.getName()).thenReturn("aggr1");
+ when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1");
+ when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE);
+ when(aggregateDetail.getAvailableBlockStorageSpace()).thenReturn(1000000.0); // Only 1MB available
+
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1")))
+ .thenReturn(aggregateDetail);
+
+ // Execute & Verify
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.createStorageVolume("test-volume", 5000000000L)); // Request 5GB
+ assertTrue(ex.getMessage().contains("No suitable aggregates found"));
+ }
+
+ @Test
+ public void testCreateStorageVolume_jobFailed() {
+ // Setup
+ setupSuccessfulConnect();
+ storageStrategy.connect();
+
+ setupAggregateForVolumeCreation();
+
+ Job job = new Job();
+ job.setUuid("job-uuid-1");
+ JobResponse jobResponse = new JobResponse();
+ jobResponse.setJob(job);
+
+ when(volumeFeignClient.createVolumeWithJob(anyString(), any(Volume.class)))
+ .thenReturn(jobResponse);
+
+ // Setup failed job
+ Job failedJob = new Job();
+ failedJob.setUuid("job-uuid-1");
+ failedJob.setState(OntapStorageConstants.JOB_FAILURE);
+ failedJob.setMessage("Volume creation failed");
+ when(jobFeignClient.getJobByUUID(anyString(), eq("job-uuid-1")))
+ .thenReturn(failedJob);
+
+ // Execute & Verify
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.createStorageVolume("test-volume", 5000000000L));
+ assertTrue(ex.getMessage().contains("failed") || ex.getMessage().contains("Job failed"));
+ }
+
+ @Test
+ public void testCreateStorageVolume_volumeNotFoundAfterCreation() {
+ // Setup
+ setupSuccessfulConnect();
+ storageStrategy.connect();
+ setupAggregateForVolumeCreation();
+ setupSuccessfulJobCreation();
+
+ // Setup empty volume response
+ OntapResponse emptyResponse = new OntapResponse<>();
+ emptyResponse.setRecords(new ArrayList<>());
+
+ when(volumeFeignClient.getAllVolumes(anyString(), anyMap()))
+ .thenReturn(emptyResponse);
+
+ // Execute & Verify
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.createStorageVolume("test-volume", 5000000000L));
+ assertTrue(ex.getMessage() != null && ex.getMessage().contains("not found after creation"));
+ }
+
+ // ========== deleteStorageVolume() Tests ==========
+
+ @Test
+ public void testDeleteStorageVolume_positive() {
+ // Setup
+ Volume volume = new Volume();
+ volume.setName("test-volume");
+ volume.setUuid("vol-uuid-1");
+
+ Job job = new Job();
+ job.setUuid("job-uuid-1");
+ JobResponse jobResponse = new JobResponse();
+ jobResponse.setJob(job);
+
+ when(volumeFeignClient.deleteVolume(anyString(), eq("vol-uuid-1")))
+ .thenReturn(jobResponse);
+
+ Job completedJob = new Job();
+ completedJob.setUuid("job-uuid-1");
+ completedJob.setState(OntapStorageConstants.JOB_SUCCESS);
+ when(jobFeignClient.getJobByUUID(anyString(), eq("job-uuid-1")))
+ .thenReturn(completedJob);
+
+ // Execute
+ storageStrategy.deleteStorageVolume(volume);
+
+ // Verify
+ verify(volumeFeignClient, times(1)).deleteVolume(anyString(), eq("vol-uuid-1"));
+ verify(jobFeignClient, atLeastOnce()).getJobByUUID(anyString(), eq("job-uuid-1"));
+ }
+
+ @Test
+ public void testDeleteStorageVolume_jobFailed() {
+ // Setup
+ Volume volume = new Volume();
+ volume.setName("test-volume");
+ volume.setUuid("vol-uuid-1");
+
+ Job job = new Job();
+ job.setUuid("job-uuid-1");
+ JobResponse jobResponse = new JobResponse();
+ jobResponse.setJob(job);
+
+ when(volumeFeignClient.deleteVolume(anyString(), eq("vol-uuid-1")))
+ .thenReturn(jobResponse);
+
+ Job failedJob = new Job();
+ failedJob.setUuid("job-uuid-1");
+ failedJob.setState(OntapStorageConstants.JOB_FAILURE);
+ failedJob.setMessage("Deletion failed");
+ when(jobFeignClient.getJobByUUID(anyString(), eq("job-uuid-1")))
+ .thenReturn(failedJob);
+
+ // Execute & Verify
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.deleteStorageVolume(volume));
+ assertTrue(ex.getMessage().contains("Job failed"));
+ }
+
+ @Test
+ public void testDeleteStorageVolume_feignException() {
+ // Setup
+ Volume volume = new Volume();
+ volume.setName("test-volume");
+ volume.setUuid("vol-uuid-1");
+
+ when(volumeFeignClient.deleteVolume(anyString(), eq("vol-uuid-1")))
+ .thenThrow(mock(FeignException.FeignClientException.class));
+
+ // Execute & Verify
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.deleteStorageVolume(volume));
+ assertTrue(ex.getMessage().contains("Failed to delete volume"));
+ }
+
+ // ========== getStoragePath() Tests ==========
+
+ @Test
+ public void testGetStoragePath_iscsi() {
+ // Setup - recreate with iSCSI protocol
+ OntapStorage iscsiStorage = new OntapStorage("admin", "password", "192.168.1.100",
+ "svm1", null, ProtocolType.ISCSI);
+ storageStrategy = new TestableStorageStrategy(iscsiStorage,
+ aggregateFeignClient, volumeFeignClient, svmFeignClient,
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
+
+ IscsiService.IscsiServiceTarget target = new IscsiService.IscsiServiceTarget();
+ target.setName("iqn.1992-08.com.netapp:sn.123456:vs.1");
+
+ IscsiService iscsiService = new IscsiService();
+ iscsiService.setTarget(target);
+
+ OntapResponse