diff --git a/.github/workflows/sonar-check.yml b/.github/workflows/sonar-check.yml
index fbb3cb9f540d..1aea7cee9d75 100644
--- a/.github/workflows/sonar-check.yml
+++ b/.github/workflows/sonar-check.yml
@@ -58,6 +58,7 @@ jobs:
run: |
mvn -B -P quality org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=apache_cloudstack -Dsonar.pullrequest.key="$PR_ID" -Dsonar.pullrequest.branch="$HEADREF" -Dsonar.pullrequest.github.repository=apache/cloudstack -Dsonar.pullrequest.provider=GitHub -Dsonar.pullrequest.github.summary_comment=true
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ if: github.repository == 'apache/cloudstack'
with:
files: ./client/target/site/jacoco-aggregate/jacoco.xml
fail_ci_if_error: true
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 14e6e2b12edc..f38f008bc6df 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
@@ -27,7 +27,6 @@
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;
-
import javax.inject.Inject;
import org.apache.cloudstack.backup.BackupManagerImpl;
diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java
index d99847fd921a..e2f41c6a78c5 100644
--- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java
+++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java
@@ -448,7 +448,7 @@ public synchronized boolean deleteStoragePool(StoragePoolType type, String uuid)
if (type == StoragePoolType.NetworkFilesystem) {
_haMonitor.removeStoragePool(uuid);
}
- boolean deleteStatus = adaptor.deleteStoragePool(uuid);;
+ boolean deleteStatus = adaptor.deleteStoragePool(uuid);
synchronized (_storagePools) {
_storagePools.remove(uuid);
}
@@ -457,10 +457,12 @@ public synchronized boolean deleteStoragePool(StoragePoolType type, String uuid)
public boolean deleteStoragePool(StoragePoolType type, String uuid, Map details) {
StorageAdaptor adaptor = getStorageAdaptor(type);
+ // For NetworkFilesystem, libvirt will take care of unmounting the nfs mount. If nfs mount has been removed before libvirt's pool
+ // delete, libvirt will throw an error.
+ boolean deleteStatus = adaptor.deleteStoragePool(uuid, details);
if (type == StoragePoolType.NetworkFilesystem) {
_haMonitor.removeStoragePool(uuid);
}
- boolean deleteStatus = adaptor.deleteStoragePool(uuid, details);
synchronized (_storagePools) {
_storagePools.remove(uuid);
}
diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java
index 4bfac31b68f9..d37f2c313324 100644
--- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java
+++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java
@@ -885,6 +885,11 @@ private boolean destroyStoragePoolHandleException(Connect conn, String uuid)
return false;
}
+ @Override
+ public boolean deleteStoragePool(String uuid, Map details) {
+ return deleteStoragePool(uuid);
+ }
+
@Override
public boolean deleteStoragePool(String uuid) {
logger.info("Attempting to remove storage pool " + uuid + " from libvirt");
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 ece29f7cd0ac..d6b7b089d6bf 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
@@ -26,12 +26,15 @@
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;
@@ -67,7 +70,6 @@
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.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
@@ -91,6 +93,7 @@ public class OntapPrimaryDatastoreDriver implements PrimaryDataStoreDriver {
@Inject private VolumeDao volumeDao;
@Inject private VolumeDetailsDao volumeDetailsDao;
@Inject private SnapshotDetailsDao snapshotDetailsDao;
+ @Inject private SnapshotDao snapshotDao;
@Override
public Map getCapabilities() {
@@ -98,6 +101,7 @@ public Map getCapabilities() {
Map mapCapabilities = new HashMap<>();
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;
}
@@ -156,6 +160,8 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet
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;
@@ -210,8 +216,14 @@ private CloudStackVolume createCloudStackVolume(StoragePoolVO storagePool, Volum
/**
* Deletes a volume or snapshot from the ONTAP storage system.
*
- * For volumes, deletes the backend storage object (LUN for iSCSI, no-op for NFS).
- * For snapshots, deletes the FlexVolume snapshot from ONTAP that was created by takeSnapshot.
+ * 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) {
@@ -237,8 +249,9 @@ public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallbac
commandResult.setResult(null);
commandResult.setSuccess(true);
} else if (data.getType() == DataObjectType.SNAPSHOT) {
- // Delete the ONTAP FlexVolume snapshot that was created by takeSnapshot
- deleteOntapSnapshot((SnapshotInfo) data, commandResult);
+ 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());
}
@@ -252,81 +265,90 @@ public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallbac
}
/**
- * Deletes an ONTAP FlexVolume snapshot.
+ * Orchestrates CloudStack volume-snapshot delete on ONTAP.
*
- * Retrieves the snapshot details stored during takeSnapshot and calls the ONTAP
- * REST API to delete the FlexVolume snapshot.
+ * 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.
*
- * @param snapshotInfo The CloudStack snapshot to delete
- * @param commandResult Result object to populate with success/failure
+ * 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 deleteOntapSnapshot(SnapshotInfo snapshotInfo, CommandResult commandResult) {
+ private void deleteCloudStackVolumeSnapshot(SnapshotInfo snapshotInfo, CommandResult commandResult) {
long snapshotId = snapshotInfo.getId();
- logger.info("deleteOntapSnapshot: Deleting ONTAP FlexVolume snapshot for CloudStack snapshot [{}]", snapshotId);
+ logger.info("deleteCloudStackVolumeSnapshot: starting ONTAP delete for CloudStack volume snapshot [{}]", snapshotId);
try {
- // 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 poolIdStr = getSnapshotDetail(snapshotId, OntapStorageConstants.PRIMARY_POOL_ID);
if (flexVolUuid == null || ontapSnapshotUuid == null) {
- logger.warn("deleteOntapSnapshot: Missing ONTAP snapshot details for snapshot [{}]. " +
- "flexVolUuid={}, ontapSnapshotUuid={}. Snapshot may have been created by a different method or already deleted.",
+ 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);
- // Consider this a success since there's nothing to delete on ONTAP
commandResult.setSuccess(true);
commandResult.setResult(null);
return;
}
- long poolId = Long.parseLong(poolIdStr);
+ long poolId = resolveSnapshotPoolId(poolIdStr, snapshotId);
Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(poolId);
-
+ String protocol = poolDetails.get(OntapStorageConstants.PROTOCOL);
StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
- SnapshotFeignClient snapshotClient = storageStrategy.getSnapshotFeignClient();
- String authHeader = storageStrategy.getAuthHeader();
- logger.info("deleteOntapSnapshot: Deleting ONTAP snapshot [{}] (uuid={}) from FlexVol [{}]",
- snapshotName, ontapSnapshotUuid, flexVolUuid);
+ logger.info("deleteCloudStackVolumeSnapshot: snapshot [{}] — protocol [{}], pool [{}], "
+ + "flexVol [{}], ontapSnapshot [{}] (name [{}])",
+ snapshotId, protocol, poolId, flexVolUuid, ontapSnapshotUuid, snapshotName);
- // Call ONTAP REST API to delete the snapshot
- JobResponse jobResponse = snapshotClient.deleteSnapshot(authHeader, flexVolUuid, ontapSnapshotUuid);
-
- if (jobResponse != null && jobResponse.getJob() != null) {
- // Poll for job completion
- Boolean jobSucceeded = storageStrategy.jobPollForSuccess(jobResponse.getJob().getUuid(), 30, 2000);
- if (!jobSucceeded) {
- throw new CloudRuntimeException("Delete job failed for snapshot [" +
- snapshotName + "] on FlexVol [" + flexVolUuid + "]");
- }
- }
-
- logger.info("deleteOntapSnapshot: Successfully deleted ONTAP snapshot [{}] (uuid={}) for CloudStack snapshot [{}]",
- snapshotName, ontapSnapshotUuid, snapshotId);
+ 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) {
- // Check if the error indicates snapshot doesn't exist (already deleted)
- String errorMsg = e.getMessage();
- if (errorMsg != null && (errorMsg.contains("404") || errorMsg.contains("not found") ||
- errorMsg.contains("does not exist"))) {
- logger.warn("deleteOntapSnapshot: ONTAP snapshot for CloudStack snapshot [{}] not found, " +
- "may have been already deleted. Treating as success.", snapshotId);
+ if (OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ logger.warn("deleteCloudStackVolumeSnapshot: ONTAP snapshot for CloudStack snapshot [{}] "
+ + "already absent (idempotent success): {}", snapshotId, e.getMessage());
commandResult.setSuccess(true);
commandResult.setResult(null);
- } else {
- logger.error("deleteOntapSnapshot: Failed to delete ONTAP snapshot for CloudStack snapshot [{}]: {}",
- snapshotId, e.getMessage(), e);
- commandResult.setSuccess(false);
- commandResult.setResult(e.getMessage());
+ return;
}
+ logger.error("deleteCloudStackVolumeSnapshot: ONTAP delete failed for CloudStack snapshot [{}]: {}",
+ snapshotId, e.getMessage(), e);
+ commandResult.setSuccess(false);
+ commandResult.setResult(e.getMessage());
}
}
+ 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
public void copyAsync(DataObject srcData, DataObject destData, AsyncCompletionCallback callback) {
throw new UnsupportedOperationException("Copy operation is not supported for ONTAP primary storage.");
@@ -647,7 +669,7 @@ public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback
*
- * Protocol-specific handling (delegated to strategy classes):
- *
- * - NFS (UnifiedNASStrategy): Uses the single-file restore API:
- * {@code POST /api/storage/volumes/{volume_uuid}/snapshots/{snapshot_uuid}/files/{file_path}/restore}
- * Restores the QCOW2 file from the FlexVolume snapshot to its original location.
- * - iSCSI (UnifiedSANStrategy): Uses the LUN restore API:
- * {@code POST /api/storage/luns/{lun.uuid}/restore}
- * Restores the LUN data from the snapshot to the specified destination path.
- *
+ * 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,
@@ -847,17 +863,7 @@ public void revertSnapshot(SnapshotInfo snapshotOnImageStore, SnapshotInfo snaps
JobResponse jobResponse = storageStrategy.revertSnapshotForCloudStackVolume(
snapshotName, flexVolUuid, ontapSnapshotUuid, volumePath, lunUuid, flexVolName);
- if (jobResponse == null || jobResponse.getJob() == null) {
- throw new CloudRuntimeException("Failed to initiate restore from snapshot [" +
- snapshotName + "]");
- }
-
- // Poll for job completion (use longer timeout for large LUNs/files)
- Boolean jobSucceeded = storageStrategy.jobPollForSuccess(jobResponse.getJob().getUuid(), 60, 2000);
- if (!jobSucceeded) {
- throw new CloudRuntimeException("Restore job failed for snapshot [" +
- snapshotName + "]");
- }
+ storageStrategy.executeCliSfsrRestore(jobResponse, "revert snapshot [" + snapshotName + "]");
logger.info("revertSnapshot: Successfully restored {} [{}] from snapshot [{}]",
ProtocolType.ISCSI.name().equalsIgnoreCase(protocol) ? "LUN" : "file",
@@ -975,23 +981,27 @@ private CloudStackVolume createDeleteCloudStackVolumeRequest(StoragePool storage
// ──────────────────────────────────────────────────────────────────────────
/**
- * Builds a snapshot name with proper length constraints.
- * Format: {@code -}
+ * Builds an ONTAP-safe snapshot name from the CloudStack UI name with uniqueness suffix.
*/
- private String buildSnapshotName(String volumeName, String snapshotUuid) {
- String name = volumeName + "-" + snapshotUuid;
- int maxLength = OntapStorageConstants.MAX_SNAPSHOT_NAME_LENGTH;
- int trimRequired = name.length() - maxLength;
+ private String buildSnapshotName(String cloudStackSnapshotName, long snapshotId) {
+ return OntapStorageUtils.buildOntapSnapshotName(cloudStackSnapshotName, OntapStorageConstants.CS + snapshotId);
+ }
- if (trimRequired > 0) {
- name = StringUtils.left(volumeName, volumeName.length() - trimRequired) + "-" + snapshotUuid;
+
+ private Storage.ImageFormat getImageFormatByHypervisor(HypervisorType hypervisorType) {
+ if (HypervisorType.KVM.equals(hypervisorType)) {
+ return Storage.ImageFormat.QCOW2;
}
- return name;
+ 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
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/AggregateFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/AggregateFeignClient.java
index f756c3d32f18..7e026b0a6b19 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/AggregateFeignClient.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/AggregateFeignClient.java
@@ -19,10 +19,14 @@
package org.apache.cloudstack.storage.feign.client;
+import java.util.Map;
+
import org.apache.cloudstack.storage.feign.model.Aggregate;
import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
+
import feign.Headers;
import feign.Param;
+import feign.QueryMap;
import feign.RequestLine;
public interface AggregateFeignClient {
@@ -33,5 +37,6 @@ public interface AggregateFeignClient {
@RequestLine("GET /api/storage/aggregates/{uuid}")
@Headers({"Authorization: {authHeader}"})
- Aggregate getAggregateByUUID(@Param("authHeader") String authHeader, @Param("uuid") String uuid);
+ Aggregate getAggregateByUUID(@Param("authHeader") String authHeader, @Param("uuid") String uuid,
+ @QueryMap Map queryParams);
}
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
index 2f0e050d6f55..cb7375aead88 100644
--- 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
@@ -23,6 +23,8 @@
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;
@@ -181,4 +183,96 @@ JobResponse restoreFileFromSnapshot(@Param("authHeader") String authHeader,
@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/Aggregate.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Aggregate.java
index 8ac1717604a5..7b57be59ec25 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Aggregate.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Aggregate.java
@@ -19,14 +19,14 @@
package org.apache.cloudstack.storage.feign.model;
+import java.util.Objects;
+
+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.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
-import java.util.Objects;
-
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Aggregate {
@@ -77,6 +77,17 @@ public int hashCode() {
@JsonProperty("space")
private AggregateSpace space = null;
+ @JsonProperty("node")
+ private Node node = null;
+
+
+ public Node getNode() {
+ return node;
+ }
+
+ public void setNode(Node node) {
+ this.node = node;
+ }
public Aggregate name(String name) {
this.name = name;
@@ -107,10 +118,18 @@ public StateEnum getState() {
return state;
}
+ public void setState(StateEnum state) {
+ this.state = state;
+ }
+
public AggregateSpace getSpace() {
return space;
}
+ public void setSpace(AggregateSpace space) {
+ this.space = space;
+ }
+
public Double getAvailableBlockStorageSpace() {
if (space != null && space.blockStorage != null) {
return space.blockStorage.available;
@@ -148,9 +167,32 @@ public String toString() {
return "DiskAggregates [name=" + name + ", uuid=" + uuid + "]";
}
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Node {
+ @JsonProperty("name")
+ private String name;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+
public static class AggregateSpace {
@JsonProperty("block_storage")
private AggregateSpaceBlockStorage blockStorage = null;
+
+ public AggregateSpaceBlockStorage getBlockStorage() {
+ return blockStorage;
+ }
+
+ public void setBlockStorage(AggregateSpaceBlockStorage blockStorage) {
+ this.blockStorage = blockStorage;
+ }
}
public static class AggregateSpaceBlockStorage {
@@ -160,6 +202,14 @@ public static class AggregateSpaceBlockStorage {
private Double size = null;
@JsonProperty("used")
private Double used = null;
+
+ public Double getAvailable() {
+ return available;
+ }
+
+ public void setAvailable(Double available) {
+ this.available = available;
+ }
}
}
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/ExportRule.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ExportRule.java
index 087e9aa681b4..15374811bf74 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ExportRule.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ExportRule.java
@@ -19,10 +19,13 @@
package org.apache.cloudstack.storage.feign.model;
+import java.util.List;
+
+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 java.util.List;
+import com.fasterxml.jackson.annotation.JsonValue;
/**
* ExportRule
@@ -54,6 +57,7 @@ public enum ProtocolsEnum {
this.value = value;
}
+ @JsonValue
public String getValue() {
return value;
}
@@ -63,9 +67,13 @@ public String toString() {
return String.valueOf(value);
}
+ @JsonCreator
public static ProtocolsEnum fromValue(String text) {
+ if (text == null) {
+ return null;
+ }
for (ProtocolsEnum b : ProtocolsEnum.values()) {
- if (String.valueOf(b.value).equals(text)) {
+ if (String.valueOf(b.value).equalsIgnoreCase(text)) {
return b;
}
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/IpInterface.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/IpInterface.java
index c15798a42b70..8070763285c8 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/IpInterface.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/IpInterface.java
@@ -19,13 +19,13 @@
package org.apache.cloudstack.storage.feign.model;
+import java.util.List;
+import java.util.Objects;
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
-import java.util.List;
-import java.util.Objects;
-
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class IpInterface {
@@ -44,6 +44,15 @@ public class IpInterface {
@JsonProperty("services")
private List services;
+ @JsonProperty("state")
+ private String state;
+
+ @JsonProperty("enabled")
+ private Boolean enabled;
+
+ @JsonProperty("location")
+ private Location location;
+
// Getters and setters
public String getUuid() {
return uuid;
@@ -85,6 +94,30 @@ public void setServices(List services) {
this.services = services;
}
+ public String getState() {
+ return state;
+ }
+
+ public void setState(String state) {
+ this.state = state;
+ }
+
+ public Boolean getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(Boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public Location getLocation() {
+ return location;
+ }
+
+ public void setLocation(Location location) {
+ this.location = location;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -98,12 +131,14 @@ public boolean equals(Object o) {
Objects.equals(name, that.name) &&
Objects.equals(ip, that.ip) &&
Objects.equals(svm, that.svm) &&
- Objects.equals(services, that.services);
+ Objects.equals(services, that.services) &&
+ Objects.equals(state, that.state) &&
+ Objects.equals(enabled, that.enabled);
}
@Override
public int hashCode() {
- return Objects.hash(uuid, name, ip, svm, services);
+ return Objects.hash(uuid, name, ip, svm, services, state, enabled);
}
@Override
@@ -114,9 +149,52 @@ public String toString() {
", ip=" + ip +
", svm=" + svm +
", services=" + services +
+ ", state='" + state + '\'' +
+ ", enabled=" + enabled +
'}';
}
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Node {
+ @JsonProperty("name")
+ private String name;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Location {
+ @JsonProperty("home_node")
+ private Node homeNode;
+
+ @JsonProperty("node")
+ private Node node;
+
+ public Node getHomeNode() {
+ return homeNode;
+ }
+
+ public void setHomeNode(Node homeNode) {
+ this.homeNode = homeNode;
+ }
+
+ public Node getNode() {
+ return node;
+ }
+
+ public void setNode(Node node) {
+ this.node = node;
+ }
+ }
+
// Nested class for IP information
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
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 fec594ea0ea6..c002db728dd1 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,20 +19,15 @@
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;
-import com.cloud.exception.InvalidParameterValueException;
-import com.cloud.host.HostVO;
-import com.cloud.hypervisor.Hypervisor;
-import com.cloud.resource.ResourceManager;
-import com.cloud.storage.Storage;
-import com.cloud.storage.StorageManager;
-import com.cloud.storage.StoragePool;
-import com.cloud.storage.StoragePoolAutomation;
-import com.cloud.utils.exception.CloudRuntimeException;
-import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import javax.inject.Inject;
+
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
@@ -40,10 +35,11 @@
import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreInfo;
import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreLifeCycle;
import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreParameters;
+import org.apache.cloudstack.engine.subsystem.api.storage.Scope;
import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
-import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
import org.apache.cloudstack.storage.datastore.lifecycle.BasePrimaryDataStoreLifeCycleImpl;
import org.apache.cloudstack.storage.feign.model.OntapStorage;
@@ -59,13 +55,21 @@
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import javax.inject.Inject;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.UUID;
+import com.cloud.agent.api.StoragePoolInfo;
+import com.cloud.alert.AlertManager;
+import com.cloud.dc.ClusterVO;
+import com.cloud.dc.dao.ClusterDao;
+import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.host.HostVO;
+import com.cloud.hypervisor.Hypervisor;
+import com.cloud.resource.ResourceManager;
+import com.cloud.storage.Storage;
+import com.cloud.storage.StorageManager;
+import com.cloud.storage.StoragePool;
+import com.cloud.storage.StoragePoolAutomation;
+import com.cloud.utils.Pair;
+import com.cloud.utils.exception.CloudRuntimeException;
+import com.google.common.base.Preconditions;
public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycleImpl implements PrimaryDataStoreLifeCycle {
@Inject private ClusterDao _clusterDao;
@@ -76,6 +80,7 @@ public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycl
@Inject private StoragePoolAutomation _storagePoolAutomation;
@Inject private PrimaryDataStoreDao storagePoolDao;
@Inject private StoragePoolDetailsDao storagePoolDetailsDao;
+ @Inject private AlertManager _alertMgr;
private static final Logger logger = LogManager.getLogger(OntapPrimaryDatastoreLifecycle.class);
private static final long ONTAP_MIN_VOLUME_SIZE_IN_BYTES = 1677721600L;
@@ -108,6 +113,8 @@ public DataStore initialize(Map dsInfos) {
@SuppressWarnings("unchecked")
Map details = (Map) dsInfos.get("details");
+ capacityBytes = validateInitializeInputs(capacityBytes, podId, clusterId, zoneId, storagePoolName, providerName, managed, details);
+
PrimaryDataStoreParameters parameters = new PrimaryDataStoreParameters();
if (clusterId != null) {
ClusterVO clusterVO = _clusterDao.findById(clusterId);
@@ -118,8 +125,6 @@ public DataStore initialize(Map dsInfos) {
parameters.setHypervisorType(clusterVO.getHypervisorType());
}
- capacityBytes = validateInitializeInputs(capacityBytes, podId, clusterId, zoneId, storagePoolName, providerName, managed, details);
-
details.put(OntapStorageConstants.SIZE, capacityBytes.toString());
ProtocolType protocol = ProtocolType.valueOf(details.get(OntapStorageConstants.PROTOCOL));
@@ -135,13 +140,9 @@ public DataStore initialize(Map dsInfos) {
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");
+ if (storageStrategy.getResolvedSvmUuid() != null && !storageStrategy.getResolvedSvmUuid().isEmpty()) {
+ details.put(OntapStorageConstants.SVM_UUID, storageStrategy.getResolvedSvmUuid());
}
- logger.info("Using Data LIF for storage access: " + dataLIF);
- details.put(OntapStorageConstants.DATA_LIF, dataLIF);
logger.info("Creating ONTAP volume '" + storagePoolName + "' with size: " + capacityBytes + " bytes (" +
(capacityBytes / (1024 * 1024 * 1024)) + " GB)");
try {
@@ -157,6 +158,15 @@ public DataStore initialize(Map dsInfos) {
logger.error("Exception occurred while creating ONTAP volume: " + storagePoolName, e);
throw new CloudRuntimeException("Failed to create ONTAP volume: " + storagePoolName + ". Error: " + e.getMessage(), e);
}
+
+ Pair lifResult;
+ try {
+ lifResult = storageStrategy.getNetworkInterface();
+ } catch (Exception e) {
+ logger.error("Exception occurred while retrieving network interface for pool: " + storagePoolName, e);
+ throw new CloudRuntimeException("Failed to retrieve Data LIF from ONTAP: " + e.getMessage(), e);
+ }
+ processDataLifSelection(lifResult, details, storagePoolName, zoneId, podId);
} else {
throw new CloudRuntimeException("ONTAP details validation failed, cannot create primary storage");
}
@@ -272,6 +282,26 @@ private long validateInitializeInputs(Long capacityBytes, Long podId, Long clust
return capacityBytes;
}
+ private void processDataLifSelection(Pair lifResult, Map details,
+ String storagePoolName, Long zoneId, Long podId) {
+ String dataLIF = lifResult.first();
+ 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);
+
+ // Persist LIF warning as a pool detail and fire a storage alert so the user is informed
+ if (lifResult.second() != null) {
+ String lifWarning = lifResult.second();
+ details.put(OntapStorageConstants.LIF_WARNING, lifWarning);
+ logger.warn("LIF selection warning for pool '" + storagePoolName + "': " + lifWarning);
+ String alertSubject = "ONTAP Storage Pool '" + storagePoolName + "': "
+ + lifWarning.split(OntapStorageConstants.SEMICOLON)[0].trim();
+ OntapStorageUtils.sendStorageAlert(_alertMgr, zoneId, podId, alertSubject, lifWarning);
+ }
+ }
+
@Override
public boolean attachCluster(DataStore dataStore, ClusterScope scope) {
logger.debug("In attachCluster for ONTAP primary storage");
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 ecdd3efd2c5c..f750e2904023 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
@@ -19,30 +19,38 @@
package org.apache.cloudstack.storage.listener;
+import java.util.List;
+import java.util.Map;
+
import javax.inject.Inject;
-import com.cloud.agent.api.ModifyStoragePoolCommand;
+import org.apache.cloudstack.engine.subsystem.api.storage.HypervisorHostListener;
+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.service.StorageStrategy;
+import org.apache.cloudstack.storage.service.model.AccessGroup;
+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.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import com.cloud.agent.AgentManager;
+import com.cloud.agent.api.Answer;
import com.cloud.agent.api.ModifyStoragePoolAnswer;
+import com.cloud.agent.api.ModifyStoragePoolCommand;
import com.cloud.agent.api.StoragePoolInfo;
import com.cloud.alert.AlertManager;
+import com.cloud.host.Host;
+import com.cloud.host.HostVO;
+import com.cloud.host.dao.HostDao;
import com.cloud.hypervisor.Hypervisor;
+import com.cloud.storage.StoragePool;
import com.cloud.storage.StoragePoolHostVO;
import com.cloud.storage.dao.StoragePoolHostDao;
-import org.apache.logging.log4j.Logger;
-import org.apache.logging.log4j.LogManager;
-import com.cloud.agent.AgentManager;
-import com.cloud.agent.api.Answer;
-import com.cloud.agent.api.DeleteStoragePoolCommand;
-import com.cloud.host.Host;
-import com.cloud.storage.StoragePool;
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());
@@ -63,26 +71,39 @@ public class OntapHostListener implements HypervisorHostListener {
@Override
public boolean hostConnect(long hostId, long poolId) {
- logger.info("Connect to host " + hostId + " from pool " + poolId);
+ logger.info("hostConnect: Connecting host {} to pool {}", hostId, poolId);
Host host = _hostDao.findById(hostId);
if (host == null) {
- logger.error("host was not found with id : {}", hostId);
+ logger.error("hostConnect: 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());
+ logger.error("hostConnect: ONTAP plugin does not support {} type host currently", host.getHypervisorType());
return false;
}
StoragePool pool = _storagePoolDao.findById(poolId);
if (pool == null) {
- logger.error("Failed to connect host - storage pool not found with id: {}", poolId);
+ logger.error("hostConnect: Failed to connect host - storage pool not found with id: {}", poolId);
return false;
}
- logger.info("Connecting host {} to ONTAP storage pool {}", host.getName(), pool.getName());
+ logger.info("hostConnect: Connecting host {} to ONTAP storage pool {}", host.getName(), pool.getName());
try {
// Load storage pool details from database to pass mount options and other config to agent
Map detailsMap = _storagePoolDetailsDao.listDetailsKeyPairs(poolId);
+ if (detailsMap == null || detailsMap.isEmpty()) {
+ logger.error("hostConnect: Failed to load storage pool details for pool id: {}", poolId);
+ return false;
+ }
+
+ if (detailsMap.get(OntapStorageConstants.PROTOCOL) == null) {
+ logger.error("hostConnect: Storage pool details missing required protocol type for pool id: {}", poolId);
+ return false;
+ }
+
+ // Update NFS export policy for this connected host when the pool protocol is NFS3.
+ updateNfsExportPolicyForConnectedHostIfNeeded(poolId, hostId, host, detailsMap);
+
// 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.
@@ -118,7 +139,7 @@ public boolean hostConnect(long hostId, long poolId) {
}
String localPath = poolInfo.getLocalPath();
- logger.info("Storage pool {} successfully mounted at: {}", pool.getName(), localPath);
+ logger.info("hostConnect: 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);
@@ -126,11 +147,11 @@ public boolean hostConnect(long hostId, long poolId) {
if (storagePoolHost == null) {
storagePoolHost = new StoragePoolHostVO(poolId, hostId, localPath);
storagePoolHostDao.persist(storagePoolHost);
- logger.info("Created storage_pool_host_ref entry for pool {} and host {}", pool.getName(), host.getName());
+ logger.info("hostConnect: Created storage_pool_host_ref entry for pool {} and host {}", pool.getName(), host.getName());
} else {
storagePoolHost.setLocalPath(localPath);
storagePoolHostDao.update(storagePoolHost.getId(), storagePoolHost);
- logger.info("Updated storage_pool_host_ref entry with local_path: {}", localPath);
+ logger.info("hostConnect: Updated storage_pool_host_ref entry with local_path: {}", localPath);
}
// Update pool capacity/usage information
@@ -139,11 +160,11 @@ public boolean hostConnect(long hostId, long poolId) {
poolVO.setCapacityBytes(poolInfo.getCapacityBytes());
poolVO.setUsedBytes(poolInfo.getCapacityBytes() - poolInfo.getAvailableBytes());
_storagePoolDao.update(poolVO.getId(), poolVO);
- logger.info("Updated storage pool capacity: {} GB, used: {} GB", poolInfo.getCapacityBytes() / (1024 * 1024 * 1024), (poolInfo.getCapacityBytes() - poolInfo.getAvailableBytes()) / (1024 * 1024 * 1024));
+ logger.info("hostConnect: Updated storage pool capacity: {} GB, used: {} GB", poolInfo.getCapacityBytes() / (1024 * 1024 * 1024), (poolInfo.getCapacityBytes() - poolInfo.getAvailableBytes()) / (1024 * 1024 * 1024));
}
} catch (Exception e) {
- logger.error("Exception while connecting host {} to storage pool {}", host.getName(), pool.getName(), e);
+ logger.error("hostConnect: 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;
@@ -151,50 +172,111 @@ public boolean hostConnect(long hostId, long poolId) {
return true;
}
- @Override
- public boolean hostDisconnected(long hostId, long poolId) {
- logger.info("Disconnect from host " + hostId + " from pool " + poolId);
+ private void updateNfsExportPolicyForConnectedHostIfNeeded(long poolId, long hostId, Host host, Map detailsMap) {
+ if (!ProtocolType.NFS3.name().equalsIgnoreCase(detailsMap.get(OntapStorageConstants.PROTOCOL))) {
+ return;
+ }
- Host hostToremove = _hostDao.findById(hostId);
- if (hostToremove == null) {
- logger.error("Failed to add host by HostListener as host was not found with id : {}", hostId);
- return false;
+ if (!isNfs3EnabledOnHost(host)) {
+ throw new CloudRuntimeException("NFS protocol is not enabled on host with id: " + hostId);
}
- StoragePool pool = _storagePoolDao.findById(poolId);
- if (pool == null) {
- logger.error("Failed to disconnect host - storage pool not found with id: {}", poolId);
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(poolId);
+ accessGroup.setHostsToConnect(List.of((HostVO) host));
+
+ StorageStrategy strategy = OntapStorageUtils.getStrategyByStoragePoolDetails(detailsMap);
+ strategy.updateAccessGroup(accessGroup);
+ logger.info("hostConnect: updateNfsExportPolicyForConnectedHostIfNeeded: Updated NFS export policy rules for host {} on storage pool {}", host.getName(), poolId);
+ }
+
+ private boolean isNfs3EnabledOnHost(Host host) {
+ if (host == null) {
return false;
}
- logger.info("Disconnecting host {} from ONTAP storage pool {}", hostToremove.getName(), pool.getName());
- try {
- DeleteStoragePoolCommand cmd = new DeleteStoragePoolCommand(pool);
- Answer answer = _agentMgr.easySend(hostId, cmd);
- if (answer != null && answer.getResult()) {
- 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: {}", hostToremove.getName(), pool.getName(), errMsg);
- return false;
- }
- } catch (Exception e) {
- logger.error("Exception while disconnecting host {} from storage pool {}", hostToremove.getName(), pool.getName(), e);
+ String storageIp = host.getStorageIpAddress() != null ? host.getStorageIpAddress().trim() : "";
+ if (storageIp.isEmpty() && StringUtils.isBlank(host.getPrivateIpAddress())) {
+ logger.warn("isNfs3EnabledOnHost: Host {} is not eligible for NFS3 protocol: both storage IP and private IP are empty",
+ host.getId());
return false;
}
+
+ return true;
}
@Override
- public boolean hostAboutToBeRemoved(long hostId) {
+ public boolean hostDisconnected(long hostId, long poolId) {
+ logger.info("hostDisconnected: Disconnecting host {} from pool {}", hostId, poolId);
+ // Note: This is not currently being called for NetApp ONTAP storage plugin.
return false;
}
+ @Override
+ public boolean hostAboutToBeRemoved(long hostId) {
+ logger.info("hostAboutToBeRemoved: Host {} is about to be removed", hostId);
+
+ Host host = _hostDao.findById(hostId);
+ if (host == null) {
+ logger.warn("hostAboutToBeRemoved: Host not found with id: {}, considering it as no-op", hostId);
+ return true;
+ }
+
+ List poolHostRefs = storagePoolHostDao.listByHostId(hostId);
+ if (poolHostRefs == null || poolHostRefs.isEmpty()) {
+ logger.debug("hostAboutToBeRemoved: No storage pool associations found for host {}", hostId);
+ return true;
+ }
+
+ for (StoragePoolHostVO ref : poolHostRefs) {
+ StoragePoolVO pool = _storagePoolDao.findById(ref.getPoolId());
+ if (pool != null) {
+ removeHostFromOntapPoolIfNeeded(pool, host);
+ }
+ }
+
+ logger.info("hostAboutToBeRemoved: Cleaned up ONTAP export policies for host {} about to be removed", hostId);
+ return true;
+ }
+
@Override
public boolean hostRemoved(long hostId, long clusterId) {
return false;
}
+ private void removeHostFromOntapPoolIfNeeded(StoragePoolVO pool, Host host) {
+ try {
+ Map detailsMap = _storagePoolDetailsDao.listDetailsKeyPairs(pool.getId());
+ if (detailsMap == null || detailsMap.isEmpty()) {
+ logger.debug("hostAboutToBeRemoved: removeHostFromOntapPoolIfNeeded: No pool details found for pool id: {}", pool.getId());
+ return;
+ }
+
+ // Skip non-NFS3 pools; Currently, for iSCSI type, iGroup rules are being handled as part of revokeAccess in OntapPrimaryDataStoreDriver, so no need to handle here.
+ if (!ProtocolType.NFS3.name().equalsIgnoreCase(detailsMap.get(OntapStorageConstants.PROTOCOL))) {
+ return;
+ }
+
+ logger.info("hostAboutToBeRemoved: removeHostFromOntapPoolIfNeeded: Removing export policy rule for host {} from storage pool {}", host.getName(), pool.getName());
+ if (!isNfs3EnabledOnHost(host)) {
+ logger.warn("hostAboutToBeRemoved: removeHostFromOntapPoolIfNeeded: Skipping NFS export policy removal for host {} on pool {} as host is not NFS-enabled",
+ host.getId(), pool.getId());
+ return;
+ }
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(pool.getId());
+ accessGroup.setHostsToConnect(List.of((HostVO) host));
+ accessGroup.setHostRuleAction(AccessGroup.HostRuleAction.REMOVE);
+
+ StorageStrategy strategy = OntapStorageUtils.getStrategyByStoragePoolDetails(detailsMap);
+ strategy.updateAccessGroup(accessGroup);
+ logger.info("hostAboutToBeRemoved: removeHostFromOntapPoolIfNeeded: Removed NFS export policy rules for removed host {} from storage pool {}", host.getName(), pool.getName());
+ } catch (Exception e) {
+ logger.warn("hostAboutToBeRemoved: removeHostFromOntapPoolIfNeeded: Failed to remove NFS export policy rule for host {} from pool {}: {}", host.getId(), pool.getName(), e.getMessage());
+ // Continue processing other pools even if one fails
+ }
+ }
+
@Override
public boolean hostEnabled(long hostId) {
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 c13b255c67ea..ac142edf57ae 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
@@ -19,13 +19,16 @@
package org.apache.cloudstack.storage.service;
-import com.cloud.utils.exception.CloudRuntimeException;
-import feign.FeignException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
import org.apache.cloudstack.storage.feign.FeignClientFactory;
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.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;
@@ -48,11 +51,10 @@
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import java.util.HashMap;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
+import com.cloud.utils.Pair;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import feign.FeignException;
/**
* Storage Strategy represents the communication path for all the ONTAP storage options
@@ -75,10 +77,17 @@ public abstract class StorageStrategy {
protected OntapStorage storage;
+ /**
+ * Holds the node name of the aggregate chosen during createStorageVolume().
+ * Used by getNetworkInterface() to prefer a LIF homed on the same node.
+ */
+ private String chosenAggregateNode;
+
/**
* 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);
@@ -98,10 +107,26 @@ public StorageStrategy(OntapStorage ontapStorage) {
this.snapshotFeignClient = feignClientFactory.createClient(SnapshotFeignClient.class, baseURL);
}
- // Connect method to validate ONTAP cluster, credentials, protocol, and SVM
+ /**
+ * Validates ONTAP cluster reachability, credentials, SVM state, protocol, and aggregate capacity
+ * for new FlexVol creation (primary pool provisioning).
+ */
public boolean connect() {
+ 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());
+ storage.getSvmName() + ", protocol " + storage.getProtocol()
+ + (validateAggregatesForVolumeCreation ? " (with aggregate validation)" : " (operations only)"));
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
String svmName = storage.getSvmName();
try {
@@ -131,47 +156,21 @@ public boolean connect() {
logger.error("ISCSI protocol is not enabled on SVM " + svmName);
throw new CloudRuntimeException("ISCSI protocol is not enabled on SVM " + svmName);
}
- List aggrs = svm.getAggregates();
- if (aggrs == null || aggrs.isEmpty()) {
- logger.error("No aggregates are assigned to SVM " + svmName);
- throw new CloudRuntimeException("No aggregates are assigned to SVM " + svmName);
- }
- // Collect all online aggregates assigned to the SVM. Capacity-based selection is
- // intentionally deferred to createStorageVolume(name, size), which validates the
- // available space against the actual requested volume size.
- List eligibleAggregates = new ArrayList<>();
- 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;
- }
- logger.debug("Aggregate " + aggr.getName() + " is online and eligible for volume operations.");
- eligibleAggregates.add(aggr);
- }
- if (eligibleAggregates.isEmpty()) {
- logger.error("No suitable aggregates found on SVM " + svmName + " for volume operations.");
- throw new CloudRuntimeException("No suitable aggregates found on SVM " + svmName + " for volume operations.");
+ this.resolvedSvmUuid = svm.getUuid();
+
+ if (validateAggregatesForVolumeCreation) {
+ validateAndSelectAggregatesForVolumeCreation(authHeader, svmName, svm.getAggregates());
+ } else {
+ logger.debug("Skipping aggregate capacity validation — not required for existing-volume operations");
}
- this.aggregates = eligibleAggregates;
- logger.info("Found " + eligibleAggregates.size() + " online aggregate(s) on SVM " + svmName + " for volume operations.");
logger.info("Successfully connected to ONTAP cluster and validated ONTAP details provided");
+ } catch (CloudRuntimeException e) {
+ throw e;
} catch (FeignException.Unauthorized e) {
- logger.error("Authentication failed while connecting to ONTAP cluster at " + storage.getStorageIP() +
- ". Please verify the username and password.", e);
- throw new CloudRuntimeException("Authentication failed: Invalid credentials for ONTAP cluster at " +
- storage.getStorageIP() + ". Please verify the username and password.");
- } catch (FeignException.Forbidden e) {
- logger.error("Authorization failed while connecting to ONTAP cluster at " + storage.getStorageIP() +
- ". The user does not have sufficient privileges.", e);
- throw new CloudRuntimeException("Authorization failed: User does not have sufficient privileges on ONTAP cluster at " +
- storage.getStorageIP() + ". Please verify user permissions.");
+ 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);
throw new CloudRuntimeException("Failed to connect to ONTAP cluster: " + e.getMessage(), e);
@@ -179,6 +178,45 @@ public boolean connect() {
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(),
+ Map.of(OntapStorageConstants.FIELDS, OntapStorageConstants.AGGREGATE_NODE
+ + OntapStorageConstants.COMMA + OntapStorageConstants.AGGREGATE_SPACE
+ + OntapStorageConstants.COMMA + OntapStorageConstants.STATE));
+ 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);
+ }
+ 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
/**
@@ -193,6 +231,8 @@ public boolean connect() {
public Volume createStorageVolume(String volumeName, Long size) {
logger.info("Creating volume: " + volumeName + " of size: " + size + " bytes");
+ this.chosenAggregateNode = null;
+
String svmName = storage.getSvmName();
if (aggregates == null || aggregates.isEmpty()) {
logger.error("No aggregates available to create volume on SVM " + svmName);
@@ -219,7 +259,10 @@ public Volume createStorageVolume(String volumeName, Long size) {
Aggregate aggrChosen = null;
for (Aggregate aggr : aggregates) {
logger.debug("Found aggregate: " + aggr.getName() + " with UUID: " + aggr.getUuid());
- Aggregate aggrResp = aggregateFeignClient.getAggregateByUUID(authHeader, aggr.getUuid());
+ Aggregate aggrResp = aggregateFeignClient.getAggregateByUUID(authHeader, aggr.getUuid(),
+ Map.of(OntapStorageConstants.FIELDS, OntapStorageConstants.AGGREGATE_NODE
+ + OntapStorageConstants.COMMA + OntapStorageConstants.AGGREGATE_SPACE
+ + OntapStorageConstants.COMMA + OntapStorageConstants.STATE));
if (aggrResp == null) {
logger.warn("Aggregate details response is null for aggregate " + aggr.getName() + ". Skipping.");
@@ -247,7 +290,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
if (availableBytes > maxAvailableAggregateSpaceBytes) {
maxAvailableAggregateSpaceBytes = availableBytes;
- aggrChosen = aggr;
+ aggrChosen = aggrResp;
}
}
@@ -257,6 +300,8 @@ public Volume createStorageVolume(String volumeName, Long size) {
}
logger.info("Selected aggregate: " + aggrChosen.getName() + " for volume operations.");
+ this.chosenAggregateNode = aggrChosen.getNode() != null ? aggrChosen.getNode().getName() : null;
+
Aggregate aggr = new Aggregate();
aggr.setName(aggrChosen.getName());
aggr.setUuid(aggrChosen.getUuid());
@@ -360,7 +405,11 @@ public void deleteStorageVolume(Volume volume) {
throw new CloudRuntimeException("Volume deletion job failed for volume: " + volume.getName());
}
logger.info("Volume deleted successfully: " + volume.getName());
- } catch (FeignException.FeignClientException e) {
+ } catch (FeignException e) {
+ if (OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ logger.warn("deleteStorageVolume: Volume '{}' not found in ONTAP, treating as no-op", volume.getName());
+ return;
+ }
logger.error("Exception while deleting volume: ", e);
throw new CloudRuntimeException("Failed to delete volume: " + e.getMessage());
}
@@ -430,12 +479,20 @@ public String getStoragePath() {
/**
- * Get the network ip interface
+ * Selects the best available data LIF for storage I/O, preferring one homed on the same node
+ * as the chosen aggregate to avoid inter-node traffic.
*
- * @return the network interface ip as a String
+ * Selection order:
+ *
+ * - LIF whose {@code location.home_node} matches the chosen aggregate's node — no warning
+ * - LIF currently running on that node (e.g. after failover) — returned with a warning
+ * - Any UP and enabled LIF — returned with a warning when aggregate node is known
+ *
+ *
+ * @return {@link Pair} where {@code first()} is the LIF's IP address and {@code second()} is
+ * a warning message (null when no warning)
*/
-
- public String getNetworkInterface() {
+ public Pair getNetworkInterface() {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
try {
Map queryParams = new HashMap<>();
@@ -453,36 +510,90 @@ public String getNetworkInterface() {
throw new CloudRuntimeException("Unsupported protocol: " + storage.getProtocol());
}
}
- queryParams.put(OntapStorageConstants.FIELDS, OntapStorageConstants.IP_ADDRESS);
+ queryParams.put(OntapStorageConstants.FIELDS,
+ OntapStorageConstants.IP_ADDRESS + OntapStorageConstants.COMMA
+ + OntapStorageConstants.STATE + OntapStorageConstants.COMMA
+ + OntapStorageConstants.LIF_ENABLED + OntapStorageConstants.COMMA
+ + OntapStorageConstants.LIF_LOCATION_HOME_NODE + OntapStorageConstants.COMMA
+ + OntapStorageConstants.LIF_LOCATION_NODE);
queryParams.put(OntapStorageConstants.RETURN_RECORDS, OntapStorageConstants.TRUE);
OntapResponse response =
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) {
- for (IpInterface iface : response.getRecords()) {
- if (iface.getIp().getAddress().contains(".")) {
- ipInterface = iface;
- break;
+ if (response == null || response.getRecords() == null || response.getRecords().isEmpty()) {
+ throw new CloudRuntimeException("No network interfaces found for SVM " + storage.getSvmName() +
+ " for protocol " + storage.getProtocol());
+ }
+
+ IpInterface currentNodeInterface = null;
+ IpInterface fallbackInterface = null;
+
+ for (IpInterface iface : response.getRecords()) {
+ if (!Boolean.TRUE.equals(iface.getEnabled()) || !OntapStorageConstants.LIF_STATE_UP.equals(iface.getState())) {
+ continue;
+ }
+ if (!isIPv4Address(iface.getIp().getAddress())) {
+ continue;
+ }
+ if (chosenAggregateNode != null) {
+ // LIF is homed on the aggregate's node
+ String homeNode = iface.getLocation() != null && iface.getLocation().getHomeNode() != null
+ ? iface.getLocation().getHomeNode().getName() : null;
+ if (chosenAggregateNode.equals(homeNode)) {
+ return new Pair<>(iface.getIp().getAddress(), null);
+ }
+ // LIF has failed over and is currently running on the aggregate's node
+ // (home_node differs). Keep as a candidate; returned with a warning if no match is found earlier.
+ if (currentNodeInterface == null) {
+ String currentNode = iface.getLocation() != null && iface.getLocation().getNode() != null
+ ? iface.getLocation().getNode().getName() : null;
+ if (chosenAggregateNode.equals(currentNode)) {
+ currentNodeInterface = iface;
}
}
}
+ if (fallbackInterface == null) {
+ fallbackInterface = iface;
+ }
+ }
- logger.info("Retrieved network interface: " + ipInterface.getIp().getAddress());
- return ipInterface.getIp().getAddress();
- } else {
- throw new CloudRuntimeException("No network interfaces found for SVM " + storage.getSvmName() +
- " for protocol " + storage.getProtocol());
+ if (currentNodeInterface == null && fallbackInterface == null) {
+ throw new CloudRuntimeException("No operationally UP and enabled LIF found for SVM '"
+ + storage.getSvmName() + "' with protocol " + storage.getProtocol()
+ + " — all " + response.getRecords().size() + " LIF(s) are either administratively disabled or operationally down");
}
- } catch (FeignException.FeignClientException e) {
+
+ if (currentNodeInterface != null) {
+ String ip = currentNodeInterface.getIp().getAddress();
+ String warning = "No home-node LIF found for aggregate node '" + chosenAggregateNode
+ + "'; using LIF '" + ip + "' currently running on that node (home node LIF may be down).";
+ logger.warn(warning);
+ return new Pair<>(ip, warning);
+ }
+
+ String ip = fallbackInterface.getIp().getAddress();
+ if (chosenAggregateNode == null) {
+ return new Pair<>(ip, null);
+ }
+ String warning = "No operational LIF found on aggregate's home node '" + chosenAggregateNode
+ + "'; using fallback LIF '" + ip + "' on a different node."
+ + " I/O will traverse an inter-node path, increasing latency.";
+ logger.warn(warning);
+ return new Pair<>(ip, warning);
+ } catch (Exception e) {
logger.error("Exception while retrieving network interfaces: ", e);
throw new CloudRuntimeException("Failed to retrieve network interfaces: " + e.getMessage());
}
}
+ /**
+ * Returns true if the given IP address string is an IPv4 address.
+ * IPv6 addresses contain colons; IPv4 addresses do not.
+ * To extend LIF selection to support IPv6, update this method and its call site in getNetworkInterface().
+ */
+ private boolean isIPv4Address(String address) {
+ return address != null && !address.contains(":");
+ }
+
/**
* Method encapsulates the behavior based on the opted protocol in subclasses.
* it is going to mimic
@@ -540,15 +651,13 @@ public String getNetworkInterface() {
abstract public CloudStackVolume getCloudStackVolume(Map cloudStackVolumeMap);
/**
- * Reverts a CloudStack volume to a snapshot using protocol-specific ONTAP APIs.
+ * Reverts a CloudStack volume to a snapshot using ONTAP CLI-based Single File Snap Restore (SFSR).
*
- * This method encapsulates the snapshot revert behavior based on protocol:
- *
- * - iSCSI/FC: Uses {@code POST /api/storage/luns/{lun.uuid}/restore}
- * to restore LUN data from the FlexVolume snapshot.
- * - NFS: Uses {@code POST /api/storage/volumes/{vol.uuid}/snapshots/{snap.uuid}/files/{path}/restore}
- * to restore a single file from the FlexVolume snapshot.
- *
+ * 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
@@ -590,7 +699,7 @@ public abstract JobResponse revertSnapshotForCloudStackVolume(String snapshotNam
* @param accessGroup the access group to update
* @return the updated AccessGroup object
*/
- abstract AccessGroup updateAccessGroup(AccessGroup accessGroup);
+ public abstract AccessGroup updateAccessGroup(AccessGroup accessGroup);
/**
* Method encapsulates the behavior based on the opted protocol in subclasses
@@ -655,11 +764,17 @@ public String getAuthHeader() {
*
* @param jobUUID UUID of the ONTAP job to poll
* @param maxRetries maximum number of poll attempts
- * @param sleepTimeInMilliSecs seconds to sleep between poll attempts
+ * @param sleepTimeInMilliSecs sleep between poll attempts
* @return true if the job completed successfully
*/
public Boolean jobPollForSuccess(String jobUUID, int maxRetries, int sleepTimeInMilliSecs) {
- //Create URI for GET Job API
+ return jobPollUntilSuccess(jobUUID, maxRetries, sleepTimeInMilliSecs) != null;
+ }
+
+ /**
+ * 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 {
@@ -684,14 +799,125 @@ public Boolean jobPollForSuccess(String jobUUID, int maxRetries, int sleepTimeIn
jobRetryCount++;
Thread.sleep(sleepTimeInMilliSecs);
}
- if (jobResp == null || !jobResp.getState().equals(OntapStorageConstants.JOB_SUCCESS)) {
- return false;
- }
+ 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);
+ }
+ }
+
+ /**
+ * 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);
+
+ try {
+ 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);
+ } catch (Exception e) {
+ if (OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ logger.warn("deleteFlexVolSnapshotForCloudStackVolume: ONTAP snapshot [{}] (uuid={}) on FlexVol [{}] "
+ + "already absent; treating delete as success: {}", snapshotName, snapshotUuid, flexVolUuid,
+ e.getMessage());
+ return;
+ }
+ if (e instanceof CloudRuntimeException) {
+ throw (CloudRuntimeException) e;
+ }
+ throw new CloudRuntimeException("Failed to delete ONTAP FlexVol snapshot [" + snapshotName + "]: "
+ + e.getMessage(), e);
}
- return true;
}
}
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 198957ca5db8..131d15bc6a38 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,21 @@
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 java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.inject.Inject;
+
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.storage.command.CreateObjectCommand;
import org.apache.cloudstack.storage.command.DeleteCommand;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
+import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest;
import org.apache.cloudstack.storage.feign.model.ExportPolicy;
import org.apache.cloudstack.storage.feign.model.ExportRule;
import org.apache.cloudstack.storage.feign.model.FileInfo;
@@ -42,19 +44,22 @@
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.cloudstack.storage.volume.VolumeObject;
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;
+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;
public class UnifiedNASStrategy extends NASStrategy {
private static final Logger logger = LogManager.getLogger(UnifiedNASStrategy.class);
@@ -176,12 +181,15 @@ public void deleteAccessGroup(AccessGroup accessGroup) {
String exportPolicyId = details.get(OntapStorageConstants.EXPORT_POLICY_ID);
try {
- nasFeignClient.deleteExportPolicyById(authHeader,exportPolicyId);
+ nasFeignClient.deleteExportPolicyById(authHeader, exportPolicyId);
logger.info("deleteAccessGroup: Successfully deleted export policy '{}'", exportPolicyName);
- } catch (Exception e) {
+ } catch (FeignException e) {
+ if (OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ logger.warn("deleteAccessGroup: Export policy '{}' not found in ONTAP, treating as no-op", exportPolicyName);
+ return;
+ }
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);
@@ -191,7 +199,134 @@ public void deleteAccessGroup(AccessGroup accessGroup) {
@Override
public AccessGroup updateAccessGroup(AccessGroup accessGroup) {
- return null;
+ if (accessGroup == null) {
+ throw new CloudRuntimeException("Invalid accessGroup object - accessGroup is null");
+ }
+ // Check if an AccessGroup was constructed without associating it to a storage pool.
+ if (accessGroup.getStoragePoolId() == null) {
+ throw new CloudRuntimeException("Invalid accessGroup object - storagePoolId is null");
+ }
+ // At least one host is required regardless of ADD or REMOVE action.
+ // An empty list means there is nothing to add to or remove from the export policy client list.
+ if (accessGroup.getHostsToConnect() == null || accessGroup.getHostsToConnect().isEmpty()) {
+ throw new CloudRuntimeException("Invalid accessGroup object - hostsToConnect is null or empty");
+ }
+
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(accessGroup.getStoragePoolId());
+ if (details == null || details.isEmpty()) {
+ throw new CloudRuntimeException("No storage pool details found for storagePoolId: " + accessGroup.getStoragePoolId());
+ }
+ String exportPolicyId = details.get(OntapStorageConstants.EXPORT_POLICY_ID);
+ if (exportPolicyId == null || exportPolicyId.isEmpty()) {
+ throw new CloudRuntimeException("No export policy found for storagePoolId: " + accessGroup.getStoragePoolId());
+ }
+
+
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ ExportPolicy existingPolicy = nasFeignClient.getExportPolicyById(authHeader, exportPolicyId);
+ // Check if the export policy was deleted externally on ONTAP or the stored ID is stale.
+ if (existingPolicy == null) {
+ throw new CloudRuntimeException("Failed to fetch existing export policy with id: " + exportPolicyId);
+ }
+
+ List rules = existingPolicy.getRules();
+ if (rules == null || rules.isEmpty()) {
+ throw new CloudRuntimeException("Export policy " + existingPolicy.getName() +
+ " has no rules — unexpected state, the plugin always creates a rule at pool registration");
+ }
+
+ ExportRule targetRule = rules.get(0);
+
+ Set hostMatches = new HashSet<>();
+ for (HostVO host : accessGroup.getHostsToConnect()) {
+ String hostStorageIp = host.getStorageIpAddress() != null ? host.getStorageIpAddress().trim() : null;
+ String ip = (hostStorageIp != null && !hostStorageIp.isEmpty()) ? hostStorageIp
+ : (host.getPrivateIpAddress() != null ? host.getPrivateIpAddress().trim() : null);
+ // Occurs when a CloudStack host has neither a storage IP nor a private IP configured
+ // (misconfigured or partially registered host). Skip it to avoid inserting a broken
+ // or empty match entry into the ONTAP export rule.
+ if (ip == null || ip.isEmpty()) {
+ logger.warn("updateAccessGroup: Host {} has no storage/private IP, skipping export rule update", host.getName());
+ continue;
+ }
+ hostMatches.add(ip + "/32");
+ }
+
+ // Occurs when every host in hostsToConnect had no valid IP (all were skipped above).
+ // There is nothing to add or remove, so skip the ONTAP API call and return early.
+ if (hostMatches.isEmpty()) {
+ accessGroup.setPolicy(existingPolicy);
+ return accessGroup;
+ }
+
+ boolean updated = false;
+ // Differentiates between removing hosts (e.g., host decommissioned or removed from the cluster)
+ // and the default ADD path (e.g., new host being connected to the storage pool).
+ List exportClients = targetRule.getClients();
+ // Existing rules can legitimately have no clients yet; treat that as an empty list.
+ if (exportClients == null) {
+ exportClients = new ArrayList<>();
+ targetRule.setClients(exportClients);
+ }
+
+ if (AccessGroup.HostRuleAction.REMOVE.equals(accessGroup.getHostRuleAction())) {
+ updated = exportClients.removeIf(c -> c != null && c.getMatch() != null && hostMatches.contains(c.getMatch()));
+ // None of the requested host IPs were present in the policy — log for diagnostics
+ // so operators can investigate whether the policy state is already correct or stale.
+ if (!updated) {
+ logger.info("updateAccessGroup: No matching host IPs found in export policy {} for removal", existingPolicy.getName());
+ }
+ } else {
+ Set existingMatches = new HashSet<>();
+ for (ExportRule.ExportClient exportClient : exportClients) {
+ // Skips null client entries or entries with a null match field that may have been
+ // inserted externally on ONTAP. Avoids polluting the dedup set with null values
+ // which would cause subsequent hosts to be incorrectly treated as duplicates.
+ if (exportClient != null && exportClient.getMatch() != null) {
+ existingMatches.add(exportClient.getMatch());
+ }
+ }
+
+ for (String match : hostMatches) {
+ // Set.add() returns false when the element was already present, acting as a dedup check.
+ // Prevents inserting a duplicate client match entry for a host that is already allowed
+ // in the export policy — ONTAP may reject or behave unpredictably with duplicate matches.
+ if (existingMatches.add(match)) {
+ ExportRule.ExportClient exportClient = new ExportRule.ExportClient();
+ exportClient.setMatch(match);
+ exportClients.add(exportClient);
+ updated = true;
+ }
+ }
+ }
+
+ // Occurs when the export policy is already in the desired state:
+ // ADD path — all provided host IPs were already present (all were duplicates).
+ // REMOVE path — none of the provided host IPs matched any existing entry.
+ // In both cases, skip the ONTAP PATCH call to avoid an unnecessary round-trip.
+ if (!updated) {
+ // Only log the "nothing to add" message for the ADD path; the REMOVE no-op
+ // is already logged above in its own branch to avoid double-logging.
+ if (!AccessGroup.HostRuleAction.REMOVE.equals(accessGroup.getHostRuleAction())) {
+ logger.info("updateAccessGroup: No new host IPs to add to export policy {}", existingPolicy.getName());
+ }
+ accessGroup.setPolicy(existingPolicy);
+ return accessGroup;
+ }
+
+ ExportPolicy updateRequest = new ExportPolicy();
+ updateRequest.setRules(rules);
+ nasFeignClient.updateExportPolicy(authHeader, exportPolicyId, updateRequest);
+
+ existingPolicy.setRules(rules);
+ accessGroup.setPolicy(existingPolicy);
+ logger.info("updateAccessGroup: Successfully updated export policy {} with new host client rules", existingPolicy.getName());
+ return accessGroup;
+ } catch (Exception e) {
+ logger.error("updateAccessGroup: Failed to update export policy for pool {}", accessGroup.getStoragePoolId(), e);
+ throw new CloudRuntimeException("Failed to update export policy for NFS host connection: " + e.getMessage(), e);
+ }
}
@Override
@@ -307,10 +442,10 @@ private ExportPolicy createExportPolicyRequest(AccessGroup accessGroup,String sv
List exportClients = new ArrayList<>();
List hosts = accessGroup.getHostsToConnect();
for (HostVO host : hosts) {
- String hostStorageIp = host.getStorageIpAddress();
+ String hostStorageIp = host.getStorageIpAddress() != null ? host.getStorageIpAddress().trim() : null;
String ip = (hostStorageIp != null && !hostStorageIp.isEmpty())
? hostStorageIp
- : host.getPrivateIpAddress();
+ : (host.getPrivateIpAddress() != null ? host.getPrivateIpAddress().trim() : null);
String ipToUse = ip + "/32";
ExportRule.ExportClient exportClient = new ExportRule.ExportClient();
exportClient.setMatch(ipToUse);
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 9815724fc1aa..8b1aa24fe5d9 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
@@ -19,21 +19,28 @@
package org.apache.cloudstack.storage.service.model;
-import com.cloud.host.HostVO;
+import java.util.List;
+
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;
-import java.util.List;
+import com.cloud.host.HostVO;
public class AccessGroup {
+ public enum HostRuleAction {
+ ADD,
+ REMOVE
+ }
+
private Igroup igroup;
private ExportPolicy exportPolicy;
private List hostsToConnect;
private Long storagePoolId;
private Scope scope;
+ private HostRuleAction hostRuleAction = HostRuleAction.ADD;
public Igroup getIgroup() {
@@ -74,4 +81,12 @@ public Scope getScope() {
public void setScope(Scope scope) {
this.scope = scope;
}
+
+ public HostRuleAction getHostRuleAction() {
+ return hostRuleAction;
+ }
+
+ public void setHostRuleAction(HostRuleAction hostRuleAction) {
+ this.hostRuleAction = hostRuleAction;
+ }
}
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 e5224237e526..5ef662dd8528 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
@@ -36,6 +36,7 @@ public class OntapStorageConstants {
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";
@@ -66,11 +67,20 @@ public class OntapStorageConstants {
public static final String INITIATORS = "initiators";
public static final String AGGREGATES = "aggregates";
public static final String STATE = "state";
+ public static final String AGGREGATE_NODE = "node";
+ public static final String AGGREGATE_SPACE = "space";
public static final String DATA_NFS = "data_nfs";
public static final String DATA_ISCSI = "data_iscsi";
public static final String IP_ADDRESS = "ip.address";
+ public static final String LIF_ENABLED = "enabled";
+ public static final String LIF_STATE_UP = "up";
+ public static final String LIF_LOCATION_HOME_NODE = "location.home_node.name";
+ public static final String LIF_LOCATION_NODE = "location.node.name";
+ public static final String LIF_WARNING = "ONTAP_LIF_WARNING";
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;
@@ -106,6 +116,17 @@ public class OntapStorageConstants {
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 8a74e77b3371..7f09b5584b40 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,9 +19,10 @@
package org.apache.cloudstack.storage.utils;
-import com.cloud.exception.InvalidParameterValueException;
-import com.cloud.utils.StringUtils;
-import com.cloud.utils.exception.CloudRuntimeException;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import feign.FeignException;
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;
@@ -36,8 +37,10 @@
import org.apache.logging.log4j.Logger;
import org.springframework.util.Base64Utils;
-import java.nio.charset.StandardCharsets;
-import java.util.Map;
+import com.cloud.alert.AlertManager;
+import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.utils.StringUtils;
+import com.cloud.utils.exception.CloudRuntimeException;
public class OntapStorageUtils {
@@ -119,7 +122,22 @@ public static String getOSTypeFromHypervisor(String hypervisorType) {
}
}
+ public static void sendStorageAlert(AlertManager alertMgr, Long zoneId, Long podId, String subject, String body) {
+ if (alertMgr != null) {
+ alertMgr.sendAlert(AlertManager.AlertType.ALERT_TYPE_STORAGE_MISC, zoneId != null ? zoneId : 0L, podId, subject, body);
+ }
+ }
+
+ /**
+ * 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("Storage pool details are null or empty");
@@ -129,7 +147,7 @@ public static StorageStrategy getStrategyByStoragePoolDetails(Map 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;
+ }
+
+ /**
+ * Returns true when the exception indicates the ONTAP Object was already removed.
+ * Delete workflows treat a missing backend object as idempotent success.
+ */
+ public static boolean isOntapObjectNotFoundError(Throwable error) {
+ if (error == null) {
+ return false;
+ }
+ if(error instanceof FeignException) {
+ FeignException feignException = (FeignException) error;
+ if (feignException.status() == 404) {
+ return true;
+ }
+ }
+ 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;
+ }
+ } else {
+ logger.warn("Error message is null for exception: {}", error.getClass().getName());
+ return false;
+ }
+ return false;
+ }
+
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java
index 7fa80a0b3fae..702a3aa5e414 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategy.java
@@ -20,21 +20,29 @@
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.CliSnapshotRestoreRequest;
+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;
@@ -72,25 +80,22 @@
import org.apache.cloudstack.storage.utils.OntapStorageConstants;
/**
- * VM Snapshot strategy for NetApp ONTAP managed storage using FlexVolume-level snapshots.
+ * 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. Instead of creating per-file clones
- * (the old approach), it takes ONTAP FlexVolume-level snapshots via the
- * ONTAP REST API ({@code POST /api/storage/volumes/{uuid}/snapshots}).
- *
- * Key Advantage:
- * When multiple CloudStack disks (ROOT + DATA) reside on the same ONTAP
- * FlexVolume, a single FlexVolume snapshot atomically captures all of them.
- * This is both faster and more storage-efficient than per-file clones.
+ * 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
- * - For each unique FlexVolume, create one ONTAP snapshot
+ * - 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
- * - Record FlexVolume → snapshot UUID mappings in {@code vm_snapshot_details}
+ * - Resolve FlexVolume → snapshot UUID mappings and persist in {@code vm_snapshot_details}
*
*
* Metadata in vm_snapshot_details:
@@ -251,12 +256,14 @@ boolean allVolumesOnOntapManagedStorage(long vmId) {
/**
* Takes a VM-level snapshot by freezing the VM, creating ONTAP FlexVolume-level
- * snapshots (one per unique FlexVolume), and then thawing the VM.
+ * snapshot(s), and then thawing the VM.
*
* Volumes are grouped by their parent FlexVolume UUID (from storage pool details).
- * For each unique FlexVolume, exactly one ONTAP snapshot is created via
- * {@code POST /api/storage/volumes/{uuid}/snapshots}. This means if a VM has
- * ROOT and DATA disks on the same FlexVolume, only one snapshot is created.
+ * 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
@@ -286,7 +293,7 @@ public VMSnapshot takeVMSnapshot(VMSnapshot vmSnapshot) {
FreezeThawVMAnswer thawAnswer = null;
long startFreeze = 0;
- // Track which FlexVolume snapshots were created (for rollback)
+ // Track which FlexVolume snapshots were created (for rollback and detail persistence)
List createdSnapshots = new ArrayList<>();
boolean result = false;
@@ -338,7 +345,8 @@ public VMSnapshot takeVMSnapshot(VMSnapshot vmSnapshot) {
CreateVMSnapshotCommand ccmd = new CreateVMSnapshotCommand(
userVm.getInstanceName(), userVm.getUuid(), target, volumeTOs, guestOS.getDisplayName());
- logger.info("takeVMSnapshot: Creating ONTAP FlexVolume VM Snapshot for VM [{}] with quiesce={}", userVm.getInstanceName(), quiesceVm);
+ 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) {
@@ -375,56 +383,20 @@ public VMSnapshot takeVMSnapshot(VMSnapshot vmSnapshot) {
userVm.getInstanceName(), quiesceVm, vmIsRunning);
}
- // ── Step 2: Create FlexVolume-level snapshots ──
+ // ── Step 2: Create FlexVolume-level snapshot(s) ──
try {
String snapshotNameBase = buildSnapshotName(vmSnapshot);
- for (Map.Entry entry : flexVolGroups.entrySet()) {
- String flexVolUuid = entry.getKey();
- FlexVolGroupInfo groupInfo = entry.getValue();
- long startSnapshot = System.nanoTime();
-
- // Build storage strategy from pool details to get the feign client
- StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(groupInfo.poolDetails);
- SnapshotFeignClient snapshotClient = storageStrategy.getSnapshotFeignClient();
- String authHeader = storageStrategy.getAuthHeader();
-
- // Use the same snapshot name for all FlexVolumes in this VM snapshot
- // (each FlexVolume gets its own independent snapshot with this name)
- FlexVolSnapshot snapshotRequest = new FlexVolSnapshot(snapshotNameBase,
- "CloudStack VM snapshot " + vmSnapshot.getName() + " for VM " + userVm.getInstanceName());
-
- logger.info("takeVMSnapshot: Creating ONTAP FlexVolume 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 + "]");
- }
-
- // 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 snapshotUuid = resolveSnapshotUuid(snapshotClient, authHeader, flexVolUuid, snapshotNameBase);
-
- String protocol = groupInfo.poolDetails.get(OntapStorageConstants.PROTOCOL);
-
- // Create one detail per CloudStack volume in this FlexVol group (for single-file restore during revert)
- for (Long volumeId : groupInfo.volumeIds) {
- String volumePath = resolveVolumePathOnOntap(volumeId, protocol, groupInfo.poolDetails);
- FlexVolSnapshotDetail detail = new FlexVolSnapshotDetail(
- flexVolUuid, snapshotUuid, snapshotNameBase, volumePath, groupInfo.poolId, protocol);
- createdSnapshots.add(detail);
- }
-
- logger.info("takeVMSnapshot: ONTAP FlexVolume snapshot [{}] (uuid={}) on FlexVol [{}] completed in {} ms. Covers volumes: {}",
- snapshotNameBase, snapshotUuid, flexVolUuid,
- TimeUnit.MILLISECONDS.convert(System.nanoTime() - startSnapshot, TimeUnit.NANOSECONDS),
- groupInfo.volumeIds);
+ // 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) ──
@@ -456,7 +428,7 @@ public VMSnapshot takeVMSnapshot(VMSnapshot vmSnapshot) {
answer.setVolumeTOs(volumeTOs);
processAnswer(vmSnapshotVO, userVm, answer, null);
- logger.info("takeVMSnapshot: ONTAP FlexVolume VM Snapshot [{}] created successfully for VM [{}] ({} FlexVol snapshot(s))",
+ logger.info("takeVMSnapshot: ONTAP VM Snapshot [{}] created successfully for VM [{}] ({} detail row(s))",
vmSnapshot.getName(), userVm.getInstanceName(), createdSnapshots.size());
long newChainSize = 0;
@@ -668,16 +640,140 @@ Map groupVolumesByFlexVol(List volumeT
}
/**
- * Builds a deterministic, ONTAP-safe snapshot name for a VM snapshot.
- * Format: {@code vmsnap__}
+ * 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.
*/
- String buildSnapshotName(VMSnapshot vmSnapshot) {
- String name = "vmsnap_" + vmSnapshot.getId() + "_" + System.currentTimeMillis();
- // ONTAP snapshot names: max 255 chars, must start with letter, only alphanumeric and underscores
- if (name.length() > OntapStorageConstants.MAX_SNAPSHOT_NAME_LENGTH) {
- name = name.substring(0, OntapStorageConstants.MAX_SNAPSHOT_NAME_LENGTH);
+ 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);
}
- return name;
+ }
+
+ /**
+ * 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);
}
/**
@@ -695,6 +791,227 @@ String resolveSnapshotUuid(SnapshotFeignClient client, String authHeader,
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.
*
@@ -735,7 +1052,7 @@ String resolveVolumePathOnOntap(Long volumeId, String protocol, Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(detail.poolId);
- StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
+ StorageStrategy storageStrategy = resolveStorageStrategy(poolDetails);
SnapshotFeignClient client = storageStrategy.getSnapshotFeignClient();
String authHeader = storageStrategy.getAuthHeader();
@@ -757,36 +1074,52 @@ void rollbackFlexVolSnapshot(FlexVolSnapshotDetail detail) {
* 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) {
- // Track which FlexVol+Snapshot pairs have already been deleted
Map deletedSnapshots = new HashMap<>();
+ CloudRuntimeException deleteFailure = null;
for (VMSnapshotDetailsVO detailVO : flexVolDetails) {
FlexVolSnapshotDetail detail = FlexVolSnapshotDetail.parse(detailVO.getValue());
String dedupeKey = detail.flexVolUuid + "::" + detail.snapshotUuid;
- // Only delete the ONTAP snapshot once per FlexVol+Snapshot pair
- if (!deletedSnapshots.containsKey(dedupeKey)) {
- Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(detail.poolId);
- StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
- SnapshotFeignClient client = storageStrategy.getSnapshotFeignClient();
- String authHeader = storageStrategy.getAuthHeader();
+ 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);
+ logger.info("deleteFlexVolSnapshots: Deleting ONTAP 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(), 30, 2000);
- }
+ 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);
+ 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());
+ }
}
+ }
- // Always remove the DB detail row
- vmSnapshotDetailsDao.remove(detailVO.getId());
+ if (deleteFailure != null) {
+ throw deleteFailure;
}
}
@@ -818,41 +1151,24 @@ void revertFlexVolSnapshots(List flexVolDetails) {
}
Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(detail.poolId);
- StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
- SnapshotFeignClient snapshotClient = storageStrategy.getSnapshotFeignClient();
- String authHeader = storageStrategy.getAuthHeader();
+ StorageStrategy storageStrategy = resolveStorageStrategy(poolDetails);
- // Get SVM name and FlexVolume name from pool details
- String svmName = poolDetails.get(OntapStorageConstants.SVM_NAME);
String flexVolName = poolDetails.get(OntapStorageConstants.VOLUME_NAME);
-
- if (svmName == null || svmName.isEmpty()) {
- throw new CloudRuntimeException("SVM name not found in pool details for pool [" + detail.poolId + "]");
- }
if (flexVolName == null || flexVolName.isEmpty()) {
throw new CloudRuntimeException("FlexVolume name not found in pool details for pool [" + detail.poolId + "]");
}
- // The path must start with "/" for the ONTAP CLI API
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);
- // Use CLI-based restore API: POST /api/private/cli/volume/snapshot/restore-file
- CliSnapshotRestoreRequest restoreRequest = new CliSnapshotRestoreRequest(
- svmName, flexVolName, detail.snapshotName, ontapFilePath);
+ JobResponse jobResponse = storageStrategy.revertSnapshotForCloudStackVolume(
+ detail.snapshotName, detail.flexVolUuid, detail.snapshotUuid,
+ detail.volumePath, null, flexVolName);
- JobResponse jobResponse = snapshotClient.restoreFileFromSnapshotCli(authHeader, restoreRequest);
-
- if (jobResponse != null && jobResponse.getJob() != null) {
- Boolean success = storageStrategy.jobPollForSuccess(jobResponse.getJob().getUuid(), 60, 2000);
- if (!success) {
- throw new CloudRuntimeException("Snapshot file restore failed for volume path [" +
- ontapFilePath + "] from snapshot [" + detail.snapshotName +
- "] on FlexVol [" + 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);
@@ -877,6 +1193,69 @@ static class FlexVolGroupInfo {
}
}
+ /**
+ * 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).
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java
index 3c139e23cb88..bad8168ba86d 100644
--- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/driver/OntapPrimaryDatastoreDriverTest.java
@@ -21,6 +21,7 @@
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;
@@ -134,6 +135,7 @@ void testGetCapabilities() {
// 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
@@ -166,6 +168,7 @@ void testCreateAsync_VolumeWithISCSI_Success() {
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);
@@ -201,6 +204,7 @@ void testCreateAsync_VolumeWithISCSI_Success() {
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));
}
}
@@ -219,6 +223,7 @@ void testCreateAsync_VolumeWithNFS_Success() {
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);
@@ -243,6 +248,7 @@ void testCreateAsync_VolumeWithNFS_Success() {
CreateCmdResult result = resultCaptor.getValue();
assertNotNull(result);
assertTrue(result.isSuccess());
+ verify(volumeVO).setFormat(Storage.ImageFormat.QCOW2);
verify(volumeDao).update(eq(100L), any(VolumeVO.class));
}
}
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 751b864ecfcc..ed538de4a49c 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
@@ -44,9 +44,11 @@
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.service.model.AccessGroup;
import com.cloud.hypervisor.Hypervisor;
+import com.cloud.alert.AlertManager;
import java.util.Map;
import java.util.List;
import java.util.ArrayList;
+import com.cloud.utils.Pair;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
@@ -54,6 +56,7 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.withSettings;
+import static org.mockito.ArgumentMatchers.contains;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -92,6 +95,9 @@ public class OntapPrimaryDatastoreLifecycleTest {
@Mock
private PrimaryDataStoreDao storagePoolDao;
+ @Mock
+ private AlertManager _alertMgr;
+
// Mock object that implements both DataStore and PrimaryDataStoreInfo
// This is needed because attachCluster(DataStore) casts DataStore to PrimaryDataStoreInfo internally
private DataStore dataStore;
@@ -116,7 +122,7 @@ void setUp() {
when(_clusterDao.findById(1L)).thenReturn(clusterVO);
when(storageStrategy.connect()).thenReturn(true);
- when(storageStrategy.getNetworkInterface()).thenReturn("testNetworkInterface");
+ when(storageStrategy.getNetworkInterface()).thenReturn(new Pair<>("testNetworkInterface", null));
Volume volume = new Volume();
volume.setUuid("test-volume-uuid");
@@ -153,6 +159,7 @@ void setUp() {
poolDetails.put("svmName", "svm1");
poolDetails.put("protocol", "NFS3");
poolDetails.put("storageIP", "192.168.1.100");
+ when(zoneScope.getScopeId()).thenReturn(1L);
}
@Test
@@ -404,6 +411,235 @@ public void testInitialize_unexpectedDetailKey() {
assertTrue(ex.getMessage().contains("Unexpected ONTAP detail key in URL"));
}
+ @Test
+ public void testInitialize_dataLifWithWarning() {
+ // Test when getNetworkInterface returns a warning in the Pair's second value
+ // This exercises the processDataLifSelection path for non-null warning
+ 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("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", detailsMap);
+
+ String warningMessage = "LIF on node-b; expected on node-a;Details about LIF failover";
+ when(storageStrategy.getNetworkInterface()).thenReturn(new Pair<>("10.0.0.1", warningMessage));
+
+ try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class);
+ MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) {
+ storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
+ ontapPrimaryDatastoreLifecycle.initialize(dsInfos);
+
+ // Verify alert was sent with warning message
+ utilityMock.verify(() -> OntapStorageUtils.sendStorageAlert(eq(_alertMgr), eq(1L), eq(1L),
+ contains("LIF on node-b"), eq(warningMessage)), times(1));
+ }
+ }
+
+ @Test
+ public void testInitialize_nullDataLif() {
+ // Test when lifResult.first() returns null
+ 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("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", detailsMap);
+
+ when(storageStrategy.getNetworkInterface()).thenReturn(new Pair<>(null, null));
+
+ try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
+ storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
+ Exception ex = assertThrows(CloudRuntimeException.class, () -> ontapPrimaryDatastoreLifecycle.initialize(dsInfos));
+ assertTrue(ex.getMessage().contains("Failed to retrieve Data LIF from ONTAP, cannot create primary storage"));
+ }
+ }
+
+ @Test
+ public void testInitialize_emptyDataLif() {
+ // Test when lifResult.first() returns empty string
+ 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("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", detailsMap);
+
+ when(storageStrategy.getNetworkInterface()).thenReturn(new Pair<>("", null));
+
+ try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
+ storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
+ Exception ex = assertThrows(CloudRuntimeException.class, () -> ontapPrimaryDatastoreLifecycle.initialize(dsInfos));
+ assertTrue(ex.getMessage().contains("Failed to retrieve Data LIF from ONTAP, cannot create primary storage"));
+ }
+ }
+
+ @Test
+ public void testInitialize_getNetworkInterfaceException() {
+ // Test when getNetworkInterface throws an exception
+ 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("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", detailsMap);
+
+ when(storageStrategy.getNetworkInterface()).thenThrow(new RuntimeException("ONTAP API error"));
+
+ try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
+ storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
+ Exception ex = assertThrows(CloudRuntimeException.class, () -> ontapPrimaryDatastoreLifecycle.initialize(dsInfos));
+ assertTrue(ex.getMessage().contains("Failed to retrieve Data LIF from ONTAP"));
+ assertTrue(ex.getCause() != null && ex.getCause().getMessage().contains("ONTAP API error"));
+ }
+ }
+
+ @Test
+ public void testInitialize_volumeCreationFailure_nullVolume() {
+ // Test when createStorageVolume returns null
+ 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("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", detailsMap);
+
+ when(storageStrategy.createStorageVolume(any(), any())).thenReturn(null);
+
+ try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
+ storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
+ Exception ex = assertThrows(CloudRuntimeException.class, () -> ontapPrimaryDatastoreLifecycle.initialize(dsInfos));
+ assertTrue(ex.getMessage().contains("Failed to create ONTAP volume"));
+ }
+ }
+
+ @Test
+ public void testInitialize_volumeCreationException() {
+ // Test when createStorageVolume throws an exception
+ 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("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", detailsMap);
+
+ when(storageStrategy.createStorageVolume(any(), any())).thenThrow(new RuntimeException("Volume creation failed"));
+
+ try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
+ storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
+ Exception ex = assertThrows(CloudRuntimeException.class, () -> ontapPrimaryDatastoreLifecycle.initialize(dsInfos));
+ assertTrue(ex.getMessage().contains("Failed to create ONTAP volume"));
+ assertTrue(ex.getCause() != null && ex.getCause().getMessage().contains("Volume creation failed"));
+ }
+ }
+
+ @Test
+ public void testInitialize_positiveWithDetailAssertions() {
+ // Enhanced positive test that verifies DATA_LIF detail is persisted and host is set correctly
+ 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("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", detailsMap);
+
+ String expectedDataLif = "192.168.1.100";
+ when(storageStrategy.getNetworkInterface()).thenReturn(new Pair<>(expectedDataLif, null));
+ when(storageStrategy.getStoragePath()).thenReturn("/vol/testVolume");
+
+ try (MockedStatic storageProviderFactory = Mockito.mockStatic(StorageProviderFactory.class)) {
+ storageProviderFactory.when(() -> StorageProviderFactory.getStrategy(any())).thenReturn(storageStrategy);
+ ontapPrimaryDatastoreLifecycle.initialize(dsInfos);
+
+ // Verify that createPrimaryDataStore was called and host parameter contains the DATA_LIF
+ verify(_dataStoreHelper, times(1)).createPrimaryDataStore(any());
+ }
+ }
+
// ========== attachCluster Tests ==========
@Test
@@ -412,12 +648,12 @@ public void testAttachCluster_positive() throws Exception {
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(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
// Mock successful host connections
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
@@ -446,12 +682,12 @@ public void testAttachCluster_withSingleHost() throws Exception {
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(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
// Execute
@@ -477,12 +713,12 @@ public void testAttachCluster_withMultipleHosts() throws Exception {
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(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
// Execute
@@ -507,6 +743,7 @@ public void testAttachCluster_hostConnectionFailure() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
// Mock host connection failure for first host
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong()))
@@ -533,12 +770,12 @@ public void testAttachCluster_emptyHostList() throws Exception {
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);
+ when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
// Execute
boolean result = ontapPrimaryDatastoreLifecycle.attachCluster(
@@ -562,6 +799,7 @@ public void testAttachCluster_secondHostConnectionFails() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
// Mock: first host succeeds, second host fails
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong()))
@@ -585,12 +823,12 @@ public void testAttachCluster_createAccessGroupCalled() throws Exception {
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(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore);
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
// Execute
@@ -608,7 +846,6 @@ public void testAttachCluster_createAccessGroupCalled() throws Exception {
@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);
@@ -618,6 +855,7 @@ public void testAttachZone_positive() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore);
// Mock successful host connections
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
@@ -643,7 +881,6 @@ public void testAttachZone_withSingleHost() throws Exception {
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);
@@ -653,6 +890,7 @@ public void testAttachZone_withSingleHost() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore);
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
// Execute
@@ -675,7 +913,6 @@ public void testAttachZone_withMultipleHosts() throws Exception {
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);
@@ -685,6 +922,7 @@ public void testAttachZone_withMultipleHosts() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore);
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
// Execute
@@ -701,7 +939,6 @@ public void testAttachZone_withMultipleHosts() throws Exception {
@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);
@@ -710,6 +947,7 @@ public void testAttachZone_hostConnectionFailure() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore);
// Mock host connection failure for first host
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong()))
@@ -733,7 +971,6 @@ 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);
@@ -743,6 +980,7 @@ public void testAttachZone_emptyHostList() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore);
// Execute
boolean result = ontapPrimaryDatastoreLifecycle.attachZone(
@@ -758,7 +996,6 @@ public void testAttachZone_emptyHostList() throws Exception {
@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);
@@ -767,6 +1004,7 @@ public void testAttachZone_secondHostConnectionFails() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore);
// Mock: first host succeeds, second host fails
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong()))
@@ -787,7 +1025,6 @@ public void testAttachZone_secondHostConnectionFails() throws Exception {
@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);
@@ -797,6 +1034,7 @@ public void testAttachZone_createAccessGroupCalled() throws Exception {
utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any()))
.thenReturn(storageStrategy);
when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null);
+ when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore);
when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true);
// Execute
@@ -834,7 +1072,6 @@ public void testAttachZone_nullHypervisorThrowsException() {
@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);
@@ -843,7 +1080,6 @@ public void testAttachZone_kvmHypervisorSetsAndUpdatesPool() throws Exception {
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(
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java
index df9afe2542f9..d8a249a4447a 100644
--- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java
@@ -18,12 +18,19 @@
*/
package org.apache.cloudstack.storage.service;
-import com.cloud.utils.exception.CloudRuntimeException;
-import feign.FeignException;
+import java.lang.reflect.Field;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
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;
@@ -39,32 +46,33 @@
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 org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
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 org.mockito.Mock;
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;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import com.cloud.utils.Pair;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import feign.FeignException;
+import feign.Request;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -88,6 +96,9 @@ public class StorageStrategyTest {
@Mock
private SANFeignClient sanFeignClient;
+ @Mock
+ private SnapshotFeignClient snapshotFeignClient;
+
private TestableStorageStrategy storageStrategy;
// Concrete implementation for testing abstract class
@@ -98,7 +109,8 @@ public TestableStorageStrategy(OntapStorage ontapStorage,
SvmFeignClient svmFeignClient,
JobFeignClient jobFeignClient,
NetworkFeignClient networkFeignClient,
- SANFeignClient sanFeignClient) {
+ SANFeignClient sanFeignClient,
+ SnapshotFeignClient snapshotFeignClient) {
super(ontapStorage);
// Use reflection to replace the private Feign client fields with mocked ones
injectMockedClient("aggregateFeignClient", aggregateFeignClient);
@@ -107,6 +119,7 @@ public TestableStorageStrategy(OntapStorage ontapStorage,
injectMockedClient("jobFeignClient", jobFeignClient);
injectMockedClient("networkFeignClient", networkFeignClient);
injectMockedClient("sanFeignClient", sanFeignClient);
+ injectMockedClient("snapshotFeignClient", snapshotFeignClient);
}
private void injectMockedClient(String fieldName, Object mockedClient) {
@@ -158,7 +171,7 @@ public void deleteAccessGroup(AccessGroup accessGroup) {
}
@Override
- AccessGroup updateAccessGroup(AccessGroup accessGroup) {
+ public AccessGroup updateAccessGroup(AccessGroup accessGroup) {
return null;
}
@@ -192,7 +205,7 @@ void setUp() {
// For testing, we'll need to mock the FeignClientFactory behavior
storageStrategy = new TestableStorageStrategy(ontapStorage,
aggregateFeignClient, volumeFeignClient, svmFeignClient,
- jobFeignClient, networkFeignClient, sanFeignClient);
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
}
// ========== connect() Tests ==========
@@ -214,13 +227,8 @@ public void testConnect_positive() {
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);
+ Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 10000000000.0);
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())).thenReturn(aggregateDetail);
// Execute
boolean result = storageStrategy.connect();
@@ -231,12 +239,7 @@ public void testConnect_positive() {
}
@Test
- public void testConnect_succeedsWhenAggregateSpaceBelowPoolCapacity() {
- // Regression: connect() must validate connectivity/SVM/aggregate-state ONLY.
- // Capacity is validated per-volume in createStorageVolume(name, size). Previously
- // connect() compared aggregate free space against the whole storage pool size
- // (storage.getSize()), which incorrectly failed data-path operations (volume/LUN
- // create, grant/revoke access, delete) once the pool FlexVolume already existed.
+ public void testConnect_operationsOnly_skipsAggregateValidation() {
Svm svm = new Svm();
svm.setName("svm1");
svm.setState(OntapStorageConstants.RUNNING);
@@ -253,14 +256,11 @@ public void testConnect_succeedsWhenAggregateSpaceBelowPoolCapacity() {
when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);
// Aggregate is ONLINE but has far less free space than the configured pool size (5GB).
- 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(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail);
+ Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 1000000.0); // only 1MB free
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())).thenReturn(aggregateDetail);
- // Execute & Verify - connect() should succeed regardless of available space.
- boolean result = storageStrategy.connect();
+ // Execute & Verify - connect(false) should succeed regardless of available space.
+ boolean result = storageStrategy.connect(false);
assertTrue(result, "connect() should succeed for an online aggregate even when its free space is below the pool capacity");
}
@@ -282,15 +282,20 @@ public void testConnect_noOnlineAggregates() {
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(null); // not online
- when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail);
+ Aggregate aggregateDetail = new Aggregate();
+ aggregateDetail.setName("aggr1");
+ aggregateDetail.setUuid("aggr-uuid-1");
+ aggregateDetail.setState(null); // not online
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())).thenReturn(aggregateDetail);
// Execute & Verify
CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
assertTrue(ex.getMessage().contains("No suitable aggregates found"));
+ boolean result = storageStrategy.connect(false);
+
+ assertTrue(result);
+ // connect(true) called getAggregateByUUID once; connect(false) must not add more calls
+ verify(aggregateFeignClient, times(1)).getAggregateByUUID(anyString(), anyString(), anyMap());
}
@Test
@@ -354,7 +359,7 @@ public void testConnect_iscsiNotEnabled() {
"svm1", 5000000000L, ProtocolType.ISCSI);
storageStrategy = new TestableStorageStrategy(iscsiStorage,
aggregateFeignClient, volumeFeignClient, svmFeignClient,
- jobFeignClient, networkFeignClient, sanFeignClient);
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
Svm svm = new Svm();
svm.setName("svm1");
@@ -408,8 +413,10 @@ public void testConnect_nullSvmResponse() {
@Test
public void testConnect_invalidCredentials() {
// Setup - ONTAP rejects the supplied username/password with HTTP 401 Unauthorized.
+ Map> emptyHeaders = Collections.emptyMap();
+ Request dummyReq = Request.create(Request.HttpMethod.GET, "http://test", emptyHeaders, (byte[]) null, (Charset) null);
when(svmFeignClient.getSvmResponse(anyMap(), anyString()))
- .thenThrow(mock(FeignException.Unauthorized.class));
+ .thenThrow(new FeignException.Unauthorized("Unauthorized", dummyReq, null));
// Execute & Verify - connect() must surface a clear "invalid credentials" error.
CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
@@ -428,14 +435,8 @@ public void testCreateStorageVolume_positive() {
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")))
+ Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 10000000000.0);
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap()))
.thenReturn(aggregateDetail);
// Setup job response
@@ -515,12 +516,12 @@ public void testCreateStorageVolume_aggregateNotOnline() {
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
+ Aggregate aggregateDetail = new Aggregate();
+ aggregateDetail.setName("aggr1");
+ aggregateDetail.setUuid("aggr-uuid-1");
+ aggregateDetail.setState(null); // null state to simulate offline
- when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1")))
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap()))
.thenReturn(aggregateDetail);
// Execute & Verify
@@ -535,13 +536,9 @@ public void testCreateStorageVolume_insufficientSpace() {
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
+ Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 1000000.0); // Only 1MB available
- when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1")))
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap()))
.thenReturn(aggregateDetail);
// Execute & Verify
@@ -667,8 +664,10 @@ public void testDeleteStorageVolume_feignException() {
volume.setName("test-volume");
volume.setUuid("vol-uuid-1");
+ Map> emptyHeaders = Collections.emptyMap();
+ Request dummyReq = Request.create(Request.HttpMethod.DELETE, "http://test", emptyHeaders, (byte[]) null, (Charset) null);
when(volumeFeignClient.deleteVolume(anyString(), eq("vol-uuid-1")))
- .thenThrow(mock(FeignException.FeignClientException.class));
+ .thenThrow(new FeignException.FeignClientException(500, "error", dummyReq, null));
// Execute & Verify
Exception ex = assertThrows(CloudRuntimeException.class,
@@ -676,6 +675,25 @@ public void testDeleteStorageVolume_feignException() {
assertTrue(ex.getMessage().contains("Failed to delete volume"));
}
+ @Test
+ public void testDeleteStorageVolume_notFound_404_returnsWithoutThrowing() {
+ // Setup
+ Volume volume = new Volume();
+ volume.setName("test-volume");
+ volume.setUuid("vol-uuid-1");
+
+ FeignException feignEx = mock(FeignException.class);
+ when(feignEx.status()).thenReturn(404);
+ when(volumeFeignClient.deleteVolume(anyString(), eq("vol-uuid-1")))
+ .thenThrow(feignEx);
+
+ // Execute - 404 means volume already gone on ONTAP, treated as no-op
+ storageStrategy.deleteStorageVolume(volume);
+
+ // Verify the delete was attempted
+ verify(volumeFeignClient).deleteVolume(anyString(), eq("vol-uuid-1"));
+ }
+
// ========== getStoragePath() Tests ==========
@Test
@@ -685,7 +703,7 @@ public void testGetStoragePath_iscsi() {
"svm1", null, ProtocolType.ISCSI);
storageStrategy = new TestableStorageStrategy(iscsiStorage,
aggregateFeignClient, volumeFeignClient, svmFeignClient,
- jobFeignClient, networkFeignClient, sanFeignClient);
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
IscsiService.IscsiServiceTarget target = new IscsiService.IscsiServiceTarget();
target.setName("iqn.1992-08.com.netapp:sn.123456:vs.1");
@@ -715,7 +733,7 @@ public void testGetStoragePath_iscsi_noService() {
"svm1", null, ProtocolType.ISCSI);
storageStrategy = new TestableStorageStrategy(iscsiStorage,
aggregateFeignClient, volumeFeignClient, svmFeignClient,
- jobFeignClient, networkFeignClient, sanFeignClient);
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
OntapResponse emptyResponse = new OntapResponse<>();
emptyResponse.setRecords(new ArrayList<>());
@@ -736,7 +754,7 @@ public void testGetStoragePath_iscsi_noTargetIqn() {
"svm1", null, ProtocolType.ISCSI);
storageStrategy = new TestableStorageStrategy(iscsiStorage,
aggregateFeignClient, volumeFeignClient, svmFeignClient,
- jobFeignClient, networkFeignClient, sanFeignClient);
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
IscsiService iscsiService = new IscsiService();
iscsiService.setTarget(null);
@@ -763,6 +781,8 @@ public void testGetNetworkInterface_nfs() {
IpInterface ipInterface = new IpInterface();
ipInterface.setIp(ipInfo);
+ ipInterface.setState(OntapStorageConstants.LIF_STATE_UP);
+ ipInterface.setEnabled(true);
OntapResponse interfaceResponse = new OntapResponse<>();
interfaceResponse.setRecords(List.of(ipInterface));
@@ -771,11 +791,12 @@ public void testGetNetworkInterface_nfs() {
.thenReturn(interfaceResponse);
// Execute
- String result = storageStrategy.getNetworkInterface();
+ Pair result = storageStrategy.getNetworkInterface();
// Verify
assertNotNull(result);
- assertEquals("192.168.1.50", result);
+ assertEquals("192.168.1.50", result.first());
+ assertTrue(result.second() == null, "Expect no warning when a suitable LIF is found");
verify(networkFeignClient, times(1)).getNetworkIpInterfaces(anyString(), anyMap());
}
@@ -786,13 +807,15 @@ public void testGetNetworkInterface_iscsi() {
"svm1", null, ProtocolType.ISCSI);
storageStrategy = new TestableStorageStrategy(iscsiStorage,
aggregateFeignClient, volumeFeignClient, svmFeignClient,
- jobFeignClient, networkFeignClient, sanFeignClient);
+ jobFeignClient, networkFeignClient, sanFeignClient, snapshotFeignClient);
IpInterface.IpInfo ipInfo = new IpInterface.IpInfo();
ipInfo.setAddress("192.168.1.51");
IpInterface ipInterface = new IpInterface();
ipInterface.setIp(ipInfo);
+ ipInterface.setState(OntapStorageConstants.LIF_STATE_UP);
+ ipInterface.setEnabled(true);
OntapResponse interfaceResponse = new OntapResponse<>();
interfaceResponse.setRecords(List.of(ipInterface));
@@ -801,11 +824,84 @@ public void testGetNetworkInterface_iscsi() {
.thenReturn(interfaceResponse);
// Execute
- String result = storageStrategy.getNetworkInterface();
+ Pair result = storageStrategy.getNetworkInterface();
// Verify
assertNotNull(result);
- assertEquals("192.168.1.51", result);
+ assertEquals("192.168.1.51", result.first());
+ assertTrue(result.second() == null, "Expect no warning when a suitable LIF is found");
+ }
+
+ @Test
+ public void testGetNetworkInterface_nfs_lifDown() {
+ // LIF exists but is operationally down — should fail
+ IpInterface.IpInfo ipInfo = new IpInterface.IpInfo();
+ ipInfo.setAddress("192.168.1.50");
+
+ IpInterface ipInterface = new IpInterface();
+ ipInterface.setIp(ipInfo);
+ ipInterface.setState("down");
+ ipInterface.setEnabled(true);
+
+ OntapResponse interfaceResponse = new OntapResponse<>();
+ interfaceResponse.setRecords(List.of(ipInterface));
+
+ when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
+ .thenReturn(interfaceResponse);
+
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.getNetworkInterface());
+ assertTrue(ex.getMessage().contains("operationally UP and enabled"));
+ }
+
+ @Test
+ public void testGetNetworkInterface_nfs_lifDisabled() {
+ // LIF exists but is administratively disabled — should fail
+ IpInterface.IpInfo ipInfo = new IpInterface.IpInfo();
+ ipInfo.setAddress("192.168.1.50");
+
+ IpInterface ipInterface = new IpInterface();
+ ipInterface.setIp(ipInfo);
+ ipInterface.setState(OntapStorageConstants.LIF_STATE_UP);
+ ipInterface.setEnabled(false);
+
+ OntapResponse interfaceResponse = new OntapResponse<>();
+ interfaceResponse.setRecords(List.of(ipInterface));
+
+ when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
+ .thenReturn(interfaceResponse);
+
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.getNetworkInterface());
+ assertTrue(ex.getMessage().contains("operationally UP and enabled"));
+ }
+
+ @Test
+ public void testGetNetworkInterface_iscsi_lifDown() {
+ // iSCSI LIF exists but is operationally down — should fail
+ 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);
+
+ IpInterface.IpInfo ipInfo = new IpInterface.IpInfo();
+ ipInfo.setAddress("192.168.1.51");
+
+ IpInterface ipInterface = new IpInterface();
+ ipInterface.setIp(ipInfo);
+ ipInterface.setState("down");
+ ipInterface.setEnabled(true);
+
+ OntapResponse interfaceResponse = new OntapResponse<>();
+ interfaceResponse.setRecords(List.of(ipInterface));
+
+ when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
+ .thenReturn(interfaceResponse);
+
+ Exception ex = assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.getNetworkInterface());
+ assertTrue(ex.getMessage().contains("operationally UP and enabled"));
}
@Test
@@ -826,8 +922,10 @@ public void testGetNetworkInterface_noInterfaces() {
@Test
public void testGetNetworkInterface_feignException() {
// Setup
+ Map> emptyHeaders = Collections.emptyMap();
+ Request dummyReq = Request.create(Request.HttpMethod.GET, "http://test", emptyHeaders, (byte[]) null, (Charset) null);
when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
- .thenThrow(mock(FeignException.FeignClientException.class));
+ .thenThrow(new FeignException.FeignClientException(500, "error", dummyReq, null));
// Execute & Verify
Exception ex = assertThrows(CloudRuntimeException.class,
@@ -835,6 +933,111 @@ public void testGetNetworkInterface_feignException() {
assertTrue(ex.getMessage().contains("Failed to retrieve network interfaces"));
}
+ // ========== getNetworkInterface() Node-Affinity Tests ==========
+
+ /**
+ * Tier 1: LIF homed on the same node as the chosen aggregate — selected without warning.
+ */
+ @Test
+ public void testGetNetworkInterface_nfs_tier1_homeNodeMatch() {
+ injectChosenAggregateNode(storageStrategy, "node-a");
+
+ IpInterface lif = buildLif("10.0.0.1", OntapStorageConstants.LIF_STATE_UP, true, "node-a", "node-a");
+ when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
+ .thenReturn(wrapLifs(List.of(lif)));
+
+ Pair result = storageStrategy.getNetworkInterface();
+
+ assertEquals("10.0.0.1", result.first());
+ assertTrue(result.second() == null, "Tier 1 should produce no warning");
+ }
+
+ /**
+ * Tier 2: No home-node match, but another UP LIF is currently running on the target node (failover).
+ * The result should carry a warning.
+ */
+ @Test
+ public void testGetNetworkInterface_nfs_tier2_currentNodeMatch() {
+ injectChosenAggregateNode(storageStrategy, "node-a");
+
+ // home node = node-b, currently running on node-a after failover
+ IpInterface lif = buildLif("10.0.0.2", OntapStorageConstants.LIF_STATE_UP, true, "node-b", "node-a");
+ when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
+ .thenReturn(wrapLifs(List.of(lif)));
+
+ Pair result = storageStrategy.getNetworkInterface();
+
+ assertEquals("10.0.0.2", result.first());
+ assertTrue(result.second() != null, "Tier 2 should produce a warning");
+ assertTrue(result.second().contains("node-a"));
+ }
+
+ /**
+ * Tier 3 fallback: No LIF matches the target node in either home_node or current node.
+ * First UP/enabled LIF used; result carries a warning directing the user to create a
+ * LIF on the correct node.
+ */
+ @Test
+ public void testGetNetworkInterface_nfs_tier3_crossNodeFallback() {
+ injectChosenAggregateNode(storageStrategy, "node-a");
+
+ // Both home_node and current node are node-b — no affinity to node-a
+ IpInterface lif = buildLif("10.0.0.3", OntapStorageConstants.LIF_STATE_UP, true, "node-b", "node-b");
+ when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
+ .thenReturn(wrapLifs(List.of(lif)));
+
+ Pair result = storageStrategy.getNetworkInterface();
+
+ assertEquals("10.0.0.3", result.first());
+ assertTrue(result.second() != null, "Tier 3 fallback should produce a warning");
+ assertTrue(result.second().contains("node-a"),
+ "Warning should mention the expected node");
+ assertTrue(result.second().contains("10.0.0.3"),
+ "Warning should mention the fallback LIF IP");
+ }
+
+ /**
+ * When chosenAggregateNode is null (volume not yet created / no aggregate info),
+ * any UP/enabled LIF is returned without warning.
+ */
+ @Test
+ public void testGetNetworkInterface_nfs_noAggregateNode_noWarning() {
+ // chosenAggregateNode is null by default — no node affinity context
+ IpInterface lif = buildLif("10.0.0.4", OntapStorageConstants.LIF_STATE_UP, true, "node-a", "node-a");
+ when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
+ .thenReturn(wrapLifs(List.of(lif)));
+
+ Pair result = storageStrategy.getNetworkInterface();
+
+ assertEquals("10.0.0.4", result.first());
+ // With no chosenAggregateNode, tier 1/2 selection is skipped — result falls through to tier 3
+ // but since there's no "expected node" in the warning message (chosenAggregateNode is null),
+ // the message text will still contain "null" — we simply verify no exception is thrown and IP is correct.
+ // (Tier 3 warning is generated when chosenAggregateNode != null; here it is null so no warning)
+ assertTrue(result.second() == null, "No warning when chosenAggregateNode is null");
+ }
+
+ /**
+ * Tier-1 LIF is down; Tier-2 LIF matches the current node and should be selected with a warning.
+ */
+ @Test
+ public void testGetNetworkInterface_nfs_tier1Down_tier2Used() {
+ injectChosenAggregateNode(storageStrategy, "node-a");
+
+ // Tier 1 candidate: home_node = node-a but operationally DOWN
+ IpInterface lifDown = buildLif("10.0.0.5", "down", true, "node-a", "node-a");
+ // Tier 2 candidate: home_node = node-b, currently on node-a
+ IpInterface lifFailover = buildLif("10.0.0.6", OntapStorageConstants.LIF_STATE_UP, true, "node-b", "node-a");
+
+ when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap()))
+ .thenReturn(wrapLifs(List.of(lifDown, lifFailover)));
+
+ Pair result = storageStrategy.getNetworkInterface();
+
+ assertEquals("10.0.0.6", result.first());
+ assertTrue(result.second() != null, "Should warn that the home-node LIF is not in use");
+ }
+
// ========== Helper Methods ==========
private void setupSuccessfulConnect() {
@@ -853,24 +1056,13 @@ private void setupSuccessfulConnect() {
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);
+ Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 10000000000.0);
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())).thenReturn(aggregateDetail);
}
private void setupAggregateForVolumeCreation() {
- 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")))
+ Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 10000000000.0);
+ when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap()))
.thenReturn(aggregateDetail);
}
@@ -900,4 +1092,171 @@ private void setupSuccessfulJobCreation() {
when(volumeFeignClient.getVolume(anyString(), anyMap()))
.thenReturn(volumeResponse);
}
+
+ /**
+ * Injects a value into the private {@code chosenAggregateNode} field of StorageStrategy
+ * so node-affinity tests can exercise all three selection tiers without having to drive
+ * the full {@code createStorageVolume()} flow.
+ */
+ private static void injectChosenAggregateNode(StorageStrategy strategy, String nodeName) {
+ try {
+ Field field = StorageStrategy.class.getDeclaredField("chosenAggregateNode");
+ field.setAccessible(true);
+ field.set(strategy, nodeName);
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ throw new RuntimeException("Failed to inject chosenAggregateNode", e);
+ }
+ }
+
+ /**
+ * Builds an {@link IpInterface} with all node-affinity fields populated.
+ *
+ * @param ip the LIF's IP address (IPv4 for NFS3 selection to work)
+ * @param state operational state (e.g. "up" or "down")
+ * @param enabled administrative state
+ * @param homeNode name of the node the LIF is homed to
+ * @param currentNode name of the node the LIF is currently running on
+ */
+ private static IpInterface buildLif(String ip, String state, boolean enabled,
+ String homeNode, String currentNode) {
+ IpInterface.IpInfo ipInfo = new IpInterface.IpInfo();
+ ipInfo.setAddress(ip);
+
+ IpInterface.Node homeNodeObj = new IpInterface.Node();
+ homeNodeObj.setName(homeNode);
+
+ IpInterface.Node currentNodeObj = new IpInterface.Node();
+ currentNodeObj.setName(currentNode);
+
+ IpInterface.Location location = new IpInterface.Location();
+ location.setHomeNode(homeNodeObj);
+ location.setNode(currentNodeObj);
+
+ IpInterface lif = new IpInterface();
+ lif.setIp(ipInfo);
+ lif.setState(state);
+ lif.setEnabled(enabled);
+ lif.setLocation(location);
+ return lif;
+ }
+
+ private static OntapResponse wrapLifs(List lifs) {
+ OntapResponse response = new OntapResponse<>();
+ response.setRecords(lifs);
+ return response;
+ }
+
+ /**
+ * Creates a real {@link Aggregate} with nested space information so tests can avoid
+ * {@code mock(Aggregate.class)} which fails on JDK 26+ due to Byte Buddy limitations.
+ */
+ private static Aggregate buildAggregate(String name, String uuid, double availableBytes) {
+ Aggregate.AggregateSpaceBlockStorage blockStorage = new Aggregate.AggregateSpaceBlockStorage();
+ blockStorage.setAvailable(availableBytes);
+
+ Aggregate.AggregateSpace space = new Aggregate.AggregateSpace();
+ space.setBlockStorage(blockStorage);
+
+ Aggregate agg = new Aggregate();
+ agg.setName(name);
+ agg.setUuid(uuid);
+ agg.setState(Aggregate.StateEnum.ONLINE);
+ agg.setSpace(space);
+ return agg;
+ }
+
+ // ========== pollJobIfPresent / executeCliSfsrRestore Tests ==========
+
+ @Test
+ void testPollJobIfPresent_NoJob_DoesNotPoll() {
+ storageStrategy.pollJobIfPresent(null, "test operation");
+ storageStrategy.pollJobIfPresent(new JobResponse(), "test operation");
+ verify(jobFeignClient, times(0)).getJobByUUID(anyString(), anyString());
+ }
+
+ @Test
+ void testPollJobIfPresent_WithJob_PollsUntilSuccess() {
+ Job job = new Job();
+ job.setUuid("sfsr-job-1");
+ JobResponse response = new JobResponse();
+ response.setJob(job);
+
+ Job completedJob = new Job();
+ completedJob.setUuid("sfsr-job-1");
+ completedJob.setState(OntapStorageConstants.JOB_SUCCESS);
+ when(jobFeignClient.getJobByUUID(anyString(), eq("sfsr-job-1"))).thenReturn(completedJob);
+
+ storageStrategy.executeCliSfsrRestore(response, "CLI SFSR restore");
+
+ verify(jobFeignClient, atLeastOnce()).getJobByUUID(anyString(), eq("sfsr-job-1"));
+ }
+
+ @Test
+ void testPollJobIfPresent_JobFailure_ThrowsCloudRuntimeException() {
+ Job job = new Job();
+ job.setUuid("sfsr-job-fail");
+ JobResponse response = new JobResponse();
+ response.setJob(job);
+
+ Job failedJob = new Job();
+ failedJob.setUuid("sfsr-job-fail");
+ failedJob.setState(OntapStorageConstants.JOB_FAILURE);
+ failedJob.setMessage("restore failed");
+ when(jobFeignClient.getJobByUUID(anyString(), eq("sfsr-job-fail"))).thenReturn(failedJob);
+
+ assertThrows(CloudRuntimeException.class,
+ () -> storageStrategy.executeCliSfsrRestore(response, "CLI SFSR restore"));
+ }
+
+ @Test
+ void testDeleteFlexVolSnapshotForCloudStackVolume_PollsJobAndSucceeds() {
+ Job job = new Job();
+ job.setUuid("delete-job-1");
+ JobResponse response = new JobResponse();
+ response.setJob(job);
+ when(snapshotFeignClient.deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1")))
+ .thenReturn(response);
+
+ Job completedJob = new Job();
+ completedJob.setUuid("delete-job-1");
+ completedJob.setState(OntapStorageConstants.JOB_SUCCESS);
+ when(jobFeignClient.getJobByUUID(anyString(), eq("delete-job-1"))).thenReturn(completedJob);
+
+ storageStrategy.deleteFlexVolSnapshotForCloudStackVolume("fv-uuid-1", "snap-uuid-1", "snap-name-1");
+
+ verify(snapshotFeignClient).deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1"));
+ }
+
+ @Test
+ void testDeleteFlexVolSnapshotForCloudStackVolume_AlreadyAbsentOnOntap() {
+ Job job = new Job();
+ job.setUuid("delete-job-missing");
+ JobResponse response = new JobResponse();
+ response.setJob(job);
+ when(snapshotFeignClient.deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1")))
+ .thenReturn(response);
+
+ Job failedJob = new Job();
+ failedJob.setUuid("delete-job-missing");
+ failedJob.setState(OntapStorageConstants.JOB_FAILURE);
+ failedJob.setMessage("entry doesn't exist");
+ when(jobFeignClient.getJobByUUID(anyString(), eq("delete-job-missing"))).thenReturn(failedJob);
+
+ storageStrategy.deleteFlexVolSnapshotForCloudStackVolume("fv-uuid-1", "snap-uuid-1", "snap-name-1");
+
+ verify(snapshotFeignClient).deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1"));
+ }
+
+ @Test
+ void testDeleteFlexVolSnapshotForCloudStackVolume_Feign404_TreatedAsSuccess() {
+ FeignException notFoundException = mock(FeignException.class);
+ when(notFoundException.status()).thenReturn(404);
+ when(snapshotFeignClient.deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1")))
+ .thenThrow(notFoundException);
+
+ storageStrategy.deleteFlexVolSnapshotForCloudStackVolume("fv-uuid-1", "snap-uuid-1", "snap-name-1");
+
+ verify(snapshotFeignClient).deleteSnapshot(anyString(), eq("fv-uuid-1"), eq("snap-uuid-1"));
+ verify(jobFeignClient, never()).getJobByUUID(anyString(), anyString());
+ }
}
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java
index c4d5ddf6878c..f0eb5f0ccced 100755
--- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/UnifiedNASStrategyTest.java
@@ -37,6 +37,7 @@
import org.apache.cloudstack.storage.feign.client.NetworkFeignClient;
import org.apache.cloudstack.storage.feign.client.SANFeignClient;
import org.apache.cloudstack.storage.feign.model.ExportPolicy;
+import org.apache.cloudstack.storage.feign.model.ExportRule;
import org.apache.cloudstack.storage.feign.model.Job;
import org.apache.cloudstack.storage.feign.model.OntapStorage;
import org.apache.cloudstack.storage.feign.model.response.JobResponse;
@@ -63,6 +64,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -72,10 +74,13 @@
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import feign.FeignException;
+
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class UnifiedNASStrategyTest {
@@ -513,6 +518,26 @@ public void testDeleteAccessGroup_Failed() {
});
}
+ // Test deleteAccessGroup - Export policy not found should be treated as no-op
+ @Test
+ public void testDeleteAccessGroup_NotFound404_NoThrow() {
+ AccessGroup accessGroup = mock(AccessGroup.class);
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.EXPORT_POLICY_NAME, "export-policy-1");
+ details.put(OntapStorageConstants.EXPORT_POLICY_ID, "1");
+
+ when(accessGroup.getStoragePoolId()).thenReturn(1L);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(details);
+
+ FeignException feignException = mock(FeignException.class);
+ when(feignException.status()).thenReturn(404);
+ doThrow(feignException).when(nasFeignClient).deleteExportPolicyById(anyString(), eq("1"));
+
+ strategy.deleteAccessGroup(accessGroup);
+
+ verify(nasFeignClient).deleteExportPolicyById(anyString(), eq("1"));
+ }
+
// Test deleteCloudStackVolume - Success
@Test
public void testDeleteCloudStackVolume_Success() throws Exception {
@@ -582,4 +607,350 @@ public void testDeleteCloudStackVolume_AnswerNull() throws Exception {
strategy.deleteCloudStackVolume(cloudStackVolume);
});
}
-}
+
+ // -------------------------------------------------------------------------
+ // updateAccessGroup tests
+ // -------------------------------------------------------------------------
+
+ private Map detailsWithExportPolicyId() {
+ Map details = new HashMap<>();
+ details.put(OntapStorageConstants.EXPORT_POLICY_ID, "policy-42");
+ return details;
+ }
+
+ private ExportPolicy existingPolicyWithClients(String... matchIps) {
+ ExportRule rule = new ExportRule();
+ List clients = new ArrayList<>();
+ for (String ip : matchIps) {
+ ExportRule.ExportClient client = new ExportRule.ExportClient();
+ client.setMatch(ip);
+ clients.add(client);
+ }
+ rule.setClients(clients);
+ ExportPolicy policy = new ExportPolicy();
+ policy.setName("test-policy");
+ policy.setRules(new ArrayList<>(List.of(rule)));
+ return policy;
+ }
+
+ // updateAccessGroup - null accessGroup
+ @Test
+ public void testUpdateAccessGroup_NullAccessGroup() {
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(null));
+ }
+
+ // updateAccessGroup - null storagePoolId
+ @Test
+ public void testUpdateAccessGroup_NullStoragePoolId() {
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setHostsToConnect(List.of(mock(HostVO.class)));
+ // storagePoolId is null by default
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+
+ // updateAccessGroup - null hostsToConnect
+ @Test
+ public void testUpdateAccessGroup_NullHostsToConnect() {
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ // hostsToConnect is null by default
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+
+ // updateAccessGroup - empty hostsToConnect
+ @Test
+ public void testUpdateAccessGroup_EmptyHostsToConnect() {
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(new ArrayList<>());
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+
+ // updateAccessGroup - storagePoolDetailsDao returns null
+ @Test
+ public void testUpdateAccessGroup_NoStoragePoolDetails() {
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(mock(HostVO.class)));
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(null);
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+
+ // updateAccessGroup - details missing EXPORT_POLICY_ID key
+ @Test
+ public void testUpdateAccessGroup_MissingExportPolicyId() {
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(mock(HostVO.class)));
+ Map details = new HashMap<>();
+ details.put("someOtherKey", "someValue");
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(details);
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+
+ // updateAccessGroup - getExportPolicyById returns null
+ @Test
+ public void testUpdateAccessGroup_ExportPolicyNotFound() {
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(mock(HostVO.class)));
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(null);
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+
+ // updateAccessGroup - existing policy has null rules
+ @Test
+ public void testUpdateAccessGroup_NullRules() {
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(mock(HostVO.class)));
+ ExportPolicy policy = new ExportPolicy();
+ policy.setName("test-policy");
+ policy.setRules(null);
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(policy);
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+
+ // updateAccessGroup - existing policy has empty rules list
+ @Test
+ public void testUpdateAccessGroup_EmptyRules() {
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(mock(HostVO.class)));
+ ExportPolicy policy = new ExportPolicy();
+ policy.setName("test-policy");
+ policy.setRules(new ArrayList<>());
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(policy);
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+
+ // updateAccessGroup - all hosts have no IP: returns early without ONTAP patch
+ @Test
+ public void testUpdateAccessGroup_AllHostsHaveNoIp_ReturnsEarly() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn(null);
+ when(host.getPrivateIpAddress()).thenReturn(null);
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+
+ ExportPolicy existingPolicy = existingPolicyWithClients("10.0.0.1/32");
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(existingPolicy);
+
+ AccessGroup result = strategy.updateAccessGroup(accessGroup);
+
+ assertNotNull(result);
+ assertSame(existingPolicy, result.getPolicy());
+ verify(nasFeignClient, never()).updateExportPolicy(anyString(), anyString(), any());
+ }
+
+ // updateAccessGroup - ADD: new host IP added to policy
+ @Test
+ public void testUpdateAccessGroup_Add_NewHost_Success() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn("10.0.0.2");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+ // default action is ADD
+
+ ExportPolicy existingPolicy = existingPolicyWithClients("10.0.0.1/32");
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(existingPolicy);
+
+ AccessGroup result = strategy.updateAccessGroup(accessGroup);
+
+ assertNotNull(result);
+ assertSame(existingPolicy, result.getPolicy());
+ // Existing client + new client = 2
+ assertEquals(2, existingPolicy.getRules().get(0).getClients().size());
+ verify(nasFeignClient).updateExportPolicy(anyString(), eq("policy-42"), any(ExportPolicy.class));
+ }
+
+ // updateAccessGroup - ADD: host uses private IP when storage IP is absent
+ @Test
+ public void testUpdateAccessGroup_Add_UsesPrivateIpWhenStorageIpAbsent() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn(null);
+ when(host.getPrivateIpAddress()).thenReturn("192.168.1.50");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+
+ ExportPolicy existingPolicy = existingPolicyWithClients();
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(existingPolicy);
+
+ AccessGroup result = strategy.updateAccessGroup(accessGroup);
+
+ assertNotNull(result);
+ List clients = existingPolicy.getRules().get(0).getClients();
+ assertEquals(1, clients.size());
+ assertEquals("192.168.1.50/32", clients.get(0).getMatch());
+ verify(nasFeignClient).updateExportPolicy(anyString(), eq("policy-42"), any(ExportPolicy.class));
+ }
+
+ // updateAccessGroup - ADD: host IP already present in policy (no-op)
+ @Test
+ public void testUpdateAccessGroup_Add_DuplicateHost_NoUpdate() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn("10.0.0.1");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+
+ ExportPolicy existingPolicy = existingPolicyWithClients("10.0.0.1/32");
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(existingPolicy);
+
+ AccessGroup result = strategy.updateAccessGroup(accessGroup);
+
+ assertNotNull(result);
+ assertSame(existingPolicy, result.getPolicy());
+ // Client count must remain 1 (no duplicate inserted)
+ assertEquals(1, existingPolicy.getRules().get(0).getClients().size());
+ verify(nasFeignClient, never()).updateExportPolicy(anyString(), anyString(), any());
+ }
+
+ // updateAccessGroup - ADD: existing rule has null clients list
+ @Test
+ public void testUpdateAccessGroup_Add_NullClientsInRule() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn("10.0.0.5");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+
+ ExportRule rule = new ExportRule();
+ rule.setClients(null); // null clients list
+ ExportPolicy policy = new ExportPolicy();
+ policy.setName("test-policy");
+ policy.setRules(new ArrayList<>(List.of(rule)));
+
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(policy);
+
+ AccessGroup result = strategy.updateAccessGroup(accessGroup);
+
+ assertNotNull(result);
+ assertEquals(1, rule.getClients().size());
+ assertEquals("10.0.0.5/32", rule.getClients().get(0).getMatch());
+ verify(nasFeignClient).updateExportPolicy(anyString(), eq("policy-42"), any(ExportPolicy.class));
+ }
+
+ // updateAccessGroup - REMOVE: matching host IP removed from policy
+ @Test
+ public void testUpdateAccessGroup_Remove_MatchingHost_Success() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn("10.0.0.1");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+ accessGroup.setHostRuleAction(AccessGroup.HostRuleAction.REMOVE);
+
+ ExportPolicy existingPolicy = existingPolicyWithClients("10.0.0.1/32", "10.0.0.2/32");
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(existingPolicy);
+
+ AccessGroup result = strategy.updateAccessGroup(accessGroup);
+
+ assertNotNull(result);
+ assertSame(existingPolicy, result.getPolicy());
+ // Only 10.0.0.2/32 should remain
+ List clients = existingPolicy.getRules().get(0).getClients();
+ assertEquals(1, clients.size());
+ assertEquals("10.0.0.2/32", clients.get(0).getMatch());
+ verify(nasFeignClient).updateExportPolicy(anyString(), eq("policy-42"), any(ExportPolicy.class));
+ }
+
+ // updateAccessGroup - REMOVE: IP not in policy (no-op)
+ @Test
+ public void testUpdateAccessGroup_Remove_IpNotPresent_NoUpdate() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn("10.0.0.99");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+ accessGroup.setHostRuleAction(AccessGroup.HostRuleAction.REMOVE);
+
+ ExportPolicy existingPolicy = existingPolicyWithClients("10.0.0.1/32");
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(existingPolicy);
+
+ AccessGroup result = strategy.updateAccessGroup(accessGroup);
+
+ assertNotNull(result);
+ assertSame(existingPolicy, result.getPolicy());
+ assertEquals(1, existingPolicy.getRules().get(0).getClients().size());
+ verify(nasFeignClient, never()).updateExportPolicy(anyString(), anyString(), any());
+ }
+
+ // updateAccessGroup - FeignException from ONTAP wrapped in CloudRuntimeException
+ @Test
+ public void testUpdateAccessGroup_FeignExceptionWrapped() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn("10.0.0.1");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42")))
+ .thenThrow(new RuntimeException("ONTAP unreachable"));
+
+ assertThrows(CloudRuntimeException.class, () -> strategy.updateAccessGroup(accessGroup));
+ }
+ // updateAccessGroup - whitespace in storage IP is trimmed before building match
+ @Test
+ public void testUpdateAccessGroup_TrimsWhitespaceFromStorageIp() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn(" 10.0.0.2 ");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+
+ ExportPolicy existingPolicy = existingPolicyWithClients();
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(existingPolicy);
+
+ strategy.updateAccessGroup(accessGroup);
+
+ List clients = existingPolicy.getRules().get(0).getClients();
+ assertEquals(1, clients.size());
+ assertEquals("10.0.0.2/32", clients.get(0).getMatch());
+ }
+
+ // updateAccessGroup - whitespace in private IP is trimmed when storage IP absent
+ @Test
+ public void testUpdateAccessGroup_TrimsWhitespaceFromPrivateIp() {
+ HostVO host = mock(HostVO.class);
+ when(host.getStorageIpAddress()).thenReturn(null);
+ when(host.getPrivateIpAddress()).thenReturn(" 192.168.1.10 ");
+
+ AccessGroup accessGroup = new AccessGroup();
+ accessGroup.setStoragePoolId(1L);
+ accessGroup.setHostsToConnect(List.of(host));
+
+ ExportPolicy existingPolicy = existingPolicyWithClients();
+ when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(detailsWithExportPolicyId());
+ when(nasFeignClient.getExportPolicyById(anyString(), eq("policy-42"))).thenReturn(existingPolicy);
+
+ strategy.updateAccessGroup(accessGroup);
+
+ List clients = existingPolicy.getRules().get(0).getClients();
+ assertEquals(1, clients.size());
+ assertEquals("192.168.1.10/32", clients.get(0).getMatch());
+ }}
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/utils/OntapStorageUtilsTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/utils/OntapStorageUtilsTest.java
index 372a75ad257d..ebe7da25ed12 100644
--- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/utils/OntapStorageUtilsTest.java
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/utils/OntapStorageUtilsTest.java
@@ -18,9 +18,11 @@
*/
package org.apache.cloudstack.storage.utils;
+import com.cloud.utils.exception.CloudRuntimeException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class OntapStorageUtilsTest {
@@ -79,4 +81,16 @@ public void getIgroupName_truncates_whenOneCharOverMaxLength() {
assertEquals(OntapStorageConstants.IGROUP_NAME_MAX_LENGTH, result.length());
}
+
+ @Test
+ public void isOntapSnapshotNotFoundError_matchesEntryDoesNotExist() {
+ CloudRuntimeException ex = new CloudRuntimeException("Job failed with error: entry doesn't exist");
+ assertTrue(OntapStorageUtils.isOntapObjectNotFoundError(ex));
+ }
+
+ @Test
+ public void isOntapSnapshotNotFoundError_rejectsUnrelatedErrors() {
+ assertFalse(OntapStorageUtils.isOntapObjectNotFoundError(
+ new CloudRuntimeException("Job failed with error: permission denied")));
+ }
}
diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java
index b069ab7246a0..a3ffed1fb0f7 100644
--- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java
+++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/vmsnapshot/OntapVMSnapshotStrategyTest.java
@@ -21,12 +21,17 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -44,6 +49,14 @@
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.ConsistencyGroup;
+import org.apache.cloudstack.storage.feign.model.ConsistencyGroupSnapshot;
+import org.apache.cloudstack.storage.feign.model.FlexVolSnapshot;
+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.to.VolumeObjectTO;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -127,6 +140,10 @@ class OntapVMSnapshotStrategyTest {
private VolumeDataFactory volumeDataFactory;
@Mock
private VolumeDetailsDao volumeDetailsDao;
+ @Mock
+ private StorageStrategy storageStrategy;
+ @Mock
+ private SnapshotFeignClient snapshotFeignClient;
@Spy
@InjectMocks
@@ -226,14 +243,18 @@ void testCanHandle_AllocatedDiskType_VmxenHypervisor_ReturnsCantHandle() {
}
@Test
- void testCanHandle_AllocatedDiskType_VmNotRunning_ReturnsCantHandle() {
+ void testCanHandle_AllocatedDiskType_VmStopped_ReturnsHighest() {
UserVmVO userVm = createMockUserVm(Hypervisor.HypervisorType.KVM, VirtualMachine.State.Stopped);
when(userVmDao.findById(VM_ID)).thenReturn(userVm);
VMSnapshotVO vmSnapshot = createMockVmSnapshot(VMSnapshot.State.Allocated, VMSnapshot.Type.Disk);
+ VolumeVO vol = createMockVolume(VOLUME_ID_1, POOL_ID_1);
+ when(volumeDao.findByInstance(VM_ID)).thenReturn(Collections.singletonList(vol));
+ StoragePoolVO pool = createOntapManagedPool(POOL_ID_1);
+ when(storagePool.findById(POOL_ID_1)).thenReturn(pool);
StrategyPriority result = strategy.canHandle(vmSnapshot);
- assertEquals(StrategyPriority.CANT_HANDLE, result);
+ assertEquals(StrategyPriority.HIGHEST, result);
}
@Test
@@ -532,6 +553,86 @@ void testGroupVolumesByFlexVol_VolumeNotFound_ThrowsException() {
() -> strategy.groupVolumesByFlexVol(Collections.singletonList(volumeTO1)));
}
+ @Test
+ void testCreateTemporaryConsistencyGroup_includesSvmName() {
+ SnapshotFeignClient client = mock(SnapshotFeignClient.class);
+ StorageStrategy storageStrategy = mock(StorageStrategy.class);
+ when(client.createConsistencyGroup(any(), any())).thenReturn(createJobResponse("job-cg-create"));
+ OntapResponse cgResponse = new OntapResponse<>();
+ ConsistencyGroup cgRecord = new ConsistencyGroup();
+ cgRecord.setUuid("cg-uuid-1");
+ cgResponse.setRecords(Collections.singletonList(cgRecord));
+ when(client.getConsistencyGroups(any(), any())).thenReturn(cgResponse);
+
+ String cgUuid = strategy.createTemporaryConsistencyGroup(client, storageStrategy, "auth",
+ "cg-name", new OntapVMSnapshotStrategy.ConsistencyGroupScope("10.0.0.1", "vs0", "svm-uuid-1"),
+ java.util.Set.of("flexvol-uuid-1", "flexvol-uuid-2"));
+
+ assertEquals("cg-uuid-1", cgUuid);
+ org.mockito.ArgumentCaptor payloadCaptor = org.mockito.ArgumentCaptor.forClass(ConsistencyGroup.class);
+ verify(client).createConsistencyGroup(eq("auth"), payloadCaptor.capture());
+ ConsistencyGroup payload = payloadCaptor.getValue();
+ assertEquals("cg-name", payload.getName());
+ assertEquals("svm-uuid-1", payload.getSvm().getUuid());
+ assertEquals(2, payload.getVolumes().size());
+ assertEquals(OntapStorageConstants.CG_VOLUME_PROVISIONING_ACTION_ADD,
+ payload.getVolumes().get(0).getProvisioningOptions().getAction());
+ }
+
+ @Test
+ void testResolveConsistencyGroupScope_rejectsDifferentStorageIpWithSameSvmName() {
+ Map groups = new HashMap<>();
+ Map poolDetails1 = new HashMap<>();
+ poolDetails1.put(OntapStorageConstants.STORAGE_IP, "10.1.1.1");
+ poolDetails1.put(OntapStorageConstants.SVM_NAME, "vs0");
+ groups.put("flexvol-uuid-1", new OntapVMSnapshotStrategy.FlexVolGroupInfo(poolDetails1, POOL_ID_1));
+
+ Map poolDetails2 = new HashMap<>();
+ poolDetails2.put(OntapStorageConstants.STORAGE_IP, "10.2.2.2");
+ poolDetails2.put(OntapStorageConstants.SVM_NAME, "vs0");
+ groups.put("flexvol-uuid-2", new OntapVMSnapshotStrategy.FlexVolGroupInfo(poolDetails2, POOL_ID_2));
+
+ assertThrows(CloudRuntimeException.class, () -> strategy.resolveConsistencyGroupScope(groups));
+ }
+
+ @Test
+ void testResolveConsistencyGroupScope_acceptsSameClusterAndSvmUuid() {
+ Map groups = new HashMap<>();
+ Map poolDetails1 = new HashMap<>();
+ poolDetails1.put(OntapStorageConstants.STORAGE_IP, "10.1.1.1");
+ poolDetails1.put(OntapStorageConstants.SVM_NAME, "vs0");
+ poolDetails1.put(OntapStorageConstants.SVM_UUID, "svm-uuid-shared");
+ groups.put("flexvol-uuid-1", new OntapVMSnapshotStrategy.FlexVolGroupInfo(poolDetails1, POOL_ID_1));
+
+ Map poolDetails2 = new HashMap<>();
+ poolDetails2.put(OntapStorageConstants.STORAGE_IP, "10.1.1.1");
+ poolDetails2.put(OntapStorageConstants.SVM_NAME, "vs0");
+ poolDetails2.put(OntapStorageConstants.SVM_UUID, "svm-uuid-shared");
+ groups.put("flexvol-uuid-2", new OntapVMSnapshotStrategy.FlexVolGroupInfo(poolDetails2, POOL_ID_2));
+
+ OntapVMSnapshotStrategy.ConsistencyGroupScope scope = strategy.resolveConsistencyGroupScope(groups);
+ assertEquals("svm-uuid-shared", scope.svmUuid);
+ assertEquals("10.1.1.1", scope.storageIp);
+ }
+
+ @Test
+ void testResolveConsistencyGroupScope_rejectsDifferentSvmUuidOnSameCluster() {
+ Map groups = new HashMap<>();
+ Map poolDetails1 = new HashMap<>();
+ poolDetails1.put(OntapStorageConstants.STORAGE_IP, "10.1.1.1");
+ poolDetails1.put(OntapStorageConstants.SVM_NAME, "vs0");
+ poolDetails1.put(OntapStorageConstants.SVM_UUID, "svm-uuid-1");
+ groups.put("flexvol-uuid-1", new OntapVMSnapshotStrategy.FlexVolGroupInfo(poolDetails1, POOL_ID_1));
+
+ Map poolDetails2 = new HashMap<>();
+ poolDetails2.put(OntapStorageConstants.STORAGE_IP, "10.1.1.1");
+ poolDetails2.put(OntapStorageConstants.SVM_NAME, "vs0");
+ poolDetails2.put(OntapStorageConstants.SVM_UUID, "svm-uuid-2");
+ groups.put("flexvol-uuid-2", new OntapVMSnapshotStrategy.FlexVolGroupInfo(poolDetails2, POOL_ID_2));
+
+ assertThrows(CloudRuntimeException.class, () -> strategy.resolveConsistencyGroupScope(groups));
+ }
+
// ══════════════════════════════════════════════════════════════════════════
// Tests: FlexVolSnapshotDetail parse/toString
// ══════════════════════════════════════════════════════════════════════════
@@ -593,10 +694,11 @@ void testFlexVolSnapshotDetail_Parse5Parts_ThrowsException() {
void testBuildSnapshotName_Format() {
VMSnapshotVO vmSnapshot = mock(VMSnapshotVO.class);
when(vmSnapshot.getId()).thenReturn(SNAPSHOT_ID);
+ when(vmSnapshot.getName()).thenReturn("UI VM Snapshot");
String name = strategy.buildSnapshotName(vmSnapshot);
- assertEquals(true, name.startsWith("vmsnap_200_"));
+ assertEquals(true, name.startsWith("UI_VM_Snapshot_vm200"));
assertEquals(true, name.length() <= OntapStorageConstants.MAX_SNAPSHOT_NAME_LENGTH);
}
@@ -732,6 +834,86 @@ void testTakeVMSnapshot_OperationTimeout_ThrowsCloudRuntimeException() throws Ex
assertEquals(true, ex.getMessage().contains("timed out"));
}
+ @Test
+ void testTakeVMSnapshot_SingleFlexVolSuccess_UsesDirectSnapshotNotCg() throws Exception {
+ VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot();
+ setupTakeSnapshotCommon(vmSnapshot);
+ setupSingleVolumeForTakeSnapshot();
+
+ String snapshotName = strategy.buildSnapshotName(vmSnapshot);
+ setupSingleFlexVolFlowMocks(snapshotName);
+
+ FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class);
+ when(freezeAnswer.getResult()).thenReturn(true);
+ FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class);
+ when(thawAnswer.getResult()).thenReturn(true);
+ when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class)))
+ .thenReturn(freezeAnswer)
+ .thenReturn(thawAnswer);
+
+ strategy.takeVMSnapshot(vmSnapshot);
+
+ verify(snapshotFeignClient, times(1)).createSnapshot(any(), eq("flexvol-uuid-1"), any());
+ verify(snapshotFeignClient, never()).createConsistencyGroup(any(), any());
+ verify(snapshotFeignClient, never()).createConsistencyGroupSnapshot(any(), any(), any());
+ verify(snapshotFeignClient, never()).commitConsistencyGroupSnapshot(any(), any(), any(), any());
+ verify(snapshotFeignClient, never()).deleteConsistencyGroup(any(), any());
+ verify(vmSnapshotDetailsDao, atLeastOnce()).persist(any(VMSnapshotDetailsVO.class));
+ }
+
+ @Test
+ void testTakeVMSnapshot_TemporaryCgTwoPhaseSuccess_PersistsDetailsAndCleansUpCg() throws Exception {
+ VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot();
+ setupTakeSnapshotCommon(vmSnapshot);
+ setupMultiFlexVolForTakeSnapshot();
+
+ String snapshotName = strategy.buildSnapshotName(vmSnapshot);
+ setupTemporaryCgFlowMocks(snapshotName);
+
+ FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class);
+ when(freezeAnswer.getResult()).thenReturn(true);
+ FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class);
+ when(thawAnswer.getResult()).thenReturn(true);
+ when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class)))
+ .thenReturn(freezeAnswer)
+ .thenReturn(thawAnswer);
+
+ strategy.takeVMSnapshot(vmSnapshot);
+
+ verify(snapshotFeignClient, times(1)).createConsistencyGroup(any(), any());
+ verify(snapshotFeignClient, times(1)).createConsistencyGroupSnapshot(any(), eq("cg-uuid-1"), any());
+ verify(snapshotFeignClient, times(1)).commitConsistencyGroupSnapshot(any(), eq("cg-uuid-1"), eq("cg-snap-uuid-1"), any());
+ verify(snapshotFeignClient, times(1)).deleteConsistencyGroup(any(), eq("cg-uuid-1"));
+ verify(vmSnapshotDetailsDao, atLeastOnce()).persist(any(VMSnapshotDetailsVO.class));
+ }
+
+ @Test
+ void testTakeVMSnapshot_TemporaryCgStartFails_TransitionsToOperationFailed() throws Exception {
+ VMSnapshotVO vmSnapshot = createTakeSnapshotVmSnapshot();
+ setupTakeSnapshotCommon(vmSnapshot);
+ setupMultiFlexVolForTakeSnapshot();
+
+ String snapshotName = strategy.buildSnapshotName(vmSnapshot);
+ setupTemporaryCgFlowMocks(snapshotName);
+ when(snapshotFeignClient.createConsistencyGroupSnapshot(any(), eq("cg-uuid-1"), any()))
+ .thenThrow(new CloudRuntimeException("start phase failed"));
+
+ FreezeThawVMAnswer freezeAnswer = mock(FreezeThawVMAnswer.class);
+ when(freezeAnswer.getResult()).thenReturn(true);
+ FreezeThawVMAnswer thawAnswer = mock(FreezeThawVMAnswer.class);
+ when(thawAnswer.getResult()).thenReturn(true);
+ when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class)))
+ .thenReturn(freezeAnswer)
+ .thenReturn(thawAnswer);
+ when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList());
+ doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed));
+
+ assertThrows(CloudRuntimeException.class, () -> strategy.takeVMSnapshot(vmSnapshot));
+
+ verify(snapshotFeignClient, times(1)).deleteConsistencyGroup(any(), eq("cg-uuid-1"));
+ verify(vmSnapshotHelper, atLeastOnce()).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed));
+ }
+
// ══════════════════════════════════════════════════════════════════════════
// Tests: Quiesce Behavior
// ══════════════════════════════════════════════════════════════════════════
@@ -746,20 +928,9 @@ void testTakeVMSnapshot_QuiesceFalse_SkipsFreezeThaw() throws Exception {
setupTakeSnapshotCommon(vmSnapshot);
setupSingleVolumeForTakeSnapshot();
+ setupSingleFlexVolFlowMocks(strategy.buildSnapshotName(vmSnapshot));
- // The FlexVolume snapshot flow will try to call Utility.getStrategyByStoragePoolDetails
- // which is a static method that makes real connections. We expect this to fail in unit tests.
- // The important thing is that freeze/thaw was NOT called before the failure.
- when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList());
- doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed));
-
- // Since Utility.getStrategyByStoragePoolDetails is static and creates real Feign clients,
- // this will fail. We just verify that freeze was never called.
- try {
- strategy.takeVMSnapshot(vmSnapshot);
- } catch (Exception e) {
- // Expected — static utility can't be mocked in unit test
- }
+ strategy.takeVMSnapshot(vmSnapshot);
// No freeze/thaw commands should be sent when quiesce is false
verify(agentMgr, never()).send(eq(HOST_ID), any(FreezeThawVMCommand.class));
@@ -790,16 +961,9 @@ void testTakeVMSnapshot_WithParentSnapshot_SetsParentId() throws Exception {
when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class)))
.thenReturn(freezeAnswer)
.thenReturn(thawAnswer);
+ setupSingleFlexVolFlowMocks(strategy.buildSnapshotName(vmSnapshot));
- when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList());
- doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed));
-
- // FlexVol snapshot flow will fail on static method, but parent should already be set
- try {
- strategy.takeVMSnapshot(vmSnapshot);
- } catch (Exception e) {
- // Expected
- }
+ strategy.takeVMSnapshot(vmSnapshot);
// Verify parent was set on the VM snapshot before the FlexVol snapshot attempt
verify(vmSnapshot).setParent(199L);
@@ -820,15 +984,9 @@ void testTakeVMSnapshot_WithNoParentSnapshot_SetsParentNull() throws Exception {
when(agentMgr.send(eq(HOST_ID), any(FreezeThawVMCommand.class)))
.thenReturn(freezeAnswer)
.thenReturn(thawAnswer);
+ setupSingleFlexVolFlowMocks(strategy.buildSnapshotName(vmSnapshot));
- when(vmSnapshotDetailsDao.listDetails(SNAPSHOT_ID)).thenReturn(Collections.emptyList());
- doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(any(), eq(VMSnapshot.Event.OperationFailed));
-
- try {
- strategy.takeVMSnapshot(vmSnapshot);
- } catch (Exception e) {
- // Expected
- }
+ strategy.takeVMSnapshot(vmSnapshot);
verify(vmSnapshot).setParent(null);
}
@@ -866,6 +1024,9 @@ private UserVmVO setupTakeSnapshotCommon(VMSnapshotVO vmSnapshot) throws Excepti
when(vmSnapshotDao.findCurrentSnapshotByVmId(VM_ID)).thenReturn(null);
doReturn(true).when(vmSnapshotHelper).vmSnapshotStateTransitTo(vmSnapshot, VMSnapshot.Event.CreateRequested);
+ doNothing().when(strategy).processAnswer(any(), any(), any(), any());
+ doNothing().when(strategy).publishUsageEvent(any(), any(), any(), any());
+ doNothing().when(strategy).publishUsageEvent(any(), any(), any(), anyLong(), anyLong());
return userVm;
}
@@ -880,6 +1041,7 @@ private void setupSingleVolumeForTakeSnapshot() {
VolumeVO volumeVO = mock(VolumeVO.class);
when(volumeVO.getId()).thenReturn(VOLUME_ID_1);
when(volumeVO.getPoolId()).thenReturn(POOL_ID_1);
+ when(volumeVO.getPath()).thenReturn("volume-301.qcow2");
when(volumeVO.getVmSnapshotChainSize()).thenReturn(null);
when(volumeDao.findById(VOLUME_ID_1)).thenReturn(volumeVO);
@@ -899,4 +1061,139 @@ private void setupSingleVolumeForTakeSnapshot() {
when(volumeInfo.getName()).thenReturn("vol-1");
when(volumeDataFactory.getVolume(VOLUME_ID_1)).thenReturn(volumeInfo);
}
+
+ private void setupMultiFlexVolForTakeSnapshot() {
+ VolumeObjectTO volumeTO1 = mock(VolumeObjectTO.class);
+ when(volumeTO1.getId()).thenReturn(VOLUME_ID_1);
+ when(volumeTO1.getSize()).thenReturn(10737418240L);
+ VolumeObjectTO volumeTO2 = mock(VolumeObjectTO.class);
+ when(volumeTO2.getId()).thenReturn(VOLUME_ID_2);
+ when(volumeTO2.getSize()).thenReturn(10737418240L);
+ List volumeTOs = Arrays.asList(volumeTO1, volumeTO2);
+ when(vmSnapshotHelper.getVolumeTOList(VM_ID)).thenReturn(volumeTOs);
+
+ VolumeVO volumeVO1 = mock(VolumeVO.class);
+ when(volumeVO1.getId()).thenReturn(VOLUME_ID_1);
+ when(volumeVO1.getPoolId()).thenReturn(POOL_ID_1);
+ when(volumeVO1.getPath()).thenReturn("volume-301.qcow2");
+ when(volumeVO1.getVmSnapshotChainSize()).thenReturn(null);
+ when(volumeDao.findById(VOLUME_ID_1)).thenReturn(volumeVO1);
+
+ VolumeVO volumeVO2 = mock(VolumeVO.class);
+ when(volumeVO2.getId()).thenReturn(VOLUME_ID_2);
+ when(volumeVO2.getPoolId()).thenReturn(POOL_ID_2);
+ when(volumeVO2.getPath()).thenReturn("volume-302.qcow2");
+ when(volumeVO2.getVmSnapshotChainSize()).thenReturn(null);
+ when(volumeDao.findById(VOLUME_ID_2)).thenReturn(volumeVO2);
+
+ Map poolDetails1 = new HashMap<>();
+ poolDetails1.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-1");
+ poolDetails1.put(OntapStorageConstants.USERNAME, "admin");
+ poolDetails1.put(OntapStorageConstants.PASSWORD, "pass");
+ poolDetails1.put(OntapStorageConstants.STORAGE_IP, "10.0.0.1");
+ poolDetails1.put(OntapStorageConstants.SVM_NAME, "svm1");
+ poolDetails1.put(OntapStorageConstants.SVM_UUID, "svm-uuid-shared");
+ poolDetails1.put(OntapStorageConstants.SIZE, "107374182400");
+ poolDetails1.put(OntapStorageConstants.PROTOCOL, "NFS3");
+ when(storagePoolDetailsDao.listDetailsKeyPairs(POOL_ID_1)).thenReturn(poolDetails1);
+
+ Map poolDetails2 = new HashMap<>();
+ poolDetails2.put(OntapStorageConstants.VOLUME_UUID, "flexvol-uuid-2");
+ poolDetails2.put(OntapStorageConstants.USERNAME, "admin");
+ poolDetails2.put(OntapStorageConstants.PASSWORD, "pass");
+ poolDetails2.put(OntapStorageConstants.STORAGE_IP, "10.0.0.1");
+ poolDetails2.put(OntapStorageConstants.SVM_NAME, "svm1");
+ poolDetails2.put(OntapStorageConstants.SVM_UUID, "svm-uuid-shared");
+ poolDetails2.put(OntapStorageConstants.SIZE, "107374182400");
+ poolDetails2.put(OntapStorageConstants.PROTOCOL, "NFS3");
+ when(storagePoolDetailsDao.listDetailsKeyPairs(POOL_ID_2)).thenReturn(poolDetails2);
+
+ VolumeInfo volumeInfo1 = mock(VolumeInfo.class);
+ when(volumeInfo1.getId()).thenReturn(VOLUME_ID_1);
+ when(volumeDataFactory.getVolume(VOLUME_ID_1)).thenReturn(volumeInfo1);
+ VolumeInfo volumeInfo2 = mock(VolumeInfo.class);
+ when(volumeInfo2.getId()).thenReturn(VOLUME_ID_2);
+ when(volumeDataFactory.getVolume(VOLUME_ID_2)).thenReturn(volumeInfo2);
+ }
+
+ private JobResponse createJobResponse(String uuid) {
+ Job job = new Job();
+ job.setUuid(uuid);
+ JobResponse response = new JobResponse();
+ response.setJob(job);
+ return response;
+ }
+
+ private void setupSingleFlexVolFlowMocks(String snapshotName) {
+ doReturn(storageStrategy).when(strategy).resolveStorageStrategy(any());
+ when(storageStrategy.getSnapshotFeignClient()).thenReturn(snapshotFeignClient);
+ when(storageStrategy.getAuthHeader()).thenReturn("Basic dGVzdDp0ZXN0");
+ when(storageStrategy.jobPollForSuccess(any(), anyInt(), anyInt())).thenReturn(true);
+
+ when(snapshotFeignClient.createSnapshot(any(), eq("flexvol-uuid-1"), any()))
+ .thenReturn(createJobResponse("job-fv-snap"));
+
+ OntapResponse flexVolSnapshots = new OntapResponse<>();
+ FlexVolSnapshot flexVolSnapshot = new FlexVolSnapshot();
+ flexVolSnapshot.setUuid("fv-snap-uuid-1");
+ flexVolSnapshot.setName(snapshotName);
+ flexVolSnapshots.setRecords(Collections.singletonList(flexVolSnapshot));
+ when(snapshotFeignClient.getSnapshots(any(), eq("flexvol-uuid-1"), any()))
+ .thenReturn(flexVolSnapshots);
+ }
+
+ private void setupTemporaryCgFlowMocks(String snapshotName) {
+ doReturn(storageStrategy).when(strategy).resolveStorageStrategy(any());
+ when(storageStrategy.getSnapshotFeignClient()).thenReturn(snapshotFeignClient);
+ when(storageStrategy.getAuthHeader()).thenReturn("Basic dGVzdDp0ZXN0");
+ when(storageStrategy.jobPollForSuccess(any(), anyInt(), anyInt())).thenReturn(true);
+ when(storageStrategy.pollJobIfPresentAndGetCompletedJob(any(), any())).thenAnswer(invocation -> {
+ Job completedJob = new Job();
+ completedJob.setState(OntapStorageConstants.JOB_SUCCESS);
+ String operationName = invocation.getArgument(1);
+ if (operationName != null && operationName.startsWith("start CG snapshot")) {
+ completedJob.setDescription(
+ "POST /api/application/consistency-groups/cg-uuid-1/snapshots/cg-snap-uuid-1");
+ }
+ return completedJob;
+ });
+
+ when(snapshotFeignClient.createConsistencyGroup(any(), any())).thenReturn(createJobResponse("job-cg-create"));
+ OntapResponse cgResponse = new OntapResponse<>();
+ ConsistencyGroup cgRecord = new ConsistencyGroup();
+ cgRecord.setUuid("cg-uuid-1");
+ cgResponse.setRecords(Collections.singletonList(cgRecord));
+ when(snapshotFeignClient.getConsistencyGroups(any(), any())).thenReturn(cgResponse);
+
+ when(snapshotFeignClient.createConsistencyGroupSnapshot(any(), eq("cg-uuid-1"), any()))
+ .thenReturn(createJobResponse("job-cg-start"));
+ OntapResponse cgSnapshotResponse = new OntapResponse<>();
+ ConsistencyGroupSnapshot cgSnapshotRecord = new ConsistencyGroupSnapshot();
+ cgSnapshotRecord.setUuid("cg-snap-uuid-1");
+ cgSnapshotRecord.setName(snapshotName);
+ cgSnapshotResponse.setRecords(Collections.singletonList(cgSnapshotRecord));
+ when(snapshotFeignClient.getConsistencyGroupSnapshots(any(), eq("cg-uuid-1"), any()))
+ .thenReturn(cgSnapshotResponse);
+ when(snapshotFeignClient.commitConsistencyGroupSnapshot(any(), eq("cg-uuid-1"), eq("cg-snap-uuid-1"), any()))
+ .thenReturn(createJobResponse("job-cg-commit"));
+
+ when(snapshotFeignClient.deleteConsistencyGroup(any(), eq("cg-uuid-1")))
+ .thenReturn(createJobResponse("job-cg-delete"));
+
+ OntapResponse flexVolSnapshots = new OntapResponse<>();
+ FlexVolSnapshot flexVolSnapshot = new FlexVolSnapshot();
+ flexVolSnapshot.setUuid("fv-snap-uuid-1");
+ flexVolSnapshot.setName(snapshotName);
+ flexVolSnapshots.setRecords(Collections.singletonList(flexVolSnapshot));
+ when(snapshotFeignClient.getSnapshots(any(), eq("flexvol-uuid-1"), any()))
+ .thenReturn(flexVolSnapshots);
+
+ OntapResponse flexVolSnapshots2 = new OntapResponse<>();
+ FlexVolSnapshot flexVolSnapshot2 = new FlexVolSnapshot();
+ flexVolSnapshot2.setUuid("fv-snap-uuid-2");
+ flexVolSnapshot2.setName(snapshotName);
+ flexVolSnapshots2.setRecords(Collections.singletonList(flexVolSnapshot2));
+ when(snapshotFeignClient.getSnapshots(any(), eq("flexvol-uuid-2"), any()))
+ .thenReturn(flexVolSnapshots2);
+ }
}
diff --git a/private-cicd/.gitignore b/private-cicd/.gitignore
new file mode 100644
index 000000000000..cb043ab88687
--- /dev/null
+++ b/private-cicd/.gitignore
@@ -0,0 +1,21 @@
+# 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.
+
+# Local overrides with secrets (templates *.example stay tracked)
+config/qa.yaml
+marvin/zones/*.cfg
+!marvin/zones/*.cfg.example
diff --git a/private-cicd/Jenkinsfile b/private-cicd/Jenkinsfile
new file mode 100644
index 000000000000..a4287c36f215
--- /dev/null
+++ b/private-cicd/Jenkinsfile
@@ -0,0 +1,311 @@
+/*
+ * 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.
+ */
+
+/*
+ * Private downstream Jenkins pipeline for Apache CloudStack.
+ * Not for inclusion in apache/cloudstack upstream.
+ *
+ * Rollout: Phase -1 = preflight (validate private-cicd only). Phase 1 = build-only. Phases 2–3 = marvin / delivery — see private-cicd/docs/IMPLEMENTATION-PHASES.md
+ *
+ * Usage:
+ * - Monorepo: Script Path = private-cicd/Jenkinsfile ; leave CLONE_SEPARATE = false.
+ * - CI-only repo: set CLONE_SEPARATE = true and point CLOUDSTACK_* at your fork/tag.
+ */
+
+pipeline {
+ agent any
+
+ options {
+ buildDiscarder(logRotator(numToKeepStr: '30'))
+ timestamps()
+ timeout(time: 4, unit: 'HOURS')
+ }
+
+ parameters {
+ choice(
+ name: 'PIPELINE_PHASE',
+ choices: ['build-ontap-fast', 'build-only', 'preflight', 'marvin', 'delivery'],
+ description: 'build-ontap-fast = ONTAP plugin -pl -am test. preflight = Phase -1. build-only = full Maven. marvin / delivery reserved; see private-cicd/docs/IMPLEMENTATION-PHASES.md'
+ )
+ string(
+ name: 'CONFIG_DEFAULTS_FILE',
+ defaultValue: 'config/defaults.yaml',
+ description: 'YAML path relative to CICD_ROOT (e.g. config/defaults.yaml or config/team-b.yaml).'
+ )
+ string(
+ name: 'CONFIG_PROFILE',
+ defaultValue: '',
+ description: 'Optional: key under profiles in the defaults YAML (e.g. example-lts-branch). Blank = use only top-level cloudstack: from the file.'
+ )
+ booleanParam(
+ name: 'CLONE_SEPARATE',
+ defaultValue: false,
+ description: 'If true, clone CloudStack into cloudstack-src/ (use when this job repo is CI-only). If false, expect CloudStack pom.xml at the workspace root (multibranch on your fork). Branch/url below apply only when this is true.'
+ )
+ string(
+ name: 'CLOUDSTACK_GIT_URL',
+ defaultValue: '',
+ description: 'Override Git URL for CloudStack. Leave blank to use config/defaults.yaml (after optional CONFIG_PROFILE).'
+ )
+ string(
+ name: 'CLOUDSTACK_GIT_BRANCH',
+ defaultValue: '',
+ description: 'Override branch or tag for clone. Leave blank to use config/defaults.yaml (after optional CONFIG_PROFILE). Ignored when CLONE_SEPARATE is false.'
+ )
+ booleanParam(
+ name: 'ENABLE_NOREDIST',
+ defaultValue: false,
+ description: 'If true, run third-party non-OSS install script then mvn -Dnoredist (see README / legal for your org).'
+ )
+ booleanParam(
+ name: 'SKIP_TESTS',
+ defaultValue: true,
+ description: 'If true, Maven uses -DskipTests=true (faster CI).'
+ )
+ booleanParam(
+ name: 'INSTALL_APT_DEPS',
+ defaultValue: true,
+ description: 'If true, run private-cicd/scripts/install-build-deps-ubuntu.sh (requires sudo on agent).'
+ )
+ booleanParam(
+ name: 'SETUP_IPMITOOL_WRAPPER',
+ defaultValue: false,
+ description: 'If true, install CloudStack-style ipmitool wrapper (requires sudo).'
+ )
+ }
+
+ environment {
+ MAVEN_OPTS = '-Xmx3072m -XX:MaxMetaspaceSize=512m'
+ }
+
+ stages {
+ stage('Phase gate') {
+ steps {
+ script {
+ switch (params.PIPELINE_PHASE) {
+ case 'preflight':
+ case 'build-ontap-fast':
+ case 'build-only':
+ break
+ case 'marvin':
+ case 'delivery':
+ error("PIPELINE_PHASE='${params.PIPELINE_PHASE}' is not implemented yet. Use preflight, build-ontap-fast, or build-only. See private-cicd/docs/IMPLEMENTATION-PHASES.md")
+ default:
+ error("PIPELINE_PHASE='${params.PIPELINE_PHASE}' is not supported.")
+ }
+ }
+ }
+ }
+
+ stage('Resolve CloudStack directory') {
+ steps {
+ script {
+ if (fileExists("${env.WORKSPACE}/private-cicd/scripts/install-build-deps-ubuntu.sh")) {
+ env.CICD_SCRIPT_DIR = "${env.WORKSPACE}/private-cicd/scripts"
+ env.CICD_ROOT = "${env.WORKSPACE}/private-cicd"
+ } else if (fileExists("${env.WORKSPACE}/scripts/install-build-deps-ubuntu.sh")) {
+ env.CICD_SCRIPT_DIR = "${env.WORKSPACE}/scripts"
+ env.CICD_ROOT = env.WORKSPACE
+ } else {
+ error('Cannot find install-build-deps-ubuntu.sh (expected private-cicd/scripts or scripts/).')
+ }
+ if (params.CLONE_SEPARATE) {
+ env.CLOUDSTACK_DIR = "${env.WORKSPACE}/cloudstack-src"
+ } else {
+ env.CLOUDSTACK_DIR = env.WORKSPACE
+ }
+
+ def rel = params.CONFIG_DEFAULTS_FILE?.trim()
+ if (!rel) {
+ rel = 'config/defaults.yaml'
+ }
+ rel = rel.replaceAll('^/+', '')
+ if (rel.contains('..')) {
+ error('CONFIG_DEFAULTS_FILE must stay under CICD_ROOT (no .. segments).')
+ }
+ def cfgPath = "${env.CICD_ROOT}/${rel}"
+ echo "Loading CI defaults from: ${cfgPath}"
+ def baseCfg = [cloudstack: [git_url: 'https://github.com/apache/cloudstack.git', branch: 'main']]
+ if (fileExists(cfgPath)) {
+ baseCfg = readYaml(file: cfgPath) ?: baseCfg
+ } else {
+ echo "No ${cfgPath}; using built-in CloudStack URL/branch defaults."
+ }
+
+ def cs = new LinkedHashMap((baseCfg.cloudstack ?: [:]) as Map)
+ def profileName = params.CONFIG_PROFILE?.trim()
+ if (profileName && baseCfg.profiles instanceof Map && baseCfg.profiles[profileName]) {
+ def overlay = baseCfg.profiles[profileName].cloudstack
+ if (overlay instanceof Map) {
+ cs.putAll(overlay as Map)
+ }
+ }
+
+ def urlOverride = params.CLOUDSTACK_GIT_URL?.trim()
+ def branchOverride = params.CLOUDSTACK_GIT_BRANCH?.trim()
+ env.EFFECTIVE_CLOUDSTACK_GIT_URL = urlOverride ?: (cs.git_url as String ?: 'https://github.com/apache/cloudstack.git')
+ env.EFFECTIVE_CLOUDSTACK_GIT_BRANCH = branchOverride ?: (cs.branch as String ?: 'main')
+
+ echo "CICD_ROOT=${env.CICD_ROOT} CICD_SCRIPT_DIR=${env.CICD_SCRIPT_DIR} CLOUDSTACK_DIR=${env.CLOUDSTACK_DIR}"
+ echo "CONFIG_PROFILE=${profileName ?: '(none)'} EFFECTIVE_CLOUDSTACK_GIT_URL=${env.EFFECTIVE_CLOUDSTACK_GIT_URL} EFFECTIVE_CLOUDSTACK_GIT_BRANCH=${env.EFFECTIVE_CLOUDSTACK_GIT_BRANCH}"
+ }
+ }
+ }
+
+ stage('Phase -1: validate private-cicd') {
+ when {
+ expression { return params.PIPELINE_PHASE == 'preflight' }
+ }
+ steps {
+ sh """CICD_ROOT='${env.CICD_ROOT}' bash '${env.CICD_SCRIPT_DIR}/validate-local.sh'"""
+ }
+ }
+
+ stage('Clone CloudStack') {
+ when {
+ allOf {
+ expression { return params.PIPELINE_PHASE in ['build-only', 'build-ontap-fast'] }
+ expression { return params.CLONE_SEPARATE }
+ }
+ }
+ steps {
+ sh 'rm -rf cloudstack-src && mkdir cloudstack-src'
+ dir('cloudstack-src') {
+ checkout(
+ [
+ $class : 'GitSCM',
+ branches : [[name: "*/${env.EFFECTIVE_CLOUDSTACK_GIT_BRANCH}"]],
+ doGenerateSubmoduleConfigurations: false,
+ extensions : [],
+ submoduleCfg : [],
+ userRemoteConfigs : [[url: env.EFFECTIVE_CLOUDSTACK_GIT_URL]]
+ ]
+ )
+ }
+ }
+ }
+
+ stage('Validate tree') {
+ when {
+ expression { return params.PIPELINE_PHASE in ['build-only', 'build-ontap-fast'] }
+ }
+ steps {
+ script {
+ def pom = "${env.CLOUDSTACK_DIR}/pom.xml"
+ if (!fileExists(pom)) {
+ error("Missing ${pom}. Enable CLONE_SEPARATE or check out CloudStack with Script Path private-cicd/Jenkinsfile at repo root.")
+ }
+ }
+ }
+ }
+
+ stage('Install OS build dependencies') {
+ when {
+ allOf {
+ expression { return params.PIPELINE_PHASE in ['build-only', 'build-ontap-fast'] }
+ expression { return params.INSTALL_APT_DEPS }
+ }
+ }
+ steps {
+ sh """bash '${env.CICD_SCRIPT_DIR}/install-build-deps-ubuntu.sh'"""
+ }
+ }
+
+ stage('Optional ipmitool wrapper') {
+ when {
+ allOf {
+ expression { return params.PIPELINE_PHASE in ['build-only', 'build-ontap-fast'] }
+ expression { return params.SETUP_IPMITOOL_WRAPPER }
+ }
+ }
+ steps {
+ sh """bash '${env.CICD_SCRIPT_DIR}/setup-ipmitool-wrapper.sh'"""
+ }
+ }
+
+ stage('Non-OSS (noredist)') {
+ when {
+ allOf {
+ expression { return params.PIPELINE_PHASE == 'build-only' }
+ expression { return params.ENABLE_NOREDIST }
+ }
+ }
+ steps {
+ dir("${env.CLOUDSTACK_DIR}") {
+ sh '''
+ set -e
+ rm -rf nonoss
+ git clone https://github.com/shapeblue/cloudstack-nonoss.git nonoss
+ cd nonoss && bash -x install-non-oss.sh
+ cd ..
+ rm -rf nonoss
+ '''
+ }
+ }
+ }
+
+ stage('Phase 1a: ONTAP fast build') {
+ when {
+ expression { return params.PIPELINE_PHASE == 'build-ontap-fast' }
+ }
+ steps {
+ dir("${env.CLOUDSTACK_DIR}") {
+ script {
+ def skip = params.SKIP_TESTS ? 'true' : 'false'
+ sh """SKIP_TESTS='${skip}' CLOUDSTACK_DIR='${env.CLOUDSTACK_DIR}' bash '${env.CICD_SCRIPT_DIR}/mvn-ontap-fast.sh'"""
+ }
+ }
+ }
+ }
+
+ stage('Phase 1: Maven build') {
+ when {
+ expression { return params.PIPELINE_PHASE == 'build-only' }
+ }
+ steps {
+ dir("${env.CLOUDSTACK_DIR}") {
+ script {
+ def skip = params.SKIP_TESTS ? '-DskipTests=true' : ''
+ def noredist = params.ENABLE_NOREDIST ? '-Dnoredist' : ''
+ sh "mvn -B -P developer,systemvm -Dsimulator ${noredist} clean install ${skip} -T\$(nproc)"
+ }
+ }
+ }
+ }
+ }
+
+ post {
+ always {
+ script {
+ if (params.PIPELINE_PHASE in ['build-only', 'build-ontap-fast']) {
+ dir("${env.CLOUDSTACK_DIR}") {
+ def junitGlob = '**/target/surefire-reports/*.xml'
+ if (params.PIPELINE_PHASE == 'build-ontap-fast') {
+ junitGlob = 'plugins/storage/volume/ontap/target/surefire-reports/*.xml'
+ }
+ junit testResults: junitGlob, allowEmptyResults: true
+ }
+ }
+ }
+ }
+ failure {
+ echo 'Pipeline failed — see console output.'
+ }
+ }
+}
diff --git a/private-cicd/README.md b/private-cicd/README.md
new file mode 100644
index 000000000000..4237784993ba
--- /dev/null
+++ b/private-cicd/README.md
@@ -0,0 +1,79 @@
+
+
+# Private CI/CD (downstream only)
+
+This directory is **not** part of Apache CloudStack upstream. Do **not** include it in pull requests to `apache/cloudstack`.
+
+## Option A (default): committed on the NetApp fork
+
+`private-cicd/` is **tracked on integration branches** (e.g. `dev_branch`, `netapp/main`). Jenkins uses the same checkout as CloudStack.
+
+- **Script Path:** `private-cicd/Jenkinsfile`
+- **Clone CloudStack separately:** leave unchecked (`CLONE_SEPARATE = false`)
+- **Upstream PRs:** use branches without `private-cicd/` commits — see [`docs/BRANCH-STRATEGY.md`](docs/BRANCH-STRATEGY.md)
+- **Layout:** [`docs/FOLDER-LAYOUT.md`](docs/FOLDER-LAYOUT.md)
+
+## Rollout phases
+
+| Phase | `PIPELINE_PHASE` | Status |
+|-------|------------------|--------|
+| **-1 — Preflight** | `preflight` | Implemented |
+| **1a — ONTAP fast** | `build-ontap-fast` | Implemented (`scripts/mvn-ontap-fast.sh`) |
+| **1 — Full build** | `build-only` | Implemented |
+| **2 — Marvin** | `marvin` | Stub — see `config/marvin.yaml`, `scripts/marvin-run.sh` |
+| **3 — CD** | `delivery` | Planned |
+
+## Quick start
+
+```bash
+# Validate CI tree only (no Maven)
+./private-cicd/scripts/validate-local.sh
+
+# ONTAP plugin compile + JUnit (from repo root; only ontap *Test.java, not whole tree)
+CLOUDSTACK_DIR=$PWD SKIP_TESTS=false ./private-cicd/scripts/mvn-ontap-fast.sh
+
+# Full build (long)
+CLOUDSTACK_DIR=$PWD SKIP_TESTS=true ./private-cicd/scripts/mvn-full.sh
+```
+
+Do not use `mvn -pl :cloud-plugin-storage-volume-ontap -am test` alone — `-am test` runs upstream module tests (e.g. `engine/schema`), which may call `sudo mount` and block on `Password:`.
+
+## Configuration
+
+| File | Purpose |
+|------|---------|
+| [`config/defaults.yaml`](config/defaults.yaml) | Git URL, branch, profiles |
+| [`config/build-fast.yaml`](config/build-fast.yaml) | ONTAP `-pl -am` settings |
+| [`config/marvin.yaml`](config/marvin.yaml) | Marvin phase (Phase 2) |
+| [`config/qa.yaml.example`](config/qa.yaml.example) | Copy to `qa.yaml` for local overrides (gitignored) |
+
+## Marvin tests
+
+- Product tests: `test/integration/plugins/ontap/`
+- CI bundles: [`marvin/bundles.txt`](marvin/bundles.txt)
+- Zone templates: [`marvin/zones/`](marvin/zones/)
+
+## Alternative: separate CI repository
+
+Copy this tree to a dedicated repo and set `CLONE_SEPARATE = true` in Jenkins. See historical note in git history if you migrate from Option A.
+
+## License
+
+Scripts and the Jenkinsfile are for internal use only; not submitted to the ASF as part of CloudStack.
diff --git a/private-cicd/config/build-fast.yaml b/private-cicd/config/build-fast.yaml
new file mode 100644
index 000000000000..4e92a99be3bb
--- /dev/null
+++ b/private-cicd/config/build-fast.yaml
@@ -0,0 +1,44 @@
+# 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.
+
+# Fast build profile: ONTAP storage plugin only (Maven reactor -pl -am).
+# Used by PIPELINE_PHASE=build-ontap-fast and scripts/mvn-ontap-fast.sh.
+
+ontap:
+ maven_artifact: cloud-plugin-storage-volume-ontap
+ maven_pl: ":cloud-plugin-storage-volume-ontap"
+ plugin_path: plugins/storage/volume/ontap
+
+maven:
+ profiles: developer
+ skip_tests: false
+ extra_args: ""
+ # Two-step: (-am -DskipTests install) then (-pl ontap test). Never use "-am test"
+ # or upstream modules (e.g. engine/schema) run sudo-mount tests and hang on Password:
+ steps:
+ - install_deps_skip_tests
+ - test_ontap_only
+
+# Paths that trigger a full build when changed (git diff); optional in Jenkins later.
+change_detection:
+ full_build_paths:
+ - client/
+ - engine/
+ - api/
+ - framework/
+ - plugins/storage/volume/default/
+ - pom.xml
diff --git a/private-cicd/config/defaults.yaml b/private-cicd/config/defaults.yaml
new file mode 100644
index 000000000000..354327cd80ef
--- /dev/null
+++ b/private-cicd/config/defaults.yaml
@@ -0,0 +1,42 @@
+# 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.
+
+# Private CI defaults — safe to extend with new repositories or profiles.
+# Jenkins resolves: parameter override > profile overlay > values here.
+#
+# Extension ideas (not all wired in Jenkins yet):
+# nonoss: { git_url: "...", branch: "..." }
+#
+# Phase 2 (Marvin) — reserved; Jenkinsfile will read when implemented:
+# marvin:
+# python_version: "3.10"
+# zone_config: setup/dev/advdualzone.cfg
+#
+# Phase 3 (CD) — reserved:
+# delivery:
+# artifact_repo_url: https://artifacts.example.com/cloudstack
+
+cloudstack:
+ git_url: https://github.com/apache/cloudstack.git
+ branch: main
+
+# Optional named presets. Select via Jenkins parameter CONFIG_PROFILE.
+profiles:
+ example-lts-branch:
+ cloudstack:
+ branch: "4.19"
+ git_url: https://github.com/apache/cloudstack.git
diff --git a/private-cicd/config/marvin.yaml b/private-cicd/config/marvin.yaml
new file mode 100644
index 000000000000..1d6768814e82
--- /dev/null
+++ b/private-cicd/config/marvin.yaml
@@ -0,0 +1,43 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Marvin / simulator integration (Phase 2). Jenkins reads this when PIPELINE_PHASE=marvin.
+
+python_version: "3.10"
+
+# Zone config under private-cicd/marvin/zones/ (lab secrets stay in Jenkins credentials).
+zone_config: marvin/zones/ontap-simulator.cfg.example
+
+# Test paths relative to CloudStack test/integration/ (see marvin/bundles.txt).
+default_bundle: ontap-smoke
+
+bundles:
+ ontap-smoke:
+ - plugins/ontap/test_ontap_smoke.py
+ ontap-all:
+ - plugins/ontap/
+
+maven:
+ # Marvin needs a built management server; run full build before marvin phase.
+ require_full_build: true
+ profiles: developer,systemvm
+ simulator: true
+
+mysql:
+ database: cloud
+ user: cloud
+ # password: set via Jenkins credential / env MARVIN_DB_PASSWORD
diff --git a/private-cicd/config/qa.yaml.example b/private-cicd/config/qa.yaml.example
new file mode 100644
index 000000000000..edf727396075
--- /dev/null
+++ b/private-cicd/config/qa.yaml.example
@@ -0,0 +1,28 @@
+# 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.
+
+# Copy to config/qa.yaml and customize (qa.yaml may be gitignored locally if it contains secrets).
+# Select in Jenkins via CONFIG_DEFAULTS_FILE=config/qa.yaml
+
+cloudstack:
+ git_url: https://github.com/NetApp/netapp-cloudstack.git
+ branch: dev_branch
+
+profiles:
+ nightly:
+ cloudstack:
+ branch: main
diff --git a/private-cicd/docker/Dockerfile.agent b/private-cicd/docker/Dockerfile.agent
new file mode 100644
index 000000000000..0f8077ac8430
--- /dev/null
+++ b/private-cicd/docker/Dockerfile.agent
@@ -0,0 +1,34 @@
+# 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.
+
+# Jenkins (or other CI) build agent image — private downstream use only.
+FROM ubuntu:22.04
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ LANG=C.UTF-8
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates curl git openssh-client \
+ openjdk-17-jdk-headless maven \
+ python3 python3-pip python3-venv \
+ && rm -rf /var/lib/apt/lists/*
+
+# Optional: run private-cicd/scripts/install-build-deps-ubuntu.sh at image build time
+COPY scripts/install-build-deps-ubuntu.sh /tmp/install-build-deps-ubuntu.sh
+RUN chmod +x /tmp/install-build-deps-ubuntu.sh && /tmp/install-build-deps-ubuntu.sh && rm -f /tmp/install-build-deps-ubuntu.sh
+
+WORKDIR /workspace
diff --git a/private-cicd/docs/BRANCH-STRATEGY.md b/private-cicd/docs/BRANCH-STRATEGY.md
new file mode 100644
index 000000000000..1185a38d2eb0
--- /dev/null
+++ b/private-cicd/docs/BRANCH-STRATEGY.md
@@ -0,0 +1,44 @@
+
+
+# Branch strategy (Option A — private-cicd on the fork)
+
+`private-cicd/` is **committed** on NetApp integration branches (e.g. `dev_branch`, `netapp/main`). It is **not** included in pull requests to `apache/cloudstack`.
+
+## Recommended workflow
+
+1. **Integration branch** (`dev_branch`, `netapp/main`) — contains `private-cicd/` and ONTAP plugin work.
+2. **Upstream PR branch** — create from `apache/cloudstack` (or rebase) **without** `private-cicd/` commits.
+3. **Jenkins** — multibranch on the fork; **Script Path** = `private-cicd/Jenkinsfile`; `CLONE_SEPARATE` = false.
+
+## Before opening an Apache PR
+
+```bash
+# Ensure private-cicd is not in the commits you push upstream
+git log --oneline apache/main..HEAD -- private-cicd/
+```
+
+If those commits appear, recreate the PR branch from upstream and cherry-pick only product changes.
+
+## Files that may stay local only
+
+- `config/qa.yaml` (copy from `config/qa.yaml.example`)
+- `marvin/zones/*.cfg` (non-`.example` configs with secrets)
+
+Optional: add `config/qa.yaml` and `marvin/zones/*.cfg` to `.gitignore` while keeping `private-cicd/` tracked.
diff --git a/private-cicd/docs/FOLDER-LAYOUT.md b/private-cicd/docs/FOLDER-LAYOUT.md
new file mode 100644
index 000000000000..f2b57b84f4e9
--- /dev/null
+++ b/private-cicd/docs/FOLDER-LAYOUT.md
@@ -0,0 +1,56 @@
+
+
+# private-cicd folder layout
+
+```text
+private-cicd/
+├── Jenkinsfile # Main pipeline (preflight, build-only, build-ontap-fast, …)
+├── README.md
+├── config/
+│ ├── defaults.yaml # Git URL, profiles
+│ ├── build-fast.yaml # ONTAP -pl -am settings
+│ ├── marvin.yaml # Phase 2 Marvin settings
+│ └── qa.yaml.example # Team override template
+├── docker/
+│ └── Dockerfile.agent # Optional Jenkins agent image
+├── docs/
+│ ├── BRANCH-STRATEGY.md # Option A: fork vs upstream PRs
+│ ├── FOLDER-LAYOUT.md # This file
+│ └── IMPLEMENTATION-PHASES.md
+├── marvin/
+│ ├── bundles.txt # Named test lists
+│ ├── README.md
+│ └── zones/ # Zone cfg templates (secrets not committed)
+├── scripts/
+│ ├── install-build-deps-ubuntu.sh
+│ ├── setup-ipmitool-wrapper.sh
+│ ├── validate-local.sh
+│ ├── mvn-ontap-fast.sh # ONTAP compile + JUnit
+│ ├── mvn-full.sh # Full mvn install
+│ └── marvin-run.sh # Phase 2 (stub)
+└── test/ # (none — Marvin tests under CloudStack test/integration/plugins/ontap/)
+```
+
+CloudStack product paths used by CI:
+
+| Path | Role |
+|------|------|
+| `plugins/storage/volume/ontap/` | ONTAP plugin source + JUnit |
+| `test/integration/plugins/ontap/` | Marvin tests |
diff --git a/private-cicd/docs/IMPLEMENTATION-PHASES.md b/private-cicd/docs/IMPLEMENTATION-PHASES.md
new file mode 100644
index 000000000000..5b93046ad601
--- /dev/null
+++ b/private-cicd/docs/IMPLEMENTATION-PHASES.md
@@ -0,0 +1,114 @@
+
+
+# Private CI/CD — three-phase rollout
+
+This file is under `private-cicd/` only (not for Apache CloudStack upstream).
+
+## Phase -1 — Preflight (`preflight`)
+
+**Goal:** Validate the private CI tree itself (shell syntax, YAML) on the **same** Jenkins agent you will use for builds — **no** CloudStack `pom.xml`, **no** Maven, **no** clone (unless you later add optional checks).
+
+**Implemented:**
+
+- Jenkins: set `PIPELINE_PHASE` to **`preflight`**. After `Resolve CloudStack directory`, the job runs `scripts/validate-local.sh` with `CICD_ROOT` pointing at this tree.
+- Locally: `./private-cicd/scripts/validate-local.sh` (same checks).
+
+**Agent needs:** `bash`; for YAML parsing, one of Ruby (stdlib `yaml`), Python with PyYAML, or `yq`. If none are present, the script skips YAML with a message (non-fatal); install a parser on agents for strict validation.
+
+**How to validate behaviour:** Run a Jenkins job with `preflight` and confirm the **Phase -1: validate private-cicd** stage is green and no Maven stages run. Then run `build-only` for Phase 1.
+
+## Phase 1a — ONTAP fast build (`build-ontap-fast`)
+
+**Goal:** Fast PR feedback — compile and JUnit for `cloud-plugin-storage-volume-ontap` only (`-pl -am test`).
+
+**Implemented:**
+
+- `scripts/mvn-ontap-fast.sh`, `config/build-fast.yaml`
+- Jenkins stage **Phase 1a: ONTAP fast build**
+- JUnit glob: `plugins/storage/volume/ontap/target/surefire-reports/*.xml`
+
+**Local:**
+
+```bash
+CLOUDSTACK_DIR=$PWD SKIP_TESTS=false ./private-cicd/scripts/mvn-ontap-fast.sh
+```
+
+**Note:** Does not replace full build before Marvin or release; run `build-only` on merge/nightly.
+
+**Important:** `mvn-ontap-fast.sh` uses two Maven invocations: `(-am -DskipTests install)` then `(-pl ontap test)`. A single `mvn -pl ontap -am test` runs upstream tests (e.g. `engine/schema` / `SystemVmTemplateRegistrationTest`) and can hang on `sudo` `Password:`.
+
+## Phase 1 — Build only (full)
+
+**Goal:** Prove a reproducible compile on your Jenkins agents (same intent as upstream `.github/workflows/build.yml`, without living in `.github/`).
+
+**Implemented:**
+
+- Declarative pipeline: `PIPELINE_PHASE` = **build-only**.
+- Optional fast path: **build-ontap-fast** (default first choice in Jenkins parameter list).
+- Checkout modes: multibranch workspace vs separate clone (`CLONE_SEPARATE`).
+- Config-driven Git URL/branch via `config/defaults.yaml` + optional `CONFIG_PROFILE`.
+- OS packages script, optional ipmitool wrapper, optional noredist, Maven `developer,systemvm` + `simulator`, JUnit collection from Surefire.
+- Local checks: `scripts/validate-local.sh`.
+- Optional agent image: `docker/Dockerfile.agent`.
+
+**Your checklist to “done” for Phase 1:**
+
+1. Jenkins: Pipeline + Git + Pipeline Utility Steps (`readYaml`); agent with JDK 17, Maven, RAM/disk.
+2. One successful run with your real `defaults.yaml` / profile and `SKIP_TESTS=true`, then decide on `SKIP_TESTS=false`.
+3. Optional: bake deps into the Docker agent and disable `INSTALL_APT_DEPS` on agents without sudo.
+
+## Phase 2 — Marvin / simulator integration (next)
+
+**Goal:** Run a subset (or matrix) of `test/integration/` against management server in simulator mode, aligned with upstream `ci.yml` patterns.
+
+**Planned work (not implemented yet):**
+
+- MySQL service, DB deploy goals, Marvin wheel install, Jacoco (optional), `nosetests` with xUnit output.
+- Jenkins **matrix** or parallel branches for test bundles; timeouts and log archival (`MarvinLogs`).
+- Extend `config/defaults.yaml` (or a dedicated `config/marvin.yaml`) for Python version, test lists, `MAVEN_OPTS`, zone config paths.
+- Either new stages in a second `Jenkinsfile` (e.g. `Jenkinsfile.marvin`) or the same repo with `PIPELINE_PHASE=marvin` wired to those stages.
+
+**Prerequisite:** Phase 1 green on the same (or larger) agent class.
+
+## Phase 3 — CD (delivery / deploy)
+
+**Goal:** Promote built artifacts (DEB/RPM, images, internal packages) to repositories and optionally to environments with approvals.
+
+**Planned work (not implemented yet):**
+
+- Package or image build stage; push to internal registry/artifact server (credentials via Jenkins).
+- Promotion model: dev → staging → prod; manual `input` or external approval gate.
+- Rollback notes and config per environment (separate YAML or Jenkins credentials).
+
+**Prerequisite:** Stable artifact identity from Phase 1 (and usually test confidence from Phase 2).
+
+---
+
+## Jenkins parameter `PIPELINE_PHASE`
+
+| Value | Behavior |
+|------------|----------|
+| `build-ontap-fast` | Phase 1a: ONTAP `-pl -am test` via `mvn-ontap-fast.sh`. |
+| `build-only` | Phase 1: full `mvn clean install`. |
+| `preflight` | Phase -1: runs `validate-local.sh` only; skips Maven and CloudStack tree validation. |
+| `marvin` | Fails fast until Phase 2 is implemented (reserved). |
+| `delivery` | Fails fast until Phase 3 is implemented (reserved). |
+
+This keeps one job definition while you extend the repo over time.
diff --git a/private-cicd/marvin/README.md b/private-cicd/marvin/README.md
new file mode 100644
index 000000000000..a4a8ef30671e
--- /dev/null
+++ b/private-cicd/marvin/README.md
@@ -0,0 +1,29 @@
+
+
+# Marvin assets (private CI)
+
+- **`zones/`** — Zone / simulator configuration templates. Copy `*.example` to `*.cfg` locally or inject paths via Jenkins; do not commit secrets.
+- **`bundles.txt`** — Named test lists for Jenkins matrix jobs (`bundle-name=path1,path2`).
+
+Product Marvin tests live under CloudStack:
+
+`test/integration/plugins/ontap/`
+
+Those tests can be submitted upstream when appropriate; this directory stays downstream-only.
diff --git a/private-cicd/marvin/bundles.txt b/private-cicd/marvin/bundles.txt
new file mode 100644
index 000000000000..994cfcdbab22
--- /dev/null
+++ b/private-cicd/marvin/bundles.txt
@@ -0,0 +1,22 @@
+# 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.
+
+# Named Marvin test bundles (paths relative to test/integration/).
+# Used by scripts/marvin-run.sh and config/marvin.yaml.
+
+ontap-smoke=plugins/ontap/test_ontap_smoke.py
+ontap-all=plugins/ontap/
diff --git a/private-cicd/marvin/zones/README.md b/private-cicd/marvin/zones/README.md
new file mode 100644
index 000000000000..8ab0bb28a33f
--- /dev/null
+++ b/private-cicd/marvin/zones/README.md
@@ -0,0 +1,27 @@
+
+
+# Zone configuration
+
+Place simulator or lab zone files here, for example:
+
+- `ontap-simulator.cfg` — used by Jenkins Marvin jobs (not committed if it contains secrets)
+- `ontap-simulator.cfg.example` — template without credentials
+
+Reference upstream samples under `setup/dev/` in the CloudStack tree when authoring configs.
diff --git a/private-cicd/marvin/zones/ontap-simulator.cfg.example b/private-cicd/marvin/zones/ontap-simulator.cfg.example
new file mode 100644
index 000000000000..af240366de44
--- /dev/null
+++ b/private-cicd/marvin/zones/ontap-simulator.cfg.example
@@ -0,0 +1,29 @@
+# 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.
+
+# Example zone stub for ONTAP Marvin jobs. Copy to ontap-simulator.cfg and align with setup/dev/*.cfg.
+# Do not commit real credentials.
+
+[cloud]
+name = CloudStack CI ONTAP
+dns1 = 8.8.8.8
+dns2 = 8.8.4.4
+internaldns1 = 8.8.8.8
+internaldns2 = 8.8.4.4
+
+# Extend with zone, pod, cluster, primary storage, and ONTAP provider settings
+# per your simulator or lab layout.
diff --git a/private-cicd/scripts/install-build-deps-ubuntu.sh b/private-cicd/scripts/install-build-deps-ubuntu.sh
new file mode 100755
index 000000000000..8ea184377ee3
--- /dev/null
+++ b/private-cicd/scripts/install-build-deps-ubuntu.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# 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.
+
+# Private downstream build dependencies (Ubuntu/Debian).
+# Aligns loosely with Apache CloudStack GitHub Actions build workflow.
+
+set -euo pipefail
+
+if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
+ SUDO="sudo"
+else
+ SUDO=""
+fi
+
+$SUDO apt-get update
+$SUDO apt-get install -y \
+ git \
+ uuid-runtime \
+ genisoimage \
+ netcat-openbsd \
+ ipmitool \
+ build-essential \
+ libgcrypt20 \
+ libgpg-error-dev \
+ libgpg-error0 \
+ libopenipmi0 \
+ libpython3-dev \
+ libssl-dev \
+ libffi-dev \
+ python3-openssl \
+ python3-dev \
+ python3-setuptools \
+ wget \
+ unzip
diff --git a/private-cicd/scripts/marvin-run.sh b/private-cicd/scripts/marvin-run.sh
new file mode 100755
index 000000000000..404f40bf1f14
--- /dev/null
+++ b/private-cicd/scripts/marvin-run.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# 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.
+
+# Run Marvin integration tests (Phase 2 stub). Requires prior full Maven build + Marvin install.
+# See private-cicd/docs/IMPLEMENTATION-PHASES.md and config/marvin.yaml.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CICD_ROOT="${CICD_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
+CLOUDSTACK_DIR="${CLOUDSTACK_DIR:?Set CLOUDSTACK_DIR}"
+
+BUNDLE="${MARVIN_BUNDLE:-ontap-smoke}"
+BUNDLES_FILE="${CICD_ROOT}/marvin/bundles.txt"
+
+echo "==> Marvin run (bundle=$BUNDLE) — not fully wired yet."
+echo " CloudStack: $CLOUDSTACK_DIR"
+echo " Bundles file: $BUNDLES_FILE"
+echo " Implement: MS simulator start, DB deploy, nosetests per config/marvin.yaml"
+exit 1
diff --git a/private-cicd/scripts/mvn-full.sh b/private-cicd/scripts/mvn-full.sh
new file mode 100755
index 000000000000..a1b21cc108e9
--- /dev/null
+++ b/private-cicd/scripts/mvn-full.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# 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.
+
+# Full CloudStack Maven build (Phase 1). Mirrors private-cicd/Jenkinsfile build-only stage.
+
+set -euo pipefail
+
+CLOUDSTACK_DIR="${CLOUDSTACK_DIR:?Set CLOUDSTACK_DIR to the CloudStack repo root}"
+
+SKIP_TESTS="${SKIP_TESTS:-false}"
+ENABLE_NOREDIST="${ENABLE_NOREDIST:-false}"
+THREADS="${MAVEN_THREADS:-$(nproc)}"
+
+skip_flag=""
+if [[ "$SKIP_TESTS" == "true" ]]; then
+ skip_flag="-DskipTests=true"
+fi
+
+noredist_flag=""
+if [[ "$ENABLE_NOREDIST" == "true" ]]; then
+ noredist_flag="-Dnoredist"
+fi
+
+echo "==> Full build in $CLOUDSTACK_DIR"
+cd "$CLOUDSTACK_DIR"
+
+exec mvn -B -P developer,systemvm -Dsimulator \
+ ${noredist_flag} \
+ clean install \
+ ${skip_flag} \
+ -T"${THREADS}"
diff --git a/private-cicd/scripts/mvn-ontap-fast.sh b/private-cicd/scripts/mvn-ontap-fast.sh
new file mode 100755
index 000000000000..64c5122cc414
--- /dev/null
+++ b/private-cicd/scripts/mvn-ontap-fast.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# 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.
+
+# Compile and run JUnit for the ONTAP volume plugin only (-pl -am). Downstream CI only.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CICD_ROOT="${CICD_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
+CLOUDSTACK_DIR="${CLOUDSTACK_DIR:-$(cd "$CICD_ROOT/.." && pwd)}"
+
+if [[ ! -f "$CLOUDSTACK_DIR/pom.xml" ]]; then
+ echo "CLOUDSTACK_DIR must contain pom.xml (got: $CLOUDSTACK_DIR)" >&2
+ exit 1
+fi
+
+SKIP_TESTS="${SKIP_TESTS:-false}"
+EXTRA="${MAVEN_EXTRA_ARGS:-}"
+
+skip_flag=""
+if [[ "$SKIP_TESTS" == "true" ]]; then
+ skip_flag="-DskipTests=true"
+fi
+
+echo "==> ONTAP fast build in $CLOUDSTACK_DIR"
+cd "$CLOUDSTACK_DIR"
+
+# Step 1: compile/install plugin dependencies (-am) without running their tests.
+# Using "mvn … -am test" would run the entire upstream reactor (e.g. engine/schema
+# SystemVmTemplateRegistrationTest), which can invoke "sudo mount" and hang on Password:
+echo "==> Step 1/2: install dependencies (skipTests)"
+mvn -B -P developer \
+ -pl :cloud-plugin-storage-volume-ontap -am \
+ -DskipTests=true \
+ $EXTRA \
+ install
+
+if [[ "$SKIP_TESTS" == "true" ]]; then
+ echo "==> Step 2/2: skipped (SKIP_TESTS=true)"
+ exit 0
+fi
+
+# Step 2: run tests only on the ONTAP plugin module (no -am).
+echo "==> Step 2/2: ONTAP plugin tests only"
+exec mvn -B -P developer \
+ -pl :cloud-plugin-storage-volume-ontap \
+ $EXTRA \
+ test
diff --git a/private-cicd/scripts/setup-ipmitool-wrapper.sh b/private-cicd/scripts/setup-ipmitool-wrapper.sh
new file mode 100755
index 000000000000..23e0bc339c28
--- /dev/null
+++ b/private-cicd/scripts/setup-ipmitool-wrapper.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# 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.
+
+# Optional CloudStack-style ipmitool wrapper (matches upstream CI expectations).
+set -euo pipefail
+
+if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
+ SUDO="sudo"
+else
+ SUDO=""
+fi
+
+$SUDO mkdir -p /usr/share/cloudstack-common
+if [[ ! -f /usr/share/cloudstack-common/ipmitool ]]; then
+ $SUDO cp /usr/bin/ipmitool /usr/share/cloudstack-common/ipmitool
+ $SUDO chmod 755 /usr/share/cloudstack-common/ipmitool
+fi
+
+$SUDO tee /usr/bin/ipmitool > /dev/null << 'EOF'
+#!/bin/bash
+/usr/share/cloudstack-common/ipmitool -C3 "$@"
+EOF
+$SUDO chmod 755 /usr/bin/ipmitool
diff --git a/private-cicd/scripts/validate-local.sh b/private-cicd/scripts/validate-local.sh
new file mode 100755
index 000000000000..e13da6ca88ce
--- /dev/null
+++ b/private-cicd/scripts/validate-local.sh
@@ -0,0 +1,136 @@
+#!/usr/bin/env bash
+# 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.
+
+# Local validation for private-cicd only — no CloudStack source required.
+# Safe to keep out of upstream Apache PR scope (entire tree under private-cicd/).
+
+set -euo pipefail
+
+usage() {
+ cat << 'EOF'
+Usage: validate-local.sh [--with-docker]
+
+ Validates shell scripts (bash -n) and YAML under this repo's private-cicd
+ (or standalone CI repo) root — independent of CloudStack merge scope.
+
+ --with-docker Also run: docker build -f docker/Dockerfile.agent .
+ (requires Docker; run from CICD_ROOT or set CICD_ROOT).
+
+Environment:
+ CICD_ROOT Root containing scripts/, config/, docker/ (default: inferred).
+
+Exit status: 0 if all checks pass, non-zero otherwise.
+EOF
+}
+
+WITH_DOCKER=false
+for arg in "$@"; do
+ case "$arg" in
+ -h|--help) usage; exit 0 ;;
+ --with-docker) WITH_DOCKER=true ;;
+ *) echo "Unknown option: $arg" >&2; usage >&2; exit 2 ;;
+ esac
+done
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+if [[ -n "${CICD_ROOT:-}" ]]; then
+ CICD_ROOT="$(cd "$CICD_ROOT" && pwd)"
+else
+ CICD_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+fi
+
+echo "==> CICD_ROOT=$CICD_ROOT"
+
+failures=0
+
+run_check() {
+ local name="$1"
+ shift
+ echo "==> $name"
+ if "$@"; then
+ echo " OK"
+ else
+ echo " FAILED" >&2
+ failures=$((failures + 1))
+ fi
+}
+
+# --- Shell: bash -n on all scripts/*.sh (no CloudStack tree required)
+while IFS= read -r -d '' f; do
+ run_check "bash -n: ${f#$CICD_ROOT/}" bash -n "$f"
+done < <(find "$CICD_ROOT/scripts" -maxdepth 1 -type f -name '*.sh' -print0 2>/dev/null || true)
+
+if [[ ! -d "$CICD_ROOT/scripts" ]]; then
+ echo "No scripts directory at $CICD_ROOT/scripts" >&2
+ failures=$((failures + 1))
+fi
+
+# --- YAML under config/
+yaml_ok=false
+if command -v ruby >/dev/null 2>&1 && ruby -ryaml -e 'true' >/dev/null 2>&1; then
+ yaml_check() { ruby -ryaml -e "YAML.load_file(ARGV[0])" "$1"; }
+ yaml_ok=true
+elif command -v python3 >/dev/null 2>&1; then
+ if python3 -c 'import yaml' >/dev/null 2>&1; then
+ yaml_check() { python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" "$1"; }
+ yaml_ok=true
+ fi
+elif command -v yq >/dev/null 2>&1; then
+ yaml_check() { yq e '.' "$1" >/dev/null; }
+ yaml_ok=true
+fi
+
+if [[ -d "$CICD_ROOT/config" ]]; then
+ shopt -s nullglob
+ yfiles=("$CICD_ROOT"/config/*.yaml "$CICD_ROOT"/config/*.yml)
+ shopt -u nullglob
+ if [[ ${#yfiles[@]} -eq 0 ]]; then
+ echo "==> YAML: no *.yaml in $CICD_ROOT/config (skipped)"
+ elif [[ "$yaml_ok" == true ]]; then
+ for f in "${yfiles[@]}"; do
+ run_check "YAML: ${f#$CICD_ROOT/}" yaml_check "$f"
+ done
+ else
+ echo "==> YAML: skipped (install Ruby+psych, PyYAML, or yq to validate)" >&2
+ fi
+else
+ echo "==> YAML: no config directory (skipped)"
+fi
+
+# --- Docker (optional)
+if [[ "$WITH_DOCKER" == true ]]; then
+ if ! command -v docker >/dev/null 2>&1; then
+ echo "==> docker: not installed, skipping" >&2
+ failures=$((failures + 1))
+ else
+ df="$CICD_ROOT/docker/Dockerfile.agent"
+ if [[ ! -f "$df" ]]; then
+ echo "==> docker: missing $df" >&2
+ failures=$((failures + 1))
+ else
+ run_check "docker build (agent image)" docker build -f "$df" -t cloudstack-private-cicd-agent:validate "$CICD_ROOT"
+ fi
+ fi
+fi
+
+if [[ "$failures" -ne 0 ]]; then
+ echo "==> Done with $failures failure(s)." >&2
+ exit 1
+fi
+
+echo "==> All checks passed."
diff --git a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
index dc33a4442a33..7ce4f0908f63 100755
--- a/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
+++ b/server/src/main/java/com/cloud/storage/snapshot/SnapshotManagerImpl.java
@@ -51,6 +51,7 @@
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreCapabilities;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager;
+import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider;
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.ObjectInDataStoreStateMachine;
@@ -1636,7 +1637,13 @@ public SnapshotInfo takeSnapshot(VolumeInfo volume) throws ResourceAllocationExc
StoragePoolType poolType = volume.getStoragePoolType();
- if (isKvmAndFileBasedStorage && backupSnapToSecondary) {
+ // NetApp ONTAP managed PRIMARY snapshots remain on primary/array storage (FlexVol).
+ // They must not use secondary archive bookkeeping (postSnapshotDirectlyToSecondary) or a physical
+ // secondary copy — delete is handled via StorageSystemSnapshotStrategy → driver deleteAsync.
+ boolean archiveSnapshotToSecondary = backupSnapToSecondary
+ && !isManagedPrimaryLocationSnapshot(storagePool, payload);
+
+ if (isKvmAndFileBasedStorage && archiveSnapshotToSecondary) {
DataStore imageStore = snapshotSrv.findSnapshotImageStore(snapshot);
if (imageStore == null) {
throw new CloudRuntimeException(String.format("Could not find any secondary storage to allocate snapshot [%s].", snapshot));
@@ -1659,7 +1666,7 @@ public SnapshotInfo takeSnapshot(VolumeInfo volume) throws ResourceAllocationExc
SnapshotInfo snapshotOnPrimary = snapshotStrategy.takeSnapshot(snapshot);
- if (backupSnapToSecondary) {
+ if (archiveSnapshotToSecondary) {
if (!isKvmAndFileBasedStorage) {
backupSnapshotToSecondary(payload.getAsyncBackup(), snapshotStrategy, snapshotOnPrimary, payload.getZoneIds(), payload.getStoragePoolIds());
if (!payload.getAsyncBackup() && ClvmPoolManager.isClvmPoolType(storagePool.getPoolType())) {
@@ -1669,7 +1676,15 @@ public SnapshotInfo takeSnapshot(VolumeInfo volume) throws ResourceAllocationExc
postSnapshotDirectlyToSecondary(snapshot, snapshotOnPrimary, snapshotId);
}
} else {
- logger.debug("Skipping backup of snapshot [{}] to secondary due to configuration [{}].", snapshotOnPrimary.getUuid(), SnapshotInfo.BackupSnapshotAfterTakingSnapshot.key());
+ if (backupSnapToSecondary && isManagedPrimaryLocationSnapshot(storagePool, payload)) {
+ logger.info("takeSnapshot: snapshot [{}] on NetApp ONTAP managed primary pool [{}] with locationType=PRIMARY — "
+ + "keeping snapshot on primary/array storage only; not archiving to secondary "
+ + "(backup.snapshot.after.take is ignored for this snapshot class)",
+ snapshotId, storagePool.getId());
+ } else {
+ logger.debug("Skipping backup of snapshot [{}] to secondary due to configuration [{}].",
+ snapshotOnPrimary.getUuid(), SnapshotInfo.BackupSnapshotAfterTakingSnapshot.key());
+ }
if (CollectionUtils.isNotEmpty(payload.getStoragePoolIds()) && payload.getAsyncBackup()) {
snapshotStrategy = _storageStrategyFactory.getSnapshotStrategy(snapshot, SnapshotOperation.COPY);
@@ -1685,10 +1700,10 @@ public SnapshotInfo takeSnapshot(VolumeInfo volume) throws ResourceAllocationExc
snapshotZoneDao.addSnapshotToZone(snapshotId, snapshot.getDataCenterId());
DataStoreRole dataStoreRole;
- if (payload.getAsyncBackup() && backupSnapToSecondary && !isKvmAndFileBasedStorage) {
+ if (payload.getAsyncBackup() && archiveSnapshotToSecondary && !isKvmAndFileBasedStorage) {
dataStoreRole = DataStoreRole.Primary;
} else {
- dataStoreRole = backupSnapToSecondary ? snapshotHelper.getDataStoreRole(snapshot) : DataStoreRole.Primary;
+ dataStoreRole = archiveSnapshotToSecondary ? snapshotHelper.getDataStoreRole(snapshot) : DataStoreRole.Primary;
}
List snapshotStoreRefs = _snapshotStoreDao.listReadyBySnapshot(snapshotId, dataStoreRole);
@@ -1704,7 +1719,7 @@ public SnapshotInfo takeSnapshot(VolumeInfo volume) throws ResourceAllocationExc
_resourceLimitMgr.decrementResourceCount(snapshotOwner.getId(), storeResourceType, volume.getSize() - snapshotStoreRef.getPhysicalSize());
if (!payload.getAsyncBackup()) {
- if (backupSnapToSecondary) {
+ if (archiveSnapshotToSecondary) {
copyNewSnapshotToZones(snapshotId, snapshot.getDataCenterId(), payload.getZoneIds());
}
if (CollectionUtils.isNotEmpty(payload.getStoragePoolIds())) {
@@ -1734,6 +1749,12 @@ public SnapshotInfo takeSnapshot(VolumeInfo volume) throws ResourceAllocationExc
return snapshot;
}
+ /**
+ * KVM file-based fast-path: records snapshot on image store without copying bytes, then drops the
+ * primary {@code snapshot_data_store} row. Used only for non-managed primary storage when
+ * {@code backup.snapshot.after.take} is enabled. NetApp ONTAP managed PRIMARY snapshots never
+ * call this method — see {@link #isManagedPrimaryLocationSnapshot}.
+ */
private void postSnapshotDirectlyToSecondary(SnapshotInfo snapshot, SnapshotInfo snapshotOnPrimary, Long snapshotId) {
logger.debug("{} was directly copied to secondary storage because the hypervisor is KVM, the primary storage is file-based and the [{}] configuration" +
" is set to true.", snapshot.getSnapshotVO().toString(), SnapshotInfo.BackupSnapshotAfterTakingSnapshot);
@@ -1750,6 +1771,23 @@ private void postSnapshotDirectlyToSecondary(SnapshotInfo snapshot, SnapshotInfo
snapshotDetailsDao.removeDetail(snapshotOnPrimary.getId(), AsyncJob.Constants.MS_ID);
}
+ /**
+ * Returns true when a volume snapshot on NetApp ONTAP is explicitly kept on managed primary/array storage.
+ *
+ * For ONTAP managed pools, {@link #updateSnapshotPayload} defaults {@code locationType} to
+ * {@link Snapshot.LocationType#PRIMARY}. ONTAP volume snapshots therefore stay on the FlexVol
+ * on primary — they are not moved or mirrored to secondary storage. The primary
+ * {@code snapshot_data_store} row must remain so volume-snapshot DELETE uses
+ * {@code StorageSystemSnapshotStrategy} and the primary datastore driver.
+ *
+ * Other managed storage providers are not affected by this check.
+ */
+ private boolean isManagedPrimaryLocationSnapshot(StoragePool storagePool, CreateSnapshotPayload payload) {
+ return storagePool != null && storagePool.isManaged()
+ && DataStoreProvider.ONTAP_PLUGIN_NAME.equals(storagePool.getStorageProviderName())
+ && Snapshot.LocationType.PRIMARY.equals(payload.getLocationType());
+ }
+
@Override
public boolean isHypervisorKvmAndFileBasedStorage(VolumeInfo volumeInfo, StoragePool storagePool) {
Set fileBasedStores = Set.of(Storage.StoragePoolType.SharedMountPoint, Storage.StoragePoolType.NetworkFilesystem, Storage.StoragePoolType.Filesystem);
diff --git a/test/integration/plugins/ontap/README.md b/test/integration/plugins/ontap/README.md
new file mode 100644
index 000000000000..6e0d0e7d6be5
--- /dev/null
+++ b/test/integration/plugins/ontap/README.md
@@ -0,0 +1,348 @@
+
+# NetApp ONTAP Integration Tests — README
+
+This folder contains end-to-end integration tests for the NetApp ONTAP primary storage plugin in Apache CloudStack. The tests use the **Marvin** framework to drive real CloudStack API calls against a live management server and verify outcomes on a real ONTAP storage system.
+
+CI wiring:
+- Bundles: `private-cicd/marvin/bundles.txt`
+- Zone config: `private-cicd/marvin/zones/` (downstream only)
+
+---
+
+## Directory layout
+
+```
+test/integration/plugins/ontap/
+├── ontap.cfg # Environment config (IPs, credentials, zone info)
+├── ontap_test_base.py # Shared base class and ONTAP REST client
+├── TEST_CASES.md # Full test case reference table (62 tests)
+├── README.md # This file
+│
+├── nfs3/
+│ ├── pool/
+│ │ ├── test_pool_lifecycle.py # Pool create/disable/enable/maintenance/delete
+│ │ ├── test_pool_with_volumes.py # Same lifecycle with a CS volume present
+│ │ └── test_zone_scoped_pool.py # Zone-scoped pool (attachZone)
+│ ├── volume/
+│ │ └── test_volume_lifecycle.py # Volume create/delete/negative-delete
+│ └── instance/
+│ └── test_vm_volume_attach.py # Pool + volume + VM + attach/detach
+│
+└── iscsi/
+ ├── pool/
+ │ ├── test_pool_lifecycle.py # iSCSI pool lifecycle + igroup assertions
+ │ ├── test_pool_with_volumes.py # Same lifecycle with a LUN-backed volume
+ │ └── test_zone_scoped_pool.py # Zone-scoped iSCSI pool
+ ├── volume/
+ │ └── test_volume_lifecycle.py # LUN create/delete/negative-delete
+ └── instance/
+ └── test_vm_volume_attach.py # Pool + LUN + VM + attach/LUN-map lifecycle
+```
+
+---
+
+## What is being tested
+
+The ONTAP plugin (`plugins/storage/volume/ontap/`) integrates CloudStack's primary storage API with the NetApp ONTAP REST API. Every test suite verifies **both sides** of an operation:
+
+1. **CloudStack side** — the expected `listStoragePools` / `listVolumes` / `listVirtualMachines` state after each API call.
+2. **ONTAP side** — the actual ONTAP object state (FlexVol, LUN, igroup, export policy, LUN-map) via direct REST API queries.
+
+### NFS3 vs iSCSI — key differences
+
+| Aspect | NFS3 | iSCSI |
+|--------|------|-------|
+| ONTAP object per pool | FlexVol + export policy | FlexVol + igroup per KVM host |
+| ONTAP object per CS volume | None (FlexVol is shared) | One LUN inside the FlexVol |
+| Host connectivity | NFS mount | iSCSI login (IQN-based) |
+| Volume detach from running VM | Works via virtio hot-unplug | Requires KVM guest to support SCSI hot-unplug |
+
+---
+
+## Prerequisites
+
+Before running any test:
+
+1. **CloudStack management server** running with the ONTAP plugin deployed (jar in `/usr/share/cloudstack-management/lib/`).
+2. **Integration API port 8096 enabled** — run on the management server:
+ ```sql
+ UPDATE configuration SET value='8096' WHERE name='integration.api.port';
+ ```
+ Then restart: `systemctl restart cloudstack-management`
+3. **MySQL accessible remotely** from your laptop (port 3306). If not:
+ ```bash
+ sudo sed -i 's/^bind-address.*/bind-address = 0.0.0.0/' /etc/mysql/mysql.conf.d/mysqld.cnf
+ sudo iptables -I INPUT -p tcp --dport 3306 -j ACCEPT
+ sudo systemctl restart mysql
+ ```
+4. **ONTAP SVM** with NFS3 service and/or iSCSI service enabled, and at least one data LIF per protocol.
+5. **KVM cluster** registered in CloudStack. For iSCSI tests, every KVM host must have iSCSI configured (its `storageUrl` starts with `iqn.`).
+6. **`ontap.cfg` populated** — see the [Configuration](#configuration--ontapcfg) section.
+
+### Python / Marvin setup
+
+```bash
+# Install Marvin from the repo's bundled tarball
+python3 -m pip install --user \
+ "$(ls tools/marvin/dist/Marvin-*.tar.gz | tail -1)"
+
+# Verify
+python3 -c "import marvin; print('Marvin OK')"
+```
+
+---
+
+## Configuration — `ontap.cfg`
+
+`ontap.cfg` is a JSON file that tells Marvin where CloudStack and ONTAP are. **Never commit real credentials.**
+
+Key sections:
+
+```json
+{
+ "mgtSvr": [{ "mgtSvrIp": "", "port": 8096, "user": "admin", "passwd": "password" }],
+ "dbSvr": { "dbSvr": "", "port": 3306, "user": "cloud", "passwd": "cloud" },
+ "ontap": { "storageIP": "", "svmName": "", "username": "admin", "password": "" }
+}
+```
+
+The test classes read `storageIP`, `svmName`, `username`, and `password` from the `ontap` section at runtime. **No credentials appear in test code.**
+
+---
+
+## Running the tests
+
+**Always run from the repo root.** The recommended entry point is [`run_tests.sh`](run_tests.sh), which runs suites sequentially (required for shared test state) and writes unified reports under `results/`.
+
+### Protocol batch commands (recommended)
+
+Run all suites for one protocol in a single batch, then inspect consolidated results:
+
+```bash
+# iSCSI only — 5 suites, ~30–45 min
+bash test/integration/plugins/ontap/run_tests.sh iscsi
+
+# NFS3 only — 5 suites, ~30–45 min
+bash test/integration/plugins/ontap/run_tests.sh nfs3
+
+# Full plugin validation: iSCSI batch, then NFS3 batch (~60–90 min)
+bash test/integration/plugins/ontap/run_tests.sh both
+
+# Default (setup_zone + iscsi + nfs3; excludes cleanup_zone)
+bash test/integration/plugins/ontap/run_tests.sh
+bash test/integration/plugins/ontap/run_tests.sh all
+```
+
+Each protocol batch runs suites in this order: pool lifecycle → pool with volumes → volume lifecycle → zone-scoped pool → VM attach (last).
+
+| Command | What it runs |
+|---------|--------------|
+| `run_tests.sh iscsi` | All 5 iSCSI suites + unified iSCSI report |
+| `run_tests.sh nfs3` | All 5 NFS3 suites + unified NFS3 report |
+| `run_tests.sh both` | iSCSI batch, then NFS3 batch + combined report |
+| `run_tests.sh all` | `setup_zone`, then `both` (iSCSI before NFS3) |
+| `run_tests.sh nfs3_workflow` | Single suite by tag (unchanged) |
+| `run_tests.sh setup_zone` | Zone setup only |
+| `run_tests.sh cleanup_zone` | Zone teardown (manual; destructive) |
+
+### Results layout
+
+After a protocol batch, artifacts are under `test/integration/plugins/ontap/results/`:
+
+```
+results/-iscsi/
+ run.meta.json # protocol, timestamps, per-suite exit codes
+ summary.tsv # all tests (tab-separated)
+ summary.json # machine-readable aggregate (CI-friendly)
+ summary.txt # human-readable TEST SUMMARY
+ suites/
+ iscsi_workflow/
+ stdout.log
+ results.txt # copy of Marvin results
+ runinfo.txt
+ ...
+```
+
+Symlinks: `results/latest-iscsi`, `results/latest-nfs3`, `results/latest-both`.
+
+For `both` / `all`, the parent folder `results/-both/` contains `iscsi/` and `nfs3/` sub-batches plus a combined `summary.txt` at the top level.
+
+Marvin also writes raw logs to `/tmp/MarvinLogs//` during execution.
+
+### Manual nose commands
+
+```bash
+# Single suite (e.g. NFS3 pool lifecycle)
+PYTHONPATH=test/integration/plugins/ontap \
+test/integration/plugins/ontap/.venv/bin/python -m nose --with-marvin \
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \
+ test/integration/plugins/ontap/nfs3/pool/test_pool_lifecycle.py -v
+
+# By tag
+PYTHONPATH=test/integration/plugins/ontap \
+test/integration/plugins/ontap/.venv/bin/python -m nose --with-marvin \
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \
+ -a tags=iscsi_workflow \
+ test/integration/plugins/ontap/iscsi/pool/test_pool_lifecycle.py -v
+```
+
+> **Important:** `PYTHONPATH=test/integration/plugins/ontap` is always required. Test files import `ontap_test_base` from the parent directory.
+
+> **Single-host lab:** Suites within a batch run **sequentially** (not in parallel). iSCSI completes before NFS3 starts in `both`/`all` so the one KVM host is not shared across protocol operations simultaneously.
+
+---
+
+## Code structure — how a test file is organised
+
+Every test file follows the same layout:
+
+```
+1. Apache 2.0 license header
+2. Module docstring ← workflow summary, prerequisites, run command
+3. Imports
+4. TestData class ← holds all config values read from ontap.cfg; builds the
+ createStoragePool command parameters
+5. Test class (extends OntapTestBase)
+ ├── Class-level state attributes (pool, volume, vm, etc.) initialised to None
+ ├── setUpClass() ← connects to CloudStack; creates a test account and
+ │ disk offering; resolves zone/cluster/hosts
+ ├── tearDownClass() ← best-effort cleanup: deletes pool (forced=True),
+ │ volume, account, disk offering
+ ├── Helper methods ← _create_pool(), _create_volume(), _poll_pool_state(),
+ │ _lun_maps() (iSCSI only), etc.
+ └── test_01 … test_N ← sequential, numbered test methods
+```
+
+### Key patterns to know
+
+**Sequential state sharing — always use `self.__class__.`**
+
+Tests share state via class attributes, never instance attributes:
+```python
+# Correct
+self.__class__.pool = pool
+pool = self.__class__.pool
+
+# Wrong — state is lost between test method invocations
+self.pool = pool
+```
+
+**Guard assertion at the start of every test (except test_01)**
+
+Every test after the first starts with an assertion that the previous step's resource exists. This produces a clear, readable failure message instead of a confusing `AttributeError`:
+```python
+def test_03_enable_storage_pool(self):
+ self.assertIsNotNone(self.__class__.pool, "Pool absent — test_01 must pass first")
+```
+
+**Creating a storage pool — always use indexed `details[N].key` syntax**
+
+The CloudStack API for `createStoragePool` requires plugin details to be passed as indexed parameters. **Never call `StoragePool.create()` directly** — it does not support this syntax:
+```python
+count = 1
+for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+```
+
+**Polling for async state changes**
+
+CloudStack operations are asynchronous. Use `_poll_pool_state()` rather than reading state immediately after an API call:
+```python
+result = self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+self.assertEqual(result.state, "Maintenance")
+```
+
+---
+
+## Shared base — `ontap_test_base.py`
+
+`OntapTestBase` provides everything individual test classes inherit:
+
+| What | Purpose |
+|------|---------|
+| `_setup_cloudstack_resources()` | Creates a test account, domain, disk offering; resolves zone/cluster/hosts |
+| `tearDownClass()` | Best-effort cleanup: deletes pool (forced=True), volume, disk offering, account |
+| `_poll_pool_state(pool_id, state, timeout)` | Polls `listStoragePools` until pool reaches the target state |
+| `_create_volume(pool_id)` | Creates a CloudStack data volume on the given pool |
+| `_delete_pool(pool_id, forced)` | Enters Maintenance then calls `deleteStoragePool` |
+| `_parse_pool_details(pool)` | Extracts key→value pairs from the pool's `details` list |
+| `OntapRestClient` | Thin HTTPS client for ONTAP REST API calls |
+
+### `OntapRestClient` methods at a glance
+
+| Method | What it checks | Used in |
+|--------|---------------|---------|
+| `get_volume(name)` | FlexVol existence and state | All suites |
+| `get_export_policy(name)` | NFS export policy existence | NFS3 suites |
+| `get_data_lifs(svm_name)` | NFS data LIF count | NFS3 pool lifecycle |
+| `get_igroup(svm_name, name)` | iSCSI igroup existence and initiator list | iSCSI suites |
+| `list_luns_in_volume(svm_name, vol_name)` | LUNs present in a FlexVol | iSCSI volume/instance suites |
+| `list_lun_maps_for_volume(svm_name, vol_name)` | Active LUN-maps for a volume | iSCSI instance suite |
+| `list_files_in_volume(svm_name, vol_name)` | Files inside a FlexVol | NFS3 instance suite |
+
+---
+
+## Test suite quick reference
+
+| Suite | File | Tests | What it covers |
+|-------|------|-------|---------------|
+| NFS3 Pool Lifecycle | `nfs3/pool/test_pool_lifecycle.py` | 8 | Create, disable, enable, maintenance, delete |
+| NFS3 Pool with Volumes | `nfs3/pool/test_pool_with_volumes.py` | 7 | Same + live volume present; negative delete guard |
+| NFS3 Zone-Scoped Pool | `nfs3/pool/test_zone_scoped_pool.py` | 4 | Zone scope — all hosts connected via `attachZone` |
+| NFS3 Volume Lifecycle | `nfs3/volume/test_volume_lifecycle.py` | 5 | Volume is metadata-only; FlexVol unchanged on delete |
+| NFS3 VM + Volume Attach | `nfs3/instance/test_vm_volume_attach.py` | 8 | Full VM lifecycle with hot-plug/detach |
+| iSCSI Pool Lifecycle | `iscsi/pool/test_pool_lifecycle.py` | 8 | Create, disable, enable, maintenance, delete + igroups |
+| iSCSI Pool with Volumes | `iscsi/pool/test_pool_with_volumes.py` | 7 | Same + live LUN present; negative delete guard |
+| iSCSI Zone-Scoped Pool | `iscsi/pool/test_zone_scoped_pool.py` | 4 | Zone scope |
+| iSCSI Volume Lifecycle | `iscsi/volume/test_volume_lifecycle.py` | 5 | LUN created per CS volume; LUN removed on delete |
+| iSCSI VM + Volume Attach | `iscsi/instance/test_vm_volume_attach.py` | 8 | Full VM lifecycle; LUN-maps on VM start/stop/detach |
+
+For the goal, dependencies, and exact success criteria of every individual test, see [TEST_CASES.md](TEST_CASES.md).
+
+---
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---------|-------------|-----|
+| `ModuleNotFoundError: No module named 'ontap_test_base'` | Missing `PYTHONPATH` prefix | Prefix every run with `PYTHONPATH=test/integration/plugins/ontap` |
+| `Marvin Init Failed` | CloudStack API unreachable | Check `mgtSvrIp:8096` is reachable; restart `cloudstack-management` |
+| `Lost connection to MySQL` | MySQL not accepting remote connections | Enable remote MySQL access (see Prerequisites §3) |
+| `sh: python: command not found` (repeated) | Marvin internal call — harmless on macOS | Ignore; Marvin Init still succeeds |
+| Pool state never reaches `Maintenance` | KVM agent not responding | Check `cloudstack-agent` on KVM host; verify host is connected in CloudStack UI |
+| iSCSI `test_07` error 530 | KVM guest does not ACK SCSI hot-unplug | Known environment limitation — see TEST_CASES.md Suite 10 note |
+| ONTAP REST `401 Unauthorized` | Wrong credentials in `ontap.cfg` | Verify `username`/`password` under `ontap` section |
+| `No ready KVM user template available` | Template still downloading | Re-run `setup_zone` (step 12 waits for template readiness); or wait in CloudStack UI |
+| `setup_zone` steps 11–12 slow on first run | System VMs and template download after zone enable | Normal — first run may take up to ~60 min; re-runs pass quickly when already ready |
+| `cleanup_zone` pool delete fails | Pool stuck in Maintenance or KVM NFS mount stale | Re-run cleanup; check host connectivity; manually `umount /mnt/` on KVM if needed |
+| `deleteZone failed` after cleanup | VMs, pools, or hosts still present in zone | Re-run `cleanup_zone`; check CloudStack UI for remaining resources |
+
+---
+
+## Adding new test cases
+
+1. Pick the existing file closest to what you need and copy its structure.
+2. Read `ontap_test_base.py` for the exact method signatures you can reuse.
+3. Copy the `_create_pool()` helper from an existing file that matches your protocol — **never** call `StoragePool.create()`.
+4. Number your methods `test_01`, `test_02`, … and add `@attr(tags=[""], required_hardware=True)` to each.
+5. Use `self.__class__.` for all state shared between test methods.
+6. Syntax-check before the first full run: `python3 -m py_compile .py`
+7. Add your test cases to [TEST_CASES.md](TEST_CASES.md).
diff --git a/test/integration/plugins/ontap/TEST_CASES.md b/test/integration/plugins/ontap/TEST_CASES.md
new file mode 100644
index 000000000000..5ef8e8a1eb6d
--- /dev/null
+++ b/test/integration/plugins/ontap/TEST_CASES.md
@@ -0,0 +1,240 @@
+
+
+# ONTAP Integration Test Cases
+
+Complete reference for all 62 test cases across 10 test suites.
+Each suite is sequential — tests must run in numbered order; each step builds on state created by the previous step.
+
+---
+
+## How to read the tables
+
+| Column | Meaning |
+|--------|---------|
+| **Test method** | Exact Python method name |
+| **Goal** | What CloudStack workflow step is being exercised |
+| **Depends on** | Which earlier tests must have passed (class state they consume) |
+| **CloudStack success criteria** | What the CS API must return for the test to pass |
+| **ONTAP success criteria** | What the ONTAP REST API must show for the test to pass |
+| **Type** | `positive` = happy path, `negative` = tests a rejection/error condition, `cleanup` = teardown step |
+
+---
+
+## Suite 1 — NFS3 Pool Lifecycle
+
+**File:** `nfs3/pool/test_pool_lifecycle.py`
+**Class:** `TestOntapNFS3PrimaryStorageWorkflow`
+**Tag:** `nfs3_workflow`
+**Total:** 8 tests | **Scope:** cluster-scoped NFS3 pool, no volumes for tests 01–06
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_primary_storage_pool` | Create a cluster-scoped NFS3 primary storage pool | setUpClass (zone, cluster, account) | `pool.state == "Up"`, `pool.type == "NetworkFilesystem"`, `nfsmountopts` contains `vers=3` | FlexVol exists and `state == "online"`, export policy exists with each cluster host IP as a rule, at least one NFS data LIF present on SVM | positive |
+| 02 | `test_02_disable_storage_pool` | Disable the pool (admin operation) | test_01 (`pool`) | `pool.state == "Disabled"` | FlexVol still `online`; export policy still present | positive |
+| 03 | `test_03_enable_storage_pool` | Re-enable the pool | test_02 | `pool.state == "Up"` | FlexVol still `online`; export policy still present | positive |
+| 04 | `test_04_enter_maintenance_mode` | Put pool into maintenance (drains new volume allocations) | test_03 | `pool.state == "Maintenance"` | FlexVol still `online`; export policy still present (maintenance is CS-only state) | positive |
+| 05 | `test_05_cancel_maintenance_mode` | Cancel maintenance, return pool to service | test_04 | `pool.state == "Up"` | FlexVol still `online`; export policy still present | positive |
+| 06 | `test_06_delete_pool_from_maintenance` | Enter maintenance then permanently delete the pool | test_05 | Pool no longer returned by `listStoragePools` (CS 431 error expected on ID lookup) | FlexVol deleted (not found by `GET /api/storage/volumes?name=`); export policy deleted | positive |
+| 07 | `test_07_create_volume_on_pool` | Create a second fresh pool and allocate a CloudStack data volume on it | test_06 (pool deleted; creates new pool) | New `pool.state == "Up"`; `createVolume` returns non-None volume object | FlexVol `online` after volume allocation; export policy present | positive |
+| 08 | `test_08_delete_volume_and_pool` | Delete the volume then force-delete the pool | test_07 (`pool`, `volume`) | Volume no longer listed; pool no longer listed | FlexVol deleted; export policy deleted | positive |
+
+---
+
+## Suite 2 — NFS3 Pool with Volumes
+
+**File:** `nfs3/pool/test_pool_with_volumes.py`
+**Class:** `TestOntapNFS3PoolWithVolumes`
+**Tag:** `nfs3_with_volumes`
+**Total:** 7 tests | **Scope:** cluster-scoped NFS3 pool with a live CloudStack volume throughout
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_pool_and_volume` | Create NFS3 pool and immediately allocate a data volume | setUpClass | `pool.state == "Up"`, volume object non-None | FlexVol `online`; export policy present | positive |
+| 02 | `test_02_disable_pool_volume_survives` | Disable pool while a volume exists — volume must survive | test_01 (`pool`, `volume`) | `pool.state == "Disabled"`; volume still listed in `listVolumes` | FlexVol still `online` | positive |
+| 03 | `test_03_enable_pool_volume_intact` | Re-enable pool with volume present | test_02 | `pool.state == "Up"`; volume still listed | FlexVol still `online` | positive |
+| 04 | `test_04_enter_maintenance_volume_present` | Enter maintenance while volume present | test_03 | `pool.state == "Maintenance"`; volume still listed | FlexVol still `online` | positive |
+| 05 | `test_05_cancel_maintenance_with_volume` | Cancel maintenance with volume — verifies the NFS3 cancel-maintenance fix | test_04 | `pool.state == "Up"`; volume still listed | FlexVol still `online` | positive |
+| 06 | `test_06_forced_false_delete_rejected` | Attempt to delete pool (forced=False) with volume present — must be rejected | test_05 | `deleteStoragePool(forced=False)` raises `CloudstackAPIException`; pool still listed in `Maintenance` state | FlexVol still `online`; no ONTAP objects removed | negative |
+| 07 | `test_07_force_delete_pool_and_cleanup` | Cancel maintenance, delete volume, then force-delete pool | test_06 | Pool no longer listed; volume no longer listed | FlexVol deleted; export policy deleted | cleanup |
+
+---
+
+## Suite 3 — NFS3 Zone-Scoped Pool
+
+**File:** `nfs3/pool/test_zone_scoped_pool.py`
+**Class:** `TestOntapZoneScopedPool`
+**Tag:** `zone_pool`
+**Total:** 4 tests | **Scope:** zone-scoped NFS3 pool (scope=ZONE, all hosts in zone connected)
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_zone_scoped_pool` | Create a zone-scoped NFS3 pool; CloudStack calls `attachZone()` to connect all eligible KVM hosts | setUpClass | `pool.state == "Up"` | FlexVol `online`; export policy exists and contains **every** cluster host IP; at least one NFS data LIF present | positive |
+| 02 | `test_02_disable_zone_scoped_pool` | Disable the zone-scoped pool | test_01 (`pool`) | `pool.state == "Disabled"` | FlexVol unchanged; export policy unchanged | positive |
+| 03 | `test_03_enable_zone_scoped_pool` | Re-enable the zone-scoped pool | test_02 | `pool.state == "Up"` | FlexVol unchanged; export policy unchanged | positive |
+| 04 | `test_04_delete_zone_scoped_pool` | Enter maintenance and force-delete the zone-scoped pool | test_03 | Pool no longer listed | FlexVol deleted; export policy deleted | positive |
+
+---
+
+## Suite 4 — NFS3 Volume Lifecycle
+
+**File:** `nfs3/volume/test_volume_lifecycle.py`
+**Class:** `TestOntapNFS3VolumeLifecycle`
+**Tag:** `nfs3_volume`
+**Total:** 5 tests | **Scope:** NFS3 CloudStack volume create/delete semantics (NFS3 volumes are metadata-only in CS)
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_pool_and_volume` | Create NFS3 pool and allocate a CloudStack data volume | setUpClass | `pool.state == "Up"`; volume object non-None | FlexVol `online` after volume allocation; export policy present — **no new ONTAP object per volume** (FlexVol is shared) | positive |
+| 02 | `test_02_delete_volume` | Delete the CS data volume — for NFS3 only the CS record is removed | test_01 (`pool`, `volume`) | Volume no longer listed in `listVolumes` | FlexVol still `online` and **unaffected**; export policy still present | positive |
+| 03 | `test_03_recreate_volume_for_delete_tests` | Re-create a volume on the pool (setup for negative tests 04–05) | test_02 | New volume object non-None | FlexVol still `online` | positive |
+| 04 | `test_04_forced_false_delete_with_volume_fails` | Enter maintenance then attempt `deleteStoragePool(forced=False)` while volume exists — must be rejected | test_03 (`pool`, `volume`) | `deleteStoragePool(forced=False)` raises `CloudstackAPIException`; pool still in `Maintenance` state | No ONTAP objects removed | negative |
+| 05 | `test_05_delete_volume_and_force_delete_pool` | Delete volume from Maintenance, then force-delete pool | test_04 | Volume no longer listed; pool no longer listed | FlexVol deleted; export policy deleted | positive |
+
+---
+
+## Suite 5 — NFS3 VM + Volume Attach
+
+**File:** `nfs3/instance/test_vm_volume_attach.py`
+**Class:** `TestOntapVMVolumeAttach`
+**Tag:** `vm_volume_workflow`
+**Total:** 8 tests | **Scope:** end-to-end — NFS3 pool, data volume, running VM, attach/detach lifecycle
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_nfs3_pool` | Create NFS3 ONTAP primary storage pool | setUpClass (zone, cluster, template) | `pool.state == "Up"` | FlexVol `online`; export policy present | positive |
+| 02 | `test_02_create_ontap_data_volume` | Allocate a CloudStack data volume on the ONTAP pool | test_01 (`pool`) | Volume non-None and listed in `listVolumes` | FlexVol still `online` | positive |
+| 03 | `test_03_deploy_vm` | Deploy a VM using the first available ready KVM template | test_02 (`pool`, `volume`) | `vm.state == "Running"`; template auto-selected from `listTemplates` | n/a | positive |
+| 04 | `test_04_attach_volume_to_vm` | Attach the ONTAP data volume to the running VM (hot-plug) | test_03 (`vm`, `volume`) | `volume.virtualmachineid == vm.id`; `attachVolume` job succeeds | FlexVol `online`; after attach, a data file matching volume UUID present in FlexVol (`list_files_in_volume`) | positive |
+| 05 | `test_05_stop_vm_export_retained` | Stop the running VM with volume attached | test_04 | `vm.state == "Stopped"` | FlexVol still `online`; NFS export policy still present | positive |
+| 06 | `test_06_start_vm_volume_accessible` | Start the stopped VM | test_05 | `vm.state == "Running"` | FlexVol still `online` | positive |
+| 07 | `test_07_detach_volume_from_vm` | Hot-detach the ONTAP volume from the running VM (TDS Detach NFS3) | test_06 (`vm`, `volume`) | `volume.virtualmachineid` cleared; `volume.state == "Ready"` | FlexVol still `online`; data file **still present** (NFS3: file persists until `deleteVolume`, not on detach) | positive |
+| 08 | `test_08_destroy_vm_and_cleanup` | Destroy VM (expunge), delete volume, enter maintenance, delete pool | test_07 | VM no longer listed; volume no longer listed; pool no longer listed | FlexVol deleted; export policy deleted | cleanup |
+
+---
+
+## Suite 6 — iSCSI Pool Lifecycle
+
+**File:** `iscsi/pool/test_pool_lifecycle.py`
+**Class:** `TestOntapISCSIPoolLifecycle`
+**Tag:** `iscsi_workflow`
+**Total:** 8 tests | **Scope:** cluster-scoped iSCSI pool, no volumes for tests 01–06
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_primary_storage_pool` | Create a cluster-scoped iSCSI primary storage pool | setUpClass | `pool.state == "Up"`, `pool.type == "Iscsi"` | FlexVol `online`; one igroup per cluster host (named `cs_{svmName}_{hostShortName}`) with host IQN as initiator | positive |
+| 02 | `test_02_disable_storage_pool` | Disable the pool | test_01 (`pool`) | `pool.state == "Disabled"` | FlexVol still `online` | positive |
+| 03 | `test_03_enable_storage_pool` | Re-enable the pool | test_02 | `pool.state == "Up"` | FlexVol still `online` | positive |
+| 04 | `test_04_enter_maintenance_mode` | Put pool into maintenance | test_03 | `pool.state == "Maintenance"` | FlexVol still `online`; igroups unchanged | positive |
+| 05 | `test_05_cancel_maintenance_mode` | Cancel maintenance | test_04 | `pool.state == "Up"` | FlexVol still `online` | positive |
+| 06 | `test_06_enter_maintenance_and_delete_pool` | Enter maintenance then force-delete the pool | test_05 | Pool no longer listed | FlexVol deleted; all igroups for cluster hosts deleted | positive |
+| 07 | `test_07_create_volume_on_pool` | Create a second fresh pool and allocate a CloudStack data volume (creates a LUN) | test_06 (new pool) | New `pool.state == "Up"`; volume object non-None | FlexVol `online`; ≥1 LUN present inside FlexVol (`list_luns_in_volume`) | positive |
+| 08 | `test_08_delete_volume_and_pool` | Delete the volume (removes LUN), enter maintenance, force-delete pool | test_07 (`pool`, `volume`) | Volume no longer listed; pool no longer listed | LUN no longer in FlexVol; FlexVol deleted; igroups deleted | positive |
+
+---
+
+## Suite 7 — iSCSI Pool with Volumes
+
+**File:** `iscsi/pool/test_pool_with_volumes.py`
+**Class:** `TestOntapISCSIPoolWithVolumes`
+**Tag:** `iscsi_workflow`
+**Total:** 7 tests | **Scope:** cluster-scoped iSCSI pool with a live CloudStack volume (LUN) throughout
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_pool_and_volume` | Create iSCSI pool and allocate a data volume (creates LUN) | setUpClass | `pool.state == "Up"`; volume non-None | FlexVol `online`; ≥1 LUN in FlexVol | positive |
+| 02 | `test_02_disable_pool_volume_survives` | Disable pool with volume present | test_01 (`pool`, `volume`) | `pool.state == "Disabled"`; volume still listed | FlexVol still `online`; LUN still present | positive |
+| 03 | `test_03_enable_pool_volume_intact` | Re-enable pool with volume | test_02 | `pool.state == "Up"`; volume still listed | FlexVol still `online`; LUN still present | positive |
+| 04 | `test_04_enter_maintenance_volume_present` | Enter maintenance with volume | test_03 | `pool.state == "Maintenance"`; volume still listed | FlexVol still `online`; LUN still present | positive |
+| 05 | `test_05_cancel_maintenance_volume_present` | Cancel maintenance with volume (TDS iSCSI cancel maintenance) | test_04 | `pool.state == "Up"`; volume still listed | FlexVol still `online`; LUN still present | positive |
+| 06 | `test_06_forced_false_delete_rejected` | Attempt `deleteStoragePool(forced=False)` with LUN-backed volume present — must be rejected | test_05 | `CloudstackAPIException` raised; pool still in `Maintenance` | No ONTAP objects removed | negative |
+| 07 | `test_07_delete_volume_and_force_delete_pool` | Delete volume (LUN removed) then force-delete pool | test_06 (`pool`, `volume`) | Volume gone; pool gone | LUN removed; FlexVol deleted; igroups deleted | cleanup |
+
+---
+
+## Suite 8 — iSCSI Zone-Scoped Pool
+
+**File:** `iscsi/pool/test_zone_scoped_pool.py`
+**Class:** `TestOntapISCSIZoneScopedPool`
+**Tag:** `iscsi_zone_pool`
+**Total:** 4 tests | **Scope:** zone-scoped iSCSI pool (scope=ZONE)
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_zone_scoped_pool` | Create a zone-scoped iSCSI pool; CS calls `attachZone()` to connect all eligible KVM hosts | setUpClass | `pool.state == "Up"` | FlexVol `online`; igroup per cluster host, each with host IQN as initiator | positive |
+| 02 | `test_02_disable_zone_scoped_pool` | Disable pool | test_01 (`pool`) | `pool.state == "Disabled"` | FlexVol unchanged; igroups unchanged | positive |
+| 03 | `test_03_enable_zone_scoped_pool` | Re-enable pool | test_02 | `pool.state == "Up"` | FlexVol unchanged; igroups unchanged | positive |
+| 04 | `test_04_delete_zone_scoped_pool` | Enter maintenance then delete pool | test_03 | Pool no longer listed | FlexVol deleted; all igroups deleted | positive |
+
+---
+
+## Suite 9 — iSCSI Volume Lifecycle
+
+**File:** `iscsi/volume/test_volume_lifecycle.py`
+**Class:** `TestOntapISCSIVolumeLifecycle`
+**Tag:** `iscsi_volume`
+**Total:** 5 tests | **Scope:** iSCSI CloudStack volume create/delete semantics (each CS volume maps to an ONTAP LUN)
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_pool_and_volume` | Create iSCSI pool and allocate a data volume — a LUN is created inside the pool's FlexVol | setUpClass | `pool.state == "Up"`; volume non-None | FlexVol `online`; ≥1 LUN in FlexVol (`list_luns_in_volume`) | positive |
+| 02 | `test_02_delete_volume` | Delete the volume — the LUN is removed from the FlexVol | test_01 (`pool`, `volume`) | Volume no longer listed | LUN no longer in FlexVol; FlexVol itself still `online` | positive |
+| 03 | `test_03_recreate_volume_for_delete_tests` | Re-create a volume (LUN re-created) — setup for negative tests | test_02 | New volume non-None | LUN present in FlexVol again | positive |
+| 04 | `test_04_forced_false_delete_with_volume_fails` | Enter maintenance then attempt `deleteStoragePool(forced=False)` with LUN present — must be rejected | test_03 (`pool`, `volume`) | `CloudstackAPIException` raised; pool still in `Maintenance` | No ONTAP objects removed | negative |
+| 05 | `test_05_delete_volume_and_force_delete_pool` | Delete volume (LUN removed) then force-delete pool | test_04 | Volume gone; pool gone | LUN removed; FlexVol deleted; igroups deleted | positive |
+
+---
+
+## Suite 10 — iSCSI VM + Volume Attach
+
+**File:** `iscsi/instance/test_vm_volume_attach.py`
+**Class:** `TestOntapVMVolumeAttachISCSI`
+**Tag:** `iscsi_vm_workflow`
+**Total:** 8 tests | **Scope:** end-to-end — iSCSI pool, data volume (LUN), running VM, attach/stop/start/detach lifecycle
+
+| # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type |
+|---|-------------|------|------------|-----------------------------|------------------------|------|
+| 01 | `test_01_create_iscsi_pool` | Create iSCSI ONTAP primary storage pool | setUpClass | `pool.state == "Up"`, `pool.type == "Iscsi"` | FlexVol `online`; igroup per cluster host with host IQN | positive |
+| 02 | `test_02_create_ontap_data_volume` | Allocate a CloudStack data volume (creates a LUN in the FlexVol) | test_01 (`pool`) | Volume non-None | ≥1 LUN in FlexVol | positive |
+| 03 | `test_03_deploy_vm` | Deploy VM using first ready KVM template; verify 0 LUN-maps exist before attach | test_02 (`volume`) | `vm.state == "Running"`; 0 LUN-maps on ONTAP | 0 LUN-maps (`list_lun_maps_for_volume` returns empty) | positive |
+| 04 | `test_04_attach_volume_to_vm` | Hot-attach the ONTAP iSCSI volume to the running VM — a LUN-map is created (TDS SN 27) | test_03 (`vm`, `volume`) | `volume.virtualmachineid == vm.id` | ≥1 LUN-map linking the LUN to the host's igroup | positive |
+| 05 | `test_05_stop_vm_lun_unmapped` | Stop VM — LUN-maps must be removed (TDS VM Stop iSCSI) | test_04 | `vm.state == "Stopped"` | 0 LUN-maps; LUN itself **still present** in FlexVol | positive |
+| 06 | `test_06_start_vm_lun_remapped` | Start VM — LUN-maps must be re-created (TDS VM Start iSCSI) | test_05 | `vm.state == "Running"` | ≥1 LUN-map re-created | positive |
+| 07 | `test_07_detach_volume_from_vm` | Hot-detach the iSCSI volume from the running VM (TDS Detach iSCSI) | test_06 (`vm`, `volume`) | `volume.virtualmachineid` cleared | 0 LUN-maps; LUN still in FlexVol | positive ⚠️ |
+| 08 | `test_08_destroy_vm_and_cleanup` | Destroy VM (expunge), delete volume, enter maintenance, delete pool | test_07 | VM gone; volume gone; pool gone | FlexVol deleted; all LUNs and igroups deleted | cleanup |
+
+> ⚠️ **test_07 known status:** iSCSI hot-detach from a running VM relies on the KVM guest acknowledging the SCSI device removal. On this environment the guest does not acknowledge in time, causing CloudStack error 530. This is a KVM-host-level or guest-template limitation, not a test code defect. All other 61 tests pass.
+
+---
+
+## Cross-suite summary
+
+| Suite | Protocol | Scope | Tests | Status |
+|-------|---------|-------|-------|--------|
+| NFS3 Pool Lifecycle | NFS3 | Cluster | 8 | ✅ |
+| NFS3 Pool with Volumes | NFS3 | Cluster | 7 | ✅ |
+| NFS3 Zone-Scoped Pool | NFS3 | Zone | 4 | ✅ |
+| NFS3 Volume Lifecycle | NFS3 | Cluster | 5 | ✅ |
+| NFS3 VM + Volume Attach | NFS3 | Cluster | 8 | ✅ |
+| iSCSI Pool Lifecycle | iSCSI | Cluster | 8 | ✅ |
+| iSCSI Pool with Volumes | iSCSI | Cluster | 7 | ✅ |
+| iSCSI Zone-Scoped Pool | iSCSI | Zone | 4 | ✅ |
+| iSCSI Volume Lifecycle | iSCSI | Cluster | 5 | ✅ |
+| iSCSI VM + Volume Attach | iSCSI | Cluster | 8 | ⚠️ 7/8 |
+| **Total** | | | **62** | **61 passing** |
diff --git a/test/integration/plugins/ontap/__init__.py b/test/integration/plugins/ontap/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/aggregate_results.py b/test/integration/plugins/ontap/aggregate_results.py
new file mode 100644
index 000000000000..b88c36075f27
--- /dev/null
+++ b/test/integration/plugins/ontap/aggregate_results.py
@@ -0,0 +1,251 @@
+# 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.
+
+"""Merge Marvin suite results into summary.tsv, summary.json, and summary.txt."""
+
+from __future__ import print_function
+
+import argparse
+import json
+import re
+import sys
+from datetime import datetime, timezone
+
+
+def parse_results_txt(path):
+ """Return list of (tag, label, test_id, status, detail) from Marvin results.txt."""
+ rows = []
+ with open(path, encoding="utf-8") as fh:
+ for line in fh:
+ line = line.rstrip("\n")
+ m = re.search(r"TestName: (\S+) \| Status : (\S+)", line)
+ if m:
+ rows.append(("", "", m.group(1), m.group(2), ""))
+ continue
+ m = re.match(r"(.+?) \.\.\. SKIP: (.+)$", line)
+ if m:
+ name = m.group(1).strip()
+ detail = m.group(2).strip()
+ rows.append(("", "", name, "SKIP", detail))
+ return rows
+
+
+def parse_tsv_line(line):
+ parts = line.rstrip("\n").split("\t", 4)
+ while len(parts) < 5:
+ parts.append("")
+ return tuple(parts)
+
+
+def normalize_status(status):
+ st = (status or "").upper()
+ if st == "SUCCESS":
+ return "PASS", "pass"
+ if st == "SKIP":
+ return "SKIP", "skip"
+ return "FAIL", "fail"
+
+
+def format_summary_text(rows):
+ lines = [
+ "================================================================",
+ " TEST SUMMARY",
+ "================================================================",
+ ]
+ pass_n = fail_n = skip_n = 0
+ current_label = None
+ for tag, label, test_id, status, detail in rows:
+ group = "[%s] %s" % (tag, label) if tag else label
+ if group != current_label:
+ if current_label is not None:
+ lines.append("")
+ lines.append(" %s" % group)
+ current_label = group
+ mark, bucket = normalize_status(status)
+ if bucket == "pass":
+ pass_n += 1
+ elif bucket == "skip":
+ skip_n += 1
+ else:
+ fail_n += 1
+ suffix = ""
+ st = (status or "").upper()
+ if st == "SKIP" and detail:
+ suffix = " — %s" % detail
+ elif st not in ("SUCCESS", "SKIP"):
+ suffix = " — %s" % status
+ lines.append(" %-4s %s%s" % (mark, test_id, suffix))
+
+ total = pass_n + fail_n + skip_n
+ lines.extend([
+ "",
+ "================================================================",
+ " TOTAL: %d passed, %d failed, %d skipped (%d tests)" % (
+ pass_n, fail_n, skip_n, total),
+ "================================================================",
+ ])
+ return "\n".join(lines) + "\n", pass_n, fail_n, skip_n
+
+
+def rows_to_json(rows, meta=None):
+ suites = {}
+ tests = []
+ pass_n = fail_n = skip_n = 0
+ for tag, label, test_id, status, detail in rows:
+ mark, bucket = normalize_status(status)
+ if bucket == "pass":
+ pass_n += 1
+ elif bucket == "skip":
+ skip_n += 1
+ else:
+ fail_n += 1
+ suite_key = tag or label
+ if suite_key not in suites:
+ suites[suite_key] = {
+ "tag": tag,
+ "label": label,
+ "passed": 0,
+ "failed": 0,
+ "skipped": 0,
+ "tests": [],
+ }
+ suites[suite_key]["tests"].append({
+ "name": test_id,
+ "status": mark,
+ "detail": detail or None,
+ })
+ if bucket == "pass":
+ suites[suite_key]["passed"] += 1
+ elif bucket == "skip":
+ suites[suite_key]["skipped"] += 1
+ else:
+ suites[suite_key]["failed"] += 1
+ tests.append({
+ "tag": tag,
+ "label": label,
+ "name": test_id,
+ "status": mark,
+ "detail": detail or None,
+ })
+
+ payload = {
+ "generatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "totals": {
+ "passed": pass_n,
+ "failed": fail_n,
+ "skipped": skip_n,
+ "total": pass_n + fail_n + skip_n,
+ },
+ "suites": list(suites.values()),
+ "tests": tests,
+ }
+ if meta:
+ payload["run"] = meta
+ return payload
+
+
+def load_rows_from_tsv(path):
+ rows = []
+ with open(path, encoding="utf-8") as fh:
+ for line in fh:
+ if line.strip():
+ rows.append(parse_tsv_line(line))
+ return rows
+
+
+def load_rows_from_suite_specs(specs):
+ rows = []
+ for spec in specs:
+ parts = spec.split(":", 2)
+ if len(parts) != 3:
+ print("Invalid --suite spec (want tag:label:path): %s" % spec,
+ file=sys.stderr)
+ sys.exit(1)
+ tag, label, path = parts
+ for _tag, _label, test_id, status, detail in parse_results_txt(path):
+ rows.append((tag, label, test_id, status, detail))
+ return rows
+
+
+def write_outputs(out_dir, rows, meta=None):
+ tsv_path = out_dir + "/summary.tsv"
+ json_path = out_dir + "/summary.json"
+ txt_path = out_dir + "/summary.txt"
+
+ with open(tsv_path, "w", encoding="utf-8") as fh:
+ for row in rows:
+ fh.write("\t".join(row) + "\n")
+
+ summary_text, pass_n, fail_n, skip_n = format_summary_text(rows)
+ with open(txt_path, "w", encoding="utf-8") as fh:
+ fh.write(summary_text)
+
+ payload = rows_to_json(rows, meta=meta)
+ with open(json_path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh, indent=2)
+ fh.write("\n")
+
+ return pass_n, fail_n, skip_n, summary_text
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Aggregate Marvin test results into summary files.")
+ parser.add_argument(
+ "--out-dir", required=True,
+ help="Directory for summary.tsv, summary.json, summary.txt")
+ parser.add_argument(
+ "--summary-tsv",
+ help="Read existing tab-separated summary (from run_tests.sh)")
+ parser.add_argument(
+ "--suite", action="append", default=[],
+ help="Suite spec tag:label:path/to/results.txt (repeatable)")
+ parser.add_argument(
+ "--meta-json",
+ help="JSON string or path to run metadata merged into summary.json")
+ parser.add_argument(
+ "--print", dest="print_summary", action="store_true",
+ help="Print human-readable summary to stdout")
+ args = parser.parse_args()
+
+ if args.summary_tsv:
+ rows = load_rows_from_tsv(args.summary_tsv)
+ elif args.suite:
+ rows = load_rows_from_suite_specs(args.suite)
+ else:
+ print("Provide --summary-tsv or at least one --suite", file=sys.stderr)
+ sys.exit(1)
+
+ meta = None
+ if args.meta_json:
+ if args.meta_json.startswith("{"):
+ meta = json.loads(args.meta_json)
+ else:
+ with open(args.meta_json, encoding="utf-8") as fh:
+ meta = json.load(fh)
+
+ pass_n, fail_n, skip_n, summary_text = write_outputs(
+ args.out_dir.rstrip("/"), rows, meta=meta)
+
+ if args.print_summary:
+ print(summary_text, end="")
+
+ return 0 if fail_n == 0 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/test/integration/plugins/ontap/iscsi/__init__.py b/test/integration/plugins/ontap/iscsi/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/iscsi/instance/__init__.py b/test/integration/plugins/ontap/iscsi/instance/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/instance/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py b/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py
new file mode 100644
index 000000000000..716659b88c91
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py
@@ -0,0 +1,839 @@
+# 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.
+
+"""
+Sequential workflow integration tests for NetApp ONTAP iSCSI data volume
+lifecycle with a running virtual machine.
+
+Covers TDS (section 10) iSCSI VM volume scenarios:
+
+ TDS Approach-1 SN 27 — Create CS Volume and allocate it to an Instance (iSCSI)
+ (attach data volume to running VM; verify LUN-map)
+ TDS VM Stop (iSCSI) — Stop running VM; verify LUN-maps are removed
+ TDS VM Start (iSCSI) — Start stopped VM; verify LUN-maps are re-created
+ TDS Detach (iSCSI) — Detach data volume; verify LUN-map removed
+
+Key iSCSI behaviour verified at each step via ONTAP REST API:
+ - createVolume → LUN is created inside the pool's FlexVol
+ - attachVolume → LUN-map is created linking the LUN to the host's igroup
+ - stopVirtualMachine → LUN-map is removed (LUN stays; just unmapped)
+ - startVirtualMachine → LUN-map is re-created
+ - detachVolume → LUN-map is removed
+
+Tests are numbered test_01 ... test_08 and must run in that order. Each step
+builds on the shared state established by the previous step.
+
+Workflow:
+ 01 Create iSCSI primary storage pool on ONTAP
+ 02 Create a CloudStack data volume on the iSCSI pool (LUN on ONTAP)
+ 03 Deploy a VM using any available KVM template
+ 04 Attach iSCSI data volume to running VM (LUN-map created)
+ 05 Stop VM — LUN-map for attached volume is removed from ONTAP
+ 06 Start VM — LUN-map is re-created on ONTAP
+ 07 Detach data volume from running VM — LUN-map removed
+ 08 Destroy VM, delete data volume, delete pool
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM cluster where every host has iSCSI initiator configured (iqn.* IQN)
+ - ONTAP SVM with iSCSI service enabled and at least one iSCSI data LIF
+ - ontap.cfg populated with real values
+ - At least one KVM template must be fully downloaded and ready (isready=True)
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/test_ontap_vm_volume_attach_iscsi.py -v
+"""
+
+import base64
+import logging
+import random
+import re
+import time
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ attachVolume as attachVolumeAPI,
+ createNetwork as createNetworkAPI,
+ createStoragePool as createStoragePoolAPI,
+ deleteNetwork as deleteNetworkAPI,
+ deleteVolume as deleteVolumeAPI,
+ deployVirtualMachine as deployVirtualMachineAPI,
+ destroyVirtualMachine as destroyVirtualMachineAPI,
+ detachVolume as detachVolumeAPI,
+ enableStorageMaintenance,
+ listNetworkOfferings as listNetworkOfferingsAPI,
+ listNetworks as listNetworksAPI,
+ listServiceOfferings as listServiceOfferingsAPI,
+ listTemplates as listTemplatesAPI,
+ listVirtualMachines as listVirtualMachinesAPI,
+ listVolumes as listVolumesAPI,
+ startVirtualMachine as startVirtualMachineAPI,
+ stopVirtualMachine as stopVirtualMachineAPI,
+)
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, get_datacenter_config
+
+logger = logging.getLogger("TestOntapVMVolumeAttachISCSI")
+
+
+# ---------------------------------------------------------------------------
+# Utility functions
+# ---------------------------------------------------------------------------
+
+def _list_vms_cmd(vm_id):
+ cmd = listVirtualMachinesAPI.listVirtualMachinesCmd()
+ cmd.id = vm_id
+ cmd.listall = True
+ return cmd
+
+
+def _wait_for_vm_state(api_client, vm_id, target_state, timeout=300,
+ interval=10):
+ """Block until the VM reaches target_state or timeout expires."""
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ vms = api_client.listVirtualMachines(_list_vms_cmd(vm_id))
+ if vms and vms[0].state.lower() == target_state.lower():
+ return vms[0]
+ time.sleep(interval)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ scope="CLUSTER", provider="NetApp ONTAP",
+ tags="ontap-iscsi", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-iscsi-vm@test.com",
+ "firstname": "ONTAP",
+ "lastname": "iSCSI-VM",
+ "username": "ontap_iscsi_vm_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapISCSIVM_%d" % random.randint(0, 9999),
+ TestData.scope: scope,
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: "ISCSI",
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Sequential workflow test class
+# ---------------------------------------------------------------------------
+
+class TestOntapVMVolumeAttachISCSI(OntapTestBase):
+ """
+ Tests iSCSI ONTAP data volume lifecycle with a running CloudStack VM.
+ All tests are sequential — state is carried on class attributes.
+ """
+
+ # ---- extra shared state beyond OntapTestBase -----------------------
+ vm = None
+ template_id = None
+ service_offering_id = None
+ network_id = None
+ _created_network_id = None # network created by this suite for Advanced zones
+
+ _vol_name_prefix = "OntapISCSIVM"
+
+ # ---- setup ---------------------------------------------------------
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapVMVolumeAttachISCSI, cls).setUpClass()
+ testclient = super(
+ TestOntapVMVolumeAttachISCSI, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ iscsi_cfg = pool_cfg.get("protocols", {}).get("iscsi", {})
+ if not iscsi_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "iSCSI tests disabled in ontap.cfg "
+ "(set protocols.iscsi.enabled=true to enable)"
+ )
+ scope = pool_cfg.get("storagePoolScope", "CLUSTER")
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = iscsi_cfg.get("storagePoolTags", "ontap-iscsi")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ scope=scope, provider=provider, tags=tags,
+ capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # Discover a ready user KVM template (exclude SYSTEM type)
+ tpl_cmd = listTemplatesAPI.listTemplatesCmd()
+ tpl_cmd.templatefilter = "all"
+ tpl_cmd.listall = True
+ tpl_cmd.zoneid = cls.zone.id
+ templates = cls.apiClient.listTemplates(tpl_cmd) or []
+ kvm_ready = [
+ t for t in templates
+ if getattr(t, "hypervisor", "").lower() == "kvm"
+ and getattr(t, "isready", False)
+ and getattr(t, "templatetype", "").upper() != "SYSTEM"
+ ]
+ cls.template_id = kvm_ready[0].id if kvm_ready else None
+ if cls.template_id is None:
+ logger.warning(
+ "No ready KVM user template found — VM tests will be skipped."
+ )
+
+ # Smallest service offering
+ so_cmd = listServiceOfferingsAPI.listServiceOfferingsCmd()
+ offerings = cls.apiClient.listServiceOfferings(so_cmd) or []
+ assert offerings, "No service offerings available in CloudStack"
+ offerings.sort(key=lambda s: getattr(s, "memory", 9999))
+ cls.service_offering_id = offerings[0].id
+
+ # Network ID for VM deployment
+ cls.network_id = None
+ zone_type = getattr(cls.zone, "networktype", "Basic")
+ if zone_type.lower() == "advanced":
+ # Find a network already accessible to the test account
+ net_cmd = listNetworksAPI.listNetworksCmd()
+ net_cmd.zoneid = cls.zone.id
+ net_cmd.account = cls.account.name
+ net_cmd.domainid = cls.domain.id
+ nets = cls.apiClient.listNetworks(net_cmd) or []
+ if nets:
+ cls.network_id = nets[0].id
+ else:
+ # Create an Isolated guest network for the test account
+ no_cmd = listNetworkOfferingsAPI.listNetworkOfferingsCmd()
+ no_cmd.state = "Enabled"
+ no_cmd.guestiptype = "Isolated"
+ no_cmd.specifyvlan = "false"
+ offerings = cls.apiClient.listNetworkOfferings(no_cmd) or []
+ snat_offering = next(
+ (o for o in offerings
+ if "SourceNat" in o.name and "Vpc" not in o.name
+ and "NSX" not in o.name and "Netris" not in o.name),
+ offerings[0] if offerings else None
+ )
+ if snat_offering:
+ cn_cmd = createNetworkAPI.createNetworkCmd()
+ cn_cmd.zoneid = cls.zone.id
+ cn_cmd.networkofferingid = snat_offering.id
+ cn_cmd.name = "ontap-iscsi-vm-net-%d" % random.randint(
+ 0, 9999)
+ cn_cmd.displaytext = "ONTAP iSCSI VM test network"
+ cn_cmd.account = cls.account.name
+ cn_cmd.domainid = cls.domain.id
+ net = cls.apiClient.createNetwork(cn_cmd)
+ cls.network_id = net.id
+ cls._created_network_id = net.id
+
+ @classmethod
+ def tearDownClass(cls):
+ """
+ Safety-net cleanup: destroy VM if still alive, delete the guest
+ network created for Advanced zones (if not already deleted by test_08),
+ then delegate pool/volume/account cleanup to the base class.
+ """
+ if cls.vm is not None:
+ try:
+ vms = cls.apiClient.listVirtualMachines(
+ _list_vms_cmd(cls.vm.id))
+ state = vms[0].state if vms else "unknown"
+ if state.lower() not in ("stopped", "destroyed",
+ "expunging", "error"):
+ stop_cmd = stopVirtualMachineAPI.stopVirtualMachineCmd()
+ stop_cmd.id = cls.vm.id
+ stop_cmd.forced = True
+ cls.apiClient.stopVirtualMachine(stop_cmd)
+ _wait_for_vm_state(cls.apiClient, cls.vm.id,
+ "Stopped", timeout=120)
+ except Exception as e:
+ logger.warning("tearDownClass: could not stop VM %s: %s"
+ % (cls.vm.id, e))
+ try:
+ dest_cmd = destroyVirtualMachineAPI.destroyVirtualMachineCmd()
+ dest_cmd.id = cls.vm.id
+ dest_cmd.expunge = True
+ cls.apiClient.destroyVirtualMachine(dest_cmd)
+ except Exception as e:
+ logger.warning("tearDownClass: could not destroy VM %s: %s"
+ % (cls.vm.id, e))
+
+ # Delete the guest network created for this account in Advanced zones.
+ # test_08 deletes it on the happy path; this is the fallback for
+ # mid-suite failures.
+ if cls._created_network_id is not None:
+ try:
+ dn_cmd = deleteNetworkAPI.deleteNetworkCmd()
+ dn_cmd.id = cls._created_network_id
+ cls.apiClient.deleteNetwork(dn_cmd)
+ cls._created_network_id = None
+ except Exception as e:
+ logger.warning(
+ "tearDownClass: could not delete network %s: %s"
+ % (cls._created_network_id, e))
+
+ super(TestOntapVMVolumeAttachISCSI, cls).tearDownClass()
+
+ # ---- helpers -------------------------------------------------------
+
+ def _create_pool(self):
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapISCSIVM_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "iscsi://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = ps[TestData.scope]
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ def _poll_vm_state(self, vm_id, target_state, timeout=300, interval=10):
+ deadline = time.time() + timeout
+ current_state = "unknown"
+ while time.time() < deadline:
+ vms = self.apiClient.listVirtualMachines(_list_vms_cmd(vm_id))
+ if vms:
+ current_state = vms[0].state
+ if current_state.lower() == target_state.lower():
+ return vms[0]
+ time.sleep(interval)
+ self.fail("VM %s did not reach '%s' within %ds (last: '%s')"
+ % (vm_id, target_state, timeout, current_state))
+
+ def _poll_volume_field(self, vol_id, field, target, timeout=120,
+ interval=5):
+ """Poll a volume field until it matches target; return the volume."""
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ cmd = listVolumesAPI.listVolumesCmd()
+ cmd.id = vol_id
+ cmd.listall = True
+ vols = self.apiClient.listVolumes(cmd)
+ if vols:
+ val = getattr(vols[0], field, None)
+ if val == target:
+ return vols[0]
+ time.sleep(interval)
+ return None
+
+ def _lun_maps(self):
+ """Return current LUN-maps for the pool's FlexVol."""
+ if self.__class__.pool is None:
+ return []
+ return self.ontap.list_lun_maps_for_volume(
+ self.svm_name, self.__class__.pool.name)
+
+ # ==================================================================
+ # Test steps
+ # ==================================================================
+
+ # ------------------------------------------------------------------
+ # Step 01 — Create iSCSI ONTAP pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_vm_workflow"], required_hardware=True)
+ def test_01_create_iscsi_pool(self):
+ """
+ Create an iSCSI primary storage pool on ONTAP.
+ Verifies:
+ - Pool reaches 'Up' state; type is 'Iscsi'
+ - ONTAP: FlexVol is online
+ - ONTAP: igroup exists for every host in the cluster that has an IQN
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state)
+ self.assertEqual(pool.type, "Iscsi",
+ "Pool type should be 'Iscsi', got '%s'" % pool.type)
+
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not found for pool '%s'" % pool.name)
+ self.assertEqual(ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online'")
+
+ # ------------------------------------------------------------------
+ # Step 02 — Create iSCSI data volume (LUN on ONTAP)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_vm_workflow"], required_hardware=True)
+ def test_02_create_ontap_data_volume(self):
+ """
+ Allocate a CloudStack data volume on the iSCSI ONTAP pool.
+ Verifies:
+ - createVolume returns a volume object
+ - ONTAP: at least one LUN is created inside the pool's FlexVol
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+
+ vol = self._create_volume(self.__class__.pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+
+ luns = self.ontap.list_luns_in_volume(
+ self.svm_name, self.__class__.pool.name)
+ self.assertTrue(
+ len(luns) > 0,
+ "Expected ≥1 LUN in ONTAP FlexVol '%s' after volume creation, "
+ "found 0" % self.__class__.pool.name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 03 — Deploy a VM
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_vm_workflow"], required_hardware=True)
+ def test_03_deploy_vm(self):
+ """
+ Deploy a VM using the first available ready KVM template.
+ Verifies:
+ - VM reaches Running state
+ - ONTAP: the iSCSI data volume's LUN is NOT yet mapped (no VM
+ attachment has been performed yet)
+ """
+ if self.__class__.template_id is None:
+ self.skipTest(
+ "No ready KVM user template available — waiting for template "
+ "download to complete"
+ )
+
+ cmd = deployVirtualMachineAPI.deployVirtualMachineCmd()
+ cmd.zoneid = self.zone.id
+ cmd.templateid = self.__class__.template_id
+ cmd.serviceofferingid = self.__class__.service_offering_id
+ cmd.account = self.account.name
+ cmd.domainid = self.domain.id
+ if self.__class__.network_id:
+ cmd.networkids = self.__class__.network_id
+
+ vm = self.apiClient.deployVirtualMachine(cmd)
+ self.__class__.vm = vm
+
+ result = self._poll_vm_state(vm.id, "Running", timeout=300)
+ self.assertEqual(
+ result.state, "Running",
+ "VM should be 'Running' after deploy, got '%s'" % result.state
+ )
+
+ # Data volume LUN-map must not exist yet (volume not yet attached)
+ lun_maps = self._lun_maps()
+ self.assertEqual(
+ len(lun_maps), 0,
+ "Expected 0 LUN-maps before volume attach, found %d: %s"
+ % (len(lun_maps), lun_maps)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 04 — Attach iSCSI volume to running VM (TDS SN 27 iSCSI)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_vm_workflow"], required_hardware=True)
+ def test_04_attach_volume_to_vm(self):
+ """
+ Attach the iSCSI data volume to the running VM.
+ Covers TDS Approach-1 SN 27 (iSCSI):
+ - attachVolume completes successfully
+ - CloudStack: volume shows virtualmachineid set
+ - ONTAP: a LUN-map is created linking the data LUN to the host's
+ igroup (the LUN is now accessible to the VM's KVM host)
+ """
+ if self.__class__.vm is None:
+ self.skipTest(
+ "VM not deployed — test_03 was skipped (no ready template)"
+ )
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_02 must pass first")
+
+ cmd = attachVolumeAPI.attachVolumeCmd()
+ cmd.id = self.__class__.volume.id
+ cmd.virtualmachineid = self.__class__.vm.id
+ self.apiClient.attachVolume(cmd)
+
+ # Poll until virtualmachineid is set on the volume
+ result = self._poll_volume_field(
+ self.__class__.volume.id, "virtualmachineid",
+ self.__class__.vm.id, timeout=120)
+ self.assertIsNotNone(
+ result,
+ "Volume virtualmachineid was not set after attachVolume"
+ )
+
+ # ONTAP: at least one LUN-map must exist for the pool's FlexVol
+ lun_maps = self._lun_maps()
+ self.assertGreater(
+ len(lun_maps), 0,
+ "Expected ≥1 LUN-map after volume attach, found 0 — "
+ "LUN is not accessible to the VM's host"
+ )
+
+ # ------------------------------------------------------------------
+ # Step 05 — Stop VM — LUN-maps should be removed
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_vm_workflow"], required_hardware=True)
+ def test_05_stop_vm_lun_unmapped(self):
+ """
+ Stop the running VM while the iSCSI data volume is still attached.
+ Covers TDS VM Stop (iSCSI): 'Luns for the volumes under this VM
+ should be unmapped.'
+ Verifies:
+ - VM reaches Stopped state
+ - ONTAP: LUN-map is removed (LUN itself stays in the FlexVol)
+ """
+ if self.__class__.vm is None:
+ self.skipTest("VM not deployed — test_03 was skipped")
+
+ cmd = stopVirtualMachineAPI.stopVirtualMachineCmd()
+ cmd.id = self.__class__.vm.id
+ self.apiClient.stopVirtualMachine(cmd)
+
+ result = self._poll_vm_state(self.__class__.vm.id, "Stopped",
+ timeout=300)
+ self.assertEqual(
+ result.state, "Stopped",
+ "VM should be 'Stopped', got '%s'" % result.state
+ )
+
+ # ONTAP: LUN-map must be removed once VM is stopped
+ lun_maps = self._lun_maps()
+ self.assertEqual(
+ len(lun_maps), 0,
+ "Expected 0 LUN-maps after VM stop, found %d: %s"
+ % (len(lun_maps), lun_maps)
+ )
+
+ # ONTAP: LUN itself must still exist in the FlexVol
+ luns = self.ontap.list_luns_in_volume(
+ self.svm_name, self.__class__.pool.name)
+ self.assertTrue(
+ len(luns) > 0,
+ "LUN should still exist in ONTAP FlexVol after VM stop"
+ )
+
+ # ------------------------------------------------------------------
+ # Step 06 — Start VM — LUN-maps should be re-created
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_vm_workflow"], required_hardware=True)
+ def test_06_start_vm_lun_remapped(self):
+ """
+ Start the stopped VM.
+ Covers TDS VM Start (iSCSI): 'luns should be re-mapped again to
+ provide access.'
+ Verifies:
+ - VM reaches Running state
+ - ONTAP: LUN-map is re-created (LUN accessible to VM's host)
+ """
+ if self.__class__.vm is None:
+ self.skipTest("VM not deployed — test_03 was skipped")
+
+ cmd = startVirtualMachineAPI.startVirtualMachineCmd()
+ cmd.id = self.__class__.vm.id
+ self.apiClient.startVirtualMachine(cmd)
+
+ result = self._poll_vm_state(self.__class__.vm.id, "Running",
+ timeout=300)
+ self.assertEqual(
+ result.state, "Running",
+ "VM should be 'Running' after start, got '%s'" % result.state
+ )
+
+ # ONTAP: LUN-map must be re-created after VM starts
+ lun_maps = self._lun_maps()
+ self.assertGreater(
+ len(lun_maps), 0,
+ "Expected ≥1 LUN-map after VM start (re-map), found 0"
+ )
+
+ # ------------------------------------------------------------------
+ # Step 07 — Detach volume from running VM
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_vm_workflow"], required_hardware=True)
+ def test_07_detach_volume_from_vm(self):
+ """
+ Detach the iSCSI data volume from the running VM.
+ Verifies:
+ - detachVolume completes successfully
+ - CloudStack: volume virtualmachineid cleared
+ - ONTAP: LUN-map is removed (LUN stays in FlexVol)
+ """
+ if self.__class__.vm is None:
+ self.skipTest("VM not deployed — test_03 was skipped")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_02 must pass first")
+
+ cmd = detachVolumeAPI.detachVolumeCmd()
+ cmd.id = self.__class__.volume.id
+
+ max_timeout = 180
+ interval = 10
+ deadline = time.time() + max_timeout
+ last_exc = None
+ while True:
+ try:
+ self.apiClient.detachVolume(cmd)
+ last_exc = None
+ break
+ except Exception as exc:
+ last_exc = exc
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ break
+ time.sleep(min(interval, remaining))
+ interval = min(interval * 2, max_timeout)
+ if last_exc is not None:
+ raise last_exc
+
+ # Poll until virtualmachineid is cleared
+ result = self._poll_volume_field(
+ self.__class__.volume.id, "virtualmachineid", None, timeout=120)
+ self.assertIsNotNone(
+ result,
+ "Volume virtualmachineid was not cleared after detachVolume"
+ )
+
+ # ONTAP: LUN-map must be removed after detach
+ lun_maps = self._lun_maps()
+ self.assertEqual(
+ len(lun_maps), 0,
+ "Expected 0 LUN-maps after volume detach, found %d: %s"
+ % (len(lun_maps), lun_maps)
+ )
+
+ # ONTAP: LUN still exists in FlexVol
+ luns = self.ontap.list_luns_in_volume(
+ self.svm_name, self.__class__.pool.name)
+ self.assertTrue(
+ len(luns) > 0,
+ "LUN should still exist in ONTAP FlexVol after detach"
+ )
+
+ # ------------------------------------------------------------------
+ # Step 08 — Destroy VM and clean up pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_vm_workflow"], required_hardware=True)
+ def test_08_destroy_vm_and_cleanup(self):
+ """
+ Destroy the VM (with expunge), delete the data volume, force-delete
+ the ONTAP pool, and delete the guest network created for this suite.
+ This test leaves no entities behind in either CloudStack or ONTAP.
+ Verifies:
+ - VM is destroyed and expunged from CloudStack
+ - deleteVolume removes the LUN from the ONTAP FlexVol
+ - deleteStoragePool(forced=True) removes the pool from CS
+ - ONTAP: FlexVol deleted
+ - ONTAP: all per-host igroups deleted
+ - CloudStack: guest network deleted (Advanced zones only)
+ """
+ pool = self.__class__.pool
+ vol = self.__class__.volume
+
+ if self.__class__.vm is not None:
+ # Ensure VM is stopped before destroying
+ vms = self.apiClient.listVirtualMachines(
+ _list_vms_cmd(self.__class__.vm.id))
+ current_state = vms[0].state if vms else "unknown"
+ if current_state.lower() not in ("stopped", "destroyed"):
+ stop_cmd = stopVirtualMachineAPI.stopVirtualMachineCmd()
+ stop_cmd.id = self.__class__.vm.id
+ stop_cmd.forced = True
+ self.apiClient.stopVirtualMachine(stop_cmd)
+ self._poll_vm_state(self.__class__.vm.id, "Stopped",
+ timeout=120)
+
+ dest_cmd = destroyVirtualMachineAPI.destroyVirtualMachineCmd()
+ dest_cmd.id = self.__class__.vm.id
+ dest_cmd.expunge = True
+ self.apiClient.destroyVirtualMachine(dest_cmd)
+ self.__class__.vm = None
+
+ if vol is not None and pool is not None:
+ pool_name = pool.name
+
+ # Delete the data volume
+ del_cmd = deleteVolumeAPI.deleteVolumeCmd()
+ del_cmd.id = vol.id
+ self.apiClient.deleteVolume(del_cmd)
+ self.__class__.volume = None
+
+ # ONTAP: LUN must be removed after volume deletion
+ luns = self.ontap.list_luns_in_volume(self.svm_name, pool_name)
+ self.assertEqual(
+ len(luns), 0,
+ "Expected 0 LUNs in FlexVol '%s' after volume delete, "
+ "found %d" % (pool_name, len(luns))
+ )
+
+ # Enter maintenance and force-delete the pool
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ self._delete_pool(pool.id, forced=True)
+ self.__class__.pool = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(
+ remaining,
+ "Pool '%s' still listed in CloudStack after force deletion"
+ % pool_name
+ )
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after pool force deletion"
+ % pool_name
+ )
+
+ # ONTAP: per-host igroups must be deleted by the pool force-delete
+ for host in self.cluster_hosts:
+ iqn = getattr(host, "storageurl", None)
+ if not iqn or not iqn.startswith("iqn."):
+ continue
+ short = host.name.split(".")[0]
+ igroup_name = "cs_%s_%s" % (
+ self.svm_name,
+ re.sub(r"[^a-zA-Z0-9_-]", "_", short),
+ )
+ igroup = self.ontap.get_igroup(self.svm_name, igroup_name)
+ self.assertIsNone(
+ igroup,
+ "ONTAP igroup '%s' still exists after pool force deletion"
+ % igroup_name
+ )
+
+ # Delete the guest network created by setUpClass for Advanced zones.
+ # Doing this inside the test (rather than only in tearDownClass) makes
+ # the full sequence self-contained when all tests pass.
+ if self.__class__._created_network_id is not None:
+ net_id = self.__class__._created_network_id
+ dn_cmd = deleteNetworkAPI.deleteNetworkCmd()
+ dn_cmd.id = net_id
+ # The management server may briefly drop the connection after the
+ # heavy teardown above; retry deleteNetwork up to 3× with 15s gaps.
+ last_net_exc = None
+ for attempt in range(3):
+ try:
+ self.apiClient.deleteNetwork(dn_cmd)
+ last_net_exc = None
+ break
+ except Exception as exc:
+ last_net_exc = exc
+ if attempt < 2:
+ time.sleep(15)
+ if last_net_exc is not None:
+ raise last_net_exc
+ self.__class__._created_network_id = None
+ self.__class__.network_id = None
+
+ # CloudStack: guest network must be gone
+ net_cmd = listNetworksAPI.listNetworksCmd()
+ net_cmd.id = net_id
+ net_cmd.listall = True
+ remaining_nets = self.apiClient.listNetworks(net_cmd) or []
+ self.assertFalse(
+ remaining_nets,
+ "Guest network %s still listed in CloudStack after deletion"
+ % net_id
+ )
diff --git a/test/integration/plugins/ontap/iscsi/pool/__init__.py b/test/integration/plugins/ontap/iscsi/pool/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/pool/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/iscsi/pool/test_pool_lifecycle.py b/test/integration/plugins/ontap/iscsi/pool/test_pool_lifecycle.py
new file mode 100644
index 000000000000..01abd239b0b7
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/pool/test_pool_lifecycle.py
@@ -0,0 +1,645 @@
+# 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.
+
+"""
+Sequential workflow integration tests for NetApp ONTAP iSCSI primary storage
+pool lifecycle (no volumes).
+
+Tests are numbered test_01 ... test_08 and must run in that order. Each step
+builds on the shared state established by the previous step.
+
+Workflow:
+ 01 Create primary storage pool
+ 02 Disable storage pool
+ 03 Enable storage pool
+ 04 Enter maintenance mode
+ 05 Cancel maintenance mode
+ 06 Enter maintenance mode and delete the storage pool
+ 07 Create a new pool and allocate a CloudStack data volume (LUN created)
+ 08 Delete the volume (LUN removed), enter maintenance, force-delete pool
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM cluster where every host has iSCSI configured (storageUrl starts with iqn.)
+ - ONTAP SVM with iSCSI service enabled and at least one iSCSI data LIF
+ - ontap.cfg populated with real values
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/iscsi/pool/ -v
+
+Note: Tests share class-level state (sequential). Always run the full suite.
+"""
+
+import base64
+import logging
+import random
+import re
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ cancelStorageMaintenance,
+ createStoragePool as createStoragePoolAPI,
+ deleteVolume as deleteVolumeAPI,
+ enableStorageMaintenance,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, get_datacenter_config, log_progress
+
+logger = logging.getLogger("TestOntapISCSIPoolLifecycle")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ scope="CLUSTER", provider="NetApp ONTAP",
+ tags="ontap-iscsi", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-iscsi-wf@test.com",
+ "firstname": "ONTAP",
+ "lastname": "iSCSI-WF",
+ "username": "ontap_iscsi_wf_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapISCSI_%d" % random.randint(0, 9999),
+ TestData.scope: scope,
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: "ISCSI",
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# iSCSI path helpers
+# ---------------------------------------------------------------------------
+
+def _igroup_name(svm_name, host_name):
+ """Mirror OntapStorageUtils.getIgroupName: cs_{svmName}_{sanitizedHostName}"""
+ short = host_name.split(".")[0]
+ sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", short)
+ return "cs_%s_%s" % (svm_name, sanitized)
+
+
+# ---------------------------------------------------------------------------
+# Sequential workflow test class
+# ---------------------------------------------------------------------------
+
+class TestOntapISCSIPoolLifecycle(OntapTestBase):
+
+ # ---- iSCSI-specific state (set/cleared by individual tests) --------
+ _vol_name_prefix = "OntapISCSIVol"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapISCSIPoolLifecycle, cls).setUpClass()
+ testclient = super(
+ TestOntapISCSIPoolLifecycle, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ iscsi_cfg = pool_cfg.get("protocols", {}).get("iscsi", {})
+ if not iscsi_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "iSCSI tests disabled in ontap.cfg "
+ "(set protocols.iscsi.enabled=true to enable)"
+ )
+ scope = pool_cfg.get("storagePoolScope", "CLUSTER")
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = iscsi_cfg.get("storagePoolTags", "ontap-iscsi")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ scope=scope, provider=provider, tags=tags,
+ capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # No per-test tearDown — state intentionally persists between steps.
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_pool(self):
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapISCSI_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "iscsi://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = ps[TestData.scope]
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ def _assert_pool_capacity(self, pool, label):
+ """Assert CloudStack capacity fields and ONTAP FlexVol size are consistent.
+
+ Logs configured bytes, reported capacity, used bytes, and ONTAP
+ FlexVol space.size at each check point. Asserts:
+ - listStoragePools.capacitybytes >= 90% of configured value
+ - listStoragePools.disksizeused >= 0 (ONTAP reports actual used bytes;
+ even a fresh FlexVol has metadata overhead so a non-zero value is
+ expected and is not an error)
+ - ONTAP FlexVol space.size >= 90% of configured value
+ """
+ configured = self.testdata[TestData.primaryStorage]["capacitybytes"]
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ self.assertIsNotNone(
+ listed,
+ "[capacity/%s] listStoragePools returned None for pool %s"
+ % (label, pool.id)
+ )
+ lp = listed[0]
+ reported = getattr(lp, "capacitybytes", 0) or 0
+ used = getattr(lp, "disksizeused", 0) or 0
+ min_expected = int(configured * 0.90)
+
+ logger.info(
+ "[capacity/%s] configured=%d B reported=%d B used=%d B",
+ label, configured, reported, used
+ )
+ self.assertGreaterEqual(
+ reported, min_expected,
+ "[capacity/%s] capacitybytes %d is >10%% below configured %d"
+ % (label, reported, configured)
+ )
+ self.assertGreaterEqual(
+ used, 0,
+ "[capacity/%s] disksizeused must not be negative, got %d"
+ % (label, used)
+ )
+
+ ontap_vol = self.ontap.get_volume(pool.name)
+ if ontap_vol:
+ ontap_size = ontap_vol.get("space", {}).get("size", 0)
+ logger.info(
+ "[capacity/%s] ONTAP FlexVol space.size=%d B",
+ label, ontap_size
+ )
+ self.assertGreaterEqual(
+ ontap_size, min_expected,
+ "[capacity/%s] ONTAP FlexVol space.size %d is >10%% below configured %d"
+ % (label, ontap_size, configured)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 01 - Create primary storage pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_workflow"], required_hardware=True)
+ def test_01_create_primary_storage_pool(self):
+ """
+ Create an iSCSI primary storage pool and verify:
+ - CloudStack state is Up, type is Iscsi
+ - ONTAP: FlexVol exists and is online
+ - ONTAP: one igroup per cluster host exists with the correct IQN initiator
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+ self.assertEqual(
+ pool.type, "Iscsi",
+ "Pool type should be 'Iscsi', got '%s'" % pool.type
+ )
+
+ # ONTAP: FlexVol must be online
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not found for pool '%s'" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: igroup must exist for each cluster host that has an IQN
+ for host in self.cluster_hosts:
+ iqn = getattr(host, "storageurl", None) or getattr(host, "StorageUrl", None)
+ if not iqn or not iqn.startswith("iqn."):
+ continue # host not iSCSI-enabled; skip igroup check for it
+ igroup_name = _igroup_name(self.svm_name, host.name)
+ igroup = self.ontap.get_igroup(self.svm_name, igroup_name)
+ self.assertIsNotNone(
+ igroup,
+ "ONTAP igroup '%s' not found for host '%s'" % (igroup_name, host.name)
+ )
+ initiator_names = [
+ i.get("name", "") for i in igroup.get("initiators", [])
+ ]
+ self.assertIn(
+ iqn, initiator_names,
+ "Host IQN '%s' not in igroup '%s' initiators: %s"
+ % (iqn, igroup_name, initiator_names)
+ )
+
+ # Capacity reporting
+ self._assert_pool_capacity(pool, "pool-created")
+
+ # ------------------------------------------------------------------
+ # Step 02 - Disable storage pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_workflow"], required_hardware=True)
+ def test_02_disable_storage_pool(self):
+ """
+ Disable the pool and verify:
+ - CloudStack reports Disabled
+ - ONTAP: FlexVol is still online (disable is a CS-only state change)
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = False
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Disabled", timeout=60)
+ self.assertEqual(result.state, "Disabled")
+
+ # ONTAP: disable must not touch the FlexVol
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after disable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' after disable, got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ------------------------------------------------------------------
+ # Step 03 - Enable storage pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_workflow"], required_hardware=True)
+ def test_03_enable_storage_pool(self):
+ """
+ Re-enable the pool and verify:
+ - CloudStack reports Up
+ - ONTAP: FlexVol is still online (enable is a CS-only state change)
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = True
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Up", timeout=60)
+ self.assertEqual(result.state, "Up")
+
+ # ONTAP: enable must not touch the FlexVol
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after enable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after enable, got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ------------------------------------------------------------------
+ # Step 04 - Enter maintenance mode
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_workflow"], required_hardware=True)
+ def test_04_enter_maintenance_mode(self):
+ """
+ Put the pool into maintenance mode and verify:
+ - CloudStack reports Maintenance
+ - ONTAP: FlexVol is still online (maintenance is a CS-only state change)
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.enableStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Maintenance", timeout=120)
+ self.assertEqual(result.state, "Maintenance")
+
+ # ONTAP: maintenance must not touch the FlexVol
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after entering maintenance")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' in maintenance, got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ------------------------------------------------------------------
+ # Step 05 - Cancel maintenance mode
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_workflow"], required_hardware=True)
+ def test_05_cancel_maintenance_mode(self):
+ """
+ Cancel maintenance and verify:
+ - CloudStack reports Up
+ - ONTAP: FlexVol is still online
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ cmd = cancelStorageMaintenance.cancelStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.cancelStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Up", timeout=120)
+ self.assertEqual(result.state, "Up")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after cancel maintenance")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after cancel maintenance, got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ------------------------------------------------------------------
+ # Step 06 - Enter maintenance mode and delete the storage pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_workflow"], required_hardware=True)
+ def test_06_enter_maintenance_and_delete_pool(self):
+ """
+ Enter maintenance mode then delete the pool.
+ Verifies the pool is removed from CloudStack and the backing ONTAP
+ FlexVol is deleted.
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+ pool = self.__class__.pool
+ pool_name = pool.name
+
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ self._delete_pool(pool.id)
+ self.__class__.pool = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(remaining, "Pool still listed in CloudStack after deletion")
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after pool deletion" % pool_name
+ )
+
+ # ONTAP: igroups for each cluster host must be deleted
+ for host in self.cluster_hosts:
+ iqn = getattr(host, "storageurl", None) or getattr(host, "StorageUrl", None)
+ if not iqn or not iqn.startswith("iqn."):
+ continue
+ igroup_name = _igroup_name(self.svm_name, host.name)
+ igroup = self.ontap.get_igroup(self.svm_name, igroup_name)
+ self.assertIsNone(
+ igroup,
+ "ONTAP igroup '%s' still exists after pool deletion" % igroup_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 07 - Create fresh pool and allocate a CloudStack volume (LUN)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_workflow"], required_hardware=True)
+ def test_07_create_volume_on_pool(self):
+ """
+ Create a new iSCSI pool and allocate a CloudStack data volume.
+ For iSCSI, createAsync creates a LUN inside the pool's ONTAP FlexVol.
+ Verifies:
+ - pool.state is Up, type is Iscsi
+ - createVolume returns a non-None volume object
+ - ONTAP: FlexVol is still online
+ - ONTAP: at least one LUN is present in the FlexVol
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+ log_progress(
+ logger, "info",
+ "test_07: created storage pool name='%s' id=%s state=%s type=%s",
+ pool.name, pool.id, pool.state, pool.type,
+ )
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+ self.assertEqual(
+ pool.type, "Iscsi",
+ "Pool type should be 'Iscsi', got '%s'" % pool.type
+ )
+
+ vol = self._create_volume(pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+ log_progress(
+ logger, "info",
+ "test_07: created CloudStack volume name='%s' id=%s state=%s "
+ "on pool='%s' (id=%s) account='%s' domain='%s' — "
+ "switch to this account in the UI to see the volume",
+ getattr(vol, "name", "?"), getattr(vol, "id", "?"),
+ getattr(vol, "state", "?"), pool.name, pool.id,
+ self.account.name, self.domain.name,
+ )
+
+ # ONTAP: FlexVol must still be online after volume allocation
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' not found after volume creation" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: at least one LUN must be present in the FlexVol
+ luns = self.ontap.list_luns_in_volume(self.svm_name, pool.name)
+ self.assertTrue(
+ len(luns) > 0,
+ "No LUNs found in ONTAP FlexVol '%s' after volume creation" % pool.name
+ )
+
+ # Capacity reporting
+ self._assert_pool_capacity(pool, "volume-allocated")
+
+ # ------------------------------------------------------------------
+ # Step 08 - Delete volume (LUN) then force-delete the pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_workflow"], required_hardware=True)
+ def test_08_delete_volume_and_pool(self):
+ """
+ Delete the volume from test_07, enter maintenance, then force-delete
+ the pool.
+ Verifies:
+ - deleteVolume removes the LUN from ONTAP
+ - Pool transitions to Maintenance
+ - Pool is removed from CloudStack after force deletion
+ - ONTAP: FlexVol deleted
+ - ONTAP: igroups for all cluster hosts deleted
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_07 must pass first")
+ self.assertIsNotNone(self.__class__.volume, "Volume absent - test_07 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+ vol = self.__class__.volume
+
+ # Delete the volume — LUN is removed from ONTAP
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol.id
+ self.apiClient.deleteVolume(cmd)
+ self.__class__.volume = None
+
+ # ONTAP: LUN must be gone from the FlexVol
+ luns = self.ontap.list_luns_in_volume(self.svm_name, pool_name)
+ self.assertEqual(
+ len(luns), 0,
+ "Expected 0 LUNs in FlexVol '%s' after volume deletion, found %d"
+ % (pool_name, len(luns))
+ )
+
+ # ONTAP: FlexVol must still be online (pool not yet deleted)
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' should still exist after volume deletion" % pool_name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' after volume deletion"
+ )
+
+ # Capacity reporting: capacity stable after volume deletion
+ self._assert_pool_capacity(pool, "volume-deleted")
+
+ # Enter maintenance then force-delete the pool
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ self._delete_pool(pool.id, forced=True)
+ self.__class__.pool = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(remaining, "Pool still listed in CloudStack after deletion")
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after pool deletion" % pool_name
+ )
+
+ # ONTAP: igroups for each cluster host must be deleted
+ for host in self.cluster_hosts:
+ iqn = getattr(host, "storageurl", None) or getattr(host, "StorageUrl", None)
+ if not iqn or not iqn.startswith("iqn."):
+ continue
+ igroup_name = _igroup_name(self.svm_name, host.name)
+ igroup = self.ontap.get_igroup(self.svm_name, igroup_name)
+ self.assertIsNone(
+ igroup,
+ "ONTAP igroup '%s' still exists after pool deletion" % igroup_name
+ )
diff --git a/test/integration/plugins/ontap/iscsi/pool/test_pool_with_volumes.py b/test/integration/plugins/ontap/iscsi/pool/test_pool_with_volumes.py
new file mode 100644
index 000000000000..03e332740f58
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/pool/test_pool_with_volumes.py
@@ -0,0 +1,707 @@
+# 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.
+
+"""
+iSCSI pool lifecycle tests with a CloudStack data volume present throughout.
+
+Covers the TDS (section 10) scenarios that require a data volume to already
+exist on the pool during pool state transitions — the iSCSI variants of those
+scenarios:
+
+ TDS Approach-1 SN 11 — Disable iSCSI pool WITH volumes
+ TDS Approach-1 SN 15 — Enable iSCSI pool WITH volumes
+ TDS Approach-1 SN 19 — Enter maintenance WITH volumes
+ TDS Approach-1 SN 23 — Cancel maintenance WITH volumes
+ TDS Negative SN 5 — Delete iSCSI pool that has volumes; forced=False rejected
+ TDS Approach-1 SN 7 — Force-delete iSCSI pool (volume deleted first from
+ Maintenance — allowed on iSCSI unlike NFS3)
+
+Key iSCSI difference from NFS3: cancelStorageMaintenance works on iSCSI because
+the KVM agent can unmount/remount iSCSI LUNs correctly. This allows the full
+maintenance-cancel-maintenance lifecycle and proper volume cleanup while pool
+is in Maintenance state.
+
+Tests are numbered test_01 ... test_07 and must run in that order. Each step
+builds on the shared state established by the previous step.
+
+Workflow:
+ 01 Create iSCSI pool and allocate a CloudStack data volume (LUN on ONTAP)
+ 02 Disable pool — volume survives; ONTAP LUN still exists (SN 11)
+ 03 Re-enable pool — volume intact; ONTAP LUN accessible (SN 15)
+ 04 Enter maintenance with volume — pool Maintenance; LUN exists (SN 19)
+ 05 Cancel maintenance with volume — pool Up; LUN accessible (SN 23)
+ 06 Re-enter maintenance; forced=False delete rejected (Neg SN 5)
+ 07 Delete volume from Maintenance, then force-delete pool (SN 7)
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM cluster where every host has iSCSI initiator configured
+ - ONTAP SVM with iSCSI service enabled and at least one iSCSI data LIF
+ - ontap.cfg populated with real values
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/iscsi/pool/test_pool_with_volumes.py -v
+"""
+
+import base64
+import logging
+import random
+import re
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ cancelStorageMaintenance,
+ createStoragePool as createStoragePoolAPI,
+ deleteVolume as deleteVolumeAPI,
+ enableStorageMaintenance,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.cloudstackException import CloudstackAPIException
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, get_datacenter_config
+
+logger = logging.getLogger("TestOntapISCSIPoolWithVolumes")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ scope="CLUSTER", provider="NetApp ONTAP",
+ tags="ontap-iscsi", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-iscsi-wv@test.com",
+ "firstname": "ONTAP",
+ "lastname": "iSCSI-WV",
+ "username": "ontap_iscsi_wv_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapISCSIWV_%d" % random.randint(0, 9999),
+ TestData.scope: scope,
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: "ISCSI",
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _igroup_name(svm_name, host_name):
+ """Mirror OntapStorageUtils.getIgroupName: cs_{svmName}_{sanitizedHostName}"""
+ short = host_name.split(".")[0]
+ sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", short)
+ return "cs_%s_%s" % (svm_name, sanitized)
+
+
+# ---------------------------------------------------------------------------
+# Test class
+# ---------------------------------------------------------------------------
+
+class TestOntapISCSIPoolWithVolumes(OntapTestBase):
+ """
+ iSCSI pool lifecycle tests with a CloudStack data volume present throughout.
+ All 7 tests are sequential and share class-level state.
+ """
+
+ _vol_name_prefix = "OntapISCSIWV"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapISCSIPoolWithVolumes, cls).setUpClass()
+ testclient = super(
+ TestOntapISCSIPoolWithVolumes, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ iscsi_cfg = pool_cfg.get("protocols", {}).get("iscsi", {})
+ if not iscsi_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "iSCSI tests disabled in ontap.cfg "
+ "(set protocols.iscsi.enabled=true to enable)"
+ )
+ scope = pool_cfg.get("storagePoolScope", "CLUSTER")
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = iscsi_cfg.get("storagePoolTags", "ontap-iscsi")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ scope=scope, provider=provider, tags=tags,
+ capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_pool(self):
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapISCSIWV_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "iscsi://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = ps[TestData.scope]
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ def _volume_exists_in_cs(self, vol_id):
+ """Return True if the volume is still listed by CloudStack."""
+ from marvin.cloudstackAPI import listVolumes as listVolumesAPI
+ cmd = listVolumesAPI.listVolumesCmd()
+ cmd.id = vol_id
+ cmd.listall = True
+ vols = self.apiClient.listVolumes(cmd) or []
+ return len(vols) > 0
+
+ def _assert_lun_exists(self, pool_name, msg_context=""):
+ """Assert that at least one LUN exists in the pool's ONTAP FlexVol."""
+ luns = self.ontap.list_luns_in_volume(self.svm_name, pool_name)
+ self.assertTrue(
+ len(luns) > 0,
+ "Expected ≥1 LUN in ONTAP FlexVol '%s'%s, found 0"
+ % (pool_name, " (%s)" % msg_context if msg_context else "")
+ )
+
+ def _assert_pool_capacity(self, pool, label):
+ """Assert CloudStack capacity fields and ONTAP FlexVol size are consistent.
+
+ Logs configured bytes, reported capacity, used bytes, and ONTAP
+ FlexVol space.size at each check point. Asserts:
+ - listStoragePools.capacitybytes >= 90% of configured value
+ - listStoragePools.disksizeused >= 0 (ONTAP reports actual used bytes;
+ even a fresh FlexVol has metadata overhead so a non-zero value is
+ expected and is not an error)
+ - ONTAP FlexVol space.size >= 90% of configured value
+ """
+ configured = self.testdata[TestData.primaryStorage]["capacitybytes"]
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ self.assertIsNotNone(
+ listed,
+ "[capacity/%s] listStoragePools returned None for pool %s"
+ % (label, pool.id)
+ )
+ lp = listed[0]
+ reported = getattr(lp, "capacitybytes", 0) or 0
+ used = getattr(lp, "disksizeused", 0) or 0
+ min_expected = int(configured * 0.90)
+
+ logger.info(
+ "[capacity/%s] configured=%d B reported=%d B used=%d B",
+ label, configured, reported, used
+ )
+ self.assertGreaterEqual(
+ reported, min_expected,
+ "[capacity/%s] capacitybytes %d is >10%% below configured %d"
+ % (label, reported, configured)
+ )
+ self.assertGreaterEqual(
+ used, 0,
+ "[capacity/%s] disksizeused must not be negative, got %d"
+ % (label, used)
+ )
+
+ ontap_vol = self.ontap.get_volume(pool.name)
+ if ontap_vol:
+ ontap_size = ontap_vol.get("space", {}).get("size", 0)
+ logger.info(
+ "[capacity/%s] ONTAP FlexVol space.size=%d B",
+ label, ontap_size
+ )
+ self.assertGreaterEqual(
+ ontap_size, min_expected,
+ "[capacity/%s] ONTAP FlexVol space.size %d is >10%% below configured %d"
+ % (label, ontap_size, configured)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 01 — Create pool and allocate a data volume
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_with_volumes"], required_hardware=True)
+ def test_01_create_pool_and_volume(self):
+ """
+ Create an iSCSI primary storage pool and allocate a CloudStack data
+ volume on it.
+ Verifies:
+ - Pool state is Up; pool type is Iscsi
+ - ONTAP: FlexVol is online
+ - ONTAP: at least one igroup exists (one per cluster host with IQN)
+ - ONTAP: after createVolume, a LUN exists in the FlexVol
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+ self.assertEqual(
+ pool.type, "Iscsi",
+ "Pool type should be 'Iscsi', got '%s'" % pool.type
+ )
+
+ # ONTAP: FlexVol must be online
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not found for pool '%s'" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: igroup must exist for each cluster host that has an IQN
+ for host in self.cluster_hosts:
+ iqn = getattr(host, "storageurl", None)
+ if not iqn or not iqn.startswith("iqn."):
+ continue
+ igroup_name = _igroup_name(self.svm_name, host.name)
+ igroup = self.ontap.get_igroup(self.svm_name, igroup_name)
+ self.assertIsNotNone(
+ igroup,
+ "ONTAP igroup '%s' not found for host '%s'"
+ % (igroup_name, host.name)
+ )
+
+ # Allocate a CloudStack data volume on this pool
+ vol = self._create_volume(pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+
+ # ONTAP: a LUN must exist in the FlexVol after volume creation
+ self._assert_lun_exists(pool.name, "after volume creation")
+
+ # Capacity reporting: LUN allocated but FlexVol size unchanged
+ self._assert_pool_capacity(pool, "volume-allocated")
+
+ # ------------------------------------------------------------------
+ # Step 02 — Disable pool with volume present (TDS SN 11)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_with_volumes"], required_hardware=True)
+ def test_02_disable_pool_volume_survives(self):
+ """
+ Disable the pool while a CloudStack data volume exists on it.
+ Covers TDS Approach-1 SN 11 (iSCSI):
+ - Pool transitions to Disabled
+ - Existing CS volume still listed
+ - ONTAP: FlexVol remains online; LUN still exists
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = False
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Disabled", timeout=60)
+ self.assertEqual(
+ result.state, "Disabled",
+ "Pool should be 'Disabled', got '%s'" % result.state
+ )
+
+ # CS volume must still exist
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume disappeared after pool disable"
+ )
+
+ # ONTAP: FlexVol still online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after pool disable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should remain 'online' after pool disable"
+ )
+
+ # ONTAP: LUN still exists
+ self._assert_lun_exists(self.__class__.pool.name, "after pool disable")
+
+ # ------------------------------------------------------------------
+ # Step 03 — Re-enable pool with volume present (TDS SN 15)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_with_volumes"], required_hardware=True)
+ def test_03_enable_pool_volume_intact(self):
+ """
+ Re-enable the pool while a CloudStack data volume exists on it.
+ Covers TDS Approach-1 SN 15 (iSCSI):
+ - Pool transitions back to Up
+ - CS volume still listed
+ - ONTAP: FlexVol online; LUN still exists
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = True
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Up", timeout=60)
+ self.assertEqual(
+ result.state, "Up",
+ "Pool should be 'Up' after re-enable, got '%s'" % result.state
+ )
+
+ # CS volume must still exist
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume disappeared after pool re-enable"
+ )
+
+ # ONTAP: FlexVol online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after pool re-enable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after pool re-enable"
+ )
+
+ # ONTAP: LUN still exists
+ self._assert_lun_exists(self.__class__.pool.name, "after pool re-enable")
+
+ # ------------------------------------------------------------------
+ # Step 04 — Enter maintenance with volume present (TDS SN 19)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_with_volumes"], required_hardware=True)
+ def test_04_enter_maintenance_volume_present(self):
+ """
+ Enter maintenance mode while a CloudStack data volume exists on the pool.
+ Covers TDS Approach-1 SN 19 (iSCSI):
+ - Pool transitions to Maintenance
+ - CS volume still listed (not destroyed)
+ - ONTAP: FlexVol remains online (maintenance is a CloudStack state)
+ - ONTAP: LUN still exists in the FlexVol
+
+ Note: the TDS additionally expects VMs using this pool to stop and their
+ LUN maps to be removed. This suite uses a standalone data volume (not
+ attached to any VM), so the VM stop behaviour is not exercised here — it
+ is covered by the VM lifecycle test suite.
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.enableStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Maintenance", timeout=120)
+ self.assertEqual(
+ result.state, "Maintenance",
+ "Pool should be 'Maintenance', got '%s'" % result.state
+ )
+
+ # CS volume must still exist
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume disappeared after pool entered Maintenance"
+ )
+
+ # ONTAP: FlexVol still online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(
+ ontap_vol, "ONTAP FlexVol disappeared after entering Maintenance")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should remain 'online' in Maintenance"
+ )
+
+ # ONTAP: LUN still exists
+ self._assert_lun_exists(self.__class__.pool.name, "after entering Maintenance")
+
+ # ------------------------------------------------------------------
+ # Step 05 — Cancel maintenance with volume present (TDS SN 23)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_with_volumes"], required_hardware=True)
+ def test_05_cancel_maintenance_volume_present(self):
+ """
+ Cancel maintenance mode while a CloudStack data volume exists on the pool.
+ Covers TDS Approach-1 SN 23 (iSCSI):
+ - cancelStorageMaintenance works on iSCSI (unlike the NFS3 variant)
+ - Pool transitions back to Up
+ - CS volume still listed
+ - ONTAP: FlexVol online; LUN still present in FlexVol
+
+ Note: when VMs are attached to volumes on this pool, ONTAP would
+ re-create the LUN-maps (igroup bindings) at cancel-maintenance time.
+ This suite has no VMs attached, so LUN-map re-creation is not verified
+ here; it is covered by the VM lifecycle test suite.
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ cmd = cancelStorageMaintenance.cancelStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.cancelStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Up", timeout=120)
+ self.assertEqual(
+ result.state, "Up",
+ "Pool should be 'Up' after cancel maintenance, got '%s'" % result.state
+ )
+
+ # CS volume must still exist
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume disappeared after cancel maintenance"
+ )
+
+ # ONTAP: FlexVol online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(
+ ontap_vol, "ONTAP FlexVol disappeared after cancel maintenance")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after cancel maintenance"
+ )
+
+ # ONTAP: LUN still exists
+ self._assert_lun_exists(self.__class__.pool.name, "after cancel maintenance")
+
+ # ------------------------------------------------------------------
+ # Step 06 — forced=False delete rejected (negative) (TDS Neg SN 5)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_with_volumes"], required_hardware=True)
+ def test_06_forced_false_delete_rejected(self):
+ """
+ Enter maintenance then attempt deleteStoragePool(forced=False) while
+ a CloudStack volume exists on the pool. The operation must be rejected.
+ Covers TDS Negative Scenario SN 5 (iSCSI):
+ - CloudstackAPIException is raised
+ - Pool remains in Maintenance state
+ - CS volume still exists
+ - ONTAP: FlexVol and LUN unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ # Re-enter Maintenance (pool is Up from test_05)
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = self.__class__.pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(self.__class__.pool.id, "Maintenance", timeout=120)
+
+ # Attempt forced=False delete — must raise
+ with self.assertRaises(Exception,
+ msg="deleteStoragePool(forced=False) with a live "
+ "volume should raise an exception"):
+ self._delete_pool(self.__class__.pool.id, forced=False)
+
+ # Pool must still be listed (in Maintenance)
+ try:
+ remaining = list_storage_pools(self.apiClient, id=self.__class__.pool.id)
+ except Exception:
+ remaining = None
+ self.assertTrue(
+ remaining,
+ "Pool was deleted even though forced=False delete should have failed"
+ )
+
+ # CS volume must still exist
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume was deleted after rejected pool deletion"
+ )
+
+ # ONTAP: FlexVol still online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol should still exist after rejected pool deletion"
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should remain 'online' after rejected deletion"
+ )
+
+ # ------------------------------------------------------------------
+ # Step 07 — Delete volume from Maintenance, then force-delete pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_with_volumes"], required_hardware=True)
+ def test_07_delete_volume_and_force_delete_pool(self):
+ """
+ Delete the CloudStack volume (while pool is in Maintenance) then
+ force-delete the pool.
+ Covers TDS Approach-1 SN 7 (iSCSI):
+ - On iSCSI, deleteVolume succeeds even when pool is in Maintenance
+ (unlike NFS3 where the KVM agent raises NPE)
+ - After volume deletion, the LUN is removed from the ONTAP FlexVol
+ - force-delete pool removes pool, FlexVol, and all igroups
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+ vol = self.__class__.volume
+
+ # Delete the volume while pool is in Maintenance
+ # (this works on iSCSI — no KVM NPE unlike NFS3)
+ del_cmd = deleteVolumeAPI.deleteVolumeCmd()
+ del_cmd.id = vol.id
+ self.apiClient.deleteVolume(del_cmd)
+ self.__class__.volume = None
+
+ # ONTAP: LUN must be gone from the FlexVol after volume deletion
+ luns_after = self.ontap.list_luns_in_volume(self.svm_name, pool_name)
+ self.assertEqual(
+ len(luns_after), 0,
+ "Expected 0 LUNs in ONTAP FlexVol '%s' after volume deletion, "
+ "found %d: %s" % (pool_name, len(luns_after), luns_after)
+ )
+
+ # ONTAP: FlexVol must still be online (pool deletion removes the FlexVol)
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' should still exist after CS volume deletion"
+ % pool_name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should remain 'online' after CS volume deletion"
+ )
+
+ # Capacity reporting: capacity fields stable after LUN removal
+ self._assert_pool_capacity(pool, "volume-deleted")
+
+ # Force-delete the pool (no live volumes remain; pool is in Maintenance)
+ self._delete_pool(pool.id, forced=True)
+ self.__class__.pool = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(
+ remaining,
+ "Pool '%s' still listed in CloudStack after force deletion" % pool_name
+ )
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol_after = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol_after,
+ "ONTAP FlexVol '%s' still exists after pool force deletion" % pool_name
+ )
+
+ # ONTAP: igroups for all cluster hosts must be deleted
+ for host in self.cluster_hosts:
+ iqn = getattr(host, "storageurl", None)
+ if not iqn or not iqn.startswith("iqn."):
+ continue
+ igroup_name = _igroup_name(self.svm_name, host.name)
+ igroup = self.ontap.get_igroup(self.svm_name, igroup_name)
+ self.assertIsNone(
+ igroup,
+ "ONTAP igroup '%s' still exists after pool force deletion"
+ % igroup_name
+ )
diff --git a/test/integration/plugins/ontap/iscsi/pool/test_zone_scoped_pool.py b/test/integration/plugins/ontap/iscsi/pool/test_zone_scoped_pool.py
new file mode 100644
index 000000000000..c7ee726ef460
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/pool/test_zone_scoped_pool.py
@@ -0,0 +1,387 @@
+# 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.
+
+"""
+Zone-scoped primary storage lifecycle tests for NetApp ONTAP (iSCSI).
+
+Creates a zone-scoped pool (scope=ZONE, no clusterid/podid). CloudStack calls
+OntapPrimaryDatastoreLifecycle.attachZone(), which connects all eligible KVM
+hosts in the zone and creates igroups for each host's IQN.
+
+Workflow:
+ 01 Create zone-scoped iSCSI pool — pool.state Up; ONTAP FlexVol online;
+ igroup present for each cluster host IQN
+ 02 Disable zone-scoped pool — pool.state Disabled; FlexVol unchanged
+ 03 Enable zone-scoped pool — pool.state Up; FlexVol unchanged
+ 04 Delete zone-scoped pool — pool gone; FlexVol deleted; igroups deleted
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM hosts with iSCSI registered in the zone
+ - ONTAP SVM with iSCSI service enabled and at least one iSCSI data LIF
+ - ontap.cfg populated with real values
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/iscsi/pool/test_zone_scoped_pool.py -v
+
+Note: Tests 01-04 share class-level state (sequential). Always run the full
+suite.
+"""
+
+import base64
+import logging
+import random
+import re
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ createStoragePool as createStoragePoolAPI,
+ enableStorageMaintenance,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, get_datacenter_config
+
+logger = logging.getLogger("TestOntapISCSIZoneScopedPool")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ provider="NetApp ONTAP", tags="ontap-iscsi", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-iscsi-zone@test.com",
+ "firstname": "ONTAP",
+ "lastname": "iSCSI-Zone",
+ "username": "ontap_iscsi_zone_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapZoneISCSI_%d" % random.randint(0, 9999),
+ TestData.scope: "ZONE",
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: "ISCSI",
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# iSCSI path helpers
+# ---------------------------------------------------------------------------
+
+def _igroup_name(svm_name, host_name):
+ """Mirror OntapStorageUtils.getIgroupName: cs_{svmName}_{sanitizedHostName}"""
+ short = host_name.split(".")[0]
+ sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", short)
+ return "cs_%s_%s" % (svm_name, sanitized)
+
+
+# ---------------------------------------------------------------------------
+# Sequential workflow test class
+# ---------------------------------------------------------------------------
+
+class TestOntapISCSIZoneScopedPool(OntapTestBase):
+
+ _vol_name_prefix = "OntapISCSIZoneVol"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapISCSIZoneScopedPool, cls).setUpClass()
+ testclient = super(
+ TestOntapISCSIZoneScopedPool, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ iscsi_cfg = pool_cfg.get("protocols", {}).get("iscsi", {})
+ if not iscsi_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "iSCSI tests disabled in ontap.cfg "
+ "(set protocols.iscsi.enabled=true to enable)"
+ )
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = iscsi_cfg.get("storagePoolTags", "ontap-iscsi")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ provider=provider, tags=tags, capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # No per-test tearDown — state intentionally persists between steps.
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_zone_pool(self):
+ """Create a zone-scoped iSCSI pool (no clusterid / podid)."""
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapZoneISCSI_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "iscsi://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ # Intentionally omit clusterid and podid — zone-scoped pool
+ cmd.scope = "ZONE"
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ def _assert_igroups_for_hosts(self, expect_present):
+ """Assert igroups are present (or absent) for each cluster host IQN."""
+ for host in self.cluster_hosts:
+ iqn = (getattr(host, "storageurl", None)
+ or getattr(host, "StorageUrl", None))
+ if not iqn or not iqn.startswith("iqn."):
+ continue
+ igroup_name = _igroup_name(self.svm_name, host.name)
+ igroup = self.ontap.get_igroup(self.svm_name, igroup_name)
+ if expect_present:
+ self.assertIsNotNone(
+ igroup,
+ "ONTAP igroup '%s' not found for host '%s' after pool creation"
+ % (igroup_name, host.name)
+ )
+ initiator_names = [
+ i.get("name", "") for i in igroup.get("initiators", [])
+ ]
+ self.assertIn(
+ iqn, initiator_names,
+ "Host IQN '%s' not in igroup '%s' initiators: %s"
+ % (iqn, igroup_name, initiator_names)
+ )
+ else:
+ self.assertIsNone(
+ igroup,
+ "ONTAP igroup '%s' still exists after pool deletion" % igroup_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 01 — Create zone-scoped iSCSI pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_zone_pool"], required_hardware=True)
+ def test_01_create_zone_scoped_pool(self):
+ """
+ Create a zone-scoped iSCSI primary storage pool (no clusterid/podid).
+ CloudStack calls attachZone(), which connects all eligible KVM hosts
+ in the zone and creates igroups for each host's IQN.
+ Verifies:
+ - pool.state is Up, type is Iscsi
+ - ONTAP: FlexVol is online
+ - ONTAP: igroup exists for each cluster host with the correct IQN
+ """
+ pool = self._create_zone_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+ self.assertEqual(
+ pool.type, "Iscsi",
+ "Pool type should be 'Iscsi', got '%s'" % pool.type
+ )
+
+ # ONTAP: FlexVol must be online
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not found for pool '%s'" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: igroups must exist for each cluster host with IQN
+ self._assert_igroups_for_hosts(expect_present=True)
+
+ # ------------------------------------------------------------------
+ # Step 02 — Disable zone-scoped pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_zone_pool"], required_hardware=True)
+ def test_02_disable_zone_scoped_pool(self):
+ """
+ Disable the zone-scoped iSCSI pool.
+ Verifies:
+ - pool.state is Disabled
+ - ONTAP: FlexVol still online; igroups unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = False
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Disabled", timeout=60)
+ self.assertEqual(result.state, "Disabled")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after disable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' after disable"
+ )
+
+ # igroups must still be present after a simple disable
+ self._assert_igroups_for_hosts(expect_present=True)
+
+ # ------------------------------------------------------------------
+ # Step 03 — Enable zone-scoped pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_zone_pool"], required_hardware=True)
+ def test_03_enable_zone_scoped_pool(self):
+ """
+ Re-enable the zone-scoped iSCSI pool.
+ Verifies:
+ - pool.state is Up
+ - ONTAP: FlexVol still online; igroups unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = True
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Up", timeout=60)
+ self.assertEqual(result.state, "Up")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after enable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after enable"
+ )
+
+ # igroups must still be present after re-enable
+ self._assert_igroups_for_hosts(expect_present=True)
+
+ # ------------------------------------------------------------------
+ # Step 04 — Delete zone-scoped pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_zone_pool"], required_hardware=True)
+ def test_04_delete_zone_scoped_pool(self):
+ """
+ Enter maintenance then delete the zone-scoped iSCSI pool.
+ Verifies:
+ - Pool is removed from CloudStack
+ - ONTAP: FlexVol deleted
+ - ONTAP: igroups deleted for all cluster hosts
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ self._delete_pool(pool.id, forced=True)
+ self.__class__.pool = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(remaining, "Pool still listed in CloudStack after deletion")
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after pool deletion" % pool_name
+ )
+
+ # ONTAP: igroups for each cluster host must be deleted
+ self._assert_igroups_for_hosts(expect_present=False)
diff --git a/test/integration/plugins/ontap/iscsi/volume/__init__.py b/test/integration/plugins/ontap/iscsi/volume/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/volume/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/iscsi/volume/test_volume_lifecycle.py b/test/integration/plugins/ontap/iscsi/volume/test_volume_lifecycle.py
new file mode 100644
index 000000000000..3056f36c422c
--- /dev/null
+++ b/test/integration/plugins/ontap/iscsi/volume/test_volume_lifecycle.py
@@ -0,0 +1,417 @@
+# 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.
+
+"""
+Sequential workflow integration tests for NetApp ONTAP iSCSI data volume
+lifecycle (LUN create / delete / negative-delete / force-delete).
+
+Tests are numbered test_01 ... test_05 and must run in that order. Each step
+builds on the shared state established by the previous step.
+
+Workflow:
+ 01 Create iSCSI primary storage pool (infrastructure) and allocate a
+ CloudStack data volume — LUN is created inside the pool's ONTAP FlexVol
+ 02 Delete the volume — LUN is removed from the FlexVol
+ 03 Recreate volume — LUN is present again (setup for negative delete tests)
+ 04 Put pool in Maintenance; attempt forced=False deleteStoragePool — must be
+ rejected because volumes exist; pool stays in Maintenance
+ 05 Delete volume from Maintenance; forced=True deleteStoragePool — FlexVol,
+ igroups, and all LUNs are removed from ONTAP
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM cluster where every host has iSCSI configured (storageUrl starts with iqn.)
+ - ONTAP SVM with iSCSI service enabled and at least one iSCSI data LIF
+ - ontap.cfg populated with real values
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/iscsi/volume/ -v
+
+Note: Tests share class-level state (sequential). Always run the full suite.
+The pool is cleaned up in test_05 on the happy path; OntapTestBase tearDownClass
+provides a safety net for mid-run failures.
+"""
+
+import base64
+import logging
+import random
+import re
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ cancelStorageMaintenance,
+ createStoragePool as createStoragePoolAPI,
+ deleteVolume as deleteVolumeAPI,
+ enableStorageMaintenance,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.cloudstackException import CloudstackAPIException
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, get_datacenter_config
+
+logger = logging.getLogger("TestOntapISCSIVolumeLifecycle")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ scope="CLUSTER", provider="NetApp ONTAP",
+ tags="ontap-iscsi", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-iscsi-vol@test.com",
+ "firstname": "ONTAP",
+ "lastname": "iSCSI-Vol",
+ "username": "ontap_iscsi_vol_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapISCSIVol_%d" % random.randint(0, 9999),
+ TestData.scope: scope,
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: "ISCSI",
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# iSCSI path helpers
+# ---------------------------------------------------------------------------
+
+def _igroup_name(svm_name, host_name):
+ """Mirror OntapStorageUtils.getIgroupName: cs_{svmName}_{sanitizedHostName}"""
+ short = host_name.split(".")[0]
+ sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", short)
+ return "cs_%s_%s" % (svm_name, sanitized)
+
+
+# ---------------------------------------------------------------------------
+# Sequential workflow test class
+# ---------------------------------------------------------------------------
+
+class TestOntapISCSIVolumeLifecycle(OntapTestBase):
+
+ _vol_name_prefix = "OntapISCSIVol"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapISCSIVolumeLifecycle, cls).setUpClass()
+ testclient = super(
+ TestOntapISCSIVolumeLifecycle, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ iscsi_cfg = pool_cfg.get("protocols", {}).get("iscsi", {})
+ if not iscsi_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "iSCSI tests disabled in ontap.cfg "
+ "(set protocols.iscsi.enabled=true to enable)"
+ )
+ scope = pool_cfg.get("storagePoolScope", "CLUSTER")
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = iscsi_cfg.get("storagePoolTags", "ontap-iscsi")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ scope=scope, provider=provider, tags=tags,
+ capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # No per-test tearDown — state intentionally persists between steps.
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_pool(self):
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapISCSIVol_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "iscsi://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = ps[TestData.scope]
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ # ------------------------------------------------------------------
+ # Step 01 - Create pool (infrastructure) and allocate a volume
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_volume"], required_hardware=True)
+ def test_01_create_pool_and_volume(self):
+ """
+ Create a new iSCSI pool and allocate a CloudStack data volume on it.
+ Verifies:
+ - pool.state is Up
+ - createVolume returns a non-None volume object
+ - ONTAP: at least one LUN exists in the pool's FlexVol
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+
+ vol = self._create_volume(pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+
+ # ONTAP: at least one LUN must be present in the pool FlexVol
+ luns = self.ontap.list_luns_in_volume(self.svm_name, pool.name)
+ self.assertTrue(
+ len(luns) > 0,
+ "No LUNs found in ONTAP FlexVol '%s' after volume creation" % pool.name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 02 - Delete volume; LUN must be removed from ONTAP
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_volume"], required_hardware=True)
+ def test_02_delete_volume(self):
+ """
+ Delete the volume created in test_01.
+ Verifies:
+ - deleteVolume completes without error
+ - ONTAP: LUN is removed from the pool's FlexVol
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume, "Volume absent - test_01 must pass first")
+
+ pool = self.__class__.pool
+ vol = self.__class__.volume
+
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol.id
+ self.apiClient.deleteVolume(cmd)
+ self.__class__.volume = None
+
+ # ONTAP: LUN must be gone from the FlexVol
+ luns = self.ontap.list_luns_in_volume(self.svm_name, pool.name)
+ self.assertEqual(
+ len(luns), 0,
+ "Expected 0 LUNs in FlexVol '%s' after volume deletion, found %d: %s"
+ % (pool.name, len(luns), luns)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 03 - Recreate volume for negative delete tests
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_volume"], required_hardware=True)
+ def test_03_recreate_volume_for_delete_tests(self):
+ """
+ Recreate a volume on the existing pool (setup for tests 04-05).
+ Verifies:
+ - volume created successfully
+ - ONTAP: LUN present in pool FlexVol
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ vol = self._create_volume(self.__class__.pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+
+ luns = self.ontap.list_luns_in_volume(self.svm_name, self.__class__.pool.name)
+ self.assertTrue(
+ len(luns) > 0,
+ "No LUNs found in ONTAP FlexVol '%s' after volume re-creation"
+ % self.__class__.pool.name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 04 - Forced=False delete with live volume must fail
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_volume"], required_hardware=True)
+ def test_04_forced_false_delete_with_volume_fails(self):
+ """
+ Put pool in Maintenance then attempt deleteStoragePool(forced=False).
+ With a live volume present CloudStack must reject the request.
+ Verifies:
+ - CloudstackAPIException is raised
+ - Pool is still listed in CloudStack (in Maintenance state)
+ - ONTAP: FlexVol still exists and is online
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume, "Volume absent - test_03 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+
+ # Enter maintenance mode
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ # Attempt forced=False delete — must raise exception because volumes exist
+ with self.assertRaises(Exception):
+ self._delete_pool(pool.id, forced=False)
+
+ # Pool must still be listed in CloudStack
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ self.assertTrue(
+ listed,
+ "Pool should still exist in CloudStack after failed forced=False delete"
+ )
+
+ # ONTAP: FlexVol must still be online
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' should still exist after failed delete" % pool_name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ------------------------------------------------------------------
+ # Step 05 - Delete volume then force-delete pool from Maintenance
+ # ------------------------------------------------------------------
+
+ @attr(tags=["iscsi_volume"], required_hardware=True)
+ def test_05_delete_volume_and_force_delete_pool(self):
+ """
+ Delete the live volume then force-delete the pool while it is still
+ in Maintenance state (pool is in Maintenance from test_04).
+ Verifies:
+ - Volume can be deleted while pool is in Maintenance
+ - Pool is removed from CloudStack using forced=True from Maintenance
+ - ONTAP: FlexVol deleted
+ - ONTAP: igroups deleted for all cluster hosts
+ """
+ self.assertIsNotNone(
+ self.__class__.pool,
+ "Pool absent - test_04 must not have cleaned up the pool"
+ )
+ self.assertIsNotNone(self.__class__.volume, "Volume absent - test_03 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+ vol = self.__class__.volume
+
+ # Delete the volume first (pool is in Maintenance — volume deletion is allowed)
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol.id
+ self.apiClient.deleteVolume(cmd)
+ self.__class__.volume = None
+
+ # Force-delete the pool from Maintenance (no live volumes remaining)
+ self._delete_pool(pool.id, forced=True)
+ self.__class__.pool = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(remaining, "Pool still listed in CloudStack after force deletion")
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after force deletion" % pool_name
+ )
+
+ # ONTAP: igroups for each cluster host must be deleted
+ for host in self.cluster_hosts:
+ iqn = getattr(host, "storageurl", None) or getattr(host, "StorageUrl", None)
+ if not iqn or not iqn.startswith("iqn."):
+ continue
+ igroup_name = _igroup_name(self.svm_name, host.name)
+ igroup = self.ontap.get_igroup(self.svm_name, igroup_name)
+ self.assertIsNone(
+ igroup,
+ "ONTAP igroup '%s' still exists after force deletion" % igroup_name
+ )
diff --git a/test/integration/plugins/ontap/nfs3/__init__.py b/test/integration/plugins/ontap/nfs3/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/nfs3/instance/__init__.py b/test/integration/plugins/ontap/nfs3/instance/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/instance/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py b/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py
new file mode 100644
index 000000000000..48158c682bd0
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py
@@ -0,0 +1,876 @@
+# 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.
+
+"""
+Sequential workflow integration tests for NetApp ONTAP data volume lifecycle
+with a running virtual machine.
+
+Tests are numbered test_01 ... test_08 and must run in that order. Each step
+builds on the shared state established by the previous step.
+
+Workflow:
+ 01 Create NFS3 primary storage pool on ONTAP
+ 02 Create a CloudStack data volume on the ONTAP pool
+ 03 Deploy a VM (template and service offering discovered at setup time)
+ 04 Attach the ONTAP data volume to the running VM
+ 05 Stop the VM — export policy stays; volume remains attached in CS
+ 06 Start the VM — VM Running; volume still attached; FlexVol online
+ 07 Detach the ONTAP data volume from the VM
+ 08 Destroy VM; delete ONTAP volume; enter maintenance; delete pool
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM cluster registered in CloudStack with at least one executable template
+ - ONTAP SVM with NFS3 service enabled and at least one NFS data LIF
+ - ontap.cfg populated with real values
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py -v
+
+Note: Tests share class-level state (sequential). Always run the full suite.
+"""
+
+import base64
+import logging
+import random
+import time
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ attachVolume as attachVolumeAPI,
+ createNetwork as createNetworkAPI,
+ createStoragePool as createStoragePoolAPI,
+ deleteNetwork as deleteNetworkAPI,
+ deleteVolume as deleteVolumeAPI,
+ deployVirtualMachine as deployVirtualMachineAPI,
+ destroyVirtualMachine as destroyVirtualMachineAPI,
+ detachVolume as detachVolumeAPI,
+ enableStorageMaintenance,
+ listNetworkOfferings as listNetworkOfferingsAPI,
+ listNetworks as listNetworksAPI,
+ listServiceOfferings as listServiceOfferingsAPI,
+ listTemplates as listTemplatesAPI,
+ listVirtualMachines as listVirtualMachinesAPI,
+ listVolumes as listVolumesAPI,
+ startVirtualMachine as startVirtualMachineAPI,
+ stopVirtualMachine as stopVirtualMachineAPI,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, _parse_pool_details, get_datacenter_config
+
+logger = logging.getLogger("TestOntapVMVolumeAttach")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ protocol="NFS3", scope="CLUSTER", provider="NetApp ONTAP",
+ tags="ontap-nfs3", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-vm-vol@test.com",
+ "firstname": "ONTAP",
+ "lastname": "VMVol",
+ "username": "ontap_vm_vol_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapVMVol_%d" % random.randint(0, 9999),
+ TestData.scope: scope,
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: protocol,
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Sequential workflow test class
+# ---------------------------------------------------------------------------
+
+class TestOntapVMVolumeAttach(OntapTestBase):
+ """
+ Tests ONTAP data volume lifecycle with a running CloudStack VM.
+
+ All tests are sequential — state is carried on class attributes.
+ """
+
+ # ---- extra shared state beyond OntapTestBase -----------------------
+ vm = None # running VirtualMachine
+ template_id = None # KVM template ID discovered at setup
+ service_offering_id = None
+ network_id = None # None for Basic zones
+ _created_network_id = None # network created by this suite for Advanced zones
+
+ _vol_name_prefix = "OntapVMVol"
+
+ # ---- setup ---------------------------------------------------------
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapVMVolumeAttach, cls).setUpClass()
+ testclient = super(
+ TestOntapVMVolumeAttach, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ nfs3_cfg = pool_cfg.get("protocols", {}).get("nfs3", {})
+ if not nfs3_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "NFS3 tests disabled in ontap.cfg "
+ "(set protocols.nfs3.enabled=true to enable)"
+ )
+ protocol = "NFS3"
+ scope = pool_cfg.get("storagePoolScope", "CLUSTER")
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = nfs3_cfg.get("storagePoolTags", "ontap-nfs3")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ protocol=protocol, scope=scope, provider=provider,
+ tags=tags, capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # Discover a suitable user KVM template in the zone (must be fully
+ # downloaded; system-type templates are excluded as they cannot be
+ # deployed as user VMs).
+ tpl_cmd = listTemplatesAPI.listTemplatesCmd()
+ tpl_cmd.templatefilter = "all"
+ tpl_cmd.listall = True
+ tpl_cmd.zoneid = cls.zone.id
+ templates = cls.apiClient.listTemplates(tpl_cmd) or []
+ kvm_ready = [
+ t for t in templates
+ if getattr(t, "hypervisor", "").lower() == "kvm"
+ and getattr(t, "isready", False)
+ and getattr(t, "templatetype", "").upper() != "SYSTEM"
+ ]
+ if kvm_ready:
+ cls.template_id = kvm_ready[0].id
+ else:
+ logger.warning(
+ "No ready user KVM template found in zone '%s'. "
+ "Tests that deploy VMs will be skipped until a template "
+ "finishes downloading." % cls.zone.name
+ )
+ cls.template_id = None
+
+ # Discover the smallest service offering
+ so_cmd = listServiceOfferingsAPI.listServiceOfferingsCmd()
+ offerings = cls.apiClient.listServiceOfferings(so_cmd) or []
+ assert offerings, "No service offerings available in CloudStack"
+ offerings.sort(key=lambda s: getattr(s, "memory", 9999))
+ cls.service_offering_id = offerings[0].id
+
+ # Detect zone type; resolve network ID for Advanced zones
+ cls.network_id = None
+ zone_type = getattr(cls.zone, "networktype", "Basic")
+ if zone_type.lower() == "advanced":
+ # Find a network already accessible to the test account
+ net_cmd = listNetworksAPI.listNetworksCmd()
+ net_cmd.zoneid = cls.zone.id
+ net_cmd.account = cls.account.name
+ net_cmd.domainid = cls.domain.id
+ nets = cls.apiClient.listNetworks(net_cmd) or []
+ if nets:
+ cls.network_id = nets[0].id
+ else:
+ # Create an Isolated guest network for the test account
+ no_cmd = listNetworkOfferingsAPI.listNetworkOfferingsCmd()
+ no_cmd.state = "Enabled"
+ no_cmd.guestiptype = "Isolated"
+ no_cmd.specifyvlan = "false"
+ no_offerings = cls.apiClient.listNetworkOfferings(no_cmd) or []
+ snat_offering = next(
+ (o for o in no_offerings
+ if "SourceNat" in o.name and "Vpc" not in o.name
+ and "NSX" not in o.name and "Netris" not in o.name),
+ no_offerings[0] if no_offerings else None
+ )
+ if snat_offering:
+ cn_cmd = createNetworkAPI.createNetworkCmd()
+ cn_cmd.zoneid = cls.zone.id
+ cn_cmd.networkofferingid = snat_offering.id
+ cn_cmd.name = "ontap-nfs3-vm-net-%d" % random.randint(
+ 0, 9999)
+ cn_cmd.displaytext = "ONTAP NFS3 VM test network"
+ cn_cmd.account = cls.account.name
+ cn_cmd.domainid = cls.domain.id
+ net = cls.apiClient.createNetwork(cn_cmd)
+ cls.network_id = net.id
+ cls._created_network_id = net.id
+
+ @classmethod
+ def tearDownClass(cls):
+ """Destroy the VM first, then delegate pool/volume cleanup to super."""
+ if cls.vm is not None:
+ try:
+ # Ensure VM is stopped before destroying
+ vms = cls.apiClient.listVirtualMachines(
+ _list_vms_cmd(cls.vm.id))
+ current_state = vms[0].state if vms else "unknown"
+ if current_state.lower() not in ("stopped", "destroyed",
+ "expunging", "error"):
+ stop_cmd = stopVirtualMachineAPI.stopVirtualMachineCmd()
+ stop_cmd.id = cls.vm.id
+ stop_cmd.forced = True
+ cls.apiClient.stopVirtualMachine(stop_cmd)
+ _wait_for_vm_state(cls.apiClient, cls.vm.id, "Stopped",
+ timeout=120)
+ except Exception as e:
+ logger.warning("tearDownClass: could not stop VM %s: %s"
+ % (cls.vm.id, e))
+ try:
+ dest_cmd = destroyVirtualMachineAPI.destroyVirtualMachineCmd()
+ dest_cmd.id = cls.vm.id
+ dest_cmd.expunge = True
+ cls.apiClient.destroyVirtualMachine(dest_cmd)
+ except Exception as e:
+ logger.warning("tearDownClass: could not destroy VM %s: %s"
+ % (cls.vm.id, e))
+
+ # Delete the guest network created for this account in Advanced zones.
+ if cls._created_network_id is not None:
+ try:
+ dn_cmd = deleteNetworkAPI.deleteNetworkCmd()
+ dn_cmd.id = cls._created_network_id
+ cls.apiClient.deleteNetwork(dn_cmd)
+ cls._created_network_id = None
+ except Exception as e:
+ logger.warning(
+ "tearDownClass: could not delete network %s: %s"
+ % (cls._created_network_id, e))
+
+ super(TestOntapVMVolumeAttach, cls).tearDownClass()
+
+ # ---- pool creation helper -----------------------------------------
+
+ def _create_pool(self):
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapVMVol_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "nfs://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = ps[TestData.scope]
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ # ---- VM state helpers ----------------------------------------------
+
+ def _poll_vm_state(self, vm_id, target_state, timeout=300, interval=10):
+ """Poll listVirtualMachines until the VM reaches target_state."""
+ deadline = time.time() + timeout
+ current_state = "unknown"
+ while time.time() < deadline:
+ vms = self.apiClient.listVirtualMachines(
+ _list_vms_cmd(vm_id))
+ if vms:
+ current_state = vms[0].state
+ if current_state.lower() == target_state.lower():
+ return vms[0]
+ time.sleep(interval)
+ self.fail(
+ "VM %s did not reach state '%s' within %ds (last: '%s')"
+ % (vm_id, target_state, timeout, current_state)
+ )
+
+ def _volume_state(self, vol_id):
+ """Return the current CloudStack state string for a volume."""
+ cmd = listVolumesAPI.listVolumesCmd()
+ cmd.id = vol_id
+ vols = self.apiClient.listVolumes(cmd)
+ return vols[0].state if vols else "unknown"
+
+ # ==================================================================
+ # Test steps
+ # ==================================================================
+
+ # ------------------------------------------------------------------
+ # Step 01 - Create NFS3 ONTAP pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["vm_volume_workflow"], required_hardware=True)
+ def test_01_create_nfs3_pool(self):
+ """
+ Create an NFS3 ONTAP primary storage pool.
+ Verifies:
+ - Pool reaches 'Up' state in CloudStack
+ - ONTAP: FlexVol is created and online
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not created for pool '%s'" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ------------------------------------------------------------------
+ # Step 02 - Create CloudStack data volume on ONTAP pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["vm_volume_workflow"], required_hardware=True)
+ def test_02_create_ontap_data_volume(self):
+ """
+ Allocate a CloudStack data volume on the ONTAP NFS3 pool.
+ Verifies:
+ - Volume is created and in 'Allocated' or 'Ready' state
+ - ONTAP: FlexVol remains online
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+
+ pool = self.__class__.pool
+ vol = self._create_volume(pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+
+ vol_state = self._volume_state(vol.id)
+ self.assertIn(
+ vol_state.lower(), ("allocated", "ready"),
+ "Volume should be 'Allocated' or 'Ready', got '%s'" % vol_state
+ )
+
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol disappeared after data volume creation"
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' after volume creation"
+ )
+
+ # ------------------------------------------------------------------
+ # Step 03 - Deploy a VM
+ # ------------------------------------------------------------------
+
+ @attr(tags=["vm_volume_workflow"], required_hardware=True)
+ def test_03_deploy_vm(self):
+ """
+ Deploy a VM using the first available KVM template and smallest
+ service offering discovered at setup time.
+ Verifies:
+ - VM reaches 'Running' state in CloudStack
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ if self.__class__.template_id is None:
+ self.skipTest(
+ "No ready user KVM template available — "
+ "waiting for template download to complete"
+ )
+ self.assertIsNotNone(self.__class__.service_offering_id,
+ "No service offering available — check setup")
+
+ cmd = deployVirtualMachineAPI.deployVirtualMachineCmd()
+ cmd.zoneid = self.zone.id
+ cmd.templateid = self.__class__.template_id
+ cmd.serviceofferingid = self.__class__.service_offering_id
+ cmd.account = self.account.name
+ cmd.domainid = self.domain.id
+ if self.__class__.network_id:
+ cmd.networkids = self.__class__.network_id
+
+ vm = self.apiClient.deployVirtualMachine(cmd)
+ self.assertIsNotNone(vm, "deployVirtualMachine returned None")
+ self.__class__.vm = vm
+
+ vm_obj = self._poll_vm_state(vm.id, "Running", timeout=600)
+ self.assertEqual(
+ vm_obj.state, "Running",
+ "VM should be 'Running', got '%s'" % vm_obj.state
+ )
+
+ # ------------------------------------------------------------------
+ # Step 04 - Attach ONTAP data volume to the running VM
+ # ------------------------------------------------------------------
+
+ @attr(tags=["vm_volume_workflow"], required_hardware=True)
+ def test_04_attach_volume_to_vm(self):
+ """
+ Attach the ONTAP data volume to the running VM.
+ Verifies:
+ - Volume virtualmachineid is set to the VM's ID in CloudStack
+ (Note: on ONTAP/NFS shared storage the volume state remains 'Ready';
+ attachment is signalled by virtualmachineid being populated)
+ - ONTAP: FlexVol remains online
+ - ONTAP: NFS3 volume data file created in FlexVol after attach (lazy creation)
+ - VM remains 'Running'
+ """
+ if self.__class__.vm is None:
+ self.skipTest("VM not deployed — test_03 was skipped (no ready template)")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_02 must pass first")
+
+ vm = self.__class__.vm
+ vol = self.__class__.volume
+
+ cmd = attachVolumeAPI.attachVolumeCmd()
+ cmd.id = vol.id
+ cmd.virtualmachineid = vm.id
+ attached = self.apiClient.attachVolume(cmd)
+ self.assertIsNotNone(attached, "attachVolume returned None")
+
+ # On ONTAP/NFS shared storage CloudStack does not transition the volume
+ # state to 'In Use' — attachment is indicated by virtualmachineid being
+ # set on the volume record. Poll on that field instead of state.
+ deadline = time.time() + 120
+ vol_vmid = None
+ while time.time() < deadline:
+ vols = self.apiClient.listVolumes(_list_vols_cmd(vol.id))
+ vol_vmid = getattr(vols[0], "virtualmachineid", None) if vols else None
+ if vol_vmid:
+ break
+ time.sleep(5)
+
+ self.assertEqual(
+ vol_vmid, vm.id,
+ "Volume should be attached to VM %s after attach, "
+ "got virtualmachineid=%s" % (vm.id, vol_vmid)
+ )
+
+ # VM must still be Running
+ vm_obj = self._poll_vm_state(vm.id, "Running", timeout=30)
+ self.assertEqual(vm_obj.state, "Running",
+ "VM should still be 'Running' after volume attach")
+
+ # ONTAP FlexVol must remain online
+ pool = self.__class__.pool
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol not found after attach")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after attach"
+ )
+
+ # ONTAP: NFS3 uses lazy file creation — the volume data file is
+ # materialised on the FlexVol only when CloudStack calls createAsync
+ # during attachVolume. Verify that the file now exists.
+ files = self.ontap.list_files_in_volume(pool.name)
+ vol_file = next((f for f in files if vol.id in f), None)
+ self.assertIsNotNone(
+ vol_file,
+ "No data file matching volume UUID '%s' found in FlexVol '%s' "
+ "after attach; files present: %s" % (vol.id, pool.name, files)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 05 - Stop VM — export policy must be retained
+ # ------------------------------------------------------------------
+
+ @attr(tags=["vm_volume_workflow"], required_hardware=True)
+ def test_05_stop_vm_export_retained(self):
+ """
+ Stop the running VM while the NFS3 data volume is still attached.
+ Unlike iSCSI (where LUN-maps are removed on VM stop), NFS3 export
+ policies are not torn down when a VM stops — the FlexVol stays
+ accessible on the same mount.
+ Verifies:
+ - VM reaches Stopped state
+ - ONTAP: FlexVol still online
+ - CloudStack: volume virtualmachineid still set (volume stays attached)
+ """
+ if self.__class__.vm is None:
+ self.skipTest("VM not deployed — test_03 was skipped (no ready template)")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_02 must pass first")
+
+ vm = self.__class__.vm
+ vol = self.__class__.volume
+
+ cmd = stopVirtualMachineAPI.stopVirtualMachineCmd()
+ cmd.id = vm.id
+ self.apiClient.stopVirtualMachine(cmd)
+
+ result = self._poll_vm_state(vm.id, "Stopped", timeout=300)
+ self.assertEqual(
+ result.state, "Stopped",
+ "VM should be 'Stopped', got '%s'" % result.state
+ )
+
+ # ONTAP: FlexVol must remain online — NFS export is not torn down on VM stop
+ pool = self.__class__.pool
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' disappeared after VM stop" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should remain 'online' after VM stop, "
+ "got '%s'" % ontap_vol.get("state")
+ )
+
+ # CloudStack: volume must still be attached (virtualmachineid set)
+ cmd_list = listVolumesAPI.listVolumesCmd()
+ cmd_list.id = vol.id
+ vols = self.apiClient.listVolumes(cmd_list)
+ vol_vmid = getattr(vols[0], "virtualmachineid", None) if vols else None
+ self.assertEqual(
+ vol_vmid, vm.id,
+ "Volume should still be attached to VM %s after stop, "
+ "got virtualmachineid=%s" % (vm.id, vol_vmid)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 06 - Start VM — volume accessible; FlexVol online
+ # ------------------------------------------------------------------
+
+ @attr(tags=["vm_volume_workflow"], required_hardware=True)
+ def test_06_start_vm_volume_accessible(self):
+ """
+ Start the stopped VM.
+ Verifies:
+ - VM reaches Running state
+ - ONTAP: FlexVol still online
+ - CloudStack: volume virtualmachineid still set (volume remains attached)
+ - VM remains 'Running' after start
+ """
+ if self.__class__.vm is None:
+ self.skipTest("VM not deployed — test_03 was skipped (no ready template)")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_02 must pass first")
+
+ vm = self.__class__.vm
+ vol = self.__class__.volume
+
+ cmd = startVirtualMachineAPI.startVirtualMachineCmd()
+ cmd.id = vm.id
+ self.apiClient.startVirtualMachine(cmd)
+
+ result = self._poll_vm_state(vm.id, "Running", timeout=300)
+ self.assertEqual(
+ result.state, "Running",
+ "VM should be 'Running' after start, got '%s'" % result.state
+ )
+
+ # ONTAP: FlexVol must remain online after VM start
+ pool = self.__class__.pool
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' not found after VM start" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after VM start, "
+ "got '%s'" % ontap_vol.get("state")
+ )
+
+ # CloudStack: volume must still be attached to the VM
+ cmd_list = listVolumesAPI.listVolumesCmd()
+ cmd_list.id = vol.id
+ vols = self.apiClient.listVolumes(cmd_list)
+ vol_vmid = getattr(vols[0], "virtualmachineid", None) if vols else None
+ self.assertEqual(
+ vol_vmid, vm.id,
+ "Volume should still be attached to VM %s after start, "
+ "got virtualmachineid=%s" % (vm.id, vol_vmid)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 07 - Detach ONTAP data volume from the VM
+ # ------------------------------------------------------------------
+
+ @attr(tags=["vm_volume_workflow"], required_hardware=True)
+ def test_07_detach_volume_from_vm(self):
+ """
+ Detach the ONTAP data volume from the running VM.
+ Verifies:
+ - Volume state returns to 'Ready' in CloudStack
+ - Volume no longer lists the VM's ID
+ - VM remains 'Running'
+ - ONTAP: FlexVol remains online
+ - ONTAP: NFS3 volume data file persists in FlexVol after detach
+ """
+ if self.__class__.vm is None:
+ self.skipTest("VM not deployed — test_03 was skipped (no ready template)")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_02 must pass first")
+
+ vm = self.__class__.vm
+ vol = self.__class__.volume
+
+ cmd = detachVolumeAPI.detachVolumeCmd()
+ cmd.id = vol.id
+
+ max_timeout = 180
+ interval = 10
+ deadline = time.time() + max_timeout
+ last_exc = None
+ while True:
+ try:
+ self.apiClient.detachVolume(cmd)
+ last_exc = None
+ break
+ except Exception as exc:
+ last_exc = exc
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ break
+ time.sleep(min(interval, remaining))
+ interval = min(interval * 2, max_timeout)
+ if last_exc is not None:
+ raise last_exc
+
+ # On ONTAP/NFS shared storage the volume state stays 'Ready' throughout.
+ # Poll until virtualmachineid is cleared instead.
+ deadline = time.time() + 120
+ vol_vmid = "pending"
+ while time.time() < deadline:
+ vols = self.apiClient.listVolumes(_list_vols_cmd(vol.id))
+ vol_vmid = getattr(vols[0], "virtualmachineid", None) if vols else None
+ if not vol_vmid:
+ break
+ time.sleep(5)
+
+ self.assertIsNone(
+ vol_vmid,
+ "Volume should have no virtualmachineid after detach, got '%s'"
+ % vol_vmid
+ )
+
+ # VM must still be Running
+ vm_obj = self._poll_vm_state(vm.id, "Running", timeout=30)
+ self.assertEqual(vm_obj.state, "Running",
+ "VM should still be 'Running' after volume detach")
+
+ # ONTAP FlexVol must remain online
+ pool = self.__class__.pool
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol, "ONTAP FlexVol not found after detach")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after detach"
+ )
+
+ # ONTAP: NFS3 volume data file must still exist after detach — the file
+ # is only removed when deleteVolume is called, not on detach.
+ files = self.ontap.list_files_in_volume(pool.name)
+ vol_file = next((f for f in files if vol.id in f), None)
+ self.assertIsNotNone(
+ vol_file,
+ "Volume data file for '%s' should persist in FlexVol '%s' after "
+ "detach; files present: %s" % (vol.id, pool.name, files)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 08 - Destroy VM, delete volume, delete pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["vm_volume_workflow"], required_hardware=True)
+ def test_08_destroy_vm_and_cleanup(self):
+ """
+ Destroy the VM, delete the ONTAP data volume, enter maintenance,
+ then delete the pool.
+ Verifies:
+ - VM is destroyed/expunged from CloudStack
+ - Volume is deleted from CloudStack
+ - ONTAP: NFS3 volume data file removed from FlexVol after deleteVolume
+ - Pool is removed from CloudStack
+ - ONTAP: FlexVol is deleted after pool removal
+ - ONTAP: Export policy is removed after pool removal
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+
+ vm = self.__class__.vm
+ vol = self.__class__.volume
+ pool = self.__class__.pool
+ pool_name = pool.name
+
+ # Stop VM if still running
+ if vm is not None:
+ vms = self.apiClient.listVirtualMachines(_list_vms_cmd(vm.id))
+ current_state = vms[0].state.lower() if vms else "unknown"
+ if current_state not in ("stopped", "destroyed",
+ "expunging", "error"):
+ stop_cmd = stopVirtualMachineAPI.stopVirtualMachineCmd()
+ stop_cmd.id = vm.id
+ self.apiClient.stopVirtualMachine(stop_cmd)
+ self._poll_vm_state(vm.id, "Stopped", timeout=300)
+
+ dest_cmd = destroyVirtualMachineAPI.destroyVirtualMachineCmd()
+ dest_cmd.id = vm.id
+ dest_cmd.expunge = True
+ self.apiClient.destroyVirtualMachine(dest_cmd)
+ self.__class__.vm = None
+
+ # Delete the ONTAP data volume
+ if vol is not None:
+ vol_id = vol.id
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol_id
+ self.apiClient.deleteVolume(cmd)
+ self.__class__.volume = None
+
+ # ONTAP: NFS3 volume data file must be removed from the FlexVol
+ # after deleteVolume (CloudStack/libvirt deletes the file from the
+ # NFS mount as part of the destroy workflow).
+ files = self.ontap.list_files_in_volume(pool_name)
+ vol_file = next((f for f in files if vol_id in f), None)
+ self.assertIsNone(
+ vol_file,
+ "Volume data file for '%s' should be gone from FlexVol '%s' "
+ "after deleteVolume; files still present: %s"
+ % (vol_id, pool_name, files)
+ )
+
+ # Enter maintenance then delete the pool
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ self._delete_pool(pool.id)
+ self.__class__.pool = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(remaining,
+ "Pool still listed in CloudStack after deletion")
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after pool deletion" % pool_name
+ )
+
+ # ONTAP: Export policy must be removed
+ ep_name = "cs-%s-%s" % (self.svm_name, pool_name)
+ ep = self.ontap.get_export_policy(ep_name)
+ self.assertIsNone(
+ ep,
+ "ONTAP export policy '%s' still exists after pool deletion"
+ % ep_name
+ )
+
+
+# ---------------------------------------------------------------------------
+# Module-level helpers (used in tearDownClass and test helpers)
+# ---------------------------------------------------------------------------
+
+def _list_vms_cmd(vm_id):
+ cmd = listVirtualMachinesAPI.listVirtualMachinesCmd()
+ cmd.id = vm_id
+ return cmd
+
+
+def _list_vols_cmd(vol_id):
+ cmd = listVolumesAPI.listVolumesCmd()
+ cmd.id = vol_id
+ return cmd
+
+
+def _wait_for_vm_state(api_client, vm_id, target_state, timeout=120,
+ interval=5):
+ """Blocking wait for a VM to reach target_state (used in tearDownClass)."""
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ vms = api_client.listVirtualMachines(_list_vms_cmd(vm_id))
+ if vms and vms[0].state.lower() == target_state.lower():
+ return
+ time.sleep(interval)
diff --git a/test/integration/plugins/ontap/nfs3/pool/__init__.py b/test/integration/plugins/ontap/nfs3/pool/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/pool/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/nfs3/pool/test_pool_lifecycle.py b/test/integration/plugins/ontap/nfs3/pool/test_pool_lifecycle.py
new file mode 100644
index 000000000000..5d1812cdad4f
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/pool/test_pool_lifecycle.py
@@ -0,0 +1,781 @@
+# 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.
+
+"""
+Sequential workflow integration tests for NetApp ONTAP NFS3 primary storage pool.
+
+Tests are numbered test_01 ... test_08 and must run in that order. Each step
+builds on the shared state established by the previous step.
+
+Workflow:
+ 01 Create primary storage pool
+ 02 Disable storage pool
+ 03 Enable storage pool
+ 04 Enter maintenance mode
+ 05 Cancel maintenance mode
+ 06 Delete the storage pool
+ 07 Create fresh pool and allocate a CloudStack volume
+ 08 Delete volume then force-delete the pool
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM cluster registered in CloudStack
+ - ONTAP SVM with NFS3 service enabled and at least one NFS data LIF
+ - ontap.cfg populated with real values
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/nfs3/pool/test_pool_lifecycle.py -v
+
+Note: Tests 01-06 share class-level state (sequential). Running a single test
+with -m "test_NN" will invoke setUpClass but the guard assertion will fail
+immediately if earlier steps have not yet run. Always run the full suite.
+"""
+
+import base64
+import logging
+import random
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ cancelStorageMaintenance,
+ createStoragePool as createStoragePoolAPI,
+ deleteVolume as deleteVolumeAPI,
+ enableStorageMaintenance,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.cloudstackException import CloudstackAPIException
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import (
+ OntapRestClient, OntapTestBase, _parse_pool_details, get_datacenter_config,
+ log_progress,
+)
+
+logger = logging.getLogger("TestOntapNFS3Workflow")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+ DETAIL_VOLUME_UUID = "volumeUUID"
+ DETAIL_VOLUME_NAME = "volumeName"
+ DETAIL_DATA_LIF = "dataLIF"
+ DETAIL_NFS_MOUNT_OPTS = "nfsmountopts"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ protocol="NFS3", scope="CLUSTER", provider="NetApp ONTAP",
+ tags="ontap-nfs3", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-nfs3-wf@test.com",
+ "firstname": "ONTAP",
+ "lastname": "NFS3-WF",
+ "username": "ontap_nfs3_wf_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapNFS3_%d" % random.randint(0, 9999),
+ TestData.scope: scope,
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: protocol,
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Sequential workflow test class
+# ---------------------------------------------------------------------------
+
+class TestOntapNFS3PrimaryStorageWorkflow(OntapTestBase):
+
+ # ---- NFS3-specific shared state ------------------------------------
+ pool_ep_name = None # NFS export policy name for pool
+ pool2_ep_name = None # export policy for pool stashed from test_01-04
+ cluster_host_ips = None
+
+ _vol_name_prefix = "OntapNFS3Vol"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapNFS3PrimaryStorageWorkflow, cls).setUpClass()
+ testclient = super(
+ TestOntapNFS3PrimaryStorageWorkflow, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ nfs3_cfg = pool_cfg.get("protocols", {}).get("nfs3", {})
+ if not nfs3_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "NFS3 tests disabled in ontap.cfg "
+ "(set protocols.nfs3.enabled=true to enable)"
+ )
+ protocol = "NFS3"
+ scope = pool_cfg.get("storagePoolScope", "CLUSTER")
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = nfs3_cfg.get("storagePoolTags", "ontap-nfs3")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ protocol=protocol, scope=scope, provider=provider,
+ tags=tags, capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # Resolve cluster host IPs for export policy rule assertions
+ cls.cluster_host_ips = [
+ h.ipaddress for h in cls.cluster_hosts
+ if getattr(h, "ipaddress", None)
+ ]
+
+ # No per-test tearDown — state intentionally persists between steps.
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_pool(self):
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapNFS3_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "nfs://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = ps[TestData.scope]
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ def _get_export_policy_name(self, pool):
+ """Extract the export policy name from pool creation response details."""
+ details = _parse_pool_details(pool)
+ ep_name = details.get("exportPolicyName")
+ if not ep_name:
+ # Fallback: plugin typically uses cs-{svmName}-{poolName}
+ ep_name = "cs-%s-%s" % (self.svm_name, pool.name)
+ return ep_name
+
+ def _assert_export_policy_has_host_ips(self, ep_name):
+ """Assert that the export policy exists and its rules include each cluster host IP."""
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' not found on ONTAP" % ep_name
+ )
+ if not self.cluster_host_ips:
+ return # no host IPs registered; skip rule-level check
+ all_clients = []
+ for rule in policy.get("rules", []):
+ for client in rule.get("clients", []):
+ all_clients.append(client.get("match", ""))
+ for ip in self.cluster_host_ips:
+ self.assertTrue(
+ any(ip in c for c in all_clients),
+ "Host IP '%s' not found in export policy '%s' rules: %s"
+ % (ip, ep_name, all_clients)
+ )
+
+ def _assert_pool_capacity(self, pool, label):
+ """Assert CloudStack capacity fields and ONTAP FlexVol size are consistent.
+
+ Logs configured bytes, reported capacity, used bytes, and ONTAP
+ FlexVol space.size at each check point. Asserts:
+ - listStoragePools.capacitybytes >= 90% of configured value
+ - listStoragePools.disksizeused >= 0 (ONTAP reports actual used bytes;
+ even a fresh FlexVol has metadata overhead so a non-zero value is
+ expected and is not an error)
+ - ONTAP FlexVol space.size >= 90% of configured value
+ """
+ configured = self.testdata[TestData.primaryStorage]["capacitybytes"]
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ self.assertIsNotNone(
+ listed,
+ "[capacity/%s] listStoragePools returned None for pool %s"
+ % (label, pool.id)
+ )
+ lp = listed[0]
+ reported = getattr(lp, "capacitybytes", 0) or 0
+ used = getattr(lp, "disksizeused", 0) or 0
+ min_expected = int(configured * 0.90)
+
+ logger.info(
+ "[capacity/%s] configured=%d B reported=%d B used=%d B",
+ label, configured, reported, used
+ )
+ self.assertGreaterEqual(
+ reported, min_expected,
+ "[capacity/%s] capacitybytes %d is >10%% below configured %d"
+ % (label, reported, configured)
+ )
+ self.assertGreaterEqual(
+ used, 0,
+ "[capacity/%s] disksizeused must not be negative, got %d"
+ % (label, used)
+ )
+
+ ontap_vol = self.ontap.get_volume(pool.name)
+ if ontap_vol:
+ ontap_size = ontap_vol.get("space", {}).get("size", 0)
+ logger.info(
+ "[capacity/%s] ONTAP FlexVol space.size=%d B",
+ label, ontap_size
+ )
+ self.assertGreaterEqual(
+ ontap_size, min_expected,
+ "[capacity/%s] ONTAP FlexVol space.size %d is >10%% below configured %d"
+ % (label, ontap_size, configured)
+ )
+
+ def _volume_exists_in_cs(self, vol_id):
+ """Return True if the volume is still listed by CloudStack."""
+ from marvin.cloudstackAPI import listVolumes as listVolumesAPI
+ cmd = listVolumesAPI.listVolumesCmd()
+ cmd.id = vol_id
+ cmd.listall = True
+ vols = self.apiClient.listVolumes(cmd) or []
+ return len(vols) > 0
+
+ def _assert_pool_gone_from_cs(self, pool_id, pool_name):
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool_id)
+ except CloudstackAPIException:
+ remaining = None
+ self.assertFalse(
+ remaining,
+ "Pool '%s' still listed in CloudStack after deletion" % pool_name
+ )
+
+ def _assert_ontap_pool_gone(self, pool_name, ep_name):
+ ontap_vol = self.ontap.get_volume(pool_name)
+ if ontap_vol is not None:
+ self.ontap.delete_volume(pool_name)
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after pool deletion" % pool_name
+ )
+ if ep_name:
+ policy = self.ontap.get_export_policy(ep_name)
+ if policy is not None:
+ self.ontap.delete_export_policy(ep_name)
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNone(
+ policy,
+ "Export policy '%s' still exists after pool deletion" % ep_name
+ )
+
+ def _force_delete_pool_in_maintenance(self, pool, ep_name):
+ """Force-delete a pool that is already in Maintenance with no volumes."""
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ if not listed:
+ return
+ self._cleanup_kvm_storage_pool_mounts(pool.id)
+ try:
+ self._delete_pool(pool.id, forced=True)
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "force-delete pool '%s' failed: %s; trying ONTAP direct cleanup",
+ pool.name, ex
+ )
+ self._assert_pool_gone_from_cs(pool.id, pool.name)
+ self._assert_ontap_pool_gone(pool.name, ep_name)
+
+ def _delete_volume_then_force_delete_pool(self, pool, vol, ep_name):
+ """Delete CS volume, enter Maintenance, unmount on KVM, force-delete pool."""
+ if vol is not None and self._volume_exists_in_cs(vol.id):
+ try:
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol.id
+ self.apiClient.deleteVolume(cmd)
+ except Exception as exc:
+ err = str(exc).lower()
+ if "storage pool not found" in err or "storage pool" in err:
+ logger.warning(
+ "deleteVolume raised expected NFS3 libvirt error; "
+ "proceeding: %s", exc
+ )
+ else:
+ raise
+
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ self.assertTrue(listed, "Pool '%s' not found before delete" % pool.name)
+ if listed[0].state != "Maintenance":
+ self._assert_pool_capacity(pool, "volume-deleted")
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ self._cleanup_kvm_storage_pool_mounts(pool.id)
+ try:
+ self._delete_pool(pool.id, forced=True)
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "force-delete pool '%s' failed: %s; trying ONTAP direct cleanup",
+ pool.name, ex
+ )
+ self._assert_pool_gone_from_cs(pool.id, pool.name)
+ self._assert_ontap_pool_gone(pool.name, ep_name)
+
+ # ------------------------------------------------------------------
+ # Step 01 — Create primary storage pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_01_create_primary_storage_pool(self):
+ """
+ Create an NFS3 primary storage pool and verify:
+ - CloudStack state is Up, type is NetworkFilesystem
+ - nfsmountopts contains 'vers=3'
+ - ONTAP: FlexVol exists and is online
+ - ONTAP: NFS export policy exists with cluster host IP rules
+ - ONTAP: at least one NFS data LIF is present on the SVM
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+ self.assertEqual(
+ pool.type, "NetworkFilesystem",
+ "Pool type should be 'NetworkFilesystem', got '%s'" % pool.type
+ )
+
+ # Verify nfsmountopts via listStoragePools
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ self.assertIsNotNone(listed, "listStoragePools returned None for pool %s" % pool.id)
+ nfs_opts = getattr(listed[0], "nfsmountopts", "")
+ self.assertIn(
+ "vers=3", nfs_opts,
+ "nfsmountopts should contain 'vers=3', got '%s'" % nfs_opts
+ )
+
+ # ONTAP: FlexVol must be online
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not found for pool '%s'" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must exist with host IP rules
+ ep_name = self._get_export_policy_name(pool)
+ self.__class__.pool_ep_name = ep_name
+ self._assert_export_policy_has_host_ips(ep_name)
+
+ # ONTAP: at least one NFS data LIF must be present
+ lifs = self.ontap.get_data_lifs(self.svm_name)
+ self.assertTrue(
+ len(lifs) > 0,
+ "No NFS data LIFs found on SVM '%s'" % self.svm_name
+ )
+
+ # Capacity reporting
+ self._assert_pool_capacity(pool, "pool-created")
+
+ # ------------------------------------------------------------------
+ # Step 02 — Disable storage pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_02_disable_storage_pool(self):
+ """
+ Disable the pool and verify:
+ - CloudStack reports Disabled
+ - ONTAP: FlexVol is still online and export policy unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent — test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = False
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Disabled", timeout=60)
+ self.assertEqual(result.state, "Disabled")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after disable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' after disable, got '%s'"
+ % ontap_vol.get("state")
+ )
+ if self.__class__.pool_ep_name:
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after disable"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 03 — Enable storage pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_03_enable_storage_pool(self):
+ """
+ Re-enable the pool and verify:
+ - CloudStack reports Up
+ - ONTAP: FlexVol is still online and export policy unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent — test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = True
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Up", timeout=60)
+ self.assertEqual(result.state, "Up")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after enable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after enable, got '%s'"
+ % ontap_vol.get("state")
+ )
+ if self.__class__.pool_ep_name:
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after enable"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 04 — Enter maintenance mode
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_04_enter_maintenance_mode(self):
+ """
+ Put the pool into maintenance mode and verify:
+ - CloudStack reports Maintenance
+ - ONTAP: FlexVol is still online and export policy unchanged
+ (maintenance is a CS-only state change)
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent — test_01 must pass first")
+
+ cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.enableStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Maintenance", timeout=120)
+ self.assertEqual(result.state, "Maintenance")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after entering maintenance")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' in maintenance, got '%s'"
+ % ontap_vol.get("state")
+ )
+ if self.__class__.pool_ep_name:
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist during maintenance"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 05 — Cancel maintenance mode
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_05_cancel_maintenance_mode(self):
+ """
+ Cancel maintenance mode and verify the pool returns to Up.
+
+ Verifies:
+ - CloudStack reports pool state Up
+ - ONTAP: FlexVol is still online
+ - ONTAP: NFS export policy still present
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+
+ cmd = cancelStorageMaintenance.cancelStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.cancelStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(
+ self.__class__.pool.id, "Up", timeout=120
+ )
+ self.assertEqual(
+ result.state, "Up",
+ "Pool should be 'Up' after cancel maintenance, got '%s'"
+ % result.state
+ )
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol disappeared after cancel maintenance"
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after cancel maintenance, got '%s'"
+ % ontap_vol.get("state")
+ )
+ if self.__class__.pool_ep_name:
+ policy = self.ontap.get_export_policy(
+ self.__class__.pool_ep_name
+ )
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after cancel maintenance"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 06 — Delete the storage pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_06_delete_pool_from_maintenance(self):
+ """
+ Enter maintenance mode then delete the storage pool.
+
+ Verifies:
+ - Pool is removed from CloudStack
+ - ONTAP: FlexVol is deleted
+ - ONTAP: NFS export policy is deleted
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent — test_01 must pass first")
+ pool = self.__class__.pool
+ pool_name = pool.name
+ ep_name = self.__class__.pool_ep_name
+
+ # Pool is Up after test_05 succeeded; must enter Maintenance before deletion.
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ self._delete_pool(pool.id)
+ self.__class__.pool = None
+ self.__class__.pool_ep_name = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(remaining, "Pool still listed in CloudStack after deletion")
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after pool deletion" % pool_name
+ )
+
+ # ONTAP: export policy must be deleted
+ if ep_name:
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNone(
+ policy,
+ "Export policy '%s' still exists after pool deletion" % ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 07 - Create fresh pool and allocate a CloudStack volume
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_07_create_volume_on_pool(self):
+ """
+ Create a new NFS3 pool and allocate a CloudStack data volume.
+ For NFS3, createAsync is a no-op on ONTAP (volume is a CloudStack record
+ only — no new ONTAP object is created).
+ Verifies:
+ - pool.state is Up
+ - createVolume returns a non-None volume object
+ - ONTAP: FlexVol is still online and export policy still present
+ """
+
+ pool = self._create_pool()
+ self.__class__.pool = pool
+ log_progress(
+ logger, "info",
+ "test_07: created storage pool name='%s' id=%s state=%s",
+ pool.name, pool.id, pool.state,
+ )
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+
+ ep_name = self._get_export_policy_name(pool)
+ self.__class__.pool_ep_name = ep_name
+
+ vol = self._create_volume(pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+ log_progress(
+ logger, "info",
+ "test_07: created CloudStack volume name='%s' id=%s state=%s "
+ "on pool='%s' (id=%s) account='%s' domain='%s' — "
+ "switch to this account in the UI to see the volume",
+ getattr(vol, "name", "?"), getattr(vol, "id", "?"),
+ getattr(vol, "state", "?"), pool.name, pool.id,
+ self.account.name, self.domain.name,
+ )
+
+ # ONTAP: FlexVol must still be online after volume allocation
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' not found after volume creation" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must still exist
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after volume creation" % ep_name
+ )
+
+ # Capacity reporting: FlexVol size and reported capacity unchanged after volume allocation
+ self._assert_pool_capacity(pool, "volume-allocated")
+
+ # ------------------------------------------------------------------
+ # Step 08 - Delete volume then force-delete the pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_workflow"], required_hardware=True)
+ def test_08_delete_volume_and_pool(self):
+ """
+ Delete the volume from test_07, enter maintenance, then force-delete
+ the pool.
+ Verifies:
+ - deleteVolume completes (or expected NFS3 libvirt pool-not-found)
+ - Pool transitions to Maintenance
+ - Pool is removed from CloudStack after force deletion
+ - ONTAP: FlexVol deleted
+ - ONTAP: export policy deleted
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_07 must pass first")
+ self.assertIsNotNone(self.__class__.volume, "Volume absent - test_07 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+ ep_name = self.__class__.pool_ep_name
+ vol = self.__class__.volume
+
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' should still exist before cleanup" % pool_name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' before cleanup"
+ )
+
+ self._delete_volume_then_force_delete_pool(pool, vol, ep_name)
+ self.__class__.pool = None
+ self.__class__.volume = None
+ self.__class__.pool_ep_name = None
+
+ # Clean up pool from test_01-04 (left in Maintenance when test_05/06 skipped).
+ pool2 = self.__class__.pool2
+ if pool2 is not None:
+ self._force_delete_pool_in_maintenance(
+ pool2, self.__class__.pool2_ep_name
+ )
+ self.__class__.pool2 = None
+ self.__class__.pool2_ep_name = None
diff --git a/test/integration/plugins/ontap/nfs3/pool/test_pool_with_volumes.py b/test/integration/plugins/ontap/nfs3/pool/test_pool_with_volumes.py
new file mode 100644
index 000000000000..b266c1920f9d
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/pool/test_pool_with_volumes.py
@@ -0,0 +1,751 @@
+# 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.
+
+"""
+NFS3 pool lifecycle tests with a CloudStack volume present throughout.
+
+Tests are numbered test_01 ... test_07 and must run in that order. Each step
+builds on the shared state established by the previous step.
+
+Workflow:
+ 01 Create NFS3 pool and allocate a CloudStack data volume
+ 02 Disable pool — volume still exists in CloudStack; ONTAP FlexVol online
+ 03 Re-enable pool — volume still accessible; FlexVol online
+ 04 Enter maintenance with volume present — Maintenance state; FlexVol online
+ 05 Cancel maintenance with volume present — pool returns to Up (fix confirmed)
+ 06 Forced=False delete rejected — pool stays in Maintenance (negative)
+ 07 Cleanup — cancel maintenance, delete volume, force-delete pool
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM cluster registered in CloudStack
+ - ONTAP SVM with NFS3 service enabled and at least one NFS data LIF
+ - ontap.cfg populated with real values (protocol=NFS3)
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/test_ontap_nfs3_pool_with_volumes.py -v
+
+Note: Tests 01-05 share class-level state (sequential). Running a single test
+with -m "test_NN" will invoke setUpClass but the guard assertion will fail
+immediately if earlier steps have not yet run. Always run the full suite.
+
+Post-run ONTAP cleanup: The suite ends with the pool in Maintenance state (from
+test_04) and a CS volume present (test_05 negative test leaves both intact). The
+OntapTestBase teardown exits Maintenance via cancelStorageMaintenance (which
+transitions CS pool state even though KVM remount fails on NFS3), deletes the
+volume, re-enters Maintenance, and force-deletes the pool. In rare cases where
+CS pool state does not transition, one orphaned ONTAP FlexVol and export policy
+may be left behind. Clean these up manually:
+
+ curl -sk -u : \\
+ "https:///api/storage/volumes?name=OntapNFS3WV_*&fields=name,state"
+ # Offline + DELETE each orphan, then DELETE the matching export policy."""
+
+import base64
+import logging
+import random
+import time
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ cancelStorageMaintenance,
+ createStoragePool as createStoragePoolAPI,
+ deleteVolume as deleteVolumeAPI,
+ enableStorageMaintenance,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.cloudstackException import CloudstackAPIException
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, _parse_pool_details, get_datacenter_config
+
+logger = logging.getLogger("TestOntapNFS3PoolWithVolumes")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ protocol="NFS3", scope="CLUSTER", provider="NetApp ONTAP",
+ tags="ontap-nfs3", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-nfs3-wv@test.com",
+ "firstname": "ONTAP",
+ "lastname": "NFS3-WV",
+ "username": "ontap_nfs3_wv_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapNFS3WV_%d" % random.randint(0, 9999),
+ TestData.scope: scope,
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: protocol,
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Test class
+# ---------------------------------------------------------------------------
+
+class TestOntapNFS3PoolWithVolumes(OntapTestBase):
+ """
+ NFS3 pool lifecycle tests with a CloudStack data volume present throughout.
+ All tests are sequential and share class-level state.
+ """
+
+ pool_ep_name = None # NFS export policy name extracted at pool creation
+
+ _vol_name_prefix = "OntapNFS3WV"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapNFS3PoolWithVolumes, cls).setUpClass()
+ testclient = super(
+ TestOntapNFS3PoolWithVolumes, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ nfs3_cfg = pool_cfg.get("protocols", {}).get("nfs3", {})
+ if not nfs3_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "NFS3 tests disabled in ontap.cfg "
+ "(set protocols.nfs3.enabled=true to enable)"
+ )
+ protocol = "NFS3"
+ scope = pool_cfg.get("storagePoolScope", "CLUSTER")
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = nfs3_cfg.get("storagePoolTags", "ontap-nfs3")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ protocol=protocol, scope=scope, provider=provider,
+ tags=tags, capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_pool(self):
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapNFS3WV_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "nfs://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = ps[TestData.scope]
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ def _get_export_policy_name(self, pool):
+ """Extract the NFS export policy name from pool creation response details."""
+ details = _parse_pool_details(pool)
+ ep_name = details.get("exportPolicyName")
+ if not ep_name:
+ ep_name = "cs-%s-%s" % (self.svm_name, pool.name)
+ return ep_name
+
+ def _volume_exists_in_cs(self, vol_id):
+ """Return True if the volume is still listed by CloudStack."""
+ from marvin.cloudstackAPI import listVolumes as listVolumesAPI
+ cmd = listVolumesAPI.listVolumesCmd()
+ cmd.id = vol_id
+ cmd.listall = True
+ vols = self.apiClient.listVolumes(cmd) or []
+ return len(vols) > 0
+
+ def _assert_pool_capacity(self, pool, label):
+ """Assert CloudStack capacity fields and ONTAP FlexVol size are consistent.
+
+ Logs configured bytes, reported capacity, used bytes, and ONTAP
+ FlexVol space.size at each check point. Asserts:
+ - listStoragePools.capacitybytes >= 90% of configured value
+ - listStoragePools.disksizeused >= 0 (ONTAP reports actual used bytes;
+ even a fresh FlexVol has metadata overhead so a non-zero value is
+ expected and is not an error)
+ - ONTAP FlexVol space.size >= 90% of configured value
+ """
+ configured = self.testdata[TestData.primaryStorage]["capacitybytes"]
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ self.assertIsNotNone(
+ listed,
+ "[capacity/%s] listStoragePools returned None for pool %s"
+ % (label, pool.id)
+ )
+ lp = listed[0]
+ reported = getattr(lp, "capacitybytes", 0) or 0
+ used = getattr(lp, "disksizeused", 0) or 0
+ min_expected = int(configured * 0.90)
+
+ logger.info(
+ "[capacity/%s] configured=%d B reported=%d B used=%d B",
+ label, configured, reported, used
+ )
+ self.assertGreaterEqual(
+ reported, min_expected,
+ "[capacity/%s] capacitybytes %d is >10%% below configured %d"
+ % (label, reported, configured)
+ )
+ self.assertGreaterEqual(
+ used, 0,
+ "[capacity/%s] disksizeused must not be negative, got %d"
+ % (label, used)
+ )
+
+ ontap_vol = self.ontap.get_volume(pool.name)
+ if ontap_vol:
+ ontap_size = ontap_vol.get("space", {}).get("size", 0)
+ logger.info(
+ "[capacity/%s] ONTAP FlexVol space.size=%d B",
+ label, ontap_size
+ )
+ self.assertGreaterEqual(
+ ontap_size, min_expected,
+ "[capacity/%s] ONTAP FlexVol space.size %d is >10%% below configured %d"
+ % (label, ontap_size, configured)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 01 — Create pool and allocate a data volume
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_with_volumes"], required_hardware=True)
+ def test_01_create_pool_and_volume(self):
+ """
+ Create an NFS3 primary storage pool and allocate a CloudStack data
+ volume on it.
+ Verifies:
+ - Pool state is Up; ONTAP FlexVol is online
+ - NFS export policy exists
+ - createVolume returns a volume object (NFS3 data vols are CS records
+ backed by a qcow2 file inside the FlexVol)
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+
+ ep_name = self._get_export_policy_name(pool)
+ self.__class__.pool_ep_name = ep_name
+
+ # ONTAP: FlexVol must be online
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not found for pool '%s'" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must exist
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' not found on ONTAP after pool creation" % ep_name
+ )
+
+ # Allocate a CloudStack data volume on this pool
+ vol = self._create_volume(pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+
+ # Capacity reporting: volume allocated on FlexVol
+ self._assert_pool_capacity(pool, "volume-allocated")
+
+ # ------------------------------------------------------------------
+ # Step 02 — Disable pool with volume present
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_with_volumes"], required_hardware=True)
+ def test_02_disable_pool_volume_survives(self):
+ """
+ Disable the pool while a CloudStack data volume exists on it:
+ - Pool should no longer be available for scheduling new CS volumes
+ - The existing CS volume should continue to exist (not deleted)
+ - ONTAP: FlexVol remains online; export policy unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = False
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Disabled", timeout=60)
+ self.assertEqual(
+ result.state, "Disabled",
+ "Pool should be 'Disabled', got '%s'" % result.state
+ )
+
+ # Volume must still exist in CloudStack
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume disappeared after pool disable"
+ )
+
+ # ONTAP: FlexVol must still be online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after pool disable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should remain 'online' after pool disable, got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must still exist
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after pool disable"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 03 — Re-enable pool with volume present
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_with_volumes"], required_hardware=True)
+ def test_03_enable_pool_volume_intact(self):
+ """
+ Re-enable the pool while a CloudStack data volume exists on it:
+ - Pool state transitions back to Up
+ - The existing CS volume is still accessible
+ - ONTAP: FlexVol remains online; export policy unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = True
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Up", timeout=60)
+ self.assertEqual(
+ result.state, "Up",
+ "Pool should be 'Up' after re-enable, got '%s'" % result.state
+ )
+
+ # Volume must still exist in CloudStack
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume disappeared after pool re-enable"
+ )
+
+ # ONTAP: FlexVol must still be online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after pool re-enable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after pool re-enable, got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must still exist
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after pool re-enable"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 04 — Enter maintenance mode with volume present
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_with_volumes"], required_hardware=True)
+ def test_04_enter_maintenance_volume_present(self):
+ """
+ Enter maintenance mode while a CloudStack data volume exists on the pool:
+ - Pool transitions to Maintenance state
+ - Existing CS volume remains in CloudStack
+ - ONTAP: FlexVol stays online (maintenance is a CS-only state)
+ - ONTAP: export policy is unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.enableStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Maintenance", timeout=120)
+ self.assertEqual(
+ result.state, "Maintenance",
+ "Pool should be 'Maintenance', got '%s'" % result.state
+ )
+
+ # Volume must still exist in CloudStack
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume disappeared after pool entered Maintenance"
+ )
+
+ # ONTAP: FlexVol must still be online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(
+ ontap_vol, "ONTAP FlexVol disappeared after entering Maintenance")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should remain 'online' in Maintenance, got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must still exist
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist during Maintenance"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 05 — Cancel maintenance mode with volume present
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_with_volumes"], required_hardware=True)
+ def test_05_cancel_maintenance_with_volume(self):
+ """
+ Cancel maintenance mode while a CloudStack data volume exists on the pool:
+ - cancelStorageMaintenance succeeds (KVM/NFS3 fix confirmed)
+ - Pool returns to Up state
+ - Existing CS volume is still present in CloudStack
+ - ONTAP: FlexVol is still online
+ - ONTAP: NFS export policy is unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ cmd = cancelStorageMaintenance.cancelStorageMaintenanceCmd()
+ cmd.id = self.__class__.pool.id
+ self.apiClient.cancelStorageMaintenance(cmd)
+
+ result = self._poll_pool_state(
+ self.__class__.pool.id, "Up", timeout=120
+ )
+ self.assertEqual(
+ result.state, "Up",
+ "Pool should be 'Up' after cancel maintenance, got '%s'" % result.state
+ )
+
+ # CS volume must still exist
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume disappeared after cancel maintenance"
+ )
+
+ # ONTAP: FlexVol must still be online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol disappeared after cancel maintenance"
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after cancel maintenance, got '%s'"
+ % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must still exist
+ if self.__class__.pool_ep_name:
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after cancel maintenance"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 06 — forced=False delete rejected when volume present (negative)
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_with_volumes"], required_hardware=True)
+ def test_06_forced_false_delete_rejected(self):
+ """
+ Enter maintenance mode then attempt to delete the pool (forced=False)
+ while a CloudStack volume still exists on it. The operation must be
+ rejected:
+ - CloudstackAPIException is raised with an appropriate error
+ - Pool remains in Maintenance state
+ - CS volume still exists
+ - ONTAP: FlexVol and export policy are unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool,
+ "Pool absent — test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume,
+ "Volume absent — test_01 must pass first")
+
+ # Pool is Up after test_05 (cancel maintenance); re-enter Maintenance
+ # before attempting the delete so it reaches the forced=False gate.
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = self.__class__.pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(self.__class__.pool.id, "Maintenance", timeout=120)
+
+ with self.assertRaises(CloudstackAPIException,
+ msg="deleteStoragePool(forced=False) with a live "
+ "volume should raise CloudstackAPIException"):
+ self._delete_pool(self.__class__.pool.id, forced=False)
+
+ # Pool must still be in Maintenance (not deleted)
+ try:
+ remaining = list_storage_pools(
+ self.apiClient, id=self.__class__.pool.id)
+ except CloudstackAPIException:
+ remaining = None
+ self.assertTrue(
+ remaining,
+ "Pool was deleted even though forced=False delete should have failed"
+ )
+
+ # Volume must still exist
+ self.assertTrue(
+ self._volume_exists_in_cs(self.__class__.volume.id),
+ "CS volume was deleted even though pool deletion was rejected"
+ )
+
+ # ONTAP: FlexVol must still be online
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol should still exist after rejected pool deletion"
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should remain 'online' after rejected deletion"
+ )
+
+ @attr(tags=["nfs3_with_volumes"], required_hardware=True)
+ def test_07_force_delete_pool_and_cleanup(self):
+ """
+ Explicit cleanup after the test_06 negative test.
+
+ The pool is in Maintenance with a CS volume still present.
+ Cleanup sequence:
+ 1. Try cancelStorageMaintenance.
+ 2. If still in Maintenance: try updateStoragePool(enabled=True) as a
+ fallback exit path (works on KVM/NFS3 even when cancel fails).
+ 3. Once pool exits Maintenance: delete volume, re-enter Maintenance.
+ 4. Force-delete the pool.
+ 5. Verify CS pool is gone.
+ 6. Verify ONTAP FlexVol and export policy are removed.
+ If the CS force-delete fails (volume couldn't be removed), the
+ ONTAP FlexVol and export policy are cleaned up directly via REST
+ so the storage array is never left with orphans. The CS pool
+ record is left for tearDownClass in that edge case only.
+ """
+ pool = self.__class__.pool
+ vol = self.__class__.volume
+ self.assertIsNotNone(pool, "No pool from test_06 to clean up")
+ pool_name = pool.name
+ ep_name = self.__class__.pool_ep_name
+
+ # Step 1: Try cancelStorageMaintenance
+ pool_state = "Maintenance"
+ try:
+ cm = cancelStorageMaintenance.cancelStorageMaintenanceCmd()
+ cm.id = pool.id
+ self.apiClient.cancelStorageMaintenance(cm)
+ deadline = time.time() + 60
+ while time.time() < deadline:
+ ps = list_storage_pools(self.apiClient, id=pool.id)
+ if ps and ps[0].state != "Maintenance":
+ pool_state = ps[0].state
+ break
+ time.sleep(5)
+ except Exception:
+ pass # falls through to step 2
+
+ # Step 2: If still in Maintenance, try updateStoragePool(enabled=True).
+ # On KVM/NFS3 this succeeds in moving the pool to Disabled/Up even
+ # when cancelStorageMaintenance fails.
+ if pool_state == "Maintenance":
+ try:
+ ec = updateStoragePoolAPI.updateStoragePoolCmd()
+ ec.id = pool.id
+ ec.enabled = True
+ self.apiClient.updateStoragePool(ec)
+ deadline = time.time() + 60
+ while time.time() < deadline:
+ ps = list_storage_pools(self.apiClient, id=pool.id)
+ if ps and ps[0].state != "Maintenance":
+ pool_state = ps[0].state
+ break
+ time.sleep(5)
+ except Exception:
+ pass
+
+ # Step 3: If pool exited Maintenance, delete the CS volume and
+ # re-enter Maintenance so the pool can be force-deleted.
+ if pool_state != "Maintenance" and vol is not None:
+ if self._volume_exists_in_cs(vol.id):
+ try:
+ del_cmd = deleteVolumeAPI.deleteVolumeCmd()
+ del_cmd.id = vol.id
+ self.apiClient.deleteVolume(del_cmd)
+ self.__class__.volume = None
+ vol = None
+ except Exception:
+ pass
+ else:
+ self.__class__.volume = None
+ vol = None
+ try:
+ mc = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ mc.id = pool.id
+ self.apiClient.enableStorageMaintenance(mc)
+ deadline = time.time() + 60
+ while time.time() < deadline:
+ ps = list_storage_pools(self.apiClient, id=pool.id)
+ if ps and ps[0].state == "Maintenance":
+ pool_state = "Maintenance"
+ break
+ time.sleep(5)
+ except Exception:
+ pass
+
+ # Step 4: Force-delete the CS pool.
+ cs_pool_deleted = False
+ if vol is None or not self._volume_exists_in_cs(vol.id):
+ # Volume is gone — safe to force-delete
+ self.__class__.volume = None
+ vol = None
+ try:
+ self._delete_pool(pool.id, forced=True)
+ self.__class__.pool = None
+ cs_pool_deleted = True
+ except CloudstackAPIException:
+ pass
+
+ # Step 5: Assert CS pool is gone (only when deletion was attempted)
+ if cs_pool_deleted:
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except CloudstackAPIException:
+ remaining = None
+ self.assertFalse(
+ remaining,
+ "Pool '%s' should have been deleted with forced=True" % pool_name
+ )
+
+ # Step 6: ONTAP FlexVol must be gone.
+ # If the CS pool could not be deleted (volume still present — an
+ # NFS3/KVM platform edge case), delete the ONTAP FlexVol and export
+ # policy directly via REST so the storage array is always clean.
+ # The orphaned CS pool record is left for tearDownClass.
+ ontap_vol = self.ontap.get_volume(pool_name)
+ if ontap_vol is not None:
+ self.ontap.delete_volume(pool_name)
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' should be gone after cleanup" % pool_name
+ )
+
+ policy = self.ontap.get_export_policy(ep_name)
+ if policy is not None:
+ self.ontap.delete_export_policy(ep_name)
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNone(
+ policy,
+ "NFS export policy '%s' should be removed after cleanup" % ep_name
+ )
diff --git a/test/integration/plugins/ontap/nfs3/pool/test_zone_scoped_pool.py b/test/integration/plugins/ontap/nfs3/pool/test_zone_scoped_pool.py
new file mode 100644
index 000000000000..88a6309f1ee1
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/pool/test_zone_scoped_pool.py
@@ -0,0 +1,440 @@
+# 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.
+
+"""
+Zone-scoped primary storage lifecycle tests for NetApp ONTAP (NFS3).
+
+Creates a zone-scoped pool (scope=ZONE, no clusterid/podid). CloudStack calls
+OntapPrimaryDatastoreLifecycle.attachZone(), which connects all eligible KVM
+hosts in the zone to the pool and creates an NFS export policy covering their
+IPs.
+
+Workflow:
+ 01 Create zone-scoped NFS3 pool — pool.state Up; ONTAP FlexVol online;
+ export policy has all cluster host IPs
+ 02 Disable zone-scoped pool — pool.state Disabled; FlexVol unchanged
+ 03 Enable zone-scoped pool — pool.state Up; FlexVol unchanged
+ 04 Delete zone-scoped pool — pool gone; FlexVol deleted; export policy deleted
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM hosts registered in the zone
+ - ONTAP SVM with NFS3 service enabled and at least one NFS data LIF
+ - ontap.cfg populated with real values (protocol=NFS3)
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/nfs3/pool/test_zone_scoped_pool.py -v
+
+Note: Tests 01-04 share class-level state (sequential). Running a single test
+with -m "test_NN" will invoke setUpClass but the guard assertion will fail
+immediately if earlier steps have not yet run. Always run the full suite.
+"""
+
+import base64
+import logging
+import random
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ createStoragePool as createStoragePoolAPI,
+ enableStorageMaintenance,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, _parse_pool_details, get_datacenter_config
+
+logger = logging.getLogger("TestOntapZoneScopedPool")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ protocol="NFS3", provider="NetApp ONTAP",
+ tags="ontap-nfs3", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-zone@test.com",
+ "firstname": "ONTAP",
+ "lastname": "Zone",
+ "username": "ontap_zone_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapZoneNFS3_%d" % random.randint(0, 9999),
+ TestData.scope: "ZONE",
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: protocol,
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Sequential workflow test class
+# ---------------------------------------------------------------------------
+
+class TestOntapZoneScopedPool(OntapTestBase):
+
+ # ---- zone-pool-specific shared state --------------------------------
+ pool_ep_name = None
+ cluster_host_ips = None
+
+ _vol_name_prefix = "OntapZoneVol"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapZoneScopedPool, cls).setUpClass()
+ testclient = super(
+ TestOntapZoneScopedPool, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ nfs3_cfg = pool_cfg.get("protocols", {}).get("nfs3", {})
+ if not nfs3_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "NFS3 tests disabled in ontap.cfg "
+ "(set protocols.nfs3.enabled=true to enable)"
+ )
+ protocol = "NFS3"
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = nfs3_cfg.get("storagePoolTags", "ontap-nfs3")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ protocol=protocol, provider=provider,
+ tags=tags, capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # Collect host IPs for export policy assertions
+ cls.cluster_host_ips = [
+ h.ipaddress for h in cls.cluster_hosts
+ if getattr(h, "ipaddress", None)
+ ]
+
+ # No per-test tearDown — state intentionally persists between steps.
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_zone_pool(self):
+ """Create a zone-scoped NFS3 pool (no clusterid / podid)."""
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapZoneNFS3_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "nfs://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ # Intentionally omit clusterid and podid — zone-scoped pool
+ cmd.scope = "ZONE"
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ def _get_export_policy_name(self, pool):
+ """Extract the export policy name from pool creation response details."""
+ details = _parse_pool_details(pool)
+ ep_name = details.get("exportPolicyName")
+ if not ep_name:
+ ep_name = "cs-%s-%s" % (self.svm_name, pool.name)
+ return ep_name
+
+ def _assert_export_policy_has_host_ips(self, ep_name):
+ """Assert export policy exists and contains each cluster host IP."""
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' not found on ONTAP" % ep_name
+ )
+ if not self.cluster_host_ips:
+ return
+ all_clients = []
+ for rule in policy.get("rules", []):
+ for client in rule.get("clients", []):
+ all_clients.append(client.get("match", ""))
+ for ip in self.cluster_host_ips:
+ self.assertTrue(
+ any(ip in c for c in all_clients),
+ "Host IP '%s' not found in export policy '%s' rules: %s"
+ % (ip, ep_name, all_clients)
+ )
+
+ # ------------------------------------------------------------------
+ # Step 01 — Create zone-scoped pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["zone_pool"], required_hardware=True)
+ def test_01_create_zone_scoped_pool(self):
+ """
+ Create a zone-scoped NFS3 primary storage pool (no clusterid/podid).
+ CloudStack calls attachZone(), which connects all eligible KVM hosts
+ in the zone and creates an NFS export policy.
+ Verifies:
+ - pool.state is Up
+ - ONTAP: FlexVol is online
+ - ONTAP: export policy exists and contains cluster host IPs
+ - ONTAP: at least one NFS data LIF is present on the SVM
+ """
+ pool = self._create_zone_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+
+ # ONTAP: FlexVol must be online
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol not found for pool '%s'" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must exist with cluster host IPs
+ ep_name = self._get_export_policy_name(pool)
+ self.__class__.pool_ep_name = ep_name
+ self._assert_export_policy_has_host_ips(ep_name)
+
+ # ONTAP: at least one NFS data LIF must be present
+ lifs = self.ontap.get_data_lifs(self.svm_name)
+ self.assertTrue(
+ len(lifs) > 0,
+ "No NFS data LIFs found on SVM '%s'" % self.svm_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 02 — Disable zone-scoped pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["zone_pool"], required_hardware=True)
+ def test_02_disable_zone_scoped_pool(self):
+ """
+ Disable the zone-scoped pool.
+ Verifies:
+ - pool.state is Disabled
+ - ONTAP: FlexVol still online; export policy unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = False
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Disabled", timeout=60)
+ self.assertEqual(result.state, "Disabled")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after disable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' after disable"
+ )
+
+ if self.__class__.pool_ep_name:
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after disable"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 03 — Enable zone-scoped pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["zone_pool"], required_hardware=True)
+ def test_03_enable_zone_scoped_pool(self):
+ """
+ Re-enable the zone-scoped pool.
+ Verifies:
+ - pool.state is Up
+ - ONTAP: FlexVol still online; export policy unchanged
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ cmd = updateStoragePoolAPI.updateStoragePoolCmd()
+ cmd.id = self.__class__.pool.id
+ cmd.enabled = True
+ self.apiClient.updateStoragePool(cmd)
+
+ result = self._poll_pool_state(self.__class__.pool.id, "Up", timeout=60)
+ self.assertEqual(result.state, "Up")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(ontap_vol, "ONTAP FlexVol disappeared after enable")
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after enable"
+ )
+
+ if self.__class__.pool_ep_name:
+ policy = self.ontap.get_export_policy(self.__class__.pool_ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after enable"
+ % self.__class__.pool_ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 04 — Delete zone-scoped pool
+ # ------------------------------------------------------------------
+
+ @attr(tags=["zone_pool"], required_hardware=True)
+ def test_04_delete_zone_scoped_pool(self):
+ """
+ Enter maintenance then delete the zone-scoped pool.
+ Verifies:
+ - Pool is removed from CloudStack
+ - ONTAP: FlexVol deleted
+ - ONTAP: export policy deleted
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+ ep_name = self.__class__.pool_ep_name
+
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ # Unmount the NFS on each KVM host BEFORE deleteStoragePool removes
+ # the ONTAP export. Without this, the mount becomes stale and
+ # KVMHAMonitor will fail its heartbeat 5 times then reboot the host
+ # via `echo b > /proc/sysrq-trigger`.
+ self._cleanup_kvm_storage_pool_mounts(pool.id)
+
+ self._delete_pool(pool.id, forced=True)
+ self.__class__.pool = None
+ self.__class__.pool_ep_name = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(remaining, "Pool still listed in CloudStack after deletion")
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after pool deletion" % pool_name
+ )
+
+ # ONTAP: export policy must be deleted
+ if ep_name:
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNone(
+ policy,
+ "Export policy '%s' still exists after pool deletion" % ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Class-level teardown
+ # ------------------------------------------------------------------
+
+ @classmethod
+ def tearDownClass(cls):
+ """
+ Clean up any lingering zone-scoped pool NFS mounts on KVM hosts
+ before the base-class teardown deletes the ONTAP FlexVol. Without
+ this, a failed test_04 leaves a stale NFS mount that will cause
+ KVMHAMonitor to reboot the host.
+ """
+ for pool in [p for p in (cls.pool2, cls.pool) if p is not None]:
+ try:
+ cls._cleanup_kvm_storage_pool_mounts(pool.id)
+ except Exception as e:
+ logger.warning(
+ "tearDownClass: KVM NFS cleanup failed for pool %s: %s"
+ % (pool.id, e)
+ )
+ super(TestOntapZoneScopedPool, cls).tearDownClass()
diff --git a/test/integration/plugins/ontap/nfs3/volume/__init__.py b/test/integration/plugins/ontap/nfs3/volume/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/volume/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/nfs3/volume/test_volume_lifecycle.py b/test/integration/plugins/ontap/nfs3/volume/test_volume_lifecycle.py
new file mode 100644
index 000000000000..332c2fd1b68c
--- /dev/null
+++ b/test/integration/plugins/ontap/nfs3/volume/test_volume_lifecycle.py
@@ -0,0 +1,471 @@
+# 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.
+
+"""
+Sequential workflow integration tests for NetApp ONTAP NFS3 data volume
+lifecycle (volume create / delete / negative-delete / force-delete).
+
+For NFS3, a CloudStack data volume is a metadata record only — no new ONTAP
+object is created per volume (the pool's single FlexVol serves all volumes).
+Volume deletion likewise removes the CloudStack record while leaving the
+FlexVol intact.
+
+Tests are numbered test_01 ... test_05 and must run in that order. Each step
+builds on the shared state established by the previous step.
+
+Workflow:
+ 01 Create NFS3 primary storage pool and allocate a CloudStack data volume
+ 02 Delete the volume — CloudStack record removed; FlexVol stays online
+ 03 Recreate volume — CS record back; FlexVol stays online (setup for 04-05)
+ 04 Put pool in Maintenance; attempt forced=False deleteStoragePool — must be
+ rejected because volumes exist; pool stays in Maintenance
+ 05 Delete volume from Maintenance; forced=True deleteStoragePool — FlexVol
+ and export policy are removed from ONTAP
+
+Prerequisites:
+ - CloudStack management server with the NetApp ONTAP plugin deployed
+ - KVM cluster where every host has NFS configured
+ - ONTAP SVM with NFS3 service enabled and at least one NFS data LIF
+ - ontap.cfg populated with real values
+
+Running:
+ nosetests --with-marvin \\
+ --marvin-config=test/integration/plugins/ontap/ontap.cfg \\
+ test/integration/plugins/ontap/nfs3/volume/ -v
+
+Note: Tests share class-level state (sequential). Always run the full suite.
+"""
+
+import base64
+import logging
+import random
+import unittest
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ createStoragePool as createStoragePoolAPI,
+ deleteVolume as deleteVolumeAPI,
+ enableStorageMaintenance,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.lib.base import StoragePool
+from marvin.lib.common import list_storage_pools
+
+from ontap_test_base import OntapRestClient, OntapTestBase, _parse_pool_details, get_datacenter_config
+
+logger = logging.getLogger("TestOntapNFS3VolumeLifecycle")
+
+
+# ---------------------------------------------------------------------------
+# Test data
+# ---------------------------------------------------------------------------
+
+class TestData:
+ account = "account"
+ ontap = "ontap"
+ primaryStorage = "primaryStorage"
+ provider = "provider"
+ scope = "scope"
+ tags = "tags"
+
+ DETAIL_USERNAME = "username"
+ DETAIL_PASSWORD = "password"
+ DETAIL_SVM_NAME = "svmName"
+ DETAIL_PROTOCOL = "protocol"
+ DETAIL_STORAGE_IP = "storageIP"
+
+ ONTAP_MIN_VOLUME_SIZE = 1677721600
+
+ def __init__(self, storage_ip, svm_name, username, password,
+ scope="CLUSTER", provider="NetApp ONTAP",
+ tags="ontap-nfs3", capacitybytes=None):
+ if capacitybytes is None:
+ capacitybytes = TestData.ONTAP_MIN_VOLUME_SIZE * 2
+ encoded_password = base64.b64encode(password.encode()).decode()
+ self.testdata = {
+ TestData.ontap: {
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: password,
+ },
+ TestData.account: {
+ "email": "ontap-nfs3-vol@test.com",
+ "firstname": "ONTAP",
+ "lastname": "NFS3-Vol",
+ "username": "ontap_nfs3_vol_%d" % random.randint(0, 9999),
+ "password": "password",
+ },
+ TestData.primaryStorage: {
+ "name": "OntapNFS3Vol_%d" % random.randint(0, 9999),
+ TestData.scope: scope,
+ TestData.provider: provider,
+ TestData.tags: tags,
+ "capacitybytes": capacitybytes,
+ "managed": True,
+ "details": {
+ TestData.DETAIL_USERNAME: username,
+ TestData.DETAIL_PASSWORD: encoded_password,
+ TestData.DETAIL_SVM_NAME: svm_name,
+ TestData.DETAIL_PROTOCOL: "NFS3",
+ TestData.DETAIL_STORAGE_IP: storage_ip,
+ },
+ },
+ }
+
+
+# ---------------------------------------------------------------------------
+# Sequential workflow test class
+# ---------------------------------------------------------------------------
+
+class TestOntapNFS3VolumeLifecycle(OntapTestBase):
+
+ _vol_name_prefix = "OntapNFS3Vol"
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestOntapNFS3VolumeLifecycle, cls).setUpClass()
+ testclient = super(
+ TestOntapNFS3VolumeLifecycle, cls
+ ).getClsTestClient()
+
+ cls.apiClient = testclient.getApiClient()
+ cls.dbConnection = testclient.getDbConnection()
+ config = get_datacenter_config(testclient, cls)
+
+ ontap_cfg = config.get("ontap", {})
+ pool_cfg = config.get("storagePool", {})
+ storage_ip = ontap_cfg.get("storageIP", "")
+ svm_name = ontap_cfg.get("svmName", "")
+ username = ontap_cfg.get("username", "")
+ password = ontap_cfg.get("password", "")
+ nfs3_cfg = pool_cfg.get("protocols", {}).get("nfs3", {})
+ if not nfs3_cfg.get("enabled", True):
+ raise unittest.SkipTest(
+ "NFS3 tests disabled in ontap.cfg "
+ "(set protocols.nfs3.enabled=true to enable)"
+ )
+ scope = pool_cfg.get("storagePoolScope", "CLUSTER")
+ provider = pool_cfg.get("storagePoolProvider", "NetApp ONTAP")
+ tags = nfs3_cfg.get("storagePoolTags", "ontap-nfs3")
+ capacitybytes = pool_cfg.get("capacitybytes", None)
+
+ cls.testdata = TestData(
+ storage_ip, svm_name, username, password,
+ scope=scope, provider=provider, tags=tags,
+ capacitybytes=capacitybytes,
+ ).testdata
+ cls.ontap = OntapRestClient(storage_ip, username, password)
+ cls.svm_name = svm_name
+
+ cls._setup_cloudstack_resources(config, cls.testdata[TestData.account])
+
+ # No per-test tearDown — state intentionally persists between steps.
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _create_pool(self):
+ ps = self.testdata[TestData.primaryStorage]
+ storage_ip = self.testdata[TestData.ontap][TestData.DETAIL_STORAGE_IP]
+ pool_name = "OntapNFS3Vol_%d" % random.randint(0, 99999)
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.name = pool_name
+ cmd.url = "nfs://%s/ontap" % storage_ip
+ cmd.zoneid = self.zone.id
+ cmd.clusterid = self.cluster.id
+ cmd.podid = self.cluster.podid
+ cmd.scope = ps[TestData.scope]
+ cmd.provider = ps[TestData.provider]
+ cmd.tags = ps[TestData.tags]
+ cmd.capacitybytes = ps["capacitybytes"]
+ cmd.hypervisor = "KVM"
+ cmd.managed = True
+
+ count = 1
+ for key, value in ps["details"].items():
+ setattr(cmd, "details[{}].{}".format(count, key), value)
+ count += 1
+
+ response = self.apiClient.createStoragePool(cmd)
+ return StoragePool(response.__dict__)
+
+ def _get_export_policy_name(self, pool):
+ """Extract the export policy name from pool creation response details."""
+ details = _parse_pool_details(pool)
+ ep_name = details.get("exportPolicyName")
+ if not ep_name:
+ ep_name = "cs-%s-%s" % (self.svm_name, pool.name)
+ return ep_name
+
+ # ------------------------------------------------------------------
+ # Step 01 - Create pool (infrastructure) and allocate a volume
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_volume"], required_hardware=True)
+ def test_01_create_pool_and_volume(self):
+ """
+ Create a new NFS3 pool and allocate a CloudStack data volume on it.
+ For NFS3, volume creation is a CloudStack metadata record only — no
+ new ONTAP object is created (the pool's FlexVol serves all volumes).
+ Verifies:
+ - pool.state is Up
+ - createVolume returns a non-None volume object
+ - ONTAP: FlexVol remains online after volume allocation
+ - ONTAP: export policy still present
+ """
+ pool = self._create_pool()
+ self.__class__.pool = pool
+
+ self.assertEqual(
+ pool.state, "Up",
+ "Pool state should be 'Up', got '%s'" % pool.state
+ )
+
+ ep_name = self._get_export_policy_name(pool)
+ self.__class__.pool_ep_name = ep_name
+
+ vol = self._create_volume(pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+
+ # ONTAP: FlexVol must remain online after volume allocation
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' not found after volume creation" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must still be present
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should exist after volume creation" % ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 02 - Delete volume; FlexVol must remain untouched
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_volume"], required_hardware=True)
+ def test_02_delete_volume(self):
+ """
+ Delete the volume created in test_01.
+ For NFS3, volume deletion removes only the CloudStack record.
+ Verifies:
+ - deleteVolume completes without error
+ - ONTAP: FlexVol is still online (unaffected by volume deletion)
+ - ONTAP: export policy still present
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume, "Volume absent - test_01 must pass first")
+
+ pool = self.__class__.pool
+ ep_name = self.__class__.pool_ep_name
+ vol = self.__class__.volume
+
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol.id
+ self.apiClient.deleteVolume(cmd)
+ self.__class__.volume = None
+
+ # ONTAP: FlexVol must still be online
+ ontap_vol = self.ontap.get_volume(pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' should still exist after volume deletion" % pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online' after volume deletion, "
+ "got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must still be present
+ if ep_name:
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after volume deletion" % ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 03 - Recreate volume for negative delete tests
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_volume"], required_hardware=True)
+ def test_03_recreate_volume_for_delete_tests(self):
+ """
+ Recreate a volume on the existing pool (setup for tests 04-05).
+ Verifies:
+ - volume created successfully
+ - ONTAP: FlexVol still online
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+
+ vol = self._create_volume(self.__class__.pool.id)
+ self.__class__.volume = vol
+ self.assertIsNotNone(vol, "createVolume returned None")
+
+ ontap_vol = self.ontap.get_volume(self.__class__.pool.name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' not found after volume re-creation"
+ % self.__class__.pool.name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should be 'online' after volume re-creation"
+ )
+
+ # ------------------------------------------------------------------
+ # Step 04 - Forced=False delete with live volume must fail
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_volume"], required_hardware=True)
+ def test_04_forced_false_delete_with_volume_fails(self):
+ """
+ Put pool in Maintenance then attempt deleteStoragePool(forced=False).
+ With a live volume present CloudStack must reject the request.
+ Verifies:
+ - Exception is raised (CloudStack rejects the delete)
+ - Pool is still listed in CloudStack (in Maintenance state)
+ - ONTAP: FlexVol still exists and is online
+ - ONTAP: export policy still present
+ """
+ self.assertIsNotNone(self.__class__.pool, "Pool absent - test_01 must pass first")
+ self.assertIsNotNone(self.__class__.volume, "Volume absent - test_03 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+ ep_name = self.__class__.pool_ep_name
+
+ # Enter maintenance mode
+ maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ maint_cmd.id = pool.id
+ self.apiClient.enableStorageMaintenance(maint_cmd)
+ self._poll_pool_state(pool.id, "Maintenance", timeout=120)
+
+ # Attempt forced=False delete — must raise exception because volumes exist
+ with self.assertRaises(Exception):
+ self._delete_pool(pool.id, forced=False)
+
+ # Pool must still be listed in CloudStack
+ listed = list_storage_pools(self.apiClient, id=pool.id)
+ self.assertTrue(
+ listed,
+ "Pool should still exist in CloudStack after failed forced=False delete"
+ )
+
+ # ONTAP: FlexVol must still be online
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNotNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' should still exist after failed delete" % pool_name
+ )
+ self.assertEqual(
+ ontap_vol.get("state"), "online",
+ "ONTAP FlexVol should still be 'online', got '%s'" % ontap_vol.get("state")
+ )
+
+ # ONTAP: export policy must still be present
+ if ep_name:
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNotNone(
+ policy,
+ "Export policy '%s' should still exist after failed delete" % ep_name
+ )
+
+ # ------------------------------------------------------------------
+ # Step 05 - Delete volume then force-delete pool from Maintenance
+ # ------------------------------------------------------------------
+
+ @attr(tags=["nfs3_volume"], required_hardware=True)
+ def test_05_delete_volume_and_force_delete_pool(self):
+ """
+ Delete the live volume then force-delete the pool while it is still
+ in Maintenance state (pool is in Maintenance from test_04).
+ Verifies:
+ - Volume can be deleted while pool is in Maintenance
+ - Pool is removed from CloudStack using forced=True from Maintenance
+ - ONTAP: FlexVol deleted
+ - ONTAP: export policy deleted
+ """
+ self.assertIsNotNone(
+ self.__class__.pool,
+ "Pool absent - test_04 must not have cleaned up the pool"
+ )
+ self.assertIsNotNone(self.__class__.volume, "Volume absent - test_03 must pass first")
+
+ pool = self.__class__.pool
+ pool_name = pool.name
+ ep_name = self.__class__.pool_ep_name
+ vol = self.__class__.volume
+
+ # Delete the volume first (pool is in Maintenance — volume deletion is
+ # allowed). For NFS3 a forced=False delete attempt in test_04 may have
+ # already destroyed the libvirt NFS pool representation on the host;
+ # if so deleteVolume raises "Storage pool not found". The CS metadata
+ # record will be cleaned up by the subsequent force-delete of the pool,
+ # so we treat that specific error as a no-op here.
+ try:
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol.id
+ self.apiClient.deleteVolume(cmd)
+ except Exception as exc:
+ if "Storage pool not found" in str(exc) or "storage pool" in str(exc).lower():
+ logger.warning(
+ "deleteVolume raised expected NFS3 libvirt pool-not-found "
+ "error; proceeding to force-delete pool: %s", exc
+ )
+ else:
+ raise
+ self.__class__.volume = None
+
+ # Force-delete the pool from Maintenance (no live volumes remaining)
+ self._delete_pool(pool.id, forced=True)
+ self.__class__.pool = None
+ self.__class__.pool_ep_name = None
+
+ # CloudStack: pool must be gone
+ try:
+ remaining = list_storage_pools(self.apiClient, id=pool.id)
+ except Exception:
+ remaining = None
+ self.assertFalse(remaining, "Pool still listed in CloudStack after force deletion")
+
+ # ONTAP: FlexVol must be deleted
+ ontap_vol = self.ontap.get_volume(pool_name)
+ self.assertIsNone(
+ ontap_vol,
+ "ONTAP FlexVol '%s' still exists after force deletion" % pool_name
+ )
+
+ # ONTAP: export policy must be deleted
+ if ep_name:
+ policy = self.ontap.get_export_policy(ep_name)
+ self.assertIsNone(
+ policy,
+ "Export policy '%s' still exists after pool deletion" % ep_name
+ )
diff --git a/test/integration/plugins/ontap/ontap.cfg b/test/integration/plugins/ontap/ontap.cfg
new file mode 100644
index 000000000000..64ee5174d379
--- /dev/null
+++ b/test/integration/plugins/ontap/ontap.cfg
@@ -0,0 +1,128 @@
+# 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.
+
+{
+ "zones": [
+ {
+ "name": "Zone1",
+ "networktype": "Advanced",
+ "dns1": "8.8.8.8",
+ "dns2": "8.8.4.4",
+ "internaldns1": "10.192.0.250",
+ "internaldns2": "10.193.0.250",
+ "localstorageenabled": true,
+ "guestcidraddress": "10.1.1.0/24",
+ "guestVlanRange": "100-300",
+ "publicIpRange": {
+ "gateway": "10.193.56.1",
+ "netmask": "255.255.255.128",
+ "startip": "10.193.56.100",
+ "endip": "10.193.56.109",
+ "vlan": "untagged"
+ },
+ "secondaryStorages": [
+ {
+ "name": "Secondary1",
+ "provider": "NFS",
+ "url": "nfs://10.193.56.62/export/secondary"
+ }
+ ],
+ "pods": [
+ {
+ "name": "Pod1",
+ "gateway": "10.193.56.1",
+ "netmask": "255.255.255.128",
+ "startip": "10.193.56.80",
+ "endip": "10.193.56.89",
+ "clusters": [
+ {
+ "clustername": "Cluster1",
+ "clustertype": "CloudManaged",
+ "hypervisor": "KVM",
+ "primaryStorages": [
+ {
+ "name": "Primary1",
+ "scope": "Cluster",
+ "url": "nfs://10.193.56.62/export/primary",
+ "provider": "DefaultPrimary",
+ "tags": "defaultPrim"
+ }
+ ],
+ "hosts": [
+ {
+ "url": "http://10.193.56.62",
+ "username": "root",
+ "password": "<>",
+ "hosttags": "kvmHost"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "dbSvr": {
+ "dbSvr": "10.193.56.62",
+ "passwd": "",
+ "db": "cloud",
+ "port": 3306,
+ "user": "root"
+ },
+ "logger": {
+ "LogFolderPath": "/tmp/"
+ },
+ "mgtSvr": [
+ {
+ "mgtSvrIp": "10.193.56.62",
+ "port": 8096,
+ "user": "admin",
+ "passwd": "password",
+ "hypervisor": "kvm"
+ }
+ ],
+ "ontap": {
+ "storageIP": "10.196.35.203",
+ "svmName": "vs0",
+ "username": "admin",
+ "password": "<>"
+ },
+ "storagePool": {
+ "storagePoolScope": "CLUSTER",
+ "storagePoolProvider": "NetApp ONTAP",
+ "capacitybytes": null,
+ "protocols": {
+ "iscsi": {
+ "enabled": true,
+ "storagePoolTags": "ontap-iscsi"
+ },
+ "nfs3": {
+ "enabled": true,
+ "storagePoolTags": "ontap-nfs3"
+ }
+ }
+ },
+ "cloudstack": {
+ "zoneName": "Zone1",
+ "clusterName": null,
+ "domainName": "ROOT",
+ "templateName": "CentOS 5.5(64-bit) no GUI (KVM)",
+ "systemVmTimeoutSec": 3600,
+ "templateReadyTimeoutSec": 3600,
+ "pollIntervalSec": 60
+ }
+}
diff --git a/test/integration/plugins/ontap/ontap_test_base.py b/test/integration/plugins/ontap/ontap_test_base.py
new file mode 100644
index 000000000000..4f60dbf9433f
--- /dev/null
+++ b/test/integration/plugins/ontap/ontap_test_base.py
@@ -0,0 +1,621 @@
+# 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.
+
+"""
+Shared base class and helper utilities for NetApp ONTAP Marvin integration tests.
+
+Provides:
+ OntapRestClient - thin wrapper around the ONTAP REST API (NFS + iSCSI methods)
+ _parse_pool_details - converts a StoragePool details attribute to a plain dict
+ OntapTestBase - base cloudstackTestCase with common tearDownClass,
+ _poll_pool_state, _create_volume, and _delete_pool
+"""
+
+import logging
+import random
+import requests
+import sys
+import time
+import urllib3
+from urllib.parse import urlparse
+
+from marvin.cloudstackAPI import (
+ cancelStorageMaintenance,
+ createVolume as createVolumeAPI,
+ deleteStoragePool as deleteStoragePoolAPI,
+ deleteVolume as deleteVolumeAPI,
+ listDiskOfferings as listDiskOfferingsAPI,
+ updateStoragePool as updateStoragePoolAPI,
+)
+from marvin.cloudstackAPI import listHosts as listHostsAPI
+from marvin.cloudstackTestCase import cloudstackTestCase
+from marvin.jsonHelper import jsonDump
+from marvin.lib.base import Account, DiskOffering
+from marvin.sshClient import SshClient
+from marvin.lib.common import get_domain, get_zone, list_clusters, list_storage_pools
+from marvin.lib.utils import cleanup_resources
+
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logger = logging.getLogger("OntapTestBase")
+
+
+def configure_console_logging(log, level=logging.INFO):
+ """Send INFO/WARNING/ERROR from *log* to stdout for live test-run visibility."""
+ if any(isinstance(h, logging.StreamHandler) for h in log.handlers):
+ return
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(logging.Formatter(
+ "%(asctime)s %(levelname)s [%(name)s] %(message)s",
+ datefmt="%H:%M:%S",
+ ))
+ handler.setLevel(level)
+ log.addHandler(handler)
+ if log.level == logging.NOTSET or log.level > level:
+ log.setLevel(level)
+ log.propagate = False
+
+
+def enable_live_logging(test_cls):
+ """Attach stdout handlers to OntapTestBase and the test module logger."""
+ configure_console_logging(logger)
+ if test_cls is not None:
+ mod = sys.modules.get(test_cls.__module__)
+ if mod is not None:
+ mod_logger = getattr(mod, "logger", None)
+ if mod_logger is not None:
+ configure_console_logging(mod_logger)
+
+
+def log_progress(log, level, msg, *args):
+ """Log to Marvin files and stdout so long polls remain visible."""
+ text = msg % args if args else msg
+ getattr(log, level)(text)
+ print("[%s] %s" % (level.upper(), text), flush=True)
+
+
+def get_datacenter_config(testclient, test_cls):
+ """
+ Return the --marvin-config file (e.g. ontap.cfg) as a plain dict.
+
+ Marvin injects the datacenter config as ``test_cls.config``. The separate
+ ``getParsedTestDataConfig()`` API defaults to test_data.py and does not
+ contain ontap/cloudstack/zones sections from ontap.cfg.
+ """
+ if getattr(test_cls, "config", None):
+ return jsonDump.dump(test_cls.config)
+ cfg = testclient.getParsedTestDataConfig() or {}
+ if cfg.get("ontap") or cfg.get("cloudstack") or cfg.get("zones"):
+ return cfg
+ return cfg
+
+
+# ---------------------------------------------------------------------------
+# Pool detail helper
+# ---------------------------------------------------------------------------
+
+def _parse_pool_details(pool):
+ details_raw = getattr(pool, "details", None)
+ if not details_raw:
+ return {}
+ if isinstance(details_raw, dict):
+ return details_raw
+ if isinstance(details_raw, list):
+ return {d.name: d.value for d in details_raw}
+ return {
+ k: v for k, v in vars(details_raw).items()
+ if not k.startswith("_") and k != "typeInfo"
+ }
+
+
+# ---------------------------------------------------------------------------
+# ONTAP REST helper
+# ---------------------------------------------------------------------------
+
+class OntapRestClient:
+ """Thin wrapper around the ONTAP REST API for backend validation."""
+
+ def __init__(self, storage_ip, username, password, port=443):
+ self._base = "https://%s:%d/api" % (storage_ip, port)
+ self._auth = (username, password)
+
+ def _get(self, path, params=None):
+ url = self._base + path
+ resp = requests.get(url, auth=self._auth, params=params,
+ verify=False, timeout=30)
+ resp.raise_for_status()
+ return resp.json()
+
+ def _delete(self, path, params=None):
+ url = self._base + path
+ resp = requests.delete(url, auth=self._auth, params=params,
+ verify=False, timeout=30)
+ resp.raise_for_status()
+
+ def delete_volume(self, name):
+ """Delete the ONTAP FlexVol with the given name. No-op if not found."""
+ data = self._get("/storage/volumes", params={"name": name})
+ records = data.get("records", [])
+ if not records:
+ return
+ uuid = records[0].get("uuid")
+ if uuid:
+ self._delete("/storage/volumes/%s" % uuid)
+
+ def delete_export_policy(self, name):
+ """Delete the NFS export policy with the given name. No-op if not found."""
+ data = self._get("/protocols/nfs/export-policies", params={"name": name})
+ records = data.get("records", [])
+ if not records:
+ return
+ policy_id = records[0].get("id")
+ if policy_id:
+ self._delete("/protocols/nfs/export-policies/%s" % policy_id)
+
+ def get_volume(self, name):
+ """Return the ONTAP FlexVol record for the given name, or None."""
+ data = self._get("/storage/volumes", params={"name": name})
+ records = data.get("records", [])
+ if not records:
+ return None
+ uuid = records[0].get("uuid")
+ if uuid:
+ return self._get("/storage/volumes/%s" % uuid,
+ params={"fields": "name,uuid,state,space"})
+ return records[0]
+
+ # -- NFS helpers ---------------------------------------------------------
+
+ def get_export_policy(self, name):
+ """Return the ONTAP NFS export policy record for the given name, or None."""
+ data = self._get("/protocols/nfs/export-policies", params={"name": name})
+ records = data.get("records", [])
+ if not records:
+ return None
+ policy_id = records[0].get("id")
+ if policy_id:
+ return self._get(
+ "/protocols/nfs/export-policies/%s" % policy_id,
+ params={"fields": "name,svm,rules"}
+ )
+ return records[0]
+
+ def get_data_lifs(self, svm_name):
+ """Return a list of NFS data LIF IP addresses for the given SVM."""
+ data = self._get(
+ "/network/ip/interfaces",
+ params={"svm.name": svm_name, "services": "data-nfs",
+ "fields": "ip,name"}
+ )
+ records = data.get("records", [])
+ return [r.get("ip", {}).get("address")
+ for r in records if r.get("ip", {}).get("address")]
+
+ # -- iSCSI helpers -------------------------------------------------------
+
+ def get_igroup(self, svm_name, igroup_name):
+ """Return the ONTAP igroup record, or None if not found."""
+ data = self._get("/protocols/san/igroups",
+ params={"svm.name": svm_name, "name": igroup_name,
+ "fields": "name,uuid,initiators"})
+ records = data.get("records", [])
+ return records[0] if records else None
+
+ def get_lun(self, svm_name, lun_path):
+ """Return the ONTAP LUN record for the given full path, or None."""
+ data = self._get("/storage/luns",
+ params={"svm.name": svm_name, "name": lun_path,
+ "fields": "name,uuid,enabled,status"})
+ records = data.get("records", [])
+ return records[0] if records else None
+
+ def list_luns_in_volume(self, svm_name, vol_name):
+ """Return all LUN records whose path starts with /vol/{vol_name}/."""
+ prefix = "/vol/%s/" % vol_name
+ data = self._get("/storage/luns",
+ params={"svm.name": svm_name,
+ "fields": "name,uuid,enabled,status"})
+ return [r for r in data.get("records", [])
+ if r.get("name", "").startswith(prefix)]
+
+ def list_lun_maps_for_volume(self, svm_name, vol_name):
+ """Return all LUN-map records for LUNs residing in the given FlexVol."""
+ prefix = "/vol/%s/" % vol_name
+ data = self._get("/protocols/san/lun-maps",
+ params={"svm.name": svm_name,
+ "fields": "lun.name,igroup.name"})
+ return [r for r in data.get("records", [])
+ if r.get("lun", {}).get("name", "").startswith(prefix)]
+
+ # -- NFS file helpers ----------------------------------------------------
+
+ def list_files_in_volume(self, vol_name, path="/"):
+ """Return a list of file names at ``path`` inside the named FlexVol.
+
+ Uses the ONTAP REST file-system API:
+ GET /api/storage/volumes/{uuid}/files/{url_encoded_path}
+
+ The path must appear in the URL (not as a query parameter). The root
+ directory is represented as ``%2F``.
+
+ Returns an empty list if the volume does not exist, the path is empty,
+ or the request fails.
+ """
+ vol = self.get_volume(vol_name)
+ if not vol:
+ return []
+ vol_uuid = vol.get("uuid", "")
+ if not vol_uuid:
+ return []
+ # URL-encode the path component (/ → %2F) and embed it in the URL.
+ from urllib.parse import quote
+ encoded_path = quote(path, safe="")
+ try:
+ resp = self._get(
+ "/storage/volumes/%s/files/%s" % (vol_uuid, encoded_path),
+ params={"fields": "name,type", "max_records": "500"}
+ )
+ except Exception:
+ return []
+ return [r.get("name", "") for r in resp.get("records", [])
+ if r.get("name") not in (".", "..")]
+
+
+# ---------------------------------------------------------------------------
+# Base test class
+# ---------------------------------------------------------------------------
+
+class OntapTestBase(cloudstackTestCase):
+
+ # ---- shared state (set/cleared by individual tests) ----------------
+ pool = None
+ volume = None
+ pool2 = None
+ volume2 = None
+ disk_offering_id = None
+ svm_name = None
+ cluster_hosts = None
+ kvm_hosts_ssh_creds = [] # [{'host': '10.x.x.x', 'user': 'root', 'password': '...'}]
+ ontap = None
+ testdata = None
+ zone = None
+ cluster = None
+ domain = None
+ account = None
+ _cleanup = []
+
+ # Subclass sets this to distinguish volume names, e.g. "OntapNFS3Vol"
+ _vol_name_prefix = "OntapVol"
+
+ # ---- zone guard ----------------------------------------------------
+
+ @classmethod
+ def _ensure_zone(cls, config, zone_name, cluster_name):
+ """
+ Verify that the named zone and cluster already exist and return
+ (zone, cluster). Raises RuntimeError with a clear message if the
+ zone is absent — run the setup_zone step first:
+
+ bash test/integration/plugins/ontap/run_tests.sh setup_zone
+ """
+ zone = get_zone(cls.apiClient, zone_name=zone_name)
+ if not zone:
+ raise RuntimeError(
+ "Zone '%s' not found. Create it first by running:\n"
+ " bash test/integration/plugins/ontap/run_tests.sh setup_zone\n"
+ "Then re-run the tests."
+ % (zone_name or "")
+ )
+ clusters = (list_clusters(cls.apiClient, name=cluster_name)
+ if cluster_name else list_clusters(cls.apiClient))
+ if not clusters:
+ raise RuntimeError(
+ "No cluster found (filter: %r) in zone '%s'. "
+ "Verify the cluster was created by the setup_zone step."
+ % (cluster_name, zone.name)
+ )
+ return zone, clusters[0]
+
+ # ---- shared setup helper -------------------------------------------
+
+ @classmethod
+ def setUpClass(cls):
+ enable_live_logging(cls)
+
+ @classmethod
+ def _setup_cloudstack_resources(cls, config, account_testdata):
+ """
+ Resolve zone, cluster, domain, account, cluster hosts, and disk
+ offering from the Marvin config. Call this from subclass setUpClass
+ after ``cls.ontap`` and ``cls.svm_name`` have been assigned.
+ """
+ cs_cfg = config.get("cloudstack", {})
+ zone_name = cs_cfg.get("zoneName", None)
+ cluster_name = cs_cfg.get("clusterName", None)
+ domain_name = cs_cfg.get("domainName", "ROOT")
+
+ cls.zone, cls.cluster = cls._ensure_zone(config, zone_name, cluster_name)
+ cls.domain = get_domain(cls.apiClient, domain_name=domain_name)
+
+ cls.account = Account.create(cls.apiClient, account_testdata, admin=1)
+ cls._cleanup = [cls.account]
+
+ list_hosts_cmd = listHostsAPI.listHostsCmd()
+ list_hosts_cmd.clusterid = cls.cluster.id
+ list_hosts_cmd.type = "Routing"
+ cls.cluster_hosts = cls.apiClient.listHosts(list_hosts_cmd) or []
+
+ list_do_cmd = listDiskOfferingsAPI.listDiskOfferingsCmd()
+ list_do_cmd.domainid = cls.domain.id
+ offerings = cls.apiClient.listDiskOfferings(list_do_cmd)
+ if offerings:
+ cls.disk_offering_id = offerings[0].id
+ else:
+ # No disk offerings exist yet — create a minimal one for tests
+ do = DiskOffering.create(
+ cls.apiClient,
+ {"name": "ontap-test-do", "displaytext": "ONTAP test disk offering", "disksize": 2},
+ )
+ cls._cleanup.append(do)
+ cls.disk_offering_id = do.id
+
+ # Parse KVM host SSH credentials from zones/pods/clusters/hosts config.
+ # Used by _cleanup_kvm_storage_pool_mounts to unmount stale NFS pools.
+ cls.kvm_hosts_ssh_creds = []
+ try:
+ for zone in config.get("zones", []):
+ for pod in zone.get("pods", []):
+ for cluster in pod.get("clusters", []):
+ for host_cfg in cluster.get("hosts", []):
+ host_ip = urlparse(
+ host_cfg.get("url", "")
+ ).hostname or ""
+ if host_ip:
+ cls.kvm_hosts_ssh_creds.append({
+ "host": host_ip,
+ "user": host_cfg.get("username", "root"),
+ "password": host_cfg.get("password", ""),
+ })
+ except Exception as parse_ex:
+ logger.warning(
+ "_setup_cloudstack_resources: could not parse KVM SSH creds: %s"
+ % parse_ex
+ )
+
+ # ---- KVM storage cleanup helper ------------------------------------
+
+ @classmethod
+ def _cleanup_kvm_storage_pool_mounts(cls, pool_uuid):
+ """
+ SSH to each KVM host and unmount the NFS storage pool mount for
+ *pool_uuid*, then destroy and undefine the libvirt storage pool.
+
+ Must be called BEFORE the ONTAP FlexVol is deleted (i.e., before
+ deleteStoragePool) so that the unmount completes while the NFS
+ export is still reachable. Prevents stale NFS mounts from
+ triggering KVMHAMonitor heartbeat failures that reboot the host
+ via ``echo b > /proc/sysrq-trigger``.
+ """
+ for creds in cls.kvm_hosts_ssh_creds:
+ host_ip = creds["host"]
+ try:
+ ssh = SshClient(
+ host_ip, 22,
+ creds["user"], creds["password"],
+ retries=3, delay=3, timeout=15.0,
+ )
+ for cmd in [
+ "umount -f -l /mnt/{u} 2>/dev/null; true".format(
+ u=pool_uuid),
+ "virsh pool-destroy {u} 2>/dev/null; true".format(
+ u=pool_uuid),
+ "virsh pool-undefine {u} 2>/dev/null; true".format(
+ u=pool_uuid),
+ ]:
+ try:
+ ssh.execute(cmd)
+ except Exception as cmd_ex:
+ logger.warning(
+ "_cleanup_kvm_storage_pool_mounts: cmd '%s' "
+ "failed on %s: %s" % (cmd, host_ip, cmd_ex)
+ )
+ except Exception as ex:
+ logger.warning(
+ "_cleanup_kvm_storage_pool_mounts: SSH to %s failed: %s"
+ % (host_ip, ex)
+ )
+
+ # ---- shared teardown -----------------------------------------------
+
+ @classmethod
+ def tearDownClass(cls):
+ """Best-effort cleanup of any resources left behind by a failed run."""
+ for pool in [p for p in (cls.pool2, cls.pool) if p is not None]:
+ try:
+ # Step 1: Check current pool state
+ pools = list_storage_pools(cls.apiClient, id=pool.id)
+ if not pools:
+ continue # already deleted
+ pool_state = pools[0].state
+
+ # Step 2: If in Maintenance, attempt to exit it
+ if pool_state == "Maintenance":
+ try:
+ cc = cancelStorageMaintenance.cancelStorageMaintenanceCmd()
+ cc.id = pool.id
+ cls.apiClient.cancelStorageMaintenance(cc)
+ time.sleep(5)
+ except Exception:
+ pass
+ try:
+ ec = updateStoragePoolAPI.updateStoragePoolCmd()
+ ec.id = pool.id
+ ec.enabled = True
+ cls.apiClient.updateStoragePool(ec)
+ time.sleep(3)
+ except Exception:
+ pass
+ pools = list_storage_pools(cls.apiClient, id=pool.id)
+ if pools:
+ pool_state = pools[0].state
+
+ # Step 3: Delete volumes — always attempt regardless of pool
+ # state. For iSCSI this works even in Maintenance; for NFS3/KVM
+ # it may fail with NPE ("storagePoolInformation is null") when
+ # pool is in Maintenance — that exception is caught below.
+ for vol in [v for v in (cls.volume2, cls.volume) if v is not None]:
+ try:
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol.id
+ cls.apiClient.deleteVolume(cmd)
+ except Exception as ve:
+ logger.warning(
+ "tearDownClass: could not delete volume %s: %s"
+ % (vol.id, ve))
+
+ # Re-enter Maintenance only if pool was Up/Disabled (avoid
+ # double-entering when cancel maintenance above already left it
+ # in Maintenance)
+ if pool_state in ("Up", "Disabled"):
+ try:
+ mc = enableStorageMaintenance.enableStorageMaintenanceCmd()
+ mc.id = pool.id
+ cls.apiClient.enableStorageMaintenance(mc)
+ deadline = time.time() + 60
+ while time.time() < deadline:
+ ps = list_storage_pools(cls.apiClient, id=pool.id)
+ if ps and ps[0].state == "Maintenance":
+ break
+ time.sleep(5)
+ except Exception:
+ pass
+
+ # Step 4: Force-delete the pool
+ dc = deleteStoragePoolAPI.deleteStoragePoolCmd()
+ dc.id = pool.id
+ dc.forced = True
+ cls.apiClient.deleteStoragePool(dc)
+ except Exception as e:
+ logger.warning("tearDownClass: could not delete pool %s: %s"
+ % (pool.id, e))
+ # Last resort: delete ONTAP FlexVol and export policy directly
+ # so that ONTAP is never left with orphaned volumes even when
+ # the CloudStack pool record cannot be removed.
+ if hasattr(cls, "ontap") and cls.ontap is not None:
+ try:
+ cls.ontap.delete_volume(pool.name)
+ logger.warning(
+ "tearDownClass: deleted ONTAP FlexVol '%s' directly"
+ % pool.name)
+ except Exception as oe:
+ logger.warning(
+ "tearDownClass: ONTAP direct volume delete '%s' "
+ "failed: %s" % (pool.name, oe))
+ try:
+ # For NFS3 pools also remove the export policy
+ ep_name = getattr(cls, "pool_ep_name", None)
+ if ep_name is None:
+ ep_name = "cs-%s-%s" % (
+ getattr(cls, "svm_name", ""), pool.name)
+ cls.ontap.delete_export_policy(ep_name)
+ logger.warning(
+ "tearDownClass: deleted export policy '%s' directly"
+ % ep_name)
+ except Exception:
+ pass
+
+ # Clean up volumes that may not have been handled with pool teardown
+ for vol in [v for v in (cls.volume2, cls.volume) if v is not None]:
+ try:
+ cmd = deleteVolumeAPI.deleteVolumeCmd()
+ cmd.id = vol.id
+ cls.apiClient.deleteVolume(cmd)
+ except Exception as e:
+ logger.warning("tearDownClass: could not delete volume %s: %s"
+ % (vol.id, e))
+
+ try:
+ cleanup_resources(cls.apiClient, cls._cleanup)
+ except Exception as e:
+ logger.debug("tearDownClass cleanup_resources: %s" % e)
+
+ # No per-test tearDown — state intentionally persists between steps.
+
+ # ---- shared helpers ------------------------------------------------
+
+ def _poll_pool_state(self, pool_id, target_state, timeout=120, interval=5):
+ """Poll listStoragePools until the pool reaches target_state or timeout."""
+ start = time.time()
+ deadline = start + timeout
+ attempt = 0
+ current_state = "unknown"
+ log_progress(
+ logger, "info",
+ "Waiting for pool %s to reach state '%s' "
+ "(timeout=%ds, poll every %ds).",
+ pool_id, target_state, timeout, interval,
+ )
+ while time.time() < deadline:
+ attempt += 1
+ elapsed = int(time.time() - start)
+ remaining = max(0, int(deadline - time.time()))
+ pools = list_storage_pools(self.apiClient, id=pool_id)
+ if pools:
+ current_state = pools[0].state
+ if current_state == target_state:
+ log_progress(
+ logger, "info",
+ "Pool %s reached state '%s' after %ds (%d polls).",
+ pool_id, target_state, elapsed, attempt,
+ )
+ return pools[0]
+ log_progress(
+ logger, "info",
+ "Pool poll #%d: pool %s state=%s (want %s) "
+ "[elapsed %ds, ~%ds left]",
+ attempt, pool_id, current_state, target_state,
+ elapsed, remaining,
+ )
+ time.sleep(interval)
+ log_progress(
+ logger, "error",
+ "Pool %s did not reach state '%s' within %ds (last: '%s').",
+ pool_id, target_state, timeout, current_state,
+ )
+ self.fail(
+ "Pool %s did not reach state '%s' within %ds (last: '%s')"
+ % (pool_id, target_state, timeout, current_state)
+ )
+
+ def _create_volume(self, pool_id):
+ """Create a data volume on the given pool; uses _vol_name_prefix."""
+ cmd = createVolumeAPI.createVolumeCmd()
+ cmd.name = "%s_%d" % (self._vol_name_prefix, random.randint(0, 99999))
+ cmd.diskofferingid = self.disk_offering_id
+ cmd.zoneid = self.zone.id
+ cmd.storageid = pool_id
+ cmd.account = self.account.name
+ cmd.domainid = self.domain.id
+ return self.apiClient.createVolume(cmd)
+
+ def _delete_pool(self, pool_id, forced=False):
+ """Issue deleteStoragePool for the given pool id."""
+ cmd = deleteStoragePoolAPI.deleteStoragePoolCmd()
+ cmd.id = pool_id
+ if forced:
+ cmd.forced = True
+ self.apiClient.deleteStoragePool(cmd)
diff --git a/test/integration/plugins/ontap/run_tests.sh b/test/integration/plugins/ontap/run_tests.sh
new file mode 100755
index 000000000000..8063fc3238e3
--- /dev/null
+++ b/test/integration/plugins/ontap/run_tests.sh
@@ -0,0 +1,415 @@
+#!/usr/bin/env bash
+# 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.
+
+# Run ONTAP Marvin integration tests by tag or protocol batch.
+# Each test file runs individually so sequential test state is preserved.
+#
+# Usage (from cloudstack repo root):
+# bash test/integration/plugins/ontap/run_tests.sh # setup + iscsi + nfs3
+# bash test/integration/plugins/ontap/run_tests.sh iscsi # all iSCSI suites
+# bash test/integration/plugins/ontap/run_tests.sh nfs3 # all NFS3 suites
+# bash test/integration/plugins/ontap/run_tests.sh both # iscsi then nfs3
+# bash test/integration/plugins/ontap/run_tests.sh nfs3_workflow # single suite
+
+ONTAP_DIR=test/integration/plugins/ontap
+CFG=${ONTAP_DIR}/ontap.cfg
+RESULTS_BASE=${ONTAP_DIR}/results
+AGGREGATE=${ONTAP_DIR}/aggregate_results.py
+export PYTHONPATH=${ONTAP_DIR}:${PYTHONPATH:-}
+export PYTHONUNBUFFERED=1
+FILTER="${1:-all}"
+
+if [[ -x ${ONTAP_DIR}/.venv/bin/python ]]; then
+ PYTHON=${ONTAP_DIR}/.venv/bin/python
+else
+ PYTHON=python3
+fi
+
+PASS=0
+FAIL=0
+SKIP=0
+SUMMARY_FILE=$(mktemp)
+BATCH_SUMMARY=""
+BATCH_FAIL=0
+GLOBAL_BATCH_FAIL=0
+RUN_DIR=""
+BATCH_PROTOCOL=""
+BATCH_START=""
+SUITE_RC_FILE=$(mktemp)
+
+trap 'rm -f "$SUMMARY_FILE" "$SUITE_RC_FILE"; [[ -n "$BATCH_SUMMARY" && -f "$BATCH_SUMMARY" ]] && rm -f "$BATCH_SUMMARY"' EXIT
+
+# ---------------------------------------------------------------------------
+# Protocol suite definitions (label|tag|file)
+# Order: pool lifecycle → with volumes → volume lifecycle → zone → VM last
+# ---------------------------------------------------------------------------
+
+ISCSI_SUITES=(
+ "iSCSI pool lifecycle|iscsi_workflow|${ONTAP_DIR}/iscsi/pool/test_pool_lifecycle.py"
+ "iSCSI pool with volumes|iscsi_with_volumes|${ONTAP_DIR}/iscsi/pool/test_pool_with_volumes.py"
+ "iSCSI volume lifecycle|iscsi_volume|${ONTAP_DIR}/iscsi/volume/test_volume_lifecycle.py"
+ "iSCSI zone-scoped pool|iscsi_zone_pool|${ONTAP_DIR}/iscsi/pool/test_zone_scoped_pool.py"
+ "iSCSI VM volume workflow|iscsi_vm_workflow|${ONTAP_DIR}/iscsi/instance/test_vm_volume_attach.py"
+)
+
+NFS3_SUITES=(
+ "NFS3 pool lifecycle|nfs3_workflow|${ONTAP_DIR}/nfs3/pool/test_pool_lifecycle.py"
+ "NFS3 pool with volumes|nfs3_with_volumes|${ONTAP_DIR}/nfs3/pool/test_pool_with_volumes.py"
+ "NFS3 volume lifecycle|nfs3_volume|${ONTAP_DIR}/nfs3/volume/test_volume_lifecycle.py"
+ "NFS3 zone-scoped pool|zone_pool|${ONTAP_DIR}/nfs3/pool/test_zone_scoped_pool.py"
+ "NFS3 VM volume attach|vm_volume_workflow|${ONTAP_DIR}/nfs3/instance/test_vm_volume_attach.py"
+)
+
+record_results() {
+ local tag="$1"
+ local label="$2"
+ local results_file="$3"
+ local dest="${4:-$SUMMARY_FILE}"
+ $PYTHON -c "
+import re, sys
+tag, label, path = sys.argv[1], sys.argv[2], sys.argv[3]
+with open(path, encoding='utf-8') as fh:
+ for line in fh:
+ line = line.rstrip('\n')
+ m = re.search(r'TestName: (\S+) \| Status : (\S+)', line)
+ if m:
+ print('%s\t%s\t%s\t%s\t' % (tag, label, m.group(1), m.group(2)))
+ continue
+ m = re.match(r'(.+?) \.\.\. SKIP: (.+)$', line)
+ if m:
+ name = m.group(1).strip()
+ if len(name) > 72:
+ name = name[:69] + '...'
+ detail = m.group(2).strip()
+ if len(detail) > 120:
+ detail = detail[:117] + '...'
+ print('%s\t%s\t%s\tSKIP\t%s' % (tag, label, name, detail))
+" "$tag" "$label" "$results_file" >> "$dest"
+}
+
+print_final_summary() {
+ echo ""
+ echo "================================================================"
+ echo " TEST SUMMARY"
+ echo "================================================================"
+ $PYTHON "$AGGREGATE" --out-dir "$(mktemp -d)" --summary-tsv "$SUMMARY_FILE" --print 2>/dev/null \
+ | sed -n '/TEST SUMMARY/,$p' | tail -n +2
+}
+
+init_batch() {
+ local protocol="$1"
+ local parent_dir="${2:-}"
+
+ BATCH_PROTOCOL="$protocol"
+ BATCH_START=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+ BATCH_FAIL=0
+ local stamp
+ stamp=$(date +"%Y%m%d-%H%M%S")
+
+ if [[ -n "$parent_dir" ]]; then
+ RUN_DIR="${parent_dir}/${protocol}"
+ else
+ RUN_DIR="${RESULTS_BASE}/${stamp}-${protocol}"
+ fi
+
+ mkdir -p "${RUN_DIR}/suites"
+ : > "$SUITE_RC_FILE"
+ BATCH_SUMMARY=$(mktemp)
+
+ if [[ -z "$parent_dir" ]]; then
+ ln -sfn "$(basename "$RUN_DIR")" "${RESULTS_BASE}/latest-${protocol}"
+ fi
+
+ echo ""
+ echo "################################################################"
+ echo " Protocol batch: $(echo "$protocol" | tr '[:lower:]' '[:upper:]')"
+ echo " Results: ${RUN_DIR}"
+ echo "################################################################"
+}
+
+finalize_batch() {
+ local batch_meta batch_summary
+ batch_summary=$(mktemp)
+ $PYTHON -c "
+import json, sys
+from datetime import datetime, timezone
+run_dir, protocol, start, rc_file = sys.argv[1:5]
+suites = []
+try:
+ with open(rc_file) as fh:
+ for line in fh:
+ line = line.strip()
+ if not line:
+ continue
+ tag, label, rc = line.split('\t', 2)
+ suites.append({'tag': tag, 'label': label, 'exitCode': int(rc)})
+except (IOError, OSError):
+ pass
+meta = {
+ 'protocol': protocol,
+ 'startedAt': start,
+ 'finishedAt': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'resultsDir': run_dir,
+ 'suites': suites,
+}
+with open(run_dir + '/run.meta.json', 'w') as fh:
+ json.dump(meta, fh, indent=2)
+ fh.write('\n')
+print(json.dumps(meta))
+" "$RUN_DIR" "$BATCH_PROTOCOL" "$BATCH_START" "$SUITE_RC_FILE" > "$batch_summary"
+
+ $PYTHON "$AGGREGATE" \
+ --out-dir "$RUN_DIR" \
+ --summary-tsv "$BATCH_SUMMARY" \
+ --meta-json "$batch_summary" \
+ --print
+
+ cat "$BATCH_SUMMARY" >> "$SUMMARY_FILE"
+
+ if [[ "$BATCH_FAIL" -ne 0 ]]; then
+ echo " Batch ${BATCH_PROTOCOL}: FAILED (see ${RUN_DIR}/summary.txt)"
+ else
+ echo " Batch ${BATCH_PROTOCOL}: all suites passed"
+ fi
+ echo " Artifacts: ${RUN_DIR}/summary.json"
+
+ rm -f "$batch_summary" "$BATCH_SUMMARY"
+ BATCH_SUMMARY=""
+}
+
+copy_suite_logs() {
+ local tag="$1"
+ local log_folder="$2"
+ local stdout_file="$3"
+
+ local dest="${RUN_DIR}/suites/${tag}"
+ mkdir -p "$dest"
+
+ if [[ -f "$stdout_file" ]]; then
+ cp "$stdout_file" "${dest}/stdout.log"
+ fi
+ if [[ -n "$log_folder" && -d "$log_folder" ]]; then
+ [[ -f "${log_folder}/results.txt" ]] && cp "${log_folder}/results.txt" "${dest}/"
+ [[ -f "${log_folder}/runinfo.txt" ]] && cp "${log_folder}/runinfo.txt" "${dest}/"
+ fi
+}
+
+should_run_tag() {
+ local tag="$1"
+
+ case "$FILTER" in
+ all)
+ [[ "$tag" != "cleanup_zone" ]]
+ ;;
+ both)
+ [[ "$tag" != "setup_zone" && "$tag" != "cleanup_zone" ]]
+ ;;
+ iscsi)
+ [[ "$tag" == iscsi_* ]]
+ ;;
+ nfs3)
+ [[ "$tag" == nfs3_* || "$tag" == "zone_pool" || "$tag" == "vm_volume_workflow" ]]
+ ;;
+ *)
+ [[ "$FILTER" == "$tag" ]]
+ ;;
+ esac
+}
+
+run_group() {
+ local label="$1"
+ local tag="$2"
+ local file="$3"
+
+ if ! should_run_tag "$tag"; then
+ return 0
+ fi
+
+ echo ""
+ echo "================================================================"
+ echo " ${label} (tag: ${tag})"
+ echo "================================================================"
+
+ local out tmpout rc log_folder record_dest
+ tmpout=$(mktemp)
+ if [[ -n "$BATCH_SUMMARY" ]]; then
+ record_dest="$BATCH_SUMMARY"
+ else
+ record_dest="$SUMMARY_FILE"
+ fi
+
+ set +e
+ $PYTHON -m nose --with-marvin --marvin-config="$CFG" "$file" -a "tags=${tag}" -v -s 2>&1 | tee "$tmpout"
+ rc=${PIPESTATUS[0]}
+ set -e
+ out=$(cat "$tmpout")
+
+ log_folder=$(echo "$out" | grep "Final results are now copied to" | sed 's/.*copied to: //; s/ ===.*//' | tr -d '[:space:]')
+ log_folder=$($PYTHON -c "import os; print(os.path.realpath('$log_folder'))" 2>/dev/null || echo "")
+
+ if [[ -n "$RUN_DIR" ]]; then
+ copy_suite_logs "$tag" "$log_folder" "$tmpout"
+ printf '%s\t%s\t%d\n' "$tag" "$label" "$rc" >> "$SUITE_RC_FILE"
+ fi
+
+ if [[ -n "$log_folder" && -f "${log_folder}/results.txt" ]]; then
+ local suite_pass suite_fail suite_skip
+ record_results "$tag" "$label" "${log_folder}/results.txt" "$record_dest"
+ while IFS= read -r line; do
+ echo " $line"
+ done < <(grep "TestName.*Status" "${log_folder}/results.txt" | grep -v "^===")
+ suite_pass=$(grep -c "Status : SUCCESS" "${log_folder}/results.txt" 2>/dev/null | tr -d '[:space:]' || echo 0)
+ suite_fail=$(grep -E "Status : FAIL|Status : EXCEPTION" "${log_folder}/results.txt" 2>/dev/null | wc -l | tr -d '[:space:]' || echo 0)
+ suite_skip=$(grep -c "\.\.\. SKIP:" "${log_folder}/results.txt" 2>/dev/null | tr -d '[:space:]' || echo 0)
+ PASS=$((PASS + suite_pass))
+ FAIL=$((FAIL + suite_fail))
+ SKIP=$((SKIP + suite_skip))
+ echo " -> ${suite_pass} passed, ${suite_fail} failed, ${suite_skip} skipped"
+ else
+ echo "$out" | grep -E "ERROR|Exception|failed" | head -5
+ echo " [could not read results — log folder: ${log_folder:-not found}]"
+ printf '%s\t%s\t%s\tFAIL\t%s\n' "$tag" "$label" "(suite)" "results not found" >> "$record_dest"
+ FAIL=$((FAIL + 1))
+ fi
+
+ rm -f "$tmpout"
+
+ if [[ "$rc" -ne 0 ]]; then
+ BATCH_FAIL=$((BATCH_FAIL + 1))
+ GLOBAL_BATCH_FAIL=$((GLOBAL_BATCH_FAIL + 1))
+ fi
+ return 0
+}
+
+run_iscsi_suites() {
+ local entry label tag file
+ for entry in "${ISCSI_SUITES[@]}"; do
+ IFS='|' read -r label tag file <<< "$entry"
+ run_group "$label" "$tag" "$file"
+ done
+}
+
+run_nfs3_suites() {
+ local entry label tag file
+ for entry in "${NFS3_SUITES[@]}"; do
+ IFS='|' read -r label tag file <<< "$entry"
+ run_group "$label" "$tag" "$file"
+ done
+}
+
+run_protocol_batch() {
+ local protocol="$1"
+ local parent_dir="${2:-}"
+
+ init_batch "$protocol" "$parent_dir"
+
+ case "$protocol" in
+ iscsi) run_iscsi_suites ;;
+ nfs3) run_nfs3_suites ;;
+ *)
+ echo "Unknown protocol: $protocol" >&2
+ return 1
+ ;;
+ esac
+
+ finalize_batch
+}
+
+run_single_suite_by_tag() {
+ local want_tag="$1"
+ local entry label tag file
+ for entry in "${ISCSI_SUITES[@]}" "${NFS3_SUITES[@]}"; do
+ IFS='|' read -r label tag file <<< "$entry"
+ if [[ "$tag" == "$want_tag" ]]; then
+ run_group "$label" "$tag" "$file"
+ return 0
+ fi
+ done
+ return 1
+}
+
+write_combined_both_summary() {
+ local both_dir="$1"
+ $PYTHON "$AGGREGATE" \
+ --out-dir "$both_dir" \
+ --summary-tsv "$SUMMARY_FILE" \
+ --meta-json "{\"filter\":\"both\",\"resultsDir\":\"${both_dir}\"}" \
+ --print
+}
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+mkdir -p "$RESULTS_BASE"
+
+case "$FILTER" in
+ all)
+ run_group "Advanced zone setup" "setup_zone" \
+ "${ONTAP_DIR}/zone_setup/test_setup_zone.py"
+
+ BOTH_DIR="${RESULTS_BASE}/$(date +"%Y%m%d-%H%M%S")-both"
+ mkdir -p "$BOTH_DIR"
+ ln -sfn "$(basename "$BOTH_DIR")" "${RESULTS_BASE}/latest-both"
+
+ run_protocol_batch iscsi "$BOTH_DIR"
+ run_protocol_batch nfs3 "$BOTH_DIR"
+ write_combined_both_summary "$BOTH_DIR"
+ ;;
+ both)
+ BOTH_DIR="${RESULTS_BASE}/$(date +"%Y%m%d-%H%M%S")-both"
+ mkdir -p "$BOTH_DIR"
+ ln -sfn "$(basename "$BOTH_DIR")" "${RESULTS_BASE}/latest-both"
+
+ run_protocol_batch iscsi "$BOTH_DIR"
+ run_protocol_batch nfs3 "$BOTH_DIR"
+ write_combined_both_summary "$BOTH_DIR"
+ ;;
+ iscsi)
+ run_protocol_batch iscsi
+ ;;
+ nfs3)
+ run_protocol_batch nfs3
+ ;;
+ setup_zone)
+ run_group "Advanced zone setup" "setup_zone" \
+ "${ONTAP_DIR}/zone_setup/test_setup_zone.py"
+ print_final_summary
+ ;;
+ cleanup_zone)
+ run_group "Advanced zone cleanup" "cleanup_zone" \
+ "${ONTAP_DIR}/zone_setup/test_cleanup_zone.py"
+ print_final_summary
+ ;;
+ *)
+ if run_single_suite_by_tag "$FILTER"; then
+ print_final_summary
+ else
+ echo "Unknown filter: $FILTER" >&2
+ echo "Use: all | both | iscsi | nfs3 | setup_zone | cleanup_zone | " >&2
+ exit 1
+ fi
+ ;;
+esac
+
+echo ""
+echo "================================================================"
+echo " GRAND TOTAL: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped"
+echo "================================================================"
+
+[[ "$FAIL" -eq 0 && "$GLOBAL_BATCH_FAIL" -eq 0 ]]
diff --git a/test/integration/plugins/ontap/setup_env.sh b/test/integration/plugins/ontap/setup_env.sh
new file mode 100755
index 000000000000..a6a9002c9038
--- /dev/null
+++ b/test/integration/plugins/ontap/setup_env.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+# 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.
+
+# One-time (or repeat) setup for ONTAP Marvin integration tests.
+#
+# Creates a local venv, generates Marvin API bindings from apidoc,
+# and installs Marvin + dependencies.
+#
+# Usage (from repo root):
+# bash test/integration/plugins/ontap/setup_env.sh
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
+VENV="${REPO_ROOT}/test/integration/plugins/ontap/.venv"
+COMMANDS_XML="${REPO_ROOT}/tools/apidoc/target/commands.xml"
+MARVIN_API="${REPO_ROOT}/tools/marvin/marvin/cloudstackAPI"
+
+echo "==> Repo root: ${REPO_ROOT}"
+
+if [[ ! -d "${VENV}" ]]; then
+ echo "==> Creating Python venv at ${VENV}"
+ python3 -m venv "${VENV}"
+fi
+
+PIP="${VENV}/bin/pip"
+PYTHON="${VENV}/bin/python"
+
+echo "==> Upgrading pip / setuptools / wheel"
+"${PIP}" install --upgrade pip wheel
+
+# nose discovers Marvin via setuptools entry points (needs pkg_resources).
+"${PIP}" install "setuptools>=40.3.0,<81"
+
+if [[ ! -f "${COMMANDS_XML}" ]]; then
+ echo "==> Building apidoc (generates commands.xml) — first run may take ~2 min"
+ (cd "${REPO_ROOT}/tools" && mvn -pl apidoc -am package -DskipTests -q)
+fi
+
+if [[ ! -d "${MARVIN_API}" ]]; then
+ echo "==> Generating Marvin cloudstackAPI from commands.xml"
+ (cd "${REPO_ROOT}/tools/marvin/marvin" && \
+ "${PYTHON}" codegenerator.py -s "${COMMANDS_XML}")
+fi
+
+echo "==> Installing Marvin (--no-compile avoids broken retries package bytecode)"
+"${PIP}" install --no-compile "${REPO_ROOT}/tools/marvin"
+
+echo "==> Pinning pyvmomi for Python 3.9 compatibility (pyvmomi 9.x requires 3.10+)"
+"${PIP}" install "pyvmomi==8.0.2.0.1"
+
+echo ""
+echo "==> Verifying installation"
+"${PYTHON}" -c "import marvin; print('Marvin OK')"
+"${PYTHON}" -m nose -p 2>&1 | grep -q "Plugin marvin" && echo "Marvin nose plugin OK"
+
+echo ""
+echo "Done. Activate the venv with:"
+echo " source test/integration/plugins/ontap/.venv/bin/activate"
+echo ""
+echo "Run zone setup tests:"
+echo " bash test/integration/plugins/ontap/run_tests.sh setup_zone"
+echo ""
+echo "Or run manually:"
+echo " test/integration/plugins/ontap/.venv/bin/python -m nose --with-marvin \\"
+echo " --marvin-config=test/integration/plugins/ontap/ontap.cfg \\"
+echo " test/integration/plugins/ontap/zone_setup/test_setup_zone.py \\"
+echo " -a \"tags=setup_zone\" -v"
diff --git a/test/integration/plugins/ontap/zone_setup/__init__.py b/test/integration/plugins/ontap/zone_setup/__init__.py
new file mode 100644
index 000000000000..13a83393a912
--- /dev/null
+++ b/test/integration/plugins/ontap/zone_setup/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/test/integration/plugins/ontap/zone_setup/test_cleanup_zone.py b/test/integration/plugins/ontap/zone_setup/test_cleanup_zone.py
new file mode 100644
index 000000000000..e901c23685f5
--- /dev/null
+++ b/test/integration/plugins/ontap/zone_setup/test_cleanup_zone.py
@@ -0,0 +1,898 @@
+# 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.
+
+"""
+Full teardown of the Advanced zone created by setup_zone.
+
+Destroys user VMs, all primary storage pools (NFS + ONTAP), secondary
+storage, guest networks, hosts, cluster, pod, physical network, and
+the zone itself. Each step is idempotent (skips when the resource is
+already gone).
+
+Tag: cleanup_zone
+
+Usage (from cloudstack root):
+ bash test/integration/plugins/ontap/run_tests.sh cleanup_zone
+
+Warning: destructive — not run as part of ``run_tests.sh all``.
+"""
+
+import logging
+import time
+import unittest
+from urllib.parse import urlparse
+
+from nose.plugins.attrib import attr
+
+from marvin.cloudstackAPI import (
+ cancelStorageMaintenance as cancelStorageMaintenanceAPI,
+ deleteCluster as deleteClusterAPI,
+ deleteHost as deleteHostAPI,
+ deleteImageStore as deleteImageStoreAPI,
+ deleteNetwork as deleteNetworkAPI,
+ deletePhysicalNetwork as deletePhysicalNetworkAPI,
+ deletePod as deletePodAPI,
+ deleteStoragePool as deleteStoragePoolAPI,
+ deleteVlanIpRange as deleteVlanIpRangeAPI,
+ deleteVolume as deleteVolumeAPI,
+ deleteZone as deleteZoneAPI,
+ destroyRouter as destroyRouterAPI,
+ destroySystemVm as destroySystemVmAPI,
+ destroyVirtualMachine as destroyVirtualMachineAPI,
+ destroyVolume as destroyVolumeAPI,
+ enableStorageMaintenance as enableStorageMaintenanceAPI,
+ listClusters as listClustersAPI,
+ listHosts as listHostsAPI,
+ listImageStores as listImageStoresAPI,
+ listNetworks as listNetworksAPI,
+ listPhysicalNetworks as listPhysicalNetworksAPI,
+ listPods as listPodsAPI,
+ listPublicIpAddresses as listPublicIpAddressesAPI,
+ listRouters as listRoutersAPI,
+ listStoragePools as listStoragePoolsAPI,
+ listSystemVms as listSystemVmsAPI,
+ listVirtualMachines as listVirtualMachinesAPI,
+ listVolumes as listVolumesAPI,
+ listVlanIpRanges as listVlanIpRangesAPI,
+ releaseIpAddress as releaseIpAddressAPI,
+ stopVirtualMachine as stopVirtualMachineAPI,
+ updatePhysicalNetwork as updatePhysicalNetworkAPI,
+ updateStoragePool as updateStoragePoolAPI,
+ updateZone as updateZoneAPI,
+)
+from marvin.cloudstackException import CloudstackAPIException
+from marvin.cloudstackTestCase import cloudstackTestCase
+from marvin.codes import FAILED
+from marvin.jsonHelper import jsonDump
+from marvin.lib.common import get_zone, list_storage_pools
+from marvin.sshClient import SshClient
+
+from ontap_test_base import OntapRestClient, enable_live_logging
+
+logger = logging.getLogger("TestAdvancedZoneCleanup")
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _list_all_zone_volumes(api_client, zone_id):
+ """List user and system VM volumes in the zone (deduped by id)."""
+ seen = {}
+ for list_system in (False, True):
+ cmd = listVolumesAPI.listVolumesCmd()
+ cmd.zoneid = zone_id
+ cmd.listall = True
+ if list_system:
+ cmd.listsystemvms = True
+ for vol in api_client.listVolumes(cmd) or []:
+ seen[vol.id] = vol
+ return list(seen.values())
+
+
+def _purge_volume(api_client, vol):
+ """Delete or expunge a single volume."""
+ state = (getattr(vol, "state", "") or "").lower()
+ if state in ("expunged",):
+ return
+ if state in ("destroy", "destroyed", "expunging"):
+ dc = destroyVolumeAPI.destroyVolumeCmd()
+ dc.id = vol.id
+ dc.expunge = True
+ api_client.destroyVolume(dc)
+ else:
+ try:
+ dc = deleteVolumeAPI.deleteVolumeCmd()
+ dc.id = vol.id
+ api_client.deleteVolume(dc)
+ except CloudstackAPIException:
+ dc = destroyVolumeAPI.destroyVolumeCmd()
+ dc.id = vol.id
+ dc.expunge = True
+ api_client.destroyVolume(dc)
+ logger.info(
+ "Purged volume %s (%s) state=%s."
+ % (vol.id, getattr(vol, "name", ""), state)
+ )
+
+
+def _purge_all_zone_volumes(api_client, zone_id):
+ """Remove every volume CloudStack still tracks for the zone."""
+ volumes = _list_all_zone_volumes(api_client, zone_id)
+ for vol in volumes:
+ try:
+ _purge_volume(api_client, vol)
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not purge volume %s: %s" % (vol.id, ex)
+ )
+ remaining = _list_all_zone_volumes(api_client, zone_id)
+ return remaining
+
+
+def _parse_kvm_ssh_creds(config):
+ """Return SSH credential dicts for KVM hosts from ontap.cfg zones block."""
+ creds = []
+ for zone in config.get("zones", []):
+ for pod in zone.get("pods", []):
+ for cluster in pod.get("clusters", []):
+ for host_cfg in cluster.get("hosts", []):
+ host_ip = urlparse(host_cfg.get("url", "")).hostname or ""
+ if host_ip:
+ creds.append({
+ "host": host_ip,
+ "user": host_cfg.get("username", "root"),
+ "password": host_cfg.get("password", ""),
+ })
+ return creds
+
+
+def _cleanup_kvm_storage_pool_mounts(pool_uuid, kvm_creds):
+ """Unmount and undefine libvirt NFS pool on each KVM host."""
+ for creds in kvm_creds:
+ host_ip = creds["host"]
+ try:
+ ssh = SshClient(
+ host_ip, 22,
+ creds["user"], creds["password"],
+ retries=3, delay=3, timeout=15.0,
+ )
+ for cmd in [
+ "umount -f -l /mnt/{u} 2>/dev/null; true".format(u=pool_uuid),
+ "virsh pool-destroy {u} 2>/dev/null; true".format(u=pool_uuid),
+ "virsh pool-undefine {u} 2>/dev/null; true".format(u=pool_uuid),
+ ]:
+ try:
+ ssh.execute(cmd)
+ except Exception as cmd_ex:
+ logger.warning(
+ "KVM cleanup cmd '%s' failed on %s: %s"
+ % (cmd, host_ip, cmd_ex)
+ )
+ except Exception as ex:
+ logger.warning("KVM cleanup SSH to %s failed: %s" % (host_ip, ex))
+
+
+def _pool_provider(pool):
+ return (getattr(pool, "provider", "") or "").upper()
+
+
+def _is_ontap_pool(pool):
+ return "ONTAP" in _pool_provider(pool)
+
+
+def _list_pools_in_zone(api_client, zone_id):
+ cmd = listStoragePoolsAPI.listStoragePoolsCmd()
+ cmd.zoneid = zone_id
+ return api_client.listStoragePools(cmd) or []
+
+
+def _delete_volumes_on_pool(api_client, pool_id):
+ cmd = listVolumesAPI.listVolumesCmd()
+ cmd.listall = True
+ cmd.storagepoolid = pool_id
+ volumes = api_client.listVolumes(cmd) or []
+ for vol in volumes:
+ try:
+ dc = deleteVolumeAPI.deleteVolumeCmd()
+ dc.id = vol.id
+ api_client.deleteVolume(dc)
+ logger.info("Deleted volume %s on pool %s." % (vol.id, pool_id))
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not delete volume %s on pool %s: %s"
+ % (vol.id, pool_id, ex)
+ )
+
+
+def _force_delete_pool(
+ api_client, pool, ontap_client=None, kvm_creds=None, svm_name=None):
+ """
+ Delete a storage pool: volumes → maintenance → forced delete.
+ Optional ONTAP REST cleanup on failure; optional KVM NFS unmount.
+ """
+ pool_id = pool.id
+ pool_name = pool.name
+ pools = list_storage_pools(api_client, id=pool_id)
+ if not pools:
+ logger.info("Pool '%s' already gone." % pool_name)
+ return True
+
+ pool_state = pools[0].state
+
+ if pool_state == "Maintenance":
+ try:
+ cc = cancelStorageMaintenanceAPI.cancelStorageMaintenanceCmd()
+ cc.id = pool_id
+ api_client.cancelStorageMaintenance(cc)
+ time.sleep(5)
+ except Exception:
+ pass
+ try:
+ ec = updateStoragePoolAPI.updateStoragePoolCmd()
+ ec.id = pool_id
+ ec.enabled = True
+ api_client.updateStoragePool(ec)
+ time.sleep(3)
+ except Exception:
+ pass
+ pools = list_storage_pools(api_client, id=pool_id)
+ if pools:
+ pool_state = pools[0].state
+
+ _delete_volumes_on_pool(api_client, pool_id)
+
+ if pool_state in ("Up", "Disabled"):
+ try:
+ mc = enableStorageMaintenanceAPI.enableStorageMaintenanceCmd()
+ mc.id = pool_id
+ api_client.enableStorageMaintenance(mc)
+ deadline = time.time() + 60
+ while time.time() < deadline:
+ ps = list_storage_pools(api_client, id=pool_id)
+ if ps and ps[0].state == "Maintenance":
+ break
+ time.sleep(5)
+ except Exception as ex:
+ logger.warning(
+ "Could not enter maintenance for pool '%s': %s"
+ % (pool_name, ex)
+ )
+
+ if kvm_creds:
+ _cleanup_kvm_storage_pool_mounts(pool_id, kvm_creds)
+
+ try:
+ dc = deleteStoragePoolAPI.deleteStoragePoolCmd()
+ dc.id = pool_id
+ dc.forced = True
+ api_client.deleteStoragePool(dc)
+ logger.info("Deleted storage pool '%s' (id=%s)." % (pool_name, pool_id))
+ return True
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "deleteStoragePool failed for '%s': %s" % (pool_name, ex)
+ )
+ if ontap_client is not None:
+ try:
+ ontap_client.delete_volume(pool_name)
+ logger.info(
+ "Deleted ONTAP FlexVol '%s' directly." % pool_name
+ )
+ except Exception as oe:
+ logger.warning(
+ "ONTAP FlexVol delete '%s' failed: %s" % (pool_name, oe)
+ )
+ try:
+ ep_name = "cs-%s-%s" % (svm_name or "", pool_name)
+ ontap_client.delete_export_policy(ep_name)
+ logger.info(
+ "Deleted export policy '%s' directly." % ep_name
+ )
+ except Exception:
+ pass
+ pools = list_storage_pools(api_client, id=pool_id)
+ if not pools:
+ return True
+ return False
+
+
+def _stop_and_destroy_vm(api_client, vm):
+ state = (getattr(vm, "state", "") or "").lower()
+ if state == "running":
+ try:
+ sc = stopVirtualMachineAPI.stopVirtualMachineCmd()
+ sc.id = vm.id
+ api_client.stopVirtualMachine(sc)
+ deadline = time.time() + 120
+ while time.time() < deadline:
+ lcmd = listVirtualMachinesAPI.listVirtualMachinesCmd()
+ lcmd.id = vm.id
+ cur = api_client.listVirtualMachines(lcmd) or []
+ if cur and cur[0].state.lower() in ("stopped", "destroyed"):
+ break
+ time.sleep(5)
+ except CloudstackAPIException as ex:
+ logger.warning("Could not stop VM %s: %s" % (vm.id, ex))
+
+ dc = destroyVirtualMachineAPI.destroyVirtualMachineCmd()
+ dc.id = vm.id
+ dc.expunge = True
+ api_client.destroyVirtualMachine(dc)
+ logger.info("Destroyed VM '%s' (id=%s)." % (vm.name, vm.id))
+
+
+def _destroy_system_vms_and_routers(api_client, zone_id):
+ """Destroy SSVM, console proxy, and virtual routers in the zone."""
+ sys_cmd = listSystemVmsAPI.listSystemVmsCmd()
+ sys_cmd.zoneid = zone_id
+ sysvms = api_client.listSystemVms(sys_cmd) or []
+ for svm in sysvms:
+ try:
+ dc = destroySystemVmAPI.destroySystemVmCmd()
+ dc.id = svm.id
+ api_client.destroySystemVm(dc)
+ logger.info(
+ "Destroyed system VM %s type=%s id=%s"
+ % (svm.name, svm.systemvmtype, svm.id)
+ )
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not destroy system VM %s: %s" % (svm.id, ex)
+ )
+
+ router_cmd = listRoutersAPI.listRoutersCmd()
+ router_cmd.zoneid = zone_id
+ router_cmd.listall = True
+ routers = api_client.listRouters(router_cmd) or []
+ for router in routers:
+ try:
+ dc = destroyRouterAPI.destroyRouterCmd()
+ dc.id = router.id
+ api_client.destroyRouter(dc)
+ logger.info("Destroyed router %s id=%s." % (router.name, router.id))
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not destroy router %s: %s" % (router.id, ex)
+ )
+
+
+def _release_zone_public_ips(api_client, zone_id):
+ """Release all allocated public IPs in the zone."""
+ cmd = listPublicIpAddressesAPI.listPublicIpAddressesCmd()
+ cmd.zoneid = zone_id
+ cmd.listall = True
+ cmd.allocatedonly = True
+ ips = api_client.listPublicIpAddresses(cmd) or []
+ for ip in ips:
+ try:
+ rc = releaseIpAddressAPI.releaseIpAddressCmd()
+ rc.id = ip.id
+ api_client.releaseIpAddress(rc)
+ logger.info(
+ "Released public IP %s (id=%s)."
+ % (getattr(ip, "ipaddress", ip.id), ip.id)
+ )
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not release public IP %s: %s" % (ip.id, ex)
+ )
+
+
+# ---------------------------------------------------------------------------
+# Test class
+# ---------------------------------------------------------------------------
+
+@attr(tags=["cleanup_zone"])
+class TestAdvancedZoneCleanup(cloudstackTestCase):
+ """
+ Tears down the full Advanced zone from ontap.cfg.
+ Idempotent: skips steps when resources are already removed.
+ """
+
+ _zone_id = None
+ _zone_name = None
+ _phynet_id = None
+ _pod_id = None
+ _cluster_id = None
+
+ _zcfg = {}
+ _pcfg = {}
+ _ccfg = {}
+ _kvm_creds = []
+ _ontap_client = None
+ _svm_name = None
+
+ @classmethod
+ def setUpClass(cls):
+ enable_live_logging(cls)
+ testclient = super(TestAdvancedZoneCleanup, cls).getClsTestClient()
+ cls.apiClient = testclient.getApiClient()
+
+ if not getattr(cls, "config", None):
+ raise RuntimeError(
+ "Marvin datacenter config not available. Run with:\n"
+ " --marvin-config=test/integration/plugins/ontap/ontap.cfg"
+ )
+ config = jsonDump.dump(cls.config)
+
+ zone_cfgs = config.get("zones", [])
+ if not zone_cfgs:
+ raise unittest.SkipTest("No zones block in ontap.cfg — nothing to clean up.")
+
+ cls._zcfg = zone_cfgs[0]
+ pods = cls._zcfg.get("pods", [])
+ cls._pcfg = pods[0] if pods else {}
+ clusters = cls._pcfg.get("clusters", []) if cls._pcfg else []
+ cls._ccfg = clusters[0] if clusters else {}
+
+ cs_cfg = config.get("cloudstack", {})
+ cls._zone_name = cs_cfg.get("zoneName") or cls._zcfg.get("name")
+ cls._kvm_creds = _parse_kvm_ssh_creds(config)
+
+ ontap_cfg = config.get("ontap", {})
+ if ontap_cfg.get("storageIP"):
+ cls._ontap_client = OntapRestClient(
+ ontap_cfg["storageIP"],
+ ontap_cfg.get("username", "admin"),
+ ontap_cfg.get("password", ""),
+ )
+ cls._svm_name = ontap_cfg.get("svmName", "")
+
+ existing = get_zone(cls.apiClient, zone_name=cls._zone_name)
+ if not existing or existing == FAILED:
+ raise unittest.SkipTest(
+ "Zone '%s' not found — nothing to clean up." % cls._zone_name
+ )
+
+ cls._zone_id = existing.id
+ cls._resolve_resources()
+
+ @classmethod
+ def _resolve_resources(cls):
+ zone_id = cls._zone_id
+ pod_name = cls._pcfg.get("name")
+ cluster_name = cls._ccfg.get("clustername")
+
+ pod_cmd = listPodsAPI.listPodsCmd()
+ pod_cmd.zoneid = zone_id
+ for pod in cls.apiClient.listPods(pod_cmd) or []:
+ if not pod_name or pod.name == pod_name:
+ cls._pod_id = pod.id
+ break
+
+ cluster_cmd = listClustersAPI.listClustersCmd()
+ cluster_cmd.zoneid = zone_id
+ if cls._pod_id:
+ cluster_cmd.podid = cls._pod_id
+ for cluster in cls.apiClient.listClusters(cluster_cmd) or []:
+ if not cluster_name or cluster.name == cluster_name:
+ cls._cluster_id = cluster.id
+ break
+
+ pnet_cmd = listPhysicalNetworksAPI.listPhysicalNetworksCmd()
+ pnet_cmd.zoneid = zone_id
+ pnets = cls.apiClient.listPhysicalNetworks(pnet_cmd) or []
+ if pnets:
+ cls._phynet_id = pnets[0].id
+
+ def setUp(self):
+ pass
+
+ # -----------------------------------------------------------------------
+ # Step 01 – disable zone
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_01_disable_zone(self):
+ """Disable the zone before removing resources."""
+ zone_id = self.__class__._zone_id
+ cmd = updateZoneAPI.updateZoneCmd()
+ cmd.id = zone_id
+ cmd.allocationstate = "Disabled"
+ try:
+ ret = self.apiClient.updateZone(cmd)
+ except CloudstackAPIException as ex:
+ if "disabled" in str(ex).lower():
+ logger.info("Zone already disabled.")
+ return
+ raise
+ self.assertIsNotNone(ret)
+ logger.info("Zone id=%s disabled." % zone_id)
+
+ # -----------------------------------------------------------------------
+ # Step 02 – destroy user VMs
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_02_destroy_user_vms(self):
+ """Destroy all non-system user VMs in the zone."""
+ zone_id = self.__class__._zone_id
+ cmd = listVirtualMachinesAPI.listVirtualMachinesCmd()
+ cmd.zoneid = zone_id
+ cmd.listall = True
+ vms = self.apiClient.listVirtualMachines(cmd) or []
+
+ user_vms = [
+ vm for vm in vms
+ if (getattr(vm, "account", "") or "").lower() != "system"
+ and getattr(vm, "state", "").lower()
+ not in ("destroyed", "expunging", "error")
+ ]
+ if not user_vms:
+ logger.info("No user VMs to destroy in zone.")
+ return
+
+ for vm in user_vms:
+ try:
+ _stop_and_destroy_vm(self.apiClient, vm)
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not destroy VM %s (%s): %s"
+ % (vm.id, vm.name, ex)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 02b – destroy system VMs and routers
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_02_system_vms_destroy(self):
+ """Destroy system VMs (SSVM, console proxy) and virtual routers."""
+ _destroy_system_vms_and_routers(
+ self.apiClient, self.__class__._zone_id
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 03 – delete volumes
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_03_delete_volumes(self):
+ """Delete remaining volumes in the zone (user + system VM volumes)."""
+ remaining = _purge_all_zone_volumes(
+ self.apiClient, self.__class__._zone_id
+ )
+ if remaining:
+ logger.warning(
+ "%d volume(s) still present after purge." % len(remaining)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 04 – delete primary storage (NFS / non-ONTAP)
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_04_delete_primary_storage(self):
+ """Delete NFS primary pools from config and any remaining non-ONTAP pools."""
+ zone_id = self.__class__._zone_id
+ all_pools = _list_pools_in_zone(self.apiClient, zone_id)
+ targets = [p for p in all_pools if not _is_ontap_pool(p)]
+
+ if not targets:
+ logger.info("No primary (non-ONTAP) storage pools to delete.")
+ return
+
+ for pool in targets:
+ _force_delete_pool(
+ self.apiClient, pool,
+ kvm_creds=self.__class__._kvm_creds,
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 05 – delete ONTAP pools
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_05_delete_ontap_pools(self):
+ """Delete NetApp ONTAP primary pools left by integration tests."""
+ zone_id = self.__class__._zone_id
+ ontap_pools = [
+ p for p in _list_pools_in_zone(self.apiClient, zone_id)
+ if _is_ontap_pool(p)
+ ]
+ if not ontap_pools:
+ logger.info("No ONTAP storage pools to delete.")
+ return
+
+ for pool in ontap_pools:
+ _force_delete_pool(
+ self.apiClient, pool,
+ ontap_client=self.__class__._ontap_client,
+ kvm_creds=self.__class__._kvm_creds,
+ svm_name=self.__class__._svm_name,
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 06 – delete secondary storage
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_06_delete_secondary_storage(self):
+ """Delete all secondary/image stores in the zone."""
+ zone_id = self.__class__._zone_id
+ cmd = listImageStoresAPI.listImageStoresCmd()
+ cmd.zoneid = zone_id
+ stores = self.apiClient.listImageStores(cmd) or []
+ if not stores:
+ logger.info("No image stores to delete in zone.")
+ return
+
+ for store in stores:
+ store_url = getattr(store, "url", "") or ""
+ try:
+ dc = deleteImageStoreAPI.deleteImageStoreCmd()
+ dc.id = store.id
+ self.apiClient.deleteImageStore(dc)
+ logger.info(
+ "Deleted image store '%s' (id=%s)." % (store_url, store.id)
+ )
+ except CloudstackAPIException as ex:
+ if "not found" in str(ex).lower():
+ logger.info("Image store already gone.")
+ else:
+ logger.warning(
+ "Could not delete image store %s: %s"
+ % (store.id, ex)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 07 – delete guest networks
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_07_delete_guest_networks(self):
+ """Delete isolated/guest networks (skip system networks)."""
+ zone_id = self.__class__._zone_id
+ cmd = listNetworksAPI.listNetworksCmd()
+ cmd.zoneid = zone_id
+ cmd.listall = True
+ networks = self.apiClient.listNetworks(cmd) or []
+
+ skip_types = frozenset({"system", "shared", "l2vlan"})
+ for net in networks:
+ net_type = (getattr(net, "type", "") or "").lower()
+ if net_type in skip_types:
+ continue
+ if getattr(net, "issystem", False):
+ continue
+ try:
+ dc = deleteNetworkAPI.deleteNetworkCmd()
+ dc.id = net.id
+ self.apiClient.deleteNetwork(dc)
+ logger.info(
+ "Deleted network '%s' (id=%s)." % (net.name, net.id)
+ )
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not delete network %s: %s" % (net.id, ex)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 08 – delete hosts
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_08_delete_hosts(self):
+ """Remove routing hosts from the zone."""
+ zone_id = self.__class__._zone_id
+ cmd = listHostsAPI.listHostsCmd()
+ cmd.zoneid = zone_id
+ cmd.type = "Routing"
+ hosts = self.apiClient.listHosts(cmd) or []
+ if not hosts:
+ logger.info("No routing hosts to delete.")
+ return
+
+ for host in hosts:
+ try:
+ dc = deleteHostAPI.deleteHostCmd()
+ dc.id = host.id
+ dc.forced = True
+ self.apiClient.deleteHost(dc)
+ logger.info("Deleted host '%s' (id=%s)." % (host.name, host.id))
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not delete host %s: %s" % (host.id, ex)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 09 – delete cluster
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_09_delete_cluster(self):
+ """Delete the cluster from config."""
+ zone_id = self.__class__._zone_id
+ cluster_id = self.__class__._cluster_id
+ cluster_name = self.__class__._ccfg.get("clustername")
+
+ if not cluster_id and cluster_name:
+ cmd = listClustersAPI.listClustersCmd()
+ cmd.zoneid = zone_id
+ for c in self.apiClient.listClusters(cmd) or []:
+ if c.name == cluster_name:
+ cluster_id = c.id
+ break
+
+ if not cluster_id:
+ logger.info("No cluster to delete.")
+ return
+
+ try:
+ dc = deleteClusterAPI.deleteClusterCmd()
+ dc.id = cluster_id
+ self.apiClient.deleteCluster(dc)
+ logger.info("Deleted cluster id=%s." % cluster_id)
+ except CloudstackAPIException as ex:
+ if "not found" in str(ex).lower():
+ logger.info("Cluster already gone.")
+ else:
+ logger.warning("Could not delete cluster %s: %s" % (cluster_id, ex))
+
+ # -----------------------------------------------------------------------
+ # Step 10 – delete pod
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_10_delete_pod(self):
+ """Delete the pod from config."""
+ zone_id = self.__class__._zone_id
+ pod_id = self.__class__._pod_id
+ pod_name = self.__class__._pcfg.get("name")
+
+ if not pod_id and pod_name:
+ cmd = listPodsAPI.listPodsCmd()
+ cmd.zoneid = zone_id
+ for p in self.apiClient.listPods(cmd) or []:
+ if p.name == pod_name:
+ pod_id = p.id
+ break
+
+ if not pod_id:
+ logger.info("No pod to delete.")
+ return
+
+ try:
+ dc = deletePodAPI.deletePodCmd()
+ dc.id = pod_id
+ self.apiClient.deletePod(dc)
+ logger.info("Deleted pod id=%s." % pod_id)
+ except CloudstackAPIException as ex:
+ if "not found" in str(ex).lower():
+ logger.info("Pod already gone.")
+ else:
+ logger.warning("Could not delete pod %s: %s" % (pod_id, ex))
+
+ # -----------------------------------------------------------------------
+ # Step 10a – release public IPs
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_10a_release_public_ips(self):
+ """Release allocated public IPs before deleting VLAN ranges."""
+ _release_zone_public_ips(self.apiClient, self.__class__._zone_id)
+
+ # -----------------------------------------------------------------------
+ # Step 11 – delete public IP ranges
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_11_delete_public_ip_ranges(self):
+ """Delete VLAN IP ranges on the physical network."""
+ phynet_id = self.__class__._phynet_id
+ if not phynet_id:
+ logger.info("No physical network — skipping IP range deletion.")
+ return
+
+ cmd = listVlanIpRangesAPI.listVlanIpRangesCmd()
+ cmd.physicalnetworkid = phynet_id
+ ranges = self.apiClient.listVlanIpRanges(cmd) or []
+ if not ranges:
+ logger.info("No public IP ranges to delete.")
+ return
+
+ for ipr in ranges:
+ try:
+ dc = deleteVlanIpRangeAPI.deleteVlanIpRangeCmd()
+ dc.id = ipr.id
+ self.apiClient.deleteVlanIpRange(dc)
+ logger.info("Deleted IP range id=%s." % ipr.id)
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not delete IP range %s: %s" % (ipr.id, ex)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 12 – delete physical network
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_12_delete_physical_network(self):
+ """Disable and delete the physical network."""
+ phynet_id = self.__class__._phynet_id
+ if not phynet_id:
+ logger.info("No physical network to delete.")
+ return
+
+ try:
+ up = updatePhysicalNetworkAPI.updatePhysicalNetworkCmd()
+ up.id = phynet_id
+ up.state = "Disabled"
+ self.apiClient.updatePhysicalNetwork(up)
+ except CloudstackAPIException as ex:
+ logger.warning(
+ "Could not disable physical network %s: %s" % (phynet_id, ex)
+ )
+
+ try:
+ dc = deletePhysicalNetworkAPI.deletePhysicalNetworkCmd()
+ dc.id = phynet_id
+ self.apiClient.deletePhysicalNetwork(dc)
+ logger.info("Deleted physical network id=%s." % phynet_id)
+ except CloudstackAPIException as ex:
+ if "not found" in str(ex).lower():
+ logger.info("Physical network already gone.")
+ else:
+ raise
+
+ # -----------------------------------------------------------------------
+ # Step 12b – final volume purge before deleteZone
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_12b_purge_zone_volumes(self):
+ """Expunge any volumes still blocking deleteZone (incl. system VM disks)."""
+ remaining = _purge_all_zone_volumes(
+ self.apiClient, self.__class__._zone_id
+ )
+ if remaining:
+ self.fail(
+ "%d volume(s) still in zone after final purge: %s"
+ % (len(remaining), [v.id for v in remaining])
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 13 – delete zone
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["cleanup_zone"])
+ def test_13_delete_zone(self):
+ """Delete the zone — final step."""
+ zone_id = self.__class__._zone_id
+ zone_name = self.__class__._zone_name
+
+ # Safety net: purge volumes that block deleteZone (e.g. system VM ROOT disks).
+ _purge_all_zone_volumes(self.apiClient, zone_id)
+
+ try:
+ dc = deleteZoneAPI.deleteZoneCmd()
+ dc.id = zone_id
+ self.apiClient.deleteZone(dc)
+ except CloudstackAPIException as ex:
+ self.fail(
+ "deleteZone failed for '%s' (id=%s): %s\n"
+ "Ensure all VMs, pools, hosts, and storage are removed first."
+ % (zone_name, zone_id, ex)
+ )
+
+ remaining = get_zone(self.apiClient, zone_name=zone_name)
+ self.assertTrue(
+ not remaining or remaining == FAILED,
+ "Zone '%s' still exists after deleteZone." % zone_name,
+ )
+ logger.info("Zone '%s' deleted." % zone_name)
diff --git a/test/integration/plugins/ontap/zone_setup/test_setup_zone.py b/test/integration/plugins/ontap/zone_setup/test_setup_zone.py
new file mode 100644
index 000000000000..58f7b69e87ea
--- /dev/null
+++ b/test/integration/plugins/ontap/zone_setup/test_setup_zone.py
@@ -0,0 +1,805 @@
+# 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.
+
+"""
+Advanced zone setup prerequisite for the ONTAP integration test suite.
+
+Run this before any other test group to ensure the CloudStack zone, pod,
+cluster, host, primary storage, and secondary storage all exist. Each
+numbered test method creates one step of the zone hierarchy. After the
+zone is enabled, steps 11–12 wait for both system VMs to reach Running
+and for the configured KVM template to become ready. Creation steps are
+idempotent (skipped when the resource already exists); wait steps always run.
+
+Tag: setup_zone
+
+Usage (from cloudstack root):
+ bash test/integration/plugins/ontap/run_tests.sh setup_zone
+"""
+
+import logging
+import re
+import time
+
+from nose.plugins.attrib import attr
+
+from ontap_test_base import enable_live_logging, log_progress
+
+from marvin.cloudstackAPI import (
+ addCluster as addClusterAPI,
+ addHost as addHostAPI,
+ addImageStore as addImageStoreAPI,
+ addTrafficType as addTrafficTypeAPI,
+ createPhysicalNetwork as createPhysicalNetworkAPI,
+ createPod as createPodAPI,
+ createStoragePool as createStoragePoolAPI,
+ createVlanIpRange as createVlanIpRangeAPI,
+ createZone as createZoneAPI,
+ listClusters as listClustersAPI,
+ listHosts as listHostsAPI,
+ listNetworkServiceProviders as listNetworkServiceProvidersAPI,
+ listPhysicalNetworks as listPhysicalNetworksAPI,
+ listPods as listPodsAPI,
+ listStoragePools as listStoragePoolsAPI,
+ listSystemVms as listSystemVmsAPI,
+ listTemplates as listTemplatesAPI,
+ listVirtualRouterElements as listVirtualRouterElementsAPI,
+ configureVirtualRouterElement as configureVirtualRouterElementAPI,
+ updateNetworkServiceProvider as updateNetworkServiceProviderAPI,
+ updatePhysicalNetwork as updatePhysicalNetworkAPI,
+ updateZone as updateZoneAPI,
+)
+from marvin.cloudstackException import CloudstackAPIException
+from marvin.cloudstackTestCase import cloudstackTestCase
+from marvin.codes import FAILED
+from marvin.jsonHelper import jsonDump
+from marvin.lib.common import get_zone
+
+logger = logging.getLogger("TestAdvancedZoneSetup")
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _normalize_template_name(name):
+ """Collapse naming variants so '(64 bit)' matches CloudStack's '(64-bit)'."""
+ if not name:
+ return ""
+ s = name.lower().strip()
+ s = re.sub(r"\s+", " ", s)
+ s = s.replace("(64 bit)", "(64-bit)")
+ s = s.replace("(64bit)", "(64-bit)")
+ return s
+
+
+def _list_kvm_templates(api_client, zone_id):
+ cmd = listTemplatesAPI.listTemplatesCmd()
+ cmd.templatefilter = "all"
+ cmd.listall = True
+ cmd.zoneid = zone_id
+ templates = api_client.listTemplates(cmd) or []
+ return [
+ t for t in templates
+ if getattr(t, "hypervisor", "").lower() == "kvm"
+ ]
+
+
+def _find_kvm_template(api_client, zone_id, template_name):
+ """Find a KVM template by normalized name (API name filter is exact-only)."""
+ kvm_templates = _list_kvm_templates(api_client, zone_id)
+ target = _normalize_template_name(template_name)
+ for tmpl in kvm_templates:
+ if _normalize_template_name(tmpl.name) == target:
+ return tmpl
+ return None
+
+
+def _wait_for_hosts_up(api_client, zone_id, cluster_id, timeout=120):
+ """Poll listHosts until all routing hosts in the cluster are Up."""
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ cmd = listHostsAPI.listHostsCmd()
+ cmd.zoneid = zone_id
+ cmd.clusterid = cluster_id
+ cmd.type = "Routing"
+ hosts = api_client.listHosts(cmd) or []
+ if hosts and all(h.state == "Up" for h in hosts):
+ logger.info("All %d host(s) in cluster are Up." % len(hosts))
+ return True
+ time.sleep(10)
+ logger.warning(
+ "_wait_for_hosts_up: hosts did not reach Up state within %ds." % timeout
+ )
+ return False
+
+
+def _cluster_has_up_host(api_client, zone_id, cluster_id):
+ cmd = listHostsAPI.listHostsCmd()
+ cmd.zoneid = zone_id
+ cmd.clusterid = cluster_id
+ cmd.type = "Routing"
+ hosts = api_client.listHosts(cmd) or []
+ return any(h.state == "Up" for h in hosts)
+
+
+def _wait_for_system_vms(api_client, zone_id, timeout=3600, interval=60):
+ """Poll listSystemVms until both SSVM and Console Proxy are Running."""
+ start = time.time()
+ deadline = start + timeout
+ attempt = 0
+ log_progress(logger,
+ "info",
+ "Waiting for system VMs (SSVM + Console Proxy) in zone %s "
+ "(timeout=%ds, poll every %ds).",
+ zone_id, timeout, interval,
+ )
+ while time.time() < deadline:
+ attempt += 1
+ elapsed = int(time.time() - start)
+ remaining = max(0, int(deadline - time.time()))
+
+ cmd = listSystemVmsAPI.listSystemVmsCmd()
+ cmd.zoneid = zone_id
+ all_vms = api_client.listSystemVms(cmd) or []
+ running = [v for v in all_vms if v.state == "Running"]
+ summary = ", ".join(
+ "%s/%s=%s" % (v.name, v.systemvmtype, v.state) for v in all_vms
+ ) or "(none yet)"
+
+ log_progress(logger,
+ "info",
+ "System VM poll #%d: %d/%d Running (%d total) "
+ "[elapsed %ds, ~%ds left] — %s",
+ attempt, len(running), 2, len(all_vms), elapsed, remaining, summary,
+ )
+
+ if len(running) >= 2:
+ for vm in running:
+ log_progress(logger,
+ "info",
+ "System VM Running: name=%s type=%s id=%s",
+ vm.name, vm.systemvmtype, vm.id,
+ )
+ return True
+
+ time.sleep(interval)
+
+ log_progress(logger,
+ "error",
+ "System VMs did not reach Running state within %ds.", timeout,
+ )
+ return False
+
+
+def _wait_for_template_ready(
+ api_client, zone_id, template_name, timeout=3600, interval=60):
+ """Poll listTemplates until the named KVM template has isready=True."""
+ start = time.time()
+ deadline = start + timeout
+ attempt = 0
+ log_progress(logger,
+ "info",
+ "Waiting for KVM template '%s' in zone %s "
+ "(timeout=%ds, poll every %ds).",
+ template_name, zone_id, timeout, interval,
+ )
+ while time.time() < deadline:
+ attempt += 1
+ elapsed = int(time.time() - start)
+ remaining = max(0, int(deadline - time.time()))
+
+ tmpl = _find_kvm_template(api_client, zone_id, template_name)
+ if tmpl and getattr(tmpl, "isready", False):
+ log_progress(logger,
+ "info",
+ "Template ready: name=%s id=%s hypervisor=%s "
+ "(configured as '%s', after %ds, %d polls)",
+ tmpl.name, tmpl.id, tmpl.hypervisor,
+ template_name, elapsed, attempt,
+ )
+ return True
+
+ if tmpl:
+ log_progress(logger,
+ "info",
+ "Template poll #%d: matched '%s' (configured '%s') "
+ "but not ready (isready=%s) [elapsed %ds, ~%ds left]",
+ attempt, tmpl.name, template_name,
+ getattr(tmpl, "isready", False),
+ elapsed, remaining,
+ )
+ else:
+ kvm_templates = _list_kvm_templates(api_client, zone_id)
+ kvm_names = [t.name for t in kvm_templates]
+ log_progress(logger,
+ "info",
+ "Template poll #%d: no match for '%s' "
+ "[elapsed %ds, ~%ds left]",
+ attempt, template_name, elapsed, remaining,
+ )
+ if attempt == 1 or attempt % 5 == 0:
+ log_progress(logger,
+ "warning",
+ "Configured template '%s' not matched. "
+ "KVM templates in zone: %s",
+ template_name,
+ kvm_names if kvm_names else "(none listed)",
+ )
+
+ time.sleep(interval)
+
+ log_progress(logger,
+ "error",
+ "Template '%s' not ready within %ds.", template_name, timeout,
+ )
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Test class
+# ---------------------------------------------------------------------------
+
+@attr(tags=["setup_zone"])
+class TestAdvancedZoneSetup(cloudstackTestCase):
+ """
+ Creates a full Advanced-zone infrastructure from ontap.cfg.
+ Creation steps are idempotent (skipped when resources already exist).
+ Wait steps (11–12) always run to verify system VMs and template readiness.
+ """
+
+ # Class-level state shared across numbered test methods
+ _zone_exists = False
+ _zone_id = None
+ _phynet_id = None
+ _pod_id = None
+ _cluster_id = None
+
+ # Raw config dicts read from ontap.cfg
+ _zcfg = {} # zones[0]
+ _pcfg = {} # zones[0].pods[0]
+ _ccfg = {} # zones[0].pods[0].clusters[0]
+ _cs_cfg = {} # cloudstack section
+ _template_name = "CentOS 5.5(64-bit) no GUI (KVM)"
+ _system_vm_timeout = 3600
+ _template_ready_timeout = 3600
+ _poll_interval = 60
+
+ @classmethod
+ def setUpClass(cls):
+ enable_live_logging(cls)
+ testclient = super(TestAdvancedZoneSetup, cls).getClsTestClient()
+ cls.apiClient = testclient.getApiClient()
+
+ # Marvin injects the --marvin-config file as cls.config (parsed JSON).
+ # getParsedTestDataConfig() defaults to test_data.py and does NOT
+ # contain the datacenter zones block from ontap.cfg.
+ if not getattr(cls, "config", None):
+ raise RuntimeError(
+ "Marvin datacenter config not available. Run with:\n"
+ " --marvin-config=test/integration/plugins/ontap/ontap.cfg"
+ )
+ config = jsonDump.dump(cls.config)
+
+ zone_cfgs = config.get("zones", [])
+ if not zone_cfgs:
+ raise RuntimeError(
+ "ontap.cfg is missing a 'zones' entry. "
+ "Add zone creation fields as described in the README."
+ )
+
+ cls._zcfg = zone_cfgs[0]
+ pods = cls._zcfg.get("pods", [])
+ cls._pcfg = pods[0] if pods else {}
+ clusters = cls._pcfg.get("clusters", []) if cls._pcfg else []
+ cls._ccfg = clusters[0] if clusters else {}
+
+ cls._cs_cfg = config.get("cloudstack", {})
+ cls._template_name = cls._cs_cfg.get(
+ "templateName", cls._template_name
+ )
+ cls._system_vm_timeout = cls._cs_cfg.get("systemVmTimeoutSec", 3600)
+ cls._template_ready_timeout = cls._cs_cfg.get(
+ "templateReadyTimeoutSec", 3600
+ )
+ cls._poll_interval = cls._cs_cfg.get("pollIntervalSec", 60)
+
+ zone_name = cls._zcfg.get("name")
+ required = {"name", "networktype", "dns1", "internaldns1"}
+ missing = required - cls._zcfg.keys()
+ if missing:
+ raise RuntimeError(
+ "ontap.cfg zones[0] is missing required creation fields: %s"
+ % sorted(missing)
+ )
+
+ existing = get_zone(cls.apiClient, zone_name=zone_name)
+ if existing and existing != FAILED:
+ logger.info(
+ "Zone '%s' already exists (id=%s) — skipping test_01 only."
+ % (zone_name, existing.id)
+ )
+ cls._zone_exists = True
+ cls._zone_id = existing.id
+ cls._resolve_existing_resources()
+
+ @classmethod
+ def _resolve_existing_resources(cls):
+ """Populate pod/cluster/phynet ids when re-running against an existing zone."""
+ zone_id = cls._zone_id
+ pod_name = cls._pcfg.get("name")
+ cluster_name = cls._ccfg.get("clustername")
+
+ pod_cmd = listPodsAPI.listPodsCmd()
+ pod_cmd.zoneid = zone_id
+ pods = cls.apiClient.listPods(pod_cmd) or []
+ for pod in pods:
+ if not pod_name or pod.name == pod_name:
+ cls._pod_id = pod.id
+ break
+
+ cluster_cmd = listClustersAPI.listClustersCmd()
+ cluster_cmd.zoneid = zone_id
+ if cls._pod_id:
+ cluster_cmd.podid = cls._pod_id
+ clusters = cls.apiClient.listClusters(cluster_cmd) or []
+ for cluster in clusters:
+ if not cluster_name or cluster.name == cluster_name:
+ cls._cluster_id = cluster.id
+ break
+
+ pnet_cmd = listPhysicalNetworksAPI.listPhysicalNetworksCmd()
+ pnet_cmd.zoneid = zone_id
+ pnets = cls.apiClient.listPhysicalNetworks(pnet_cmd) or []
+ if pnets:
+ cls._phynet_id = pnets[0].id
+
+ def setUp(self):
+ pass
+
+ # -----------------------------------------------------------------------
+ # Step 1 – zone
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_01_create_zone(self):
+ """Create the Advanced zone."""
+ if self.__class__._zone_exists:
+ self.skipTest(
+ "Zone '%s' already exists (id=%s)."
+ % (self.__class__._zcfg.get("name"), self.__class__._zone_id)
+ )
+
+ zcfg = self.__class__._zcfg
+
+ cmd = createZoneAPI.createZoneCmd()
+ cmd.name = zcfg["name"]
+ cmd.networktype = zcfg["networktype"]
+ cmd.dns1 = zcfg["dns1"]
+ cmd.dns2 = zcfg.get("dns2", "")
+ cmd.internaldns1 = zcfg["internaldns1"]
+ cmd.internaldns2 = zcfg.get("internaldns2", "")
+ cmd.localstorageenabled = zcfg.get("localstorageenabled", False)
+ cmd.guestcidraddress = zcfg.get("guestcidraddress", "10.1.1.0/24")
+
+ zone = self.apiClient.createZone(cmd)
+ self.assertIsNotNone(zone, "createZone returned None")
+ self.assertIsNotNone(zone.id, "Zone id is None after createZone")
+
+ self.__class__._zone_id = zone.id
+ logger.info("Zone '%s' created with id=%s." % (zcfg["name"], zone.id))
+
+ # -----------------------------------------------------------------------
+ # Step 2 – physical network + traffic types
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_02_create_physical_network(self):
+ """Create a single physical network with Guest, Management, and Public traffic types."""
+ if self.__class__._phynet_id:
+ self.skipTest(
+ "Physical network already exists (id=%s)." % self.__class__._phynet_id
+ )
+
+ zone_id = self.__class__._zone_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+
+ cmd = createPhysicalNetworkAPI.createPhysicalNetworkCmd()
+ cmd.zoneid = zone_id
+ cmd.name = "PhysNet1"
+ cmd.isolationmethods = "VLAN"
+
+ phynet = self.apiClient.createPhysicalNetwork(cmd)
+ self.assertIsNotNone(phynet, "createPhysicalNetwork returned None")
+ self.assertIsNotNone(phynet.id, "Physical network id is None")
+
+ pnet_id = phynet.id
+ self.__class__._phynet_id = pnet_id
+ logger.info("Physical network created with id=%s." % pnet_id)
+
+ for traffic_type in ("Guest", "Management", "Public"):
+ tt_cmd = addTrafficTypeAPI.addTrafficTypeCmd()
+ tt_cmd.physicalnetworkid = pnet_id
+ tt_cmd.traffictype = traffic_type
+ ret = self.apiClient.addTrafficType(tt_cmd)
+ self.assertIsNotNone(ret, "addTrafficType returned None for %s" % traffic_type)
+ logger.info("Traffic type '%s' added." % traffic_type)
+
+ # -----------------------------------------------------------------------
+ # Step 3 – configure VR providers + enable physical network
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_03_configure_providers_and_enable_network(self):
+ """Enable VirtualRouter and VpcVirtualRouter providers; set VLAN range; enable network."""
+ pnet_id = self.__class__._phynet_id
+ self.assertIsNotNone(pnet_id, "phynet_id not set — did test_02 pass?")
+
+ vlan_range = self.__class__._zcfg.get("guestVlanRange", "100-300")
+
+ for provider_name in ("VirtualRouter", "VpcVirtualRouter"):
+ list_cmd = listNetworkServiceProvidersAPI.listNetworkServiceProvidersCmd()
+ list_cmd.physicalnetworkid = pnet_id
+ list_cmd.name = provider_name
+ providers = self.apiClient.listNetworkServiceProviders(list_cmd) or []
+
+ if not providers:
+ logger.warning(
+ "Provider '%s' not found on physical network %s — skipping."
+ % (provider_name, pnet_id)
+ )
+ continue
+
+ provider = providers[0]
+
+ # Configure the VirtualRouter element (enable it)
+ vr_cmd = listVirtualRouterElementsAPI.listVirtualRouterElementsCmd()
+ vr_cmd.nspid = provider.id
+ vr_elements = self.apiClient.listVirtualRouterElements(vr_cmd) or []
+ if vr_elements:
+ cfg_cmd = configureVirtualRouterElementAPI.configureVirtualRouterElementCmd()
+ cfg_cmd.id = vr_elements[0].id
+ cfg_cmd.enabled = "true"
+ self.apiClient.configureVirtualRouterElement(cfg_cmd)
+ logger.info("VR element for '%s' configured." % provider_name)
+
+ # Enable the provider
+ upd_cmd = updateNetworkServiceProviderAPI.updateNetworkServiceProviderCmd()
+ upd_cmd.id = provider.id
+ upd_cmd.state = "Enabled"
+ self.apiClient.updateNetworkServiceProvider(upd_cmd)
+ logger.info("Provider '%s' enabled." % provider_name)
+
+ # Enable physical network and set guest VLAN range
+ upnet_cmd = updatePhysicalNetworkAPI.updatePhysicalNetworkCmd()
+ upnet_cmd.id = pnet_id
+ upnet_cmd.state = "Enabled"
+ upnet_cmd.vlan = vlan_range
+ self.apiClient.updatePhysicalNetwork(upnet_cmd)
+ logger.info(
+ "Physical network %s enabled with VLAN range %s." % (pnet_id, vlan_range)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 4 – public IP range
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_04_create_public_ip_range(self):
+ """Create the public traffic IP range."""
+ zone_id = self.__class__._zone_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+
+ ipr = self.__class__._zcfg.get("publicIpRange", {})
+ if not ipr:
+ self.skipTest("No publicIpRange defined in ontap.cfg — skipping.")
+
+ cmd = createVlanIpRangeAPI.createVlanIpRangeCmd()
+ cmd.zoneid = zone_id
+ cmd.gateway = ipr["gateway"]
+ cmd.netmask = ipr["netmask"]
+ cmd.startip = ipr["startip"]
+ cmd.endip = ipr["endip"]
+ cmd.vlan = ipr.get("vlan", "untagged")
+ cmd.forvirtualnetwork = "true"
+
+ try:
+ ret = self.apiClient.createVlanIpRange(cmd)
+ except CloudstackAPIException as ex:
+ if "overlap" in str(ex).lower() or "already" in str(ex).lower():
+ self.skipTest("Public IP range already exists: %s" % ex)
+ raise
+ self.assertIsNotNone(ret, "createVlanIpRange returned None")
+ logger.info(
+ "Public IP range %s–%s created." % (ipr["startip"], ipr["endip"])
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 5 – pod
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_05_create_pod(self):
+ """Create the management pod with reserved system IPs."""
+ if self.__class__._pod_id:
+ self.skipTest("Pod already exists (id=%s)." % self.__class__._pod_id)
+
+ zone_id = self.__class__._zone_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+
+ pcfg = self.__class__._pcfg
+ if not pcfg:
+ self.skipTest("No pod config found in ontap.cfg — skipping.")
+
+ cmd = createPodAPI.createPodCmd()
+ cmd.zoneid = zone_id
+ cmd.name = pcfg["name"]
+ cmd.gateway = pcfg["gateway"]
+ cmd.netmask = pcfg["netmask"]
+ cmd.startip = pcfg["startip"]
+ cmd.endip = pcfg["endip"]
+
+ pod = self.apiClient.createPod(cmd)
+ self.assertIsNotNone(pod, "createPod returned None")
+ self.assertIsNotNone(pod.id, "Pod id is None")
+
+ self.__class__._pod_id = pod.id
+ logger.info("Pod '%s' created with id=%s." % (pcfg["name"], pod.id))
+
+ # -----------------------------------------------------------------------
+ # Step 6 – cluster
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_06_add_cluster(self):
+ """Add the KVM cluster."""
+ if self.__class__._cluster_id:
+ self.skipTest(
+ "Cluster already exists (id=%s)." % self.__class__._cluster_id
+ )
+
+ zone_id = self.__class__._zone_id
+ pod_id = self.__class__._pod_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+ self.assertIsNotNone(pod_id, "pod_id not set — did test_05 pass?")
+
+ ccfg = self.__class__._ccfg
+ if not ccfg:
+ self.skipTest("No cluster config found in ontap.cfg — skipping.")
+
+ cmd = addClusterAPI.addClusterCmd()
+ cmd.zoneid = zone_id
+ cmd.podid = pod_id
+ cmd.clustername = ccfg["clustername"]
+ cmd.clustertype = ccfg.get("clustertype", "CloudManaged")
+ cmd.hypervisor = ccfg.get("hypervisor", "KVM")
+
+ clusters = self.apiClient.addCluster(cmd)
+ self.assertTrue(
+ clusters and len(clusters) > 0, "addCluster returned empty response"
+ )
+ cluster_id = clusters[0].id
+ self.__class__._cluster_id = cluster_id
+ logger.info(
+ "Cluster '%s' added with id=%s." % (ccfg["clustername"], cluster_id)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 7 – host(s)
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_07_add_host(self):
+ """Add KVM host(s) to the cluster and wait for them to come Up."""
+ zone_id = self.__class__._zone_id
+ pod_id = self.__class__._pod_id
+ cluster_id = self.__class__._cluster_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+ self.assertIsNotNone(pod_id, "pod_id not set — did test_05 pass?")
+ self.assertIsNotNone(cluster_id, "cluster_id not set — did test_06 pass?")
+
+ hosts_cfg = self.__class__._ccfg.get("hosts", [])
+ hypervisor = self.__class__._ccfg.get("hypervisor", "KVM")
+
+ if not hosts_cfg:
+ self.skipTest("No host config found in ontap.cfg — skipping.")
+
+ if _cluster_has_up_host(self.apiClient, zone_id, cluster_id):
+ self.skipTest("Cluster already has at least one Up routing host.")
+
+ for hcfg in hosts_cfg:
+ cmd = addHostAPI.addHostCmd()
+ cmd.zoneid = zone_id
+ cmd.podid = pod_id
+ cmd.clusterid = cluster_id
+ cmd.hypervisor = hypervisor
+ cmd.url = hcfg["url"]
+ cmd.username = hcfg["username"]
+ cmd.password = hcfg["password"]
+ if hcfg.get("hosttags"):
+ cmd.hosttags = hcfg["hosttags"]
+
+ try:
+ ret = self.apiClient.addHost(cmd)
+ except CloudstackAPIException as ex:
+ self.skipTest(
+ "addHost failed for %s — verify SSH from the management "
+ "server and host credentials in ontap.cfg: %s"
+ % (hcfg["url"], ex)
+ )
+ self.assertTrue(
+ ret and len(ret) > 0,
+ "addHost returned empty response for %s" % hcfg["url"],
+ )
+ logger.info("Host '%s' added." % hcfg["url"])
+
+ if not _wait_for_hosts_up(self.apiClient, zone_id, cluster_id, timeout=120):
+ self.skipTest(
+ "Host(s) were added but did not reach Up state within 120s."
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 8 – primary storage
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_08_create_primary_storage(self):
+ """Create NFS primary storage pool (cluster-scoped)."""
+ zone_id = self.__class__._zone_id
+ pod_id = self.__class__._pod_id
+ cluster_id = self.__class__._cluster_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+ self.assertIsNotNone(pod_id, "pod_id not set — did test_05 pass?")
+ self.assertIsNotNone(cluster_id, "cluster_id not set — did test_06 pass?")
+
+ primary_storages = self.__class__._ccfg.get("primaryStorages", [])
+ if not primary_storages:
+ self.skipTest("No primaryStorages defined in cluster config — skipping.")
+
+ if not _cluster_has_up_host(self.apiClient, zone_id, cluster_id):
+ self.skipTest(
+ "No Up routing host in cluster — primary storage requires a "
+ "connected KVM host (see test_07_add_host)."
+ )
+
+ for pscfg in primary_storages:
+ pool_cmd = listStoragePoolsAPI.listStoragePoolsCmd()
+ pool_cmd.zoneid = zone_id
+ pool_cmd.name = pscfg["name"]
+ existing = self.apiClient.listStoragePools(pool_cmd) or []
+ if existing:
+ logger.info(
+ "Primary storage '%s' already exists (id=%s) — skipping."
+ % (pscfg["name"], existing[0].id)
+ )
+ continue
+
+ cmd = createStoragePoolAPI.createStoragePoolCmd()
+ cmd.zoneid = zone_id
+ cmd.name = pscfg["name"]
+ cmd.url = pscfg["url"]
+ cmd.scope = pscfg.get("scope", "Cluster")
+ if cmd.scope.lower() == "cluster":
+ cmd.podid = pod_id
+ cmd.clusterid = cluster_id
+ if pscfg.get("tags"):
+ cmd.tags = pscfg["tags"]
+
+ ret = self.apiClient.createStoragePool(cmd)
+ self.assertIsNotNone(ret, "createStoragePool returned None for '%s'" % pscfg["name"])
+ logger.info("Primary storage '%s' created with id=%s." % (pscfg["name"], ret.id))
+
+ # -----------------------------------------------------------------------
+ # Step 9 – secondary storage
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_09_add_secondary_storage(self):
+ """Add NFS secondary storage (image store)."""
+ zone_id = self.__class__._zone_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+
+ secondary_storages = self.__class__._zcfg.get("secondaryStorages", [])
+ if not secondary_storages:
+ self.skipTest("No secondaryStorages defined in zones[0] config — skipping.")
+
+ for sscfg in secondary_storages:
+ cmd = addImageStoreAPI.addImageStoreCmd()
+ cmd.provider = sscfg.get("provider", "NFS")
+ cmd.url = sscfg["url"]
+ cmd.zoneid = zone_id
+
+ try:
+ ret = self.apiClient.addImageStore(cmd)
+ except CloudstackAPIException as ex:
+ if "already exists" in str(ex).lower():
+ logger.info(
+ "Secondary storage '%s' already exists — skipping."
+ % sscfg.get("url")
+ )
+ continue
+ raise
+ self.assertIsNotNone(ret, "addImageStore returned None for '%s'" % sscfg.get("name"))
+ logger.info(
+ "Secondary storage '%s' added with id=%s."
+ % (sscfg.get("name", ret.id), ret.id)
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 10 – enable zone
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_10_enable_zone(self):
+ """Enable the zone (allocationstate=Enabled)."""
+ zone_id = self.__class__._zone_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+
+ cmd = updateZoneAPI.updateZoneCmd()
+ cmd.id = zone_id
+ cmd.allocationstate = "Enabled"
+ ret = self.apiClient.updateZone(cmd)
+
+ self.assertIsNotNone(ret, "updateZone returned None")
+ logger.info(
+ "Zone id=%s enabled (allocationstate=Enabled)." % zone_id
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 11 – wait for system VMs
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_11_wait_for_system_vms(self):
+ """Wait until both system VMs (SSVM + Console Proxy) are Running."""
+ zone_id = self.__class__._zone_id
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+
+ ok = _wait_for_system_vms(
+ self.apiClient,
+ zone_id,
+ timeout=self.__class__._system_vm_timeout,
+ interval=self.__class__._poll_interval,
+ )
+ self.assertTrue(
+ ok,
+ "Both system VMs did not reach Running within %ds."
+ % self.__class__._system_vm_timeout,
+ )
+
+ # -----------------------------------------------------------------------
+ # Step 12 – wait for KVM template
+ # -----------------------------------------------------------------------
+
+ @attr(tags=["setup_zone"])
+ def test_12_wait_for_kvm_template(self):
+ """Wait until the configured KVM template is ready (isready=True)."""
+ zone_id = self.__class__._zone_id
+ template_name = self.__class__._template_name
+ self.assertIsNotNone(zone_id, "zone_id not set — did test_01 pass?")
+
+ ok = _wait_for_template_ready(
+ self.apiClient,
+ zone_id,
+ template_name,
+ timeout=self.__class__._template_ready_timeout,
+ interval=self.__class__._poll_interval,
+ )
+ self.assertTrue(
+ ok,
+ "Template '%s' not ready within %ds."
+ % (template_name, self.__class__._template_ready_timeout),
+ )