diff --git a/.github/workflows/sonar-check.yml b/.github/workflows/sonar-check.yml
index 3fe8c53fecea..dac2a84d05e2 100644
--- a/.github/workflows/sonar-check.yml
+++ b/.github/workflows/sonar-check.yml
@@ -61,6 +61,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/api/src/main/java/com/cloud/storage/Storage.java b/api/src/main/java/com/cloud/storage/Storage.java
index 275f6d25268d..5eea0f35d647 100644
--- a/api/src/main/java/com/cloud/storage/Storage.java
+++ b/api/src/main/java/com/cloud/storage/Storage.java
@@ -185,7 +185,8 @@ public static enum StoragePoolType {
Linstor(true, true, EncryptionSupport.Storage),
DatastoreCluster(true, true, EncryptionSupport.Unsupported), // for VMware, to abstract pool of clusters
StorPool(true, true, EncryptionSupport.Hypervisor),
- FiberChannel(true, true, EncryptionSupport.Unsupported); // Fiber Channel Pool for KVM hypervisors is used to find the volume by WWN value (/dev/disk/by-id/wwn-)
+ FiberChannel(true, true, EncryptionSupport.Unsupported), // Fiber Channel Pool for KVM hypervisors is used to find the volume by WWN value (/dev/disk/by-id/wwn-)
+ OntapiSCSI(true, true, EncryptionSupport.Unsupported); // NetApp ONTAP iSCSI
private final boolean shared;
private final boolean overProvisioning;
diff --git a/api/src/main/java/com/cloud/vm/VmDetailConstants.java b/api/src/main/java/com/cloud/vm/VmDetailConstants.java
index 877df55c6d67..67e5555f1d75 100644
--- a/api/src/main/java/com/cloud/vm/VmDetailConstants.java
+++ b/api/src/main/java/com/cloud/vm/VmDetailConstants.java
@@ -67,6 +67,10 @@ public interface VmDetailConstants {
String CPU_SPEED = "cpuSpeed";
String MEMORY = "memory";
+ // VM deployment with custom root disk offering params
+ String MIN_IOPS = "minIops";
+ String MAX_IOPS = "maxIops";
+
// Misc details for internal usage (not to be set/changed by user or admin)
String CPU_OVER_COMMIT_RATIO = "cpuOvercommitRatio";
String MEMORY_OVER_COMMIT_RATIO = "memoryOvercommitRatio";
diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/CreateStoragePoolCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/CreateStoragePoolCmd.java
index 2aef856f58f3..784b9e77bdb6 100644
--- a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/CreateStoragePoolCmd.java
+++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/CreateStoragePoolCmd.java
@@ -19,8 +19,8 @@
import java.net.UnknownHostException;
import java.util.Map;
-
import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiArgValidator;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ApiErrorCode;
import org.apache.cloudstack.api.BaseCmd;
@@ -80,10 +80,10 @@ public class CreateStoragePoolCmd extends BaseCmd {
@Parameter(name = ApiConstants.MANAGED, type = CommandType.BOOLEAN, required = false, description = "Whether the storage should be managed by CloudStack")
private Boolean managed;
- @Parameter(name = ApiConstants.CAPACITY_IOPS, type = CommandType.LONG, required = false, description = "IOPS CloudStack can provision from this storage pool")
+ @Parameter(name = ApiConstants.CAPACITY_IOPS, type = CommandType.LONG, required = false, description = "IOPS CloudStack can provision from this storage pool", validations = {ApiArgValidator.PositiveNumber})
private Long capacityIops;
- @Parameter(name = ApiConstants.CAPACITY_BYTES, type = CommandType.LONG, required = false, description = "Bytes CloudStack can provision from this storage pool")
+ @Parameter(name = ApiConstants.CAPACITY_BYTES, type = CommandType.LONG, required = false, description = "Bytes CloudStack can provision from this storage pool", validations = {ApiArgValidator.PositiveNumber})
private Long capacityBytes;
@Parameter(name = ApiConstants.HYPERVISOR,
diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java
index 4b0a6ba00b28..31e72cb2ee7a 100644
--- a/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java
+++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/storage/UpdateStoragePoolCmd.java
@@ -19,20 +19,20 @@
import java.util.List;
import java.util.Map;
-import org.apache.cloudstack.api.ApiCommandResourceType;
-
import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiArgValidator;
+import org.apache.cloudstack.api.ApiCommandResourceType;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ApiErrorCode;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.Parameter;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.response.StoragePoolResponse;
+import org.apache.commons.collections.MapUtils;
+import org.apache.commons.lang3.ObjectUtils;
import com.cloud.storage.StoragePool;
import com.cloud.user.Account;
-import org.apache.commons.collections.MapUtils;
-import org.apache.commons.lang3.ObjectUtils;
@SuppressWarnings("rawtypes")
@APICommand(name = "updateStoragePool", description = "Updates a storage pool.", responseObject = StoragePoolResponse.class, since = "3.0.0",
@@ -53,10 +53,11 @@ public class UpdateStoragePoolCmd extends BaseCmd {
@Parameter(name = ApiConstants.TAGS, type = CommandType.LIST, collectionType = CommandType.STRING, description = "Comma-separated list of tags for the storage pool")
private List tags;
- @Parameter(name = ApiConstants.CAPACITY_IOPS, type = CommandType.LONG, required = false, description = "IOPS CloudStack can provision from this storage pool")
+ @Parameter(name = ApiConstants.CAPACITY_IOPS, type = CommandType.LONG, required = false, description = "IOPS CloudStack can provision from this storage pool",
+ validations = {ApiArgValidator.PositiveNumber})
private Long capacityIops;
- @Parameter(name = ApiConstants.CAPACITY_BYTES, type = CommandType.LONG, required = false, description = "Bytes CloudStack can provision from this storage pool")
+ @Parameter(name = ApiConstants.CAPACITY_BYTES, type = CommandType.LONG, required = false, description = "Bytes CloudStack can provision from this storage pool",validations={ApiArgValidator.PositiveNumber})
private Long capacityBytes;
@Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, required = false, description = "False to disable the pool for allocation of new volumes, true to" +
diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ResizeVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ResizeVolumeCmd.java
index f8f744285c04..60f4f308503d 100644
--- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ResizeVolumeCmd.java
+++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ResizeVolumeCmd.java
@@ -15,14 +15,14 @@
// specific language governing permissions and limitations
// under the License.
package org.apache.cloudstack.api.command.user.volume;
-import org.apache.cloudstack.api.BaseAsyncCmd;
-
import org.apache.cloudstack.acl.SecurityChecker.AccessType;
import org.apache.cloudstack.api.ACL;
import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiArgValidator;
import org.apache.cloudstack.api.ApiCommandResourceType;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ApiErrorCode;
+import org.apache.cloudstack.api.BaseAsyncCmd;
import org.apache.cloudstack.api.Parameter;
import org.apache.cloudstack.api.ResponseObject.ResponseView;
import org.apache.cloudstack.api.ServerApiException;
@@ -60,7 +60,7 @@ public class ResizeVolumeCmd extends BaseAsyncCmd implements UserCmd {
@Parameter(name = ApiConstants.MAX_IOPS, type = CommandType.LONG, required = false, description = "New maximum number of IOPS")
private Long maxIops;
- @Parameter(name = ApiConstants.SIZE, type = CommandType.LONG, required = false, description = "New volume size in GB")
+ @Parameter(name = ApiConstants.SIZE, type = CommandType.LONG, required = false, description = "New volume size in GB",validations = {ApiArgValidator.PositiveNumber})
private Long size;
@Parameter(name = ApiConstants.SHRINK_OK, type = CommandType.BOOLEAN, required = false, description = "Verify OK to Shrink")
diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java
index 031d9315cd08..8cde09af1212 100644
--- a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java
+++ b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java
@@ -117,6 +117,10 @@ public class VolumeResponse extends BaseResponseWithTagInformation implements Co
@Param(description = "Max IOPS of the disk volume")
private Long maxIops;
+ @SerializedName("iscustomizediops")
+ @Param(description = "True if the volume's disk offering uses custom IOPS, false otherwise")
+ private Boolean customizedIops;
+
@SerializedName(ApiConstants.CREATED)
@Param(description = "The date the disk volume was created")
private Date created;
@@ -410,6 +414,10 @@ public void setMaxIops(Long maxIops) {
this.maxIops = maxIops;
}
+ public void setCustomizedIops(Boolean customizedIops) {
+ this.customizedIops = customizedIops;
+ }
+
public void setCreated(Date created) {
this.created = created;
}
@@ -712,6 +720,14 @@ public Long getMaxIops() {
return maxIops;
}
+ public Boolean isCustomizedIops() {
+ return customizedIops;
+ }
+
+ public Boolean getCustomizedIops() {
+ return customizedIops;
+ }
+
public Date getCreated() {
return created;
}
diff --git a/api/src/test/java/com/cloud/storage/StorageTest.java b/api/src/test/java/com/cloud/storage/StorageTest.java
index 2bcc28e2b4b6..b9edda569dab 100644
--- a/api/src/test/java/com/cloud/storage/StorageTest.java
+++ b/api/src/test/java/com/cloud/storage/StorageTest.java
@@ -49,6 +49,7 @@ public void isSharedStoragePool() {
Assert.assertTrue(StoragePoolType.ManagedNFS.isShared());
Assert.assertTrue(StoragePoolType.DatastoreCluster.isShared());
Assert.assertTrue(StoragePoolType.Linstor.isShared());
+ Assert.assertTrue(StoragePoolType.OntapiSCSI.isShared());
}
@Test
@@ -73,6 +74,7 @@ public void supportsOverProvisioningTestAllStoragePoolTypes() {
Assert.assertFalse(StoragePoolType.ManagedNFS.supportsOverProvisioning());
Assert.assertTrue(StoragePoolType.DatastoreCluster.supportsOverProvisioning());
Assert.assertTrue(StoragePoolType.Linstor.supportsOverProvisioning());
+ Assert.assertTrue(StoragePoolType.OntapiSCSI.supportsOverProvisioning());
}
@Test
diff --git a/core/src/main/java/com/cloud/storage/template/HttpTemplateDownloader.java b/core/src/main/java/com/cloud/storage/template/HttpTemplateDownloader.java
index 71c329796d1b..4056de31bc15 100755
--- a/core/src/main/java/com/cloud/storage/template/HttpTemplateDownloader.java
+++ b/core/src/main/java/com/cloud/storage/template/HttpTemplateDownloader.java
@@ -251,7 +251,8 @@ public long download(boolean resume, DownloadCompleteCallback callback) {
private boolean copyBytes(File file, InputStream in, RandomAccessFile out) throws IOException {
byte[] buffer = new byte[CHUNK_SIZE];
- long offset = 0;
+ // Absolute file position for subsequent writes, seeks, and format verification.
+ long offset = out.getFilePointer();
VerifyFormat verifyFormat = new VerifyFormat(file);
status = Status.IN_PROGRESS;
while (status != Status.ABORTED) {
diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java
index 9f6d02cc1234..9a06e7285e4d 100644
--- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java
+++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java
@@ -61,14 +61,12 @@
import com.cloud.vm.NicProfile;
import com.cloud.vm.VMInstanceVO;
import com.cloud.vm.VirtualMachineManager;
+import com.cloud.vm.VmDetailConstants;
import com.cloud.vm.VmDiskInfo;
import com.cloud.vm.dao.UserVmDao;
import com.cloud.vm.dao.VMInstanceDetailsDao;
import com.cloud.vm.dao.VMInstanceDao;
-import static org.apache.cloudstack.api.ApiConstants.MAX_IOPS;
-import static org.apache.cloudstack.api.ApiConstants.MIN_IOPS;
-
@Component
public class CloudOrchestrator implements OrchestrationService {
@@ -205,8 +203,8 @@ public VirtualMachineEntity createVirtualMachine(String id, String owner, String
Map userVmDetails = _vmInstanceDetailsDao.listDetailsKeyPairs(vm.getId());
if (userVmDetails != null) {
- String minIops = userVmDetails.get(MIN_IOPS);
- String maxIops = userVmDetails.get(MAX_IOPS);
+ String minIops = userVmDetails.get(VmDetailConstants.MIN_IOPS);
+ String maxIops = userVmDetails.get(VmDetailConstants.MAX_IOPS);
rootDiskOfferingInfo.setMinIops(minIops != null && minIops.trim().length() > 0 ? Long.parseLong(minIops) : null);
rootDiskOfferingInfo.setMaxIops(maxIops != null && maxIops.trim().length() > 0 ? Long.parseLong(maxIops) : null);
diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java
new file mode 100644
index 000000000000..fd7deac04548
--- /dev/null
+++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/CloudOrchestratorTest.java
@@ -0,0 +1,111 @@
+// 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.engine.orchestration;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import com.cloud.hypervisor.Hypervisor.HypervisorType;
+import com.cloud.offering.DiskOfferingInfo;
+import com.cloud.service.ServiceOfferingVO;
+import com.cloud.service.dao.ServiceOfferingDao;
+import com.cloud.storage.DiskOfferingVO;
+import com.cloud.storage.VMTemplateVO;
+import com.cloud.storage.dao.DiskOfferingDao;
+import com.cloud.storage.dao.VMTemplateDao;
+import com.cloud.utils.component.ComponentContext;
+import com.cloud.vm.VMInstanceVO;
+import com.cloud.vm.VirtualMachineManager;
+import com.cloud.vm.VmDetailConstants;
+import com.cloud.vm.dao.VMInstanceDao;
+import com.cloud.vm.dao.VMInstanceDetailsDao;
+import org.apache.cloudstack.engine.cloud.entity.api.VirtualMachineEntityImpl;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class CloudOrchestratorTest {
+
+ private static final long VM_ID = 1L;
+ private static final long SERVICE_OFFERING_ID = 2L;
+ private static final long ROOT_DISK_OFFERING_ID = 3L;
+ private static final String TEMPLATE_ID = "4";
+
+ @InjectMocks
+ private CloudOrchestrator cloudOrchestrator = new CloudOrchestrator();
+
+ @Mock
+ private VirtualMachineManager _itMgr;
+ @Mock
+ private VMTemplateDao _templateDao;
+ @Mock
+ private VMInstanceDao _vmDao;
+ @Mock
+ private VMInstanceDetailsDao _vmInstanceDetailsDao;
+ @Mock
+ private ServiceOfferingDao _serviceOfferingDao;
+ @Mock
+ private DiskOfferingDao _diskOfferingDao;
+
+ @Test
+ public void createVirtualMachineSetsCustomIopsFromVmDetails() throws Exception {
+ VMInstanceVO vm = Mockito.mock(VMInstanceVO.class);
+ ServiceOfferingVO serviceOffering = Mockito.mock(ServiceOfferingVO.class);
+ DiskOfferingVO rootDiskOffering = Mockito.mock(DiskOfferingVO.class);
+ VMTemplateVO template = Mockito.mock(VMTemplateVO.class);
+ VirtualMachineEntityImpl vmEntity = Mockito.mock(VirtualMachineEntityImpl.class);
+
+ Mockito.when(_vmDao.findByUuid("vm-uuid")).thenReturn(vm);
+ Mockito.when(vm.getId()).thenReturn(VM_ID);
+ Mockito.when(vm.getServiceOfferingId()).thenReturn(SERVICE_OFFERING_ID);
+ Mockito.when(vm.getInstanceName()).thenReturn("i-1-1-VM");
+ Mockito.when(_serviceOfferingDao.findById(VM_ID, SERVICE_OFFERING_ID)).thenReturn(serviceOffering);
+ Mockito.when(_diskOfferingDao.findById(ROOT_DISK_OFFERING_ID)).thenReturn(rootDiskOffering);
+ Mockito.when(rootDiskOffering.isCustomizedIops()).thenReturn(true);
+ Mockito.when(_templateDao.findById(Long.valueOf(TEMPLATE_ID))).thenReturn(template);
+
+ Map details = new HashMap<>();
+ details.put(VmDetailConstants.MIN_IOPS, "100");
+ details.put(VmDetailConstants.MAX_IOPS, "1000");
+ Mockito.when(_vmInstanceDetailsDao.listDetailsKeyPairs(VM_ID)).thenReturn(details);
+
+ try (MockedStatic componentContext = Mockito.mockStatic(ComponentContext.class)) {
+ componentContext.when(() -> ComponentContext.inject(VirtualMachineEntityImpl.class)).thenReturn(vmEntity);
+
+ cloudOrchestrator.createVirtualMachine("vm-uuid", "owner", TEMPLATE_ID, "host", "display", HypervisorType.KVM.name(),
+ 1, 1000, 1024, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null,
+ null, null, null, null, ROOT_DISK_OFFERING_ID, null, null, null, null);
+ }
+
+ ArgumentCaptor rootDiskOfferingInfo = ArgumentCaptor.forClass(DiskOfferingInfo.class);
+ Mockito.verify(_itMgr).allocate(Mockito.eq("i-1-1-VM"), Mockito.eq(template), Mockito.eq(serviceOffering), rootDiskOfferingInfo.capture(),
+ Mockito.anyList(), Mockito.anyList(), Mockito.any(LinkedHashMap.class), Mockito.isNull(), Mockito.eq(HypervisorType.KVM),
+ Mockito.isNull(), Mockito.isNull(), Mockito.isNull(), Mockito.isNull());
+
+ Assert.assertEquals(Long.valueOf(100), rootDiskOfferingInfo.getValue().getMinIops());
+ Assert.assertEquals(Long.valueOf(1000), rootDiskOfferingInfo.getValue().getMaxIops());
+ }
+}
diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java
index 4cd9a8e23bfc..16b8eb975ac4 100644
--- a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java
+++ b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java
@@ -182,6 +182,13 @@ public interface VolumeDao extends GenericDao, StateDao implements Vol
protected static final String SELECT_HYPERTYPE_FROM_CLUSTER_VOLUME = "SELECT c.hypervisor_type from volumes v, storage_pool s, cluster c where v.pool_id = s.id and s.cluster_id = c.id and v.id = ?";
protected static final String SELECT_HYPERTYPE_FROM_ZONE_VOLUME = "SELECT s.hypervisor from volumes v, storage_pool s where v.pool_id = s.id and v.id = ?";
protected static final String SELECT_POOLSCOPE = "SELECT s.scope from storage_pool s, volumes v where s.id = v.pool_id and v.id = ?";
+ // Looks for a VM whose Ready root disk is on this pool and whose attached Ready data disk is on another pool.
+ // LIMIT 1 stops after the first match so we do not load all volumes.
+ private static final String HAS_MULTI_PRIMARY_STORAGE_POOL_VM =
+ "SELECT 1 FROM volumes root INNER JOIN volumes data ON data.instance_id = root.instance_id "
+ + "WHERE root.pool_id = ? AND root.volume_type = 'ROOT' AND root.instance_id IS NOT NULL "
+ + "AND root.removed IS NULL AND root.state = 'Ready' "
+ + "AND data.volume_type = 'DATADISK' AND data.pool_id IS NOT NULL AND data.pool_id <> ? "
+ + "AND data.removed IS NULL AND data.state = 'Ready' LIMIT 1";
private static final String ORDER_POOLS_NUMBER_OF_VOLUMES_FOR_ACCOUNT_PART1 = "SELECT pool.id, SUM(IF(vol.state='Ready' AND vol.account_id = ?, 1, 0)) FROM `cloud`.`storage_pool` pool LEFT JOIN `cloud`.`volumes` vol ON pool.id = vol.pool_id WHERE pool.data_center_id = ? ";
private static final String ORDER_POOLS_NUMBER_OF_VOLUMES_FOR_ACCOUNT_PART2 = " GROUP BY pool.id ORDER BY 2 ASC ";
@@ -998,6 +1006,21 @@ public boolean existsWithKmsKey(long kmsKeyId) {
return findOneBy(sc) != null;
}
+ @Override
+ @DB
+ public boolean hasMultiPrimaryStoragePoolVm(long poolId) {
+ TransactionLegacy txn = TransactionLegacy.currentTxn();
+ try (PreparedStatement pstmt = txn.prepareAutoCloseStatement(HAS_MULTI_PRIMARY_STORAGE_POOL_VM)) {
+ pstmt.setLong(1, poolId);
+ pstmt.setLong(2, poolId);
+ try (ResultSet rs = pstmt.executeQuery()) {
+ return rs.next();
+ }
+ } catch (SQLException e) {
+ throw new CloudRuntimeException("DB Exception on: " + HAS_MULTI_PRIMARY_STORAGE_POOL_VM, e);
+ }
+ }
+
public VolumeVO findByExternalUuid(String externalUuid) {
SearchCriteria sc = ExternalUuidSearch.create();
sc.setParameters("externalUuid", externalUuid);
diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql
index 8f59321627db..0141c90d45ad 100644
--- a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql
+++ b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql
@@ -226,3 +226,25 @@ CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group_details` (
PRIMARY KEY (`id`),
CONSTRAINT `fk_instance_boot_group_details__group_id` FOREIGN KEY (`boot_group_id`) REFERENCES `instance_boot_group` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Re-tag NetApp ONTAP iSCSI storage pools to the dedicated 'OntapiSCSI' pool type.
+UPDATE `cloud`.`storage_pool`
+SET `pool_type` = 'OntapiSCSI'
+WHERE `storage_provider_name` = 'NetApp ONTAP'
+ AND `pool_type` = 'Iscsi';
+
+-- The denormalized copy on every volume that lives on such a pool.
+UPDATE `cloud`.`volumes` v
+ JOIN `cloud`.`storage_pool` sp ON sp.`id` = v.`pool_id`
+ SET v.`pool_type` = 'OntapiSCSI'
+WHERE sp.`storage_provider_name` = 'NetApp ONTAP'
+ AND sp.`pool_type` IN ('Iscsi', 'OntapiSCSI')
+ AND v.`pool_type` = 'Iscsi';
+
+-- Volume format realignment.
+UPDATE `cloud`.`volumes` v
+ JOIN `cloud`.`storage_pool` sp ON sp.`id` = v.`pool_id`
+ SET v.`format` = 'RAW'
+WHERE sp.`storage_provider_name` = 'NetApp ONTAP'
+ AND sp.`pool_type` IN ('Iscsi', 'OntapiSCSI')
+ AND (v.`format` IS NULL OR v.`format` <> 'RAW');
diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java
index 7674f1ce25a1..dec21c805395 100644
--- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java
+++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java
@@ -616,8 +616,8 @@ private void handleVolumeMigrationFromManagedStorageToNonManagedStorage(VolumeIn
private void verifyFormatWithPoolType(ImageFormat imageFormat, StoragePoolType poolType) {
if (imageFormat != ImageFormat.VHD && imageFormat != ImageFormat.OVA && imageFormat != ImageFormat.QCOW2 &&
!(imageFormat == ImageFormat.RAW && (StoragePoolType.PowerFlex == poolType ||
- StoragePoolType.FiberChannel == poolType))) {
- throw new CloudRuntimeException(String.format("Only the following image types are currently supported: %s, %s, %s, %s (for PowerFlex and FiberChannel)",
+ StoragePoolType.FiberChannel == poolType || StoragePoolType.OntapiSCSI == poolType))) {
+ throw new CloudRuntimeException(String.format("Only the following image types are currently supported: %s, %s, %s, %s (for PowerFlex, FiberChannel and OntapiSCSI)",
ImageFormat.VHD.toString(), ImageFormat.OVA.toString(), ImageFormat.QCOW2.toString(), ImageFormat.RAW.toString()));
}
}
@@ -1787,7 +1787,11 @@ else if (HypervisorType.KVM.equals(snapshotInfo.getHypervisorType())) {
VolumeObjectTO newVolume = new VolumeObjectTO();
newVolume.setSize(volumeInfo.getSize());
- newVolume.setPath(volumeInfo.get_iScsiName());
+ String path = volumeInfo.get_iScsiName();
+ if (path == null) {
+ path = volumeInfo.getPath();
+ }
+ newVolume.setPath(path);
newVolume.setFormat(volumeInfo.getFormat());
copyCmdAnswer = new CopyCmdAnswer(newVolume);
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 4cb1d0361aa9..ea921d4d7a9e 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 com.cloud.utils.StringUtils;
diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java
index 69b40f64af19..df3a7cd7e29b 100644
--- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java
+++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java
@@ -765,7 +765,11 @@ protected Void managedCopyBaseImageCallback(AsyncCallbackDispatcher/device/delete — the standard Linux kernel SCSI
- * API for removing a single device without tearing down the entire iSCSI session.
- * Once the kernel processes the delete, it also removes the by-path symlink.
- *
- * This is used instead of iscsiadm --logout when other LUNs on the same IQN are still
- * active (ONTAP single-IQN-per-SVM model), since logout would tear down ALL LUNs.
- */
- private void removeStaleScsiDevice(String host, int port, String iqn, String lun) {
- String byPath = getByPath(host, port, "/" + iqn + "/" + lun);
- Path byPathLink = Paths.get(byPath);
- if (!Files.exists(byPathLink)) {
- logger.debug("by-path entry for LUN " + lun + " already gone, nothing to remove");
- return;
- }
- try {
- Path realDevice = byPathLink.toRealPath();
- String devName = realDevice.getFileName().toString();
- File deleteFile = new File("/sys/block/" + devName + "/device/delete");
- if (!deleteFile.exists()) {
- logger.warn("sysfs delete entry not found for device " + devName + " — cannot remove stale SCSI device");
- return;
- }
- try (FileWriter fw = new FileWriter(deleteFile)) {
- fw.write("1");
- }
- logger.info("Removed stale SCSI device " + devName + " for LUN /" + iqn + "/" + lun + " via sysfs");
- } catch (Exception e) {
- logger.warn("Failed to remove stale SCSI device for LUN /" + iqn + "/" + lun + ": " + e.getMessage());
- }
- }
-
private boolean disconnectPhysicalDisk(String host, int port, String iqn, String lun) {
- // Check if other LUNs on the same IQN target are still in use.
- // ONTAP (and similar) uses a single IQN per SVM with multiple LUNs.
- // Doing iscsiadm --logout tears down the ENTIRE target session,
- // which would destroy access to ALL LUNs — not just the one being disconnected.
- if (hasOtherActiveLuns(host, port, iqn, lun)) {
- logger.info("Skipping iSCSI logout for /" + iqn + "/" + lun +
- " — other LUNs on the same target are still active. Removing stale SCSI device for this LUN only.");
- removeStaleScsiDevice(host, port, iqn, lun);
- // After removing this LUN's device, re-check: if no other LUNs remain active,
- // If it is the last one then must logout to clean up the iSCSI session entirely.
- if (hasOtherActiveLuns(host, port, iqn, lun)) {
- logger.info("Other LUNs still active after removing /" + iqn + "/" + lun + " — session kept alive.");
- return true;
- }
- logger.info("No more active LUNs on target after removing /" + iqn + "/" + lun + " — proceeding with iSCSI logout.");
- }
-
- // No other LUNs active on this target — safe to logout and delete the node record.
+ // use iscsiadm to log out of the iSCSI target and un-discover it
// ex. sudo iscsiadm -m node -T iqn.2012-03.com.test:volume1 -p 192.168.233.10:3260 --logout
Script iScsiAdmCmd = new Script(true, "iscsiadm", 0, logger);
@@ -536,8 +387,8 @@ public List listPhysicalDisks(String storagePoolUuid, KVMStorag
@Override
public KVMPhysicalDisk createDiskFromTemplate(KVMPhysicalDisk template, String name, PhysicalDiskFormat format,
- ProvisioningType provisioningType, long size,
- KVMStoragePool destPool, int timeout, byte[] passphrase) {
+ ProvisioningType provisioningType, long size,
+ KVMStoragePool destPool, int timeout, byte[] passphrase) {
throw new UnsupportedOperationException("Creating a disk from a template is not yet supported for this configuration.");
}
@@ -571,19 +422,6 @@ public KVMPhysicalDisk copyPhysicalDisk(KVMPhysicalDisk srcDisk, String destVolu
try {
QemuImg q = new QemuImg(timeout);
q.convert(srcFile, destFile);
- // Below fix is required when vendor depends on host based copy rather than storage CAN_CREATE_VOLUME_FROM_VOLUME capability
- // When host based template copy is triggered , small size template sits in RAM(depending on host memory and RAM) and copy is marked successful and by the time flush to storage is triggered
- // disconnectPhysicalDisk would disconnect the lun , hence template staying in RAM is not copied to storage lun. Below does flushing of data to storage and marking
- // copy as successful once flush is complete.
- Script flushCmd = new Script(true, "blockdev", 0, logger);
- flushCmd.add("--flushbufs", destDisk.getPath());
- String flushResult = flushCmd.execute();
- if (flushResult != null) {
- logger.warn("iSCSI copyPhysicalDisk: blockdev --flushbufs returned: {}", flushResult);
- }
- Script syncCmd = new Script(true, "sync", 0, logger);
- syncCmd.execute();
- logger.info("iSCSI copyPhysicalDisk: flush/sync completed ");
} catch (QemuImgException | LibvirtException ex) {
String msg = "Failed to copy data from " + srcDisk.getPath() + " to " +
destDisk.getPath() + ". The error was the following: " + ex.getMessage();
diff --git a/plugins/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..3c1318dbec51 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,13 @@ public synchronized boolean deleteStoragePool(StoragePoolType type, String uuid)
public boolean deleteStoragePool(StoragePoolType type, String uuid, Map details) {
StorageAdaptor adaptor = getStorageAdaptor(type);
+ boolean deleteStatus;
if (type == StoragePoolType.NetworkFilesystem) {
- _haMonitor.removeStoragePool(uuid);
+ _haMonitor.unregisterStoragePool(uuid);
+ deleteStatus = adaptor.deleteStoragePool(uuid);
+ } else {
+ deleteStatus = adaptor.deleteStoragePool(uuid, details);
}
- 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/KVMStorageProcessor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
index 0cb32da318ea..630d02d3c480 100644
--- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
+++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
@@ -421,7 +421,8 @@ public Answer copyTemplateToPrimaryStorage(final CopyCommand cmd) {
StoragePoolType.PowerFlex,
StoragePoolType.Linstor,
StoragePoolType.FiberChannel,
- StoragePoolType.CLVM).contains(primaryPool.getType())) {
+ StoragePoolType.CLVM,
+ StoragePoolType.OntapiSCSI).contains(primaryPool.getType())) {
newTemplate.setFormat(ImageFormat.RAW);
} else {
newTemplate.setFormat(ImageFormat.QCOW2);
@@ -3513,7 +3514,8 @@ private Storage.ImageFormat getFormat(StoragePoolType poolType) {
StoragePoolType.PowerFlex,
StoragePoolType.Linstor,
StoragePoolType.FiberChannel,
- StoragePoolType.CLVM).contains(poolType)) {
+ StoragePoolType.CLVM,
+ StoragePoolType.OntapiSCSI).contains(poolType)) {
return ImageFormat.RAW;
} else {
return ImageFormat.QCOW2;
diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/OntapIscsiStorageAdaptor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/OntapIscsiStorageAdaptor.java
new file mode 100644
index 000000000000..4e07a5023ce0
--- /dev/null
+++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/OntapIscsiStorageAdaptor.java
@@ -0,0 +1,753 @@
+// 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 com.cloud.hypervisor.kvm.storage;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.apache.cloudstack.utils.qemu.QemuImg;
+import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat;
+import org.apache.cloudstack.utils.qemu.QemuImgException;
+import org.apache.cloudstack.utils.qemu.QemuImgFile;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.libvirt.LibvirtException;
+
+import com.cloud.agent.api.to.DiskTO;
+import com.cloud.storage.Storage;
+import com.cloud.storage.Storage.ProvisioningType;
+import com.cloud.storage.Storage.StoragePoolType;
+import com.cloud.utils.exception.CloudRuntimeException;
+import com.cloud.utils.script.OutputInterpreter;
+import com.cloud.utils.script.Script;
+
+/**
+ * Serves {@link StoragePoolType#OntapiSCSI} pools:
+ *
+ * This must stay in the {@code com.cloud.hypervisor.kvm.storage} package: {@link KVMStoragePoolManager}
+ * discovers adaptors by a Reflections scan of that package alone, and an unregistered type silently
+ * falls back to {@link LibvirtStorageAdaptor} rather than failing at startup.
+ */
+public class OntapIscsiStorageAdaptor implements StorageAdaptor {
+
+ protected Logger logger = LogManager.getLogger(getClass());
+ private static final Map MAP_STORAGE_UUID_TO_STORAGE_POOL = new HashMap<>();
+ private static final String BY_ID_WWN_PREFIX = "/dev/disk/by-id/wwn-0x";
+
+ /**
+ * A LUN WWID is NetApp's NAA prefix (type 6, OUI 00a098, vendor nibble 0) followed by the hex of
+ * a 12-character serial: 32 hex digits.
+ * Anchoring on the prefix is what stops this adaptor claiming another vendor's WWN device
+ * when KVMStoragePoolManager.disconnectPhysicalDiskByPath scans every adaptor.
+ */
+ private static final String NETAPP_NAA_PREFIX = "600a0980";
+ private static final Pattern LUN_WWID = Pattern.compile(NETAPP_NAA_PREFIX + "[0-9a-f]{24}");
+
+ private static final String SYS_BLOCK = "/sys/block";
+ private static final String SYS_ISCSI_SESSION = "/sys/class/iscsi_session";
+ private static final String SESSION_DIR_PREFIX = "session";
+
+ /** iscsiadm's ISCSI_ERR_NO_OBJS_FOUND: returned by "-m session" when no session is established. */
+ private static final int ISCSI_ERR_NO_OBJS_FOUND = 21;
+
+ /** iscsiadm's ISCSI_ERR_SESS_EXISTS: returned by "--login" when already logged in (e.g. Ubuntu). */
+ private static final int ISCSI_SESSION_EXISTS_CODE = 15;
+
+ private static final int DEVICE_WAIT_TRIES = 15;
+ private static final int DEVICE_WAIT_INTERVAL_MS = 1000;
+ private static final int DEFAULT_ISCSI_PORT = 3260;
+
+ @Override
+ public StoragePoolType getStoragePoolType() {
+ return StoragePoolType.OntapiSCSI;
+ }
+
+ @Override
+ public KVMStoragePool createStoragePool(String uuid, String host, int port, String path, String userInfo,
+ StoragePoolType storagePoolType, Map details, boolean isPrimaryStorage) {
+ IscsiAdmStoragePool storagePool = new IscsiAdmStoragePool(uuid, host, port, storagePoolType, this);
+
+ MAP_STORAGE_UUID_TO_STORAGE_POOL.put(uuid, storagePool);
+
+ return storagePool;
+ }
+
+ @Override
+ public KVMStoragePool getStoragePool(String uuid) {
+ return MAP_STORAGE_UUID_TO_STORAGE_POOL.get(uuid);
+ }
+
+ @Override
+ public KVMStoragePool getStoragePool(String uuid, boolean refreshInfo) {
+ return MAP_STORAGE_UUID_TO_STORAGE_POOL.get(uuid);
+ }
+
+ @Override
+ public boolean deleteStoragePool(String uuid) {
+ return MAP_STORAGE_UUID_TO_STORAGE_POOL.remove(uuid) != null;
+ }
+
+ @Override
+ public boolean deleteStoragePool(KVMStoragePool pool) {
+ return deleteStoragePool(pool.getUuid());
+ }
+
+ @Override
+ public KVMPhysicalDisk getPhysicalDisk(String volumeUuid, KVMStoragePool pool) {
+ String devicePath = getDeviceById(getLunWwid(volumeUuid));
+
+ KVMPhysicalDisk physicalDisk = new KVMPhysicalDisk(devicePath, volumeUuid, pool);
+ physicalDisk.setFormat(PhysicalDiskFormat.RAW);
+
+ long deviceSize = getDeviceSize(devicePath);
+
+ physicalDisk.setSize(deviceSize);
+ physicalDisk.setVirtualSize(deviceSize);
+
+ return physicalDisk;
+ }
+
+ @Override
+ public boolean connectPhysicalDisk(String volumePath, KVMStoragePool pool, Map details, boolean isVMMigrate) {
+ final String host = pool.getSourceHost();
+ final int port = pool.getSourcePort();
+ final String iqn = getTargetIqn(volumePath);
+
+ if (!createIscsiNode(host, port, iqn, volumePath)) {
+ return false;
+ }
+
+ if (!applyChapCredentials(host, port, iqn, volumePath, details)) {
+ return false;
+ }
+
+ if (!loginOrRescanExistingSession(iqn, host, port, volumePath)) {
+ return false;
+ }
+
+ // Logging in can return before the kernel has added the device, so the disk is not usable
+ // the moment iscsiadm succeeds. Waiting on a non-zero size also guards against reporting
+ // success when the device never appears, which would otherwise let a caller create a plain
+ // file where the LUN device was expected.
+ if (!waitForDeviceToAppear(volumePath, pool)) {
+ logger.warn("iSCSI device for LUN {} on target {} at {}:{} did not become available",
+ getLunWwid(volumePath), iqn, host, port);
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public boolean disconnectPhysicalDisk(String volumePath, KVMStoragePool pool) {
+ return disconnectLun(pool.getSourceHost(), pool.getSourcePort(), getTargetIqn(volumePath), getLunWwid(volumePath));
+ }
+
+ @Override
+ public boolean disconnectPhysicalDisk(Map volumeToDisconnect) {
+ String host = volumeToDisconnect.get(DiskTO.STORAGE_HOST);
+ String port = volumeToDisconnect.get(DiskTO.STORAGE_PORT);
+ String path = volumeToDisconnect.get(DiskTO.IQN);
+
+ if (host == null || port == null || path == null) {
+ return false;
+ }
+
+ return disconnectLun(host, Integer.parseInt(port), getTargetIqn(path), getLunWwid(path));
+ }
+
+ /**
+ * Claims the by-id devices this adaptor hands out.
+ *
+ * The target IQN and portal are not recoverable from a by-id name, so they are read back from
+ * the device's iSCSI session in sysfs.
+ *
+ * Returning false for anything else is required by the {@link StorageAdaptor} contract:
+ * {@code KVMStoragePoolManager.disconnectPhysicalDiskByPath} scans every adaptor and stops at
+ * the first one that claims the path.
+ */
+ @Override
+ public boolean disconnectPhysicalDiskByPath(String localPath) {
+ if (!isOntapDevicePath(localPath)) {
+ return false;
+ }
+
+ String kernelDevice = resolveKernelDevice(localPath);
+ if (kernelDevice == null) {
+ logger.info("Device {} is already gone, nothing to disconnect", localPath);
+ return true;
+ }
+
+ Integer sessionId = findSessionId(kernelDevice);
+ String iqn = sessionId == null ? null : readSessionAttribute(sessionId, "targetname");
+ if (iqn == null) {
+ // A real ONTAP LUN always sits under an iSCSI session. If this one does not, it is not
+ // ours, so leave it alone and let the manager try the next adaptor.
+ logger.debug("Device {} ({}) is not on a readable iSCSI session, not claiming it",
+ localPath, kernelDevice);
+ return false;
+ }
+
+ return disconnectLun(null, 0, iqn, localPath.substring(BY_ID_WWN_PREFIX.length()));
+ }
+
+ @Override
+ public KVMPhysicalDisk copyPhysicalDisk(KVMPhysicalDisk disk, String name, KVMStoragePool destPool, int timeout) {
+ return copyPhysicalDisk(disk, name, destPool, timeout, null, null, null);
+ }
+
+ @Override
+ public KVMPhysicalDisk copyPhysicalDisk(KVMPhysicalDisk srcDisk, String destVolumeUuid, KVMStoragePool destPool,
+ int timeout, byte[] srcPassphrase, byte[] destPassphrase, ProvisioningType provisioningType) {
+ KVMStoragePool srcPool = srcDisk.getPool();
+ QemuImgFile srcFile = srcPool.getType() == StoragePoolType.RBD
+ ? new QemuImgFile(KVMPhysicalDisk.RBDStringBuilder(srcPool, srcDisk.getPath()), srcDisk.getFormat())
+ : new QemuImgFile(srcDisk.getPath(), srcDisk.getFormat());
+
+ KVMPhysicalDisk destDisk = destPool.getPhysicalDisk(destVolumeUuid);
+ QemuImgFile destFile = new QemuImgFile(destDisk.getPath(), destDisk.getFormat());
+
+ try {
+ new QemuImg(timeout).convert(srcFile, destFile);
+
+ // A small template can still be sitting in the page cache when convert returns. The LUN
+ // is disconnected right after a copy, so without an explicit flush that data would never
+ // reach the array and the copy would be reported successful while the LUN stayed empty.
+ flushToDevice(destDisk.getPath());
+ } catch (QemuImgException | LibvirtException ex) {
+ String msg = "Failed to copy data from " + srcDisk.getPath() + " to " + destDisk.getPath()
+ + ". The error was the following: " + ex.getMessage();
+ logger.error(msg);
+ throw new CloudRuntimeException(msg);
+ }
+
+ return destPool.getPhysicalDisk(destVolumeUuid);
+ }
+
+ @Override
+ public boolean refresh(KVMStoragePool pool) {
+ return true;
+ }
+
+ @Override
+ public KVMPhysicalDisk createPhysicalDisk(String volumeUuid, KVMStoragePool pool, PhysicalDiskFormat format,
+ ProvisioningType provisioningType, long size, byte[] passphrase) {
+ throw new UnsupportedOperationException("Creating a physical disk is not supported; ONTAP LUNs are provisioned by the management server.");
+ }
+
+ @Override
+ public boolean deletePhysicalDisk(String volumeUuid, KVMStoragePool pool, Storage.ImageFormat format) {
+ throw new UnsupportedOperationException("Deleting a physical disk is not supported; ONTAP LUNs are removed by the management server.");
+ }
+
+ @Override
+ public List listPhysicalDisks(String storagePoolUuid, KVMStoragePool pool) {
+ throw new UnsupportedOperationException("Listing disks is not supported for this configuration.");
+ }
+
+ @Override
+ public KVMPhysicalDisk createDiskFromTemplate(KVMPhysicalDisk template, String name, PhysicalDiskFormat format,
+ ProvisioningType provisioningType, long size, KVMStoragePool destPool,
+ int timeout, byte[] passphrase) {
+ throw new UnsupportedOperationException("Creating a disk from a template is not supported for this configuration.");
+ }
+
+ @Override
+ public KVMPhysicalDisk createTemplateFromDisk(KVMPhysicalDisk disk, String name, PhysicalDiskFormat format, long size, KVMStoragePool destPool) {
+ throw new UnsupportedOperationException("Creating a template from a disk is not supported for this configuration.");
+ }
+
+ @Override
+ public boolean createFolder(String uuid, String path) {
+ return createFolder(uuid, path, null);
+ }
+
+ @Override
+ public boolean createFolder(String uuid, String path, String localPath) {
+ throw new UnsupportedOperationException("A folder cannot be created in this configuration.");
+ }
+
+ @Override
+ public KVMPhysicalDisk createDiskFromTemplateBacking(KVMPhysicalDisk template, String name, PhysicalDiskFormat format,
+ long size, KVMStoragePool destPool, int timeout, byte[] passphrase) {
+ return null;
+ }
+
+ @Override
+ public KVMPhysicalDisk createTemplateFromDirectDownloadFile(String templateFilePath, String destTemplatePath,
+ KVMStoragePool destPool, Storage.ImageFormat format, int timeout) {
+ return null;
+ }
+
+ private boolean createIscsiNode(String host, int port, String iqn, String volumePath) {
+ String result = runIscsiadmNodeCommand(host, port, iqn, "-o", "new");
+
+ if (result == null) {
+ logger.debug("Added iSCSI node for target {}", iqn);
+ return true;
+ }
+ if (result.toLowerCase().contains("exists")) {
+ logger.debug("iSCSI node already exists for target {}, proceeding", iqn);
+ return true;
+ }
+ logger.warn("Failed to add iSCSI node for {}: {}", volumePath, result);
+ return false;
+ }
+
+ private boolean applyChapCredentials(String host, int port, String iqn, String volumePath, Map details) {
+ if (details == null) {
+ return true;
+ }
+
+ String username = details.get(DiskTO.CHAP_INITIATOR_USERNAME);
+ String secret = details.get(DiskTO.CHAP_INITIATOR_SECRET);
+
+ if (!StringUtils.isNoneBlank(username, secret)) {
+ return true;
+ }
+
+ return updateNodeSetting(host, port, iqn, "node.session.auth.authmethod", "CHAP", volumePath)
+ && updateNodeSetting(host, port, iqn, "node.session.auth.username", username, volumePath)
+ && updateNodeSetting(host, port, iqn, "node.session.auth.password", secret, volumePath);
+ }
+
+ private boolean updateNodeSetting(String host, int port, String iqn, String name, String value, String volumePath) {
+ String result = runIscsiadmNodeCommand(host, port, iqn, "--op", "update", "-n", name, "-v", value);
+ if (result != null) {
+ // The value is not logged: for the CHAP settings it is a credential.
+ logger.warn("Failed to set {} on iSCSI target {} for {}: {}", name, iqn, volumePath, result);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Logs in, rescanning instead when the session was already established.
+ *
+ * Login is idempotent but its exit status is not portable: re-login exits 0 on some
+ * distributions and ISCSI_ERR_SESS_EXISTS on others, where it also produces an error message
+ * that would otherwise read as a failure. The session is therefore checked beforehand and a
+ * pre-existing session treated as success.
+ */
+ private boolean loginOrRescanExistingSession(String iqn, String host, int port, String volumePath) {
+ boolean sessionAlreadyActive = isIscsiSessionActive(iqn, host);
+
+ Script login = new Script(true, "iscsiadm", 0, logger);
+ login.add("-m", "node");
+ login.add("-T", iqn);
+ login.add("-p", host + ":" + port);
+ login.add("--login");
+
+ String result = login.execute();
+ boolean sessionPreExisted = login.getExitValue() == ISCSI_SESSION_EXISTS_CODE || sessionAlreadyActive;
+
+ if (sessionPreExisted) {
+ logger.debug("iSCSI session for target {} at {}:{} pre-existed, rescanning", iqn, host, port);
+ rescanIscsiSession(host, port, iqn);
+ return true;
+ }
+ if (result == null) {
+ logger.debug("Logged in to iSCSI target {} for {}", iqn, volumePath);
+ return true;
+ }
+ logger.warn("Failed to log in to iSCSI target {} for {}: {}", iqn, volumePath, result);
+ return false;
+ }
+
+ /**
+ * Reports whether a session to this target and portal already exists.
+ *
+ * ISCSI_ERR_NO_OBJS_FOUND simply means no session exists, which is a normal outcome here; any
+ * other non-zero exit is treated as "not confirmed active" so that login is still attempted.
+ */
+ private boolean isIscsiSessionActive(String iqn, String host) {
+ Script sessionCmd = new Script(true, "iscsiadm", 0, logger);
+ sessionCmd.add("-m", "session");
+
+ OutputInterpreter.AllLinesParser parser = new OutputInterpreter.AllLinesParser();
+ sessionCmd.executeIgnoreExitValue(parser, ISCSI_ERR_NO_OBJS_FOUND);
+
+ int exitValue = sessionCmd.getExitValue();
+ if (exitValue != 0 && exitValue != ISCSI_ERR_NO_OBJS_FOUND) {
+ logger.warn("Unable to determine iSCSI session state for target {} at {}: 'iscsiadm -m session' exited with {}",
+ iqn, host, exitValue);
+ return false;
+ }
+
+ String sessions = parser.getLines();
+ if (StringUtils.isBlank(sessions)) {
+ return false;
+ }
+ for (String line : sessions.split("\n")) {
+ if (line.contains(iqn) && line.contains(host)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void rescanIscsiSession(String host, int port, String iqn) {
+ String result = runIscsiadmNodeCommand(host, port, iqn, "--rescan");
+ if (result != null) {
+ logger.warn("iSCSI session rescan of target {} returned: {}", iqn, result);
+ }
+ }
+
+ /**
+ * Releases one LUN, tearing the session down only once it is the last one on the target.
+ *
+ * ONTAP presents all of an SVM's LUNs through a single IQN, so {@code iscsiadm --logout} would
+ * drop every LUN on the session rather than just this one. While siblings remain, the LUN is
+ * released by deleting its SCSI device through sysfs instead; the kernel does not remove
+ * devices from a live session on its own when the array unmaps a LUN.
+ *
+ * @param host the portal address, or null to read it back from the device's session
+ */
+ private boolean disconnectLun(String host, int port, String iqn, String lunWwid) {
+ String devicePath = getDeviceById(lunWwid);
+ String kernelDevice = resolveKernelDevice(devicePath);
+
+ if (kernelDevice == null) {
+ logger.info("Device {} for LUN {} on target {} is already gone; nothing to disconnect", devicePath, lunWwid, iqn);
+ return true;
+ }
+
+ Integer sessionId = findSessionId(kernelDevice);
+ if (sessionId == null) {
+ logger.warn("Unable to resolve the iSCSI session id for device {} (LUN {}, target {}); "
+ + "Removing the SCSI device only ", kernelDevice, lunWwid, iqn);
+ removeScsiDevice(kernelDevice);
+ return true;
+ }
+ if (hasOtherLunsInSession(sessionId, kernelDevice)) {
+ logger.info("Skipping iSCSI logout for LUN {} on target {}: other LUNs on the same session are still "
+ + "active. Removing device {} only.", lunWwid, iqn, kernelDevice);
+ removeScsiDevice(kernelDevice);
+ return true;
+ }
+
+ if (host == null) {
+ host = readConnectionAttribute(sessionId, "persistent_address");
+ port = parsePort(readConnectionAttribute(sessionId, "persistent_port"));
+ }
+ if (host == null) {
+ logger.warn("Unable to determine the portal of target {} even with sessionId={}; removing device {} without logging out",
+ iqn, sessionId, kernelDevice);
+ removeScsiDevice(kernelDevice);
+ return true;
+ }
+
+ if (!logoutAndForgetTarget(host, port, iqn, lunWwid)) {
+ return false;
+ }
+
+ waitForDeviceToDisappear(devicePath);
+ return true;
+ }
+
+ private boolean logoutAndForgetTarget(String host, int port, String iqn, String lunWwid) {
+ String logoutResult = runIscsiadmNodeCommand(host, port, iqn, "--logout");
+ if (logoutResult != null) {
+ logger.warn("Failed to log out of iSCSI target {} while releasing LUN {}: {}", iqn, lunWwid, logoutResult);
+ return false;
+ }
+ logger.debug("Logged out of iSCSI target {} while releasing LUN {}", iqn, lunWwid);
+
+ String deleteResult = runIscsiadmNodeCommand(host, port, iqn, "-o", "delete");
+ if (deleteResult != null) {
+ logger.warn("Failed to delete the iSCSI node record for target {}: {}", iqn, deleteResult);
+ return false;
+ }
+ logger.debug("Deleted the iSCSI node record for target {}", iqn);
+
+ return true;
+ }
+
+ /** @return null on success, or the command output describing the failure */
+ private String runIscsiadmNodeCommand(String host, int port, String iqn, String... operation) {
+ Script command = new Script(true, "iscsiadm", 0, logger);
+ command.add("-m", "node");
+ command.add("-T", iqn);
+ command.add("-p", host + ":" + port);
+ for (String argument : operation) {
+ command.add(argument);
+ }
+ return command.execute();
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Device and sysfs handling
+ // ---------------------------------------------------------------------------------------------
+
+ /**
+ * Removes a single SCSI device from the kernel, the standard alternative to tearing down the
+ * whole session. The kernel removes the device's udev aliases once it processes the delete.
+ */
+ private void removeScsiDevice(String kernelDevice) {
+ File deleteFile = new File(SYS_BLOCK + "/" + kernelDevice + "/device/delete");
+ if (!deleteFile.exists()) {
+ logger.warn("No sysfs delete entry for device {}; cannot remove it", kernelDevice);
+ return;
+ }
+ try (FileWriter writer = new FileWriter(deleteFile)) {
+ writer.write("1");
+ logger.info("Removed SCSI device {} via sysfs", kernelDevice);
+ } catch (IOException ex) {
+ logger.warn("Failed to remove SCSI device {}: {}", kernelDevice, ex.getMessage());
+ }
+ }
+
+ /**
+ * Reports whether the session carries any LUN other than {@code ownDevice}.
+ *
+ * Every SCSI disk reachable through an iSCSI session has that session in its sysfs device path,
+ * so comparing session ids across {@code /sys/block} identifies the siblings without needing to
+ * know any logical unit numbers.
+ */
+ private boolean hasOtherLunsInSession(int sessionId, String ownDevice) {
+ File[] blockDevices = new File(SYS_BLOCK).listFiles();
+ if (blockDevices == null) {
+ return false;
+ }
+ for (File blockDevice : blockDevices) {
+ String name = blockDevice.getName();
+ if (name.equals(ownDevice)) {
+ continue;
+ }
+ Integer otherSessionId = findSessionId(name);
+ if (otherSessionId != null && otherSessionId == sessionId) {
+ logger.debug("Device {} is another LUN on iSCSI session {}", name, sessionId);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Extracts the iSCSI session id from a block device's sysfs path, which looks like
+ * {@code /sys/devices/platform/host33/session13/target33:0:0/33:0:0:1/block/sdc}.
+ *
+ * @return the session id, or null if the device is not backed by iSCSI
+ */
+ private Integer findSessionId(String kernelDevice) {
+ try {
+ Path deviceLink = Paths.get(SYS_BLOCK, kernelDevice, "device");
+ if (!Files.exists(deviceLink)) {
+ return null;
+ }
+ for (Path element : deviceLink.toRealPath()) {
+ String name = element.toString();
+ if (name.startsWith(SESSION_DIR_PREFIX)) {
+ return Integer.parseInt(name.substring(SESSION_DIR_PREFIX.length()));
+ }
+ }
+ } catch (IOException | NumberFormatException ex) {
+ logger.debug("Unable to determine the iSCSI session of device {}: {}", kernelDevice, ex.getMessage());
+ }
+ return null;
+ }
+
+ private String readSessionAttribute(int sessionId, String attribute) {
+ return readSysfsValue(Paths.get(SYS_ISCSI_SESSION, SESSION_DIR_PREFIX + sessionId, attribute));
+ }
+
+ /**
+ * Reads a connection attribute of a session. A connection's own index is not guaranteed to
+ * match its session's, so the connection directory is discovered under the session rather than
+ * assumed to be {@code connection:0}.
+ */
+ private String readConnectionAttribute(int sessionId, String attribute) {
+ Path sessionDevice = Paths.get(SYS_ISCSI_SESSION, SESSION_DIR_PREFIX + sessionId, "device");
+ File[] children = sessionDevice.toFile().listFiles();
+ if (children == null) {
+ return null;
+ }
+ for (File child : children) {
+ String name = child.getName();
+ if (!name.startsWith("connection")) {
+ continue;
+ }
+ String value = readSysfsValue(sessionDevice.resolve(name).resolve("iscsi_connection").resolve(name).resolve(attribute));
+ if (value != null) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private String readSysfsValue(Path path) {
+ try {
+ if (!Files.exists(path)) {
+ return null;
+ }
+ String value = new String(Files.readAllBytes(path)).trim();
+ return value.isEmpty() ? null : value;
+ } catch (IOException ex) {
+ logger.debug("Unable to read {}: {}", path, ex.getMessage());
+ return null;
+ }
+ }
+
+ private int parsePort(String port) {
+ try {
+ return port != null ? Integer.parseInt(port) : DEFAULT_ISCSI_PORT;
+ } catch (NumberFormatException ex) {
+ return DEFAULT_ISCSI_PORT;
+ }
+ }
+
+ private String resolveKernelDevice(String devicePath) {
+ try {
+ Path link = Paths.get(devicePath);
+ if (!Files.exists(link)) {
+ return null;
+ }
+ return link.toRealPath().getFileName().toString();
+ } catch (IOException ex) {
+ logger.debug("Unable to resolve {}: {}", devicePath, ex.getMessage());
+ return null;
+ }
+ }
+
+ private boolean waitForDeviceToAppear(String volumePath, KVMStoragePool pool) {
+ for (int attempt = 0; attempt < DEVICE_WAIT_TRIES; attempt++) {
+ if (getPhysicalDisk(volumePath, pool).getSize() > 0) {
+ return true;
+ }
+ if (!sleepBetweenAttempts()) {
+ return false;
+ }
+ }
+ return getPhysicalDisk(volumePath, pool).getSize() > 0;
+ }
+
+ private void waitForDeviceToDisappear(String devicePath) {
+ for (int attempt = 0; attempt < DEVICE_WAIT_TRIES && getDeviceSize(devicePath) > 0; attempt++) {
+ if (!sleepBetweenAttempts()) {
+ return;
+ }
+ }
+ }
+
+ private boolean sleepBetweenAttempts() {
+ try {
+ Thread.sleep(DEVICE_WAIT_INTERVAL_MS);
+ return true;
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+
+ /**
+ * @return the device size in bytes, or 0 if the device is absent or not a block device
+ */
+ private long getDeviceSize(String devicePath) {
+ Path path = Paths.get(devicePath);
+
+ if (!Files.exists(path)) {
+ logger.debug("Device does not exist yet: {}", devicePath);
+ return 0L;
+ }
+ if (Files.isRegularFile(path)) {
+ // A plain file here means something wrote to the device name before the LUN appeared;
+ // treating it as a disk would silently back a volume with local storage.
+ logger.warn("Found a regular file at {} where a block device was expected; it must be removed manually", devicePath);
+ return 0L;
+ }
+
+ Script command = new Script(true, "blockdev", 0, logger);
+ command.add("--getsize64", devicePath);
+
+ OutputInterpreter.OneLineParser parser = new OutputInterpreter.OneLineParser();
+ String result = command.execute(parser);
+
+ if (result != null) {
+ logger.warn("Unable to get the size of device {}: {}", devicePath, result);
+ return 0L;
+ }
+
+ try {
+ return Long.parseLong(parser.getLine().trim());
+ } catch (NumberFormatException | NullPointerException ex) {
+ logger.warn("Unable to parse the size of device {}", devicePath);
+ return 0L;
+ }
+ }
+
+ private void flushToDevice(String devicePath) {
+ Script flush = new Script(true, "blockdev", 0, logger);
+ flush.add("--flushbufs", devicePath);
+ String flushResult = flush.execute();
+ if (flushResult != null) {
+ logger.warn("blockdev --flushbufs on {} returned: {}", devicePath, flushResult);
+ }
+ new Script(true, "sync", 0, logger).execute();
+ logger.debug("Flushed buffers to {}", devicePath);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Volume path handling
+ // ---------------------------------------------------------------------------------------------
+
+ private boolean isOntapDevicePath(String localPath) {
+ return localPath != null
+ && localPath.startsWith(BY_ID_WWN_PREFIX)
+ && LUN_WWID.matcher(localPath.substring(BY_ID_WWN_PREFIX.length())).matches();
+ }
+
+ private String getDeviceById(String lunWwid) {
+ return BY_ID_WWN_PREFIX + lunWwid;
+ }
+
+ private String getTargetIqn(String volumePath) {
+ return getPathComponent(volumePath, 1);
+ }
+
+ private String getLunWwid(String volumePath) {
+ return getPathComponent(volumePath, 2);
+ }
+
+ /**
+ * Splits a {@code //} volume path.
+ *
+ * The WWID occupies the component a logical unit number would hold for other iSCSI vendors, so
+ * the path keeps the two-component shape every hypervisor resource expects. That is also why
+ * the path carries the WWID rather than the ONTAP serial it encodes: serials contain '/'.
+ */
+ private String getPathComponent(String volumePath, int index) {
+ String[] components = volumePath == null ? new String[0] : volumePath.split("/");
+
+ if (components.length != 3) {
+ String message = "Wrong format for ONTAP iSCSI path: " + volumePath
+ + ". It should be formatted as '/targetIQN/lunWwid'.";
+ logger.warn(message);
+ throw new CloudRuntimeException(message);
+ }
+
+ return components[index].trim();
+ }
+}
diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/storage/OntapIscsiStorageAdaptorTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/storage/OntapIscsiStorageAdaptorTest.java
new file mode 100644
index 000000000000..dc3ced853f5c
--- /dev/null
+++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/storage/OntapIscsiStorageAdaptorTest.java
@@ -0,0 +1,184 @@
+// 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 com.cloud.hypervisor.kvm.storage;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.lang.reflect.Modifier;
+import java.util.Set;
+
+import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat;
+import org.junit.Test;
+import org.reflections.Reflections;
+
+import com.cloud.storage.Storage.StoragePoolType;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+public class OntapIscsiStorageAdaptorTest {
+
+ @Test
+ public void getStoragePoolTypeReturnsOntapIscsi() {
+ assertEquals(StoragePoolType.OntapiSCSI, new OntapIscsiStorageAdaptor().getStoragePoolType());
+ }
+
+ @Test
+ public void createdPoolCarriesOntapIscsiTypeAndRawFormat() {
+ OntapIscsiStorageAdaptor adaptor = new OntapIscsiStorageAdaptor();
+
+ KVMStoragePool pool = adaptor.createStoragePool("ontap-iscsi-pool-uuid", "10.0.0.1", 3260, null, null,
+ StoragePoolType.OntapiSCSI, null, true);
+
+ assertEquals(StoragePoolType.OntapiSCSI, pool.getType());
+ // Attach builds a block-based disk off the physical disk format rather than the pool type,
+ // which is why splitting OntapiSCSI out of Iscsi leaves the generated domain XML unchanged.
+ assertEquals(PhysicalDiskFormat.RAW, pool.getDefaultFormat());
+ assertSame(pool, adaptor.getStoragePool("ontap-iscsi-pool-uuid"));
+ }
+
+ /**
+ * KVMStoragePoolManager discovers adaptors by a Reflections scan of its own package, instantiating
+ * each concrete implementation through a no-arg constructor and keying it on getStoragePoolType().
+ * A type with no adaptor silently falls back to LibvirtStorageAdaptor instead of failing at
+ * startup, so this reproduces the discovery preconditions rather than waiting for the symptom.
+ * The manager itself is not constructed here because doing so also instantiates
+ * MultipathSCSIAdapterBase, which requires agent scripts resolvable from the working directory.
+ */
+ @Test
+ public void adaptorSatisfiesThePoolManagerDiscoveryContract() throws ReflectiveOperationException {
+ String scannedPackage = KVMStoragePoolManager.class.getPackage().getName();
+ Set> discovered =
+ new Reflections(scannedPackage).getSubTypesOf(StorageAdaptor.class);
+
+ assertTrue("OntapIscsiStorageAdaptor must live in " + scannedPackage + " to be discovered",
+ discovered.contains(OntapIscsiStorageAdaptor.class));
+ assertFalse("An abstract adaptor is skipped by the scan",
+ Modifier.isAbstract(OntapIscsiStorageAdaptor.class.getModifiers()));
+
+ StorageAdaptor adaptor = OntapIscsiStorageAdaptor.class.getDeclaredConstructor().newInstance();
+ assertEquals(StoragePoolType.OntapiSCSI, adaptor.getStoragePoolType());
+ assertEquals("IscsiAdmStorageAdaptor must keep serving the other iSCSI vendors",
+ StoragePoolType.Iscsi, new IscsiAdmStorageAdaptor().getStoragePoolType());
+ }
+
+ /**
+ * The two adaptors are deliberately unrelated: device naming runs through nearly every method
+ * that does real work, so inheriting IscsiAdmStorageAdaptor coupled them through behaviour
+ * neither could change safely. Registration only requires implementing StorageAdaptor, and
+ * KVMStoragePoolManager keys adaptors on getStoragePoolType(), so nothing depends on a shared
+ * superclass. Asserting it keeps a later "reuse" refactor from quietly restoring by-path
+ * naming through an un-overridden inherited method.
+ */
+ @Test
+ public void adaptorDoesNotInheritTheByPathBasedIscsiAdaptor() {
+ assertTrue("The adaptor must implement StorageAdaptor directly",
+ StorageAdaptor.class.isAssignableFrom(OntapIscsiStorageAdaptor.class));
+ assertFalse("The adaptor must not extend the by-path based iSCSI adaptor",
+ IscsiAdmStorageAdaptor.class.isAssignableFrom(OntapIscsiStorageAdaptor.class));
+ assertEquals("It should sit directly on StorageAdaptor, with no intermediate base class",
+ Object.class, OntapIscsiStorageAdaptor.class.getSuperclass());
+ }
+
+ /**
+ * The WWID occupies the component the superclass reads a logical unit number from, which is what
+ * keeps the path at the two components every hypervisor resource's '/targetIQN/LUN' parsing
+ * demands. ONTAP serial numbers contain '/' (byte 0x2f), so carrying the raw serial here instead
+ * of its hex WWID would split into four components and throw.
+ */
+ @Test
+ public void physicalDiskIsNamedByLunWwidRatherThanLogicalUnitNumber() {
+ OntapIscsiStorageAdaptor adaptor = new OntapIscsiStorageAdaptor();
+ KVMStoragePool pool = adaptor.createStoragePool("ontap-iscsi-pool-uuid", "10.196.37.157", 3260, null, null,
+ StoragePoolType.OntapiSCSI, null, true);
+
+ String targetIqn = "iqn.1992-08.com.netapp:sn.45048fd9b65111f1b106005056bd83cf:vs.3";
+ String lunWwid = "600a098078304d2d383f2f6b45734a54";
+
+ KVMPhysicalDisk disk = adaptor.getPhysicalDisk("/" + targetIqn + "/" + lunWwid, pool);
+
+ assertEquals("/dev/disk/by-id/wwn-0x" + lunWwid, disk.getPath());
+ assertFalse("The reported path must carry nothing host-specific", disk.getPath().contains("lun-"));
+ assertFalse("The reported path must not be derived from by-path", disk.getPath().contains("by-path"));
+ assertEquals(PhysicalDiskFormat.RAW, disk.getFormat());
+ }
+
+ @Test
+ public void malformedVolumePathIsRejected() {
+ OntapIscsiStorageAdaptor adaptor = new OntapIscsiStorageAdaptor();
+ KVMStoragePool pool = adaptor.createStoragePool("ontap-iscsi-pool-uuid", "10.196.37.157", 3260, null, null,
+ StoragePoolType.OntapiSCSI, null, true);
+
+ // A raw ONTAP serial such as 'x0M-8?/kEsJT' would land here, splitting into four components.
+ assertThrows(CloudRuntimeException.class,
+ () -> adaptor.getPhysicalDisk("/iqn.1992-08.com.netapp:sn.abc:vs.3/x0M-8?/kEsJT", pool));
+ assertThrows(CloudRuntimeException.class,
+ () -> adaptor.getPhysicalDisk("/iqn.1992-08.com.netapp:sn.abc:vs.3", pool));
+ }
+
+ /**
+ * KVMStoragePoolManager.disconnectPhysicalDiskByPath walks every registered adaptor and stops at
+ * the first one returning true, so an adaptor that over-claims tears down another vendor's
+ * session. By-path devices still belong to the superclass, which serves the other iSCSI vendors.
+ */
+ @Test
+ public void disconnectByPathOnlyClaimsTheDevicesThisAdaptorHandsOut() {
+ OntapIscsiStorageAdaptor adaptor = new OntapIscsiStorageAdaptor();
+
+ assertFalse("A by-path device is the superclass's to disconnect", adaptor.disconnectPhysicalDiskByPath(
+ "/dev/disk/by-path/ip-10.0.0.1:3260-iscsi-iqn.1992-08.com.netapp:sn.abc:vs.3-lun-0"));
+ assertFalse("PowerFlex publishes its own by-id names",
+ adaptor.disconnectPhysicalDiskByPath("/dev/disk/by-id/emc-vol-1235dc1s0-4a2f6b45"));
+ assertFalse("A partition is not the LUN itself",
+ adaptor.disconnectPhysicalDiskByPath("/dev/disk/by-id/wwn-0x600a098078304d2d383f2f6b45734a51-part1"));
+ assertFalse("Only the wwn-0x form is handed out, never the scsi-3 alias",
+ adaptor.disconnectPhysicalDiskByPath("/dev/disk/by-id/scsi-3600a098078304d2d383f2f6b45734a51"));
+ assertFalse("A bare kernel name carries no identity", adaptor.disconnectPhysicalDiskByPath("/dev/sdb"));
+ assertFalse(adaptor.disconnectPhysicalDiskByPath(null));
+ }
+
+ /**
+ * Other vendors publish the same wwn-0x plus 32 hex shape. Only NetApp's NAA prefix is ours, so a
+ * different array's LUN or a local RAID volume must fall through to the next adaptor.
+ */
+ @Test
+ public void disconnectByPathDoesNotClaimOtherVendorsWwnDevices() {
+ OntapIscsiStorageAdaptor adaptor = new OntapIscsiStorageAdaptor();
+
+ assertFalse("Pure FlashArray also publishes wwn-0x names",
+ adaptor.disconnectPhysicalDiskByPath("/dev/disk/by-id/wwn-0x624a9370f1f4f5a4b0d3c2e1000113aa"));
+ assertFalse("HPE Primera also publishes wwn-0x names",
+ adaptor.disconnectPhysicalDiskByPath("/dev/disk/by-id/wwn-0x60002ac0000000000000012300020a1b"));
+ assertFalse("A local RAID volume also publishes wwn-0x names",
+ adaptor.disconnectPhysicalDiskByPath("/dev/disk/by-id/wwn-0x600508b1001c4d2e8a0f6b1c2d3e4f50"));
+ assertFalse("NetApp E-Series is not managed by this adaptor",
+ adaptor.disconnectPhysicalDiskByPath("/dev/disk/by-id/wwn-0x600a0b800029e3a4000012345f6a7b8c"));
+ }
+
+ /**
+ * A claimed device that has already gone reports success: there is no session left to tear down,
+ * and returning false would send the manager on to adaptors that would mishandle the path.
+ */
+ @Test
+ public void disconnectByPathSucceedsWhenTheClaimedDeviceIsAlreadyGone() {
+ OntapIscsiStorageAdaptor adaptor = new OntapIscsiStorageAdaptor();
+
+ assertTrue(adaptor.disconnectPhysicalDiskByPath("/dev/disk/by-id/wwn-0x600a098078304d2d383f2f6b45734a51"));
+ }
+}
diff --git a/plugins/storage/volume/ontap/README.md b/plugins/storage/volume/ontap/README.md
index 059bf99e09d8..7521ae890e2c 100644
--- a/plugins/storage/volume/ontap/README.md
+++ b/plugins/storage/volume/ontap/README.md
@@ -65,7 +65,7 @@ The NetApp ONTAP Storage Plugin provides integration between Apache CloudStack a
### Minimum Volume Size
-ONTAP requires a minimum volume size of **1.56 GB** (1,677,721,600 bytes). The plugin will automatically adjust requested sizes below this threshold.
+ONTAP requires a minimum volume size of **20 MB** (20,971,520 bytes). Requests below this threshold are rejected.
## Configuration
@@ -116,7 +116,7 @@ username=admin;password=secretpass;svmName=svm1;protocol=ISCSI;managementLIF=192
3. **Capacity Errors**
- Check aggregate space availability
- - Ensure requested volume size meets minimum requirements (1.56 GB)
+ - Ensure requested volume size meets minimum requirements (20 MB)
4. **Host Connection Issues**
- For iSCSI: Verify host IQN is properly configured in host's storage URL
diff --git a/plugins/storage/volume/ontap/pom.xml b/plugins/storage/volume/ontap/pom.xml
index 0ab54c67d343..c18bf559f92a 100644
--- a/plugins/storage/volume/ontap/pom.xml
+++ b/plugins/storage/volume/ontap/pom.xml
@@ -86,6 +86,11 @@
cloud-engine-storage-volume${project.version}
+
+ org.apache.cloudstack
+ cloud-framework-cluster
+ ${project.version}
+ io.swaggerswagger-annotations
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/asup/OntapAsupManager.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/asup/OntapAsupManager.java
new file mode 100644
index 000000000000..5ab2aa18833e
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/asup/OntapAsupManager.java
@@ -0,0 +1,673 @@
+/*
+ * 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.asup;
+
+import com.cloud.cluster.ManagementServerHostVO;
+import com.cloud.cluster.dao.ManagementServerHostDao;
+import com.cloud.event.EventTypes;
+import com.cloud.server.ManagementService;
+import com.cloud.storage.Volume;
+import com.cloud.storage.SnapshotVO;
+import com.cloud.storage.VolumeVO;
+import com.cloud.storage.dao.SnapshotDao;
+import com.cloud.storage.dao.VolumeDao;
+import com.cloud.vm.snapshot.VMSnapshot;
+import com.cloud.vm.snapshot.VMSnapshotVO;
+import com.cloud.vm.snapshot.dao.VMSnapshotDao;
+import com.cloud.utils.Ternary;
+import com.cloud.utils.component.ManagerBase;
+import com.cloud.utils.concurrency.NamedThreadFactory;
+import com.cloud.utils.db.GlobalLock;
+import com.cloud.utils.net.NetUtils;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.messagebus.MessageBus;
+import org.apache.cloudstack.managed.context.ManagedContextRunnable;
+import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.storage.feign.model.Cluster;
+import org.apache.cloudstack.storage.feign.model.EmsApplicationLog;
+import org.apache.cloudstack.storage.service.StorageStrategy;
+import org.apache.cloudstack.storage.utils.OntapConfigurationManager;
+import org.apache.cloudstack.storage.utils.OntapStorageConstants;
+import org.apache.cloudstack.storage.utils.OntapStorageUtils;
+import org.apache.commons.collections.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+
+import javax.inject.Inject;
+import javax.naming.ConfigurationException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Periodic ASUP (AutoSupport) telemetry pusher for the NetApp ONTAP plugin.
+ *
+ *
Each run pushes, for every ONTAP-backed primary storage pool, two minimal EMS
+ * application-log messages to the backing ONTAP cluster:
+ *
+ *
+ *
event-id 0 (heartbeat): identifies the CloudStack deployment (CloudStack
+ * version, management host) connected to the ONTAP cluster (ONTAP cluster version).
+ *
event-id 1 (pool): maps the CloudStack storage pool to its backing ONTAP
+ * volume - protocol (NFS/iSCSI), ONTAP FlexVolume UUID, SVM, disk usage, and
+ * snapshot telemetry (counts by state, total provisioned size).
+ *
+ *
+ *
Runs are booked one at a time: after each cycle starts, the next run is scheduled for
+ * {@link OntapConfigurationManager#AsupIntervalHours} after that start, so production
+ * intervals (hours) are start-to-start. REST work sits inside the interval; it is not added
+ * after it. Editing the ONTAP ASUP interval re-books the pending run immediately, with no
+ * management-server restart.
+ */
+public class OntapAsupManager extends ManagerBase {
+ private static final int ASUP_LOCK_TIMEOUT_SECONDS = 5;
+
+ /**
+ * Volume states that guarantee a physical object exists on the ONTAP FlexVolume.
+ * States like {@link Volume.State#Allocated} have a CloudStack DB row pointing to this
+ * pool but ONTAP provisioning has not been called yet — they must be excluded to avoid
+ * inflating disk counts and provisioned-size totals. Upload-family states live on
+ * secondary storage, not on the primary ONTAP volume, so they are also excluded.
+ */
+ private static final Set CS_VOLUME_STATES = EnumSet.of(
+ Volume.State.Ready,
+ Volume.State.Snapshotting,
+ Volume.State.RevertSnapshotting,
+ Volume.State.Attaching,
+ Volume.State.Restoring,
+ Volume.State.Expunging,
+ Volume.State.Destroying
+ );
+
+ /** Serializes the structured event-description payloads to JSON. */
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /**
+ * Timestamp of the last ASUP cycle start (or {@link Instant#EPOCH} if none has run).
+ * The next run is due {@link OntapConfigurationManager#AsupIntervalHours} after this
+ * instant. {@code volatile} so the scheduler thread's write is visible without extra locking.
+ */
+ volatile Instant lastPushTime = Instant.EPOCH;
+
+ /** Single daemon thread that runs the ASUP pushes; owned by this manager. */
+ private ScheduledExecutorService asupScheduler;
+
+ /** The one pending run. Cancelled and re-booked whenever the schedule changes. */
+ private ScheduledFuture> pendingRun;
+
+ @Inject
+ private PrimaryDataStoreDao storagePoolDao;
+ @Inject
+ private StoragePoolDetailsDao storagePoolDetailsDao;
+ @Inject
+ private VolumeDao volumeDao;
+ @Inject
+ private SnapshotDao snapshotDao;
+ @Inject
+ private VMSnapshotDao vmSnapshotDao;
+ @Inject
+ private ManagementService managementService;
+ @Inject
+ private ManagementServerHostDao managementServerHostDao;
+ @Inject
+ private MessageBus messageBus;
+
+ @Override
+ public boolean configure(String name, Map params) throws ConfigurationException {
+ super.configure(name, params);
+ // React to edits of ontap.autosupport.interval so a new value re-books the pending run
+ // instead of waiting for it to fire on the old schedule.
+ messageBus.subscribe(EventTypes.EVENT_CONFIGURATION_VALUE_EDIT, this::onAsupConfigEdited);
+ return true;
+ }
+
+ @Override
+ public boolean start() {
+ asupScheduler = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("OntapAsup"));
+ logger.info("OntapAsupManager started; ASUP telemetry interval={}h",
+ getAsupIntervalHours(OntapConfigurationManager.AsupIntervalHours.value()));
+ scheduleNextRun();
+ return super.start();
+ }
+
+ @Override
+ public boolean stop() {
+ if (asupScheduler != null) {
+ asupScheduler.shutdownNow();
+ }
+ return super.stop();
+ }
+
+ /**
+ * Books the single pending run for the moment the configured interval elapses, cancelling
+ * whatever was booked before. Called at start-up, after every run, and whenever
+ * {@code ontap.autosupport.interval} is edited, so the live config always decides the next run.
+ *
+ *
When the interval is {@link OntapStorageConstants#ASUP_DISABLED_INTERVAL_HOURS}
+ * nothing is booked; setting a non-zero interval publishes a configuration-edit event,
+ * which books a run again.
+ */
+ private synchronized void scheduleNextRun() {
+ if (asupScheduler == null || asupScheduler.isShutdown()) {
+ return;
+ }
+ if (pendingRun != null) {
+ pendingRun.cancel(false);
+ pendingRun = null;
+ }
+ int intervalHours = getAsupIntervalHours(OntapConfigurationManager.AsupIntervalHours.value());
+ if (intervalHours == OntapStorageConstants.ASUP_DISABLED_INTERVAL_HOURS) {
+ logger.debug("ONTAP ASUP: telemetry is disabled ({}={}); no run scheduled.",
+ OntapStorageConstants.ASUP_INTERVAL_CONFIG_KEY,
+ OntapStorageConstants.ASUP_DISABLED_INTERVAL_HOURS);
+ return;
+ }
+ long delayMs = millisUntilNextPush();
+ pendingRun = asupScheduler.schedule(new OntapAsupTask(), delayMs, TimeUnit.MILLISECONDS);
+ logger.debug("ONTAP ASUP: next telemetry push scheduled in {} ms.", delayMs);
+ }
+
+ /**
+ * Milliseconds remaining until {@link #lastPushTime} plus the live configured interval.
+ * Zero when a push is already overdue — for example after the interval is shortened, the
+ * previous cycle ran longer than the interval, or on the very first run when
+ * {@link #lastPushTime} is still {@link Instant#EPOCH}.
+ */
+ long millisUntilNextPush() {
+ Duration configuredInterval = Duration.ofHours(
+ getAsupIntervalHours(OntapConfigurationManager.AsupIntervalHours.value()));
+ Duration remaining = configuredInterval.minus(Duration.between(lastPushTime, Instant.now()));
+ return remaining.isNegative() ? 0L : remaining.toMillis();
+ }
+
+ /**
+ * CloudStack publishes this after invalidating the config cache, so the new value is already
+ * readable. Re-books the pending run: shortening the interval past the due time schedules
+ * with zero delay, which pushes right away on the scheduler thread rather than blocking the
+ * API thread that served {@code updateConfiguration}.
+ */
+ @SuppressWarnings("unchecked")
+ private void onAsupConfigEdited(String senderAddress, String subject, Object args) {
+ if (!(args instanceof Ternary)) {
+ return;
+ }
+ String updatedKey = ((Ternary) args).first();
+ if (!OntapConfigurationManager.AsupIntervalHours.key().equals(updatedKey)) {
+ return;
+ }
+ logger.debug("ONTAP ASUP: [{}] was updated; re-booking the next push.", updatedKey);
+ scheduleNextRun();
+ }
+
+ /**
+ * One ASUP run inside a managed CloudStack context, which the DAO calls made during the push
+ * require.
+ *
+ *
{@link #lastPushTime} is stamped at the start of the cycle so the next wait is the
+ * remainder of the interval (work does not get added on top). The next run is booked again
+ * in {@code finally} so the schedule never stalls.
Guarded by a {@link GlobalLock} so that, in a multi-management-server deployment,
+ * only one node emits per cycle.
+ */
+ protected void pushAsupTelemetry() {
+ List pools = storagePoolDao.findPoolsByProvider(OntapStorageConstants.ONTAP_PLUGIN_NAME);
+ if (CollectionUtils.isEmpty(pools)) {
+ logger.debug("ONTAP ASUP: no ONTAP-backed storage pools found; nothing to push.");
+ return;
+ }
+
+ GlobalLock lock = GlobalLock.getInternLock(OntapStorageConstants.ASUP_GLOBAL_LOCK_NAME);
+ try {
+ if (!lock.lock(ASUP_LOCK_TIMEOUT_SECONDS)) {
+ logger.debug("ONTAP ASUP: another management server holds the ASUP lock; skipping this cycle.");
+ return;
+ }
+ try {
+ logger.debug("ONTAP ASUP: pushing telemetry for {} pool(s) [CloudStack version={}]",
+ pools.size(), getCloudStackVersion());
+ Map clientsByStorageIp = new HashMap<>();
+ for (StoragePoolVO pool : pools) {
+ pushAsupForStoragePool(pool, clientsByStorageIp);
+ }
+ } finally {
+ lock.unlock();
+ }
+ } finally {
+ lock.releaseRef();
+ }
+ }
+
+ /**
+ * Pushes the heartbeat (event-id 0) and pool (event-id 1) ASUP messages for a single pool.
+ *
+ *
{@code clientsByStorageIp} is the per-cycle cache keyed by management {@code storageIP}.
+ * A miss builds the strategy, GETs cluster info and node model, and sends event-0. A hit
+ * reuses that client and skips those calls and event-0. Event-1 is always sent. SVM name
+ * in the payload still comes from this pool's details.
+ *
+ *
Best-effort: any failure is logged and swallowed.
+ */
+ protected void pushAsupForStoragePool(StoragePoolVO pool, Map clientsByStorageIp) {
+ try {
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(pool.getId());
+ if (details == null || details.isEmpty()) {
+ logger.warn("ONTAP ASUP: storage pool [{}] has no details; skipping.", pool.getId());
+ return;
+ }
+
+ String storageIp = details.get(OntapStorageConstants.STORAGE_IP);
+ AsupClusterClient asupClusterClient = StringUtils.isNotBlank(storageIp)
+ ? clientsByStorageIp.get(storageIp) : null;
+ boolean sendHeartbeat = asupClusterClient == null;
+ if (asupClusterClient == null) {
+ StorageStrategy strategy = OntapStorageUtils.resolveStrategyFromPoolDetails(details);
+ Cluster cluster = strategy.getClusterInfo();
+ asupClusterClient = new AsupClusterClient(strategy, cluster);
+ if (StringUtils.isNotBlank(storageIp)) {
+ clientsByStorageIp.put(storageIp, asupClusterClient);
+ }
+ }
+
+ StorageStrategy strategy = asupClusterClient.strategy;
+ Cluster cluster = asupClusterClient.cluster;
+ String ontapVersion = strategy.getClusterVersion(cluster);
+ String clusterUuid = cluster != null ? cluster.getUuid() : null;
+ String cloudStackVersion = getCloudStackVersion();
+ String computerName = getComputerName();
+
+ if (sendHeartbeat) {
+ EmsApplicationLog heartbeat = buildBaseMessage(computerName, cloudStackVersion);
+ heartbeat.setEventId(OntapStorageConstants.ASUP_EVENT_ID_HEARTBEAT);
+ heartbeat.setEventDescription(buildHeartbeatDescription(
+ cloudStackVersion, ontapVersion, cluster));
+ strategy.sendAsupMessage(heartbeat);
+ } else {
+ logger.debug("ONTAP ASUP: heartbeat already sent this cycle for storage IP [{}]; skipping for pool [{}]",
+ storageIp, pool.getId());
+ }
+
+ // event-id 1: CloudStack storage pool -> backing ONTAP volume mapping, once per pool.
+ // The description also includes disk usage and snapshot telemetry
+ EmsApplicationLog poolMessage = buildBaseMessage(computerName, cloudStackVersion);
+ poolMessage.setEventId(OntapStorageConstants.ASUP_EVENT_ID_STORAGE_POOL);
+ poolMessage.setEventDescription(buildPoolDescription(pool, details, clusterUuid));
+ strategy.sendAsupMessage(poolMessage);
+
+ logger.debug("ONTAP ASUP: pushed telemetry for pool [{}] (ONTAP version={})",
+ pool.getId(), defaultUnknown(ontapVersion));
+ } catch (Exception e) {
+ // Best-effort telemetry; never propagate.
+ logger.warn("ONTAP ASUP: failed to push telemetry for pool [{}]: {}", pool.getId(), e.getMessage());
+ }
+ }
+
+ /**
+ * Builds the heartbeat (event-id 0) description as a JSON object carrying the CloudStack and
+ * ONTAP versions, cluster hardware model, the management-server operating system platform,
+ * and the ONTAP cluster UUID.
+ * Example: {@code {"message":"CloudStack connected to ONTAP cluster","cloudstackVersion":
+ * "4.23.0.0","platform":"Linux 5.15.0-91-generic (amd64)","ontapVersion":"9.17.1",
+ * "ontapClusterModel":"AFF-A400","ontapPlatformType":"performance","clusterUuid":"...",
+ * "managementServerCount":2}}
+ */
+ private String buildHeartbeatDescription(String cloudStackVersion, String ontapVersion, Cluster cluster) {
+ Map payload = new LinkedHashMap<>();
+ payload.put(OntapStorageConstants.ASUP_MESSAGE, OntapStorageConstants.ASUP_HEARTBEAT_MESSAGE);
+ payload.put(OntapStorageConstants.ASUP_CLOUDSTACK_VERSION, defaultUnknown(cloudStackVersion));
+ payload.put(OntapStorageConstants.ASUP_PLATFORM, getOperatingSystem());
+ payload.put(OntapStorageConstants.ASUP_ONTAP_VERSION, defaultUnknown(ontapVersion));
+ payload.put(OntapStorageConstants.ASUP_ONTAP_CLUSTER_MODEL,
+ defaultUnknown(cluster != null ? cluster.getModel() : null));
+ payload.put(OntapStorageConstants.ASUP_ONTAP_PLATFORM_TYPE,
+ defaultUnknown(cluster != null ? cluster.getPlatformType() : null));
+ payload.put(OntapStorageConstants.ASUP_CLUSTER_UUID,
+ defaultUnknown(cluster != null ? cluster.getUuid() : null));
+ payload.put(OntapStorageConstants.ASUP_MANAGEMENT_SERVER_COUNT, getManagementServerCount());
+ return toJson(payload);
+ }
+
+ /**
+ * Builds the pool description (event-id 1) as a JSON object combining the backing-volume
+ * mapping, disk usage, and snapshot telemetry into a single EMS message.
+ *
+ *
+ */
+ private String buildPoolDescription(StoragePoolVO pool, Map details,
+ String clusterUuid) {
+ Map payload = new LinkedHashMap<>();
+ payload.put(OntapStorageConstants.ASUP_MESSAGE, OntapStorageConstants.ASUP_POOL_MESSAGE);
+ payload.put(OntapStorageConstants.ASUP_POOL_NAME, defaultUnknown(pool.getName()));
+ payload.put(OntapStorageConstants.ASUP_POOL_STATUS,
+ pool.getStatus() == null ? OntapStorageConstants.ASUP_UNKNOWN : pool.getStatus().toString());
+ payload.put(OntapStorageConstants.ASUP_PROTOCOL, defaultUnknown(details.get(OntapStorageConstants.PROTOCOL)));
+ payload.put(OntapStorageConstants.ASUP_CLUSTER_UUID, defaultUnknown(clusterUuid));
+ payload.put(OntapStorageConstants.ASUP_SVM, defaultUnknown(details.get(OntapStorageConstants.SVM_NAME)));
+ payload.put(OntapStorageConstants.ASUP_ONTAP_VOLUME_UUID, defaultUnknown(details.get(OntapStorageConstants.VOLUME_UUID)));
+ List volumes = null;
+ try {
+ volumes = loadNonDestroyedVolumes(pool);
+ } catch (Exception e) {
+ logger.error("ONTAP ASUP: failed to load volumes for pool [{}]: {}", pool.getId(), e.getMessage());
+ }
+ if (volumes != null) {
+ addStoragePoolUsage(pool, payload, volumes);
+ addSnapshotMetrics(pool, payload, volumes);
+ }
+ hasMultiPrimaryStoragePoolVm(pool, payload);
+ return toJson(payload);
+ }
+
+ /**
+ * Single CloudStack DB read of non-destroyed volumes on this pool. Shared by usage and
+ * snapshot counts so the list is not queried three times. Never returns null.
+ */
+ private List loadNonDestroyedVolumes(StoragePoolVO pool) {
+ List volumes = volumeDao.findNonDestroyedVolumesByPoolId(pool.getId(), null);
+ return volumes == null ? java.util.Collections.emptyList() : volumes;
+ }
+
+ /**
+ * Computes pool usage from CloudStack's volume records and adds it to the payload:
+ *
+ *
{@code rootDiskCount} - number of ROOT (boot) disks physically on this pool
+ *
{@code dataDiskCount} - number of DATADISK disks physically on this pool
+ *
{@code totalLogicalSizeBytes} - sum of those volumes' provisioned (logical) sizes
+ * in bytes; for thin-provisioned volumes this is the logical size requested at
+ * creation time, not the physical space consumed on ONTAP
+ *
+ * {@code volumes} is the non-destroyed list for this pool (already loaded). Only
+ * {@link #CS_VOLUME_STATES} are counted. Best-effort: any failure leaves the usage
+ * fields out and never breaks telemetry.
+ */
+ private void addStoragePoolUsage(StoragePoolVO pool, Map payload, List volumes) {
+ try {
+ // Only count volumes that definitely have a physical object on the ONTAP FlexVolume.
+ // "Allocated" volumes have a pool_id row in the CS DB but ONTAP provisioning has not
+ // yet been called, so including them would inflate counts and provisioned size.
+ long rootDiskCount = 0L;
+ long dataDiskCount = 0L;
+ long totalLogicalSizeBytes = 0L;
+ for (VolumeVO volume : volumes) {
+ if (!CS_VOLUME_STATES.contains(volume.getState())) {
+ continue;
+ }
+ if (Volume.Type.ROOT.equals(volume.getVolumeType())) {
+ rootDiskCount++;
+ } else if (Volume.Type.DATADISK.equals(volume.getVolumeType())) {
+ dataDiskCount++;
+ }
+ totalLogicalSizeBytes += volume.getSize() != null ? volume.getSize() : 0L;
+ }
+ payload.put(OntapStorageConstants.ASUP_ROOT_DISK_COUNT, rootDiskCount);
+ payload.put(OntapStorageConstants.ASUP_DATA_DISK_COUNT, dataDiskCount);
+ payload.put(OntapStorageConstants.ASUP_TOTAL_LOGICAL_SIZE_BYTES, totalLogicalSizeBytes);
+ } catch (Exception e) {
+ logger.error("ONTAP ASUP: failed to compute usage for pool [{}]: {}", pool.getId(), e.getMessage());
+ }
+ }
+
+ /**
+ * Adds {@code hasMultiPrimaryStoragePoolVm}: true when at least one VM with a Ready ROOT
+ * on this pool also has an attached Ready DATADISK on a different primary storage pool.
+ * Uses a single {@code LIMIT 1} existence query.
+ */
+ private void hasMultiPrimaryStoragePoolVm(StoragePoolVO pool, Map payload) {
+ try {
+ payload.put(OntapStorageConstants.ASUP_MULTI_PRIMARY_STORAGE_POOL_VM,
+ volumeDao.hasMultiPrimaryStoragePoolVm(pool.getId()));
+ } catch (Exception e) {
+ logger.warn("ONTAP ASUP: failed to compute multiPrimaryStoragePoolVm for pool [{}]: {}",
+ pool.getId(), e.getMessage());
+ }
+ }
+
+ /**
+ * Computes and adds two groups of snapshot telemetry to the pool description payload.
+ *
+ *
Volume-snapshot metrics ({@code volumeSnapshotCount}): counts all
+ * non-destroyed CloudStack volume-level snapshots for volumes on this pool.
+ *
+ *
VM-snapshot metrics ({@code vmSnapshotCount}): counts all active
+ * (non-expunging, non-removed) VM snapshots for VMs whose ROOT disk is on this pool.
+ * A data disk on another pool does not duplicate the count.
+ *
+ *
Best-effort: any failure leaves the fields out without breaking telemetry.
+ */
+ private void addSnapshotMetrics(StoragePoolVO pool, Map payload, List volumes) {
+ addVmSnapshotMetrics(pool, payload, volumes);
+ addVolumeSnapshotMetrics(pool, payload, volumes);
+ }
+
+ /**
+ * Adds {@code volumeSnapshotCount} to the payload.
+ * Counts all non-destroyed CloudStack volume-level snapshots for volumes on this pool.
+ */
+ private void addVolumeSnapshotMetrics(StoragePoolVO pool, Map payload, List volumes) {
+ try {
+ if (volumes.isEmpty()) {
+ payload.put(OntapStorageConstants.ASUP_VOLUME_SNAPSHOT_COUNT, 0);
+ return;
+ }
+
+ List volumeIds = volumes.stream()
+ .map(VolumeVO::getId)
+ .collect(java.util.stream.Collectors.toList());
+
+ List snapshots = snapshotDao.searchByVolumes(volumeIds);
+ long snapCount = snapshots == null ? 0L : snapshots.stream()
+ .filter(snap -> !com.cloud.storage.Snapshot.State.Destroyed.equals(snap.getState()))
+ .count();
+
+ payload.put(OntapStorageConstants.ASUP_VOLUME_SNAPSHOT_COUNT, snapCount);
+ } catch (Exception e) {
+ logger.warn("ONTAP ASUP: failed to compute volume-snapshot metrics for pool [{}]: {}",
+ pool.getId(), e.getMessage());
+ }
+ }
+
+ /**
+ * Adds {@code vmSnapshotCount} to the payload.
+ * Counts all active (non-expunging, non-removed) VM snapshots for VMs whose ROOT
+ * disk is on this pool, so a multi-pool VM is counted once (on the root pool).
+ */
+ private void addVmSnapshotMetrics(StoragePoolVO pool, Map payload, List volumes) {
+ try {
+ if (volumes.isEmpty()) {
+ payload.put(OntapStorageConstants.ASUP_VM_SNAPSHOT_COUNT, 0);
+ return;
+ }
+
+ java.util.Set vmIds = volumes.stream()
+ .filter(v -> Volume.Type.ROOT.equals(v.getVolumeType()))
+ .map(VolumeVO::getInstanceId)
+ .filter(java.util.Objects::nonNull)
+ .collect(java.util.stream.Collectors.toSet());
+
+ if (vmIds.isEmpty()) {
+ payload.put(OntapStorageConstants.ASUP_VM_SNAPSHOT_COUNT, 0);
+ return;
+ }
+
+ List vmSnapshots = vmSnapshotDao.searchByVms(new java.util.ArrayList<>(vmIds));
+ long vmSnapCount = vmSnapshots == null ? 0L : vmSnapshots.stream()
+ .filter(vmSnap -> !VMSnapshot.State.Expunging.equals(vmSnap.getState())
+ && vmSnap.getRemoved() == null)
+ .count();
+
+ payload.put(OntapStorageConstants.ASUP_VM_SNAPSHOT_COUNT, vmSnapCount);
+ } catch (Exception e) {
+ logger.warn("ONTAP ASUP: failed to compute VM-snapshot metrics for pool [{}]: {}",
+ pool.getId(), e.getMessage());
+ }
+ }
+
+ /**
+ * Serializes a payload map to a JSON string. Falls back to the map's {@code toString()} if
+ * serialization unexpectedly fails, so telemetry is still emitted (best-effort).
+ */
+ private String toJson(Map payload) {
+ try {
+ return objectMapper.writeValueAsString(payload);
+ } catch (Exception e) {
+ logger.warn("ONTAP ASUP: failed to serialize event description to JSON: {}", e.getMessage());
+ return String.valueOf(payload);
+ }
+ }
+
+ /** Builds the common EMS message envelope shared by all ASUP messages. */
+ private EmsApplicationLog buildBaseMessage(String computerName, String appVersion) {
+ EmsApplicationLog message = new EmsApplicationLog();
+ message.setComputerName(computerName);
+ message.setEventSource(OntapStorageConstants.ASUP_EVENT_SOURCE);
+ message.setAppVersion(appVersion);
+ message.setCategory(OntapStorageConstants.ASUP_CATEGORY);
+ message.setSeverity(OntapStorageConstants.ASUP_SEVERITY);
+ message.setAutosupportRequired(Boolean.FALSE);
+ return message;
+ }
+
+ /**
+ * CloudStack version of this management server, same source as {@link ManagementService#getVersion()}
+ * / {@code listCapabilities}. Falls back to "unknown" when the server JAR has no manifest
+ * (for example running from an IDE).
+ */
+ protected String getCloudStackVersion() {
+ String version = managementService != null ? managementService.getVersion() : null;
+ return StringUtils.isBlank(version) ? OntapStorageConstants.ASUP_UNKNOWN : version;
+ }
+
+ /**
+ * Number of management servers registered in {@code mshost} (not removed), including nodes
+ * that are not currently {@code Up}.
+ */
+ protected int getManagementServerCount() {
+ try {
+ List hosts = managementServerHostDao.listAll();
+ return hosts == null ? 0 : hosts.size();
+ } catch (Exception e) {
+ logger.debug("ONTAP ASUP: unable to count management servers: {}", e.getMessage());
+ return 0;
+ }
+ }
+
+ /** Resolves the management server hostname for the EMS computer-name field. */
+ protected String getComputerName() {
+ String hostName = NetUtils.getCanonicalHostName();
+ return StringUtils.isBlank(hostName) ? OntapStorageConstants.ASUP_UNKNOWN : hostName;
+ }
+
+ /**
+ * Resolves the management server operating system (name, version and architecture) from JVM
+ * system properties, e.g. {@code "Linux 5.15.0-91-generic (amd64)"}. Falls back to "unknown".
+ */
+ protected String getOperatingSystem() {
+ String osName = System.getProperty("os.name");
+ String osVersion = System.getProperty("os.version");
+ String osArch = System.getProperty("os.arch");
+ if (StringUtils.isBlank(osName)) {
+ return OntapStorageConstants.ASUP_UNKNOWN;
+ }
+ StringBuilder sb = new StringBuilder(osName);
+ if (StringUtils.isNotBlank(osVersion)) {
+ sb.append(' ').append(osVersion);
+ }
+ if (StringUtils.isNotBlank(osArch)) {
+ sb.append(" (").append(osArch).append(')');
+ }
+ return sb.toString();
+ }
+
+ private String defaultUnknown(String value) {
+ return StringUtils.isBlank(value) ? OntapStorageConstants.ASUP_UNKNOWN : value;
+ }
+
+ /**
+ * Returns a usable interval in hours. {@link OntapStorageConstants#ASUP_DISABLED_INTERVAL_HOURS}
+ * means telemetry is off. Out-of-range or missing DB values (for example set outside the API)
+ * fall back to the default so ASUP is not sent on an unintended cadence. The scheduler
+ * converts a non-zero value to seconds via {@link Duration#ofHours(long)}.
+ */
+ int getAsupIntervalHours(Integer configured) {
+ if (configured == null) {
+ return OntapStorageConstants.ASUP_DEFAULT_INTERVAL_HOURS;
+ }
+ if (configured == OntapStorageConstants.ASUP_DISABLED_INTERVAL_HOURS) {
+ return OntapStorageConstants.ASUP_DISABLED_INTERVAL_HOURS;
+ }
+ if (configured < OntapStorageConstants.ASUP_MIN_INTERVAL_HOURS
+ || configured > OntapStorageConstants.ASUP_MAX_INTERVAL_HOURS) {
+ logger.warn("ONTAP ASUP: {} value [{}] is outside [{}-{}]; using default [{}]",
+ OntapStorageConstants.ASUP_INTERVAL_CONFIG_KEY, configured,
+ OntapStorageConstants.ASUP_MIN_INTERVAL_HOURS,
+ OntapStorageConstants.ASUP_MAX_INTERVAL_HOURS,
+ OntapStorageConstants.ASUP_DEFAULT_INTERVAL_HOURS);
+ return OntapStorageConstants.ASUP_DEFAULT_INTERVAL_HOURS;
+ }
+ return configured;
+ }
+
+ /**
+ * One ONTAP cluster HTTP client plus the cluster GET and node-model GET for this ASUP cycle,
+ * keyed by {@code storageIP}. Discarded when the cycle ends.
+ */
+ static final class AsupClusterClient {
+ final StorageStrategy strategy;
+ final Cluster cluster;
+
+ AsupClusterClient(StorageStrategy strategy, Cluster cluster) {
+ this.strategy = strategy;
+ this.cluster = cluster;
+ }
+ }
+
+}
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..2bc06c12ca6d 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
@@ -23,17 +23,25 @@
import com.cloud.agent.api.to.DataObjectType;
import com.cloud.agent.api.to.DataStoreTO;
import com.cloud.agent.api.to.DataTO;
+import com.cloud.agent.api.to.DiskTO;
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.DataStoreRole;
+import com.cloud.storage.ResizeVolumePayload;
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.VMTemplateStoragePoolVO;
+import com.cloud.storage.dao.SnapshotDao;
import com.cloud.storage.dao.SnapshotDetailsDao;
import com.cloud.storage.dao.SnapshotDetailsVO;
+import com.cloud.storage.dao.VMTemplatePoolDao;
import com.cloud.storage.dao.VolumeDao;
import com.cloud.storage.dao.VolumeDetailsDao;
import com.cloud.utils.Pair;
@@ -44,23 +52,31 @@
import org.apache.cloudstack.engine.subsystem.api.storage.DataObject;
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.ObjectInDataStoreStateMachine;
+import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreDriver;
import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo;
import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo;
import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
import org.apache.cloudstack.framework.async.AsyncCompletionCallback;
import org.apache.cloudstack.storage.command.CommandResult;
+import org.apache.cloudstack.storage.command.CopyCmdAnswer;
import org.apache.cloudstack.storage.command.CreateObjectAnswer;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
import org.apache.cloudstack.storage.feign.client.SnapshotFeignClient;
+import org.apache.cloudstack.storage.feign.model.FileInfo;
import org.apache.cloudstack.storage.feign.model.FlexVolSnapshot;
import org.apache.cloudstack.storage.feign.model.Lun;
+import org.apache.cloudstack.storage.feign.model.LunSpace;
+import org.apache.cloudstack.storage.feign.model.Svm;
+import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy;
import org.apache.cloudstack.storage.feign.model.response.JobResponse;
import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
import org.apache.cloudstack.storage.service.SANStrategy;
import org.apache.cloudstack.storage.service.StorageStrategy;
+import org.apache.cloudstack.storage.service.UnifiedNASStrategy;
import org.apache.cloudstack.storage.service.UnifiedSANStrategy;
import org.apache.cloudstack.storage.service.model.AccessGroup;
import org.apache.cloudstack.storage.service.model.CloudStackVolume;
@@ -74,9 +90,13 @@
import javax.inject.Inject;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.nio.charset.StandardCharsets;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
/**
* Primary datastore driver for NetApp ONTAP storage systems.
@@ -91,6 +111,8 @@ public class OntapPrimaryDatastoreDriver implements PrimaryDataStoreDriver {
@Inject private VolumeDao volumeDao;
@Inject private VolumeDetailsDao volumeDetailsDao;
@Inject private SnapshotDetailsDao snapshotDetailsDao;
+ @Inject private SnapshotDao snapshotDao;
+ @Inject private VMTemplatePoolDao vmTemplatePoolDao;
@Override
public Map getCapabilities() {
@@ -98,6 +120,15 @@ 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());
+ // Enables the framework to cache a template on the FlexVolume once and serve every later
+ // deployment with an array-side clone instead of another copy from secondary storage.
+ mapCapabilities.put(DataStoreCapabilities.CAN_CREATE_VOLUME_FROM_VOLUME.toString(), Boolean.TRUE.toString());
+ // createTemplate(snapshotid) reads the snapshot from this pool instead of backing it up to secondary first.
+ mapCapabilities.put(DataStoreCapabilities.CAN_CREATE_TEMPLATE_FROM_SNAPSHOT.toString(), Boolean.TRUE.toString());
+ // Must be present and false: StorageSystemDataMotionStrategy then clones the snapshot into a
+ // temporary volume (see copyAsync) and copies that volume to secondary storage.
+ mapCapabilities.put(OntapStorageConstants.CAN_DIRECT_ATTACH_SNAPSHOT, Boolean.FALSE.toString());
return mapCapabilities;
}
@@ -115,6 +146,18 @@ public boolean volumesRequireGrantAccessWhenUsed() {
return true;
}
+ @Override
+ public boolean zoneWideVolumesAvailableWithoutClusterMotion(){
+ logger.trace("zoneWideVolumesAvailableWithoutClusterMotion invoked");
+ return true;
+ }
+
+ @Override
+ public boolean requiresAccessForMigration(DataObject dataObject) {
+ logger.trace("requiresAccessForMigration invoked");
+ return true;
+ }
+
/**
* Creates a volume on the ONTAP storage system.
*/
@@ -144,6 +187,7 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet
}
Map details = storagePoolDetailsDao.listDetailsKeyPairs(dataStore.getId());
+ validateProtocol(details, dataStore);
if (dataObject.getType() == DataObjectType.VOLUME) {
VolumeInfo volInfo = (VolumeInfo) dataObject;
@@ -151,35 +195,44 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet
// Update CloudStack volume record with storage pool association and protocol-specific details
VolumeVO volumeVO = volumeDao.findById(volInfo.getId());
if (volumeVO != null) {
- // Create the backend storage object (LUN for iSCSI, no-op for NFS)
- CloudStackVolume created = createCloudStackVolume(storagePool, volInfo, details);
-
- volumeVO.setPoolType(storagePool.getPoolType());
- volumeVO.setPoolId(storagePool.getId());
-
- if (ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
- String lunName = created != null && created.getLun() != null ? created.getLun().getName() : null;
- if (lunName == null) {
- throw new CloudRuntimeException("Missing LUN name for volume " + volInfo.getId());
- }
-
- // Persist LUN details for future operations (delete, grant/revoke access)
- volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_UUID, created.getLun().getUuid(), false);
- volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_NAME, lunName, false);
- if (created.getLun().getUuid() != null) {
- volumeVO.setFolder(created.getLun().getUuid());
- }
-
- logger.info("createAsync: Created LUN [{}] for volume [{}]. LUN mapping will occur during grantAccess() to per-host igroup.",
- lunName, volumeVO.getId());
- createCmdResult = new CreateCmdResult(lunName, new Answer(null, true, null));
- } else if (ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
- createCmdResult = new CreateCmdResult(volInfo.getUuid(), new Answer(null, true, null));
- logger.info("createAsync: Managed NFS volume [{}] with path [{}] associated with pool {}",
- volumeVO.getId(), volInfo.getUuid(), storagePool.getId());
+ /*
+ * Create-volume combinations on ONTAP primary (v1):
+ *
+ * 1) cloneOfSnapshot — StorageSystemDataMotionStrategy sets volume_details.cloneOfSnapshot
+ * when createVolume(snapshotid) targets a managed backend snapshot.
+ * - Same primary pool / FlexVol only (PRIMARY_POOL_ID must match dataStore).
+ * - DATA or ROOT snapshot → new attachable data volume (ROOT is never bootable
+ * via this path; bootable recovery is createTemplate(snapshotid) → deploy).
+ * - Backend: iSCSI → POST /api/storage/luns (clone.source in .snapshot/);
+ * NFS → POST /api/storage/file/clone with source_path
+ * ./.snapshot//.
+ * - IOPS: the new volume's min/max (snapshot values, or the root offering when one
+ * is supplied). Applied here; the temporary template clone does not get a policy.
+ *
+ * 2) cloneOfTemplate — deploy / create from cached template on this pool.
+ *
+ * 3) else — blank LUN (iSCSI) or qcow2 (NFS).
+ *
+ * Mutually exclusive from the motion/orchestrator layer; snapshot is checked first
+ * (SolidFire-style) so a create-from-snapshot never accidentally falls through to blank create.
+ */
+ Long cloneOfSnapshotId = getSnapshotIdForCloning(volInfo.getId());
+ Long cloneOfTemplateId = getTemplateIdForCloning(volInfo.getId());
+ CloudStackVolume clonedCloudStackVolume;
+ if (cloneOfSnapshotId != null) {
+ clonedCloudStackVolume = cloneCloudStackVolumeFromSnapshot(
+ storagePool, volInfo, details, cloneOfSnapshotId, true);
+ } else if (cloneOfTemplateId != null) {
+ clonedCloudStackVolume = cloneCloudStackVolumeFromTemplate(
+ storagePool, volInfo, details, cloneOfTemplateId);
+ } else {
+ clonedCloudStackVolume = createCloudStackVolume(storagePool, volInfo, details);
}
- volumeDao.update(volumeVO.getId(), volumeVO);
+
+ createCmdResult = recordCreatedVolume(storagePool, volInfo, volumeVO, details, clonedCloudStackVolume);
}
+ } else if (dataObject.getType() == DataObjectType.TEMPLATE) {
+ createCmdResult = createTemplateOnPrimary(storagePool, (TemplateInfo) dataObject, details);
} else {
errMsg = "Invalid DataObjectType (" + dataObject.getType() + ") passed to createAsync";
logger.error(errMsg);
@@ -189,7 +242,7 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet
errMsg = e.getMessage();
logger.error("createAsync: Failed for dataObject name [{}]: {}", dataObject.getName(), errMsg);
createCmdResult = new CreateCmdResult(null, new Answer(null, false, errMsg));
- createCmdResult.setResult(e.toString());
+ createCmdResult.setResult(errMsg);
} finally {
if (createCmdResult != null && createCmdResult.isSuccess()) {
logger.info("createAsync: Operation completed successfully for {}", dataObject.getType());
@@ -198,20 +251,548 @@ public void createAsync(DataStore dataStore, DataObject dataObject, AsyncComplet
}
}
+ /**
+ * Records pool association, image format and protocol-specific identity (LUN name/uuid for iSCSI)
+ * of a volume that was just created or cloned on ONTAP, and returns the create result whose path
+ * the framework stores on the volume.
+ */
+ private CreateCmdResult recordCreatedVolume(StoragePoolVO storagePool, VolumeInfo volInfo, VolumeVO volumeVO,
+ Map details, CloudStackVolume createdCloudStackVolume) {
+ CreateCmdResult createCmdResult = null;
+ volumeVO.setPoolType(storagePool.getPoolType());
+ volumeVO.setPoolId(storagePool.getId());
+ volumeVO.setFormat(getImageFormat(storagePool));
+ logger.info("createAsync: Volume format set to [{}] for pool type [{}]", volumeVO.getFormat(), storagePool.getPoolType());
+
+ if (ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ // createCloudStackVolume validates the Feign response (LUN name + uuid) before returning
+ Lun createdLun = createdCloudStackVolume.getLun();
+ String lunName = createdLun.getName();
+
+ // Persist LUN details for future operations (delete, grant/revoke access)
+ volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_UUID, createdLun.getUuid(), false);
+ volumeDetailsDao.addDetail(volInfo.getId(), OntapStorageConstants.LUN_DOT_NAME, lunName, false);
+ volumeVO.setFolder(createdLun.getUuid());
+
+ logger.info("createAsync: Created LUN [{}] for volume [{}]. LUN mapping will occur during grantAccess() to per-host igroup.",
+ lunName, volumeVO.getId());
+ createCmdResult = new CreateCmdResult(lunName, new Answer(null, true, null));
+ } else if (ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ createCmdResult = new CreateCmdResult(volInfo.getUuid(), new Answer(null, true, null));
+ logger.info("createAsync: Managed NFS volume [{}] with path [{}] associated with pool {}",
+ volumeVO.getId(), volInfo.getUuid(), storagePool.getId());
+ }
+ volumeDao.update(volumeVO.getId(), volumeVO);
+ return createCmdResult;
+ }
+
/**
* Creates a volume on the ONTAP backend.
*/
private CloudStackVolume createCloudStackVolume(StoragePoolVO storagePool, VolumeInfo volumeObject, Map details) {
+ verifySufficientIopsForStoragePool(storagePool, volumeObject.getMinIops(), volumeObject.getId());
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ VolumeQosPolicy qosPolicy = null;
+ Volume.Type volumeType = volumeObject.getVolumeType();
+ if (volumeType == Volume.Type.DATADISK || volumeType == Volume.Type.ROOT) {
+ qosPolicy = createQosPolicyIfNeeded(storageStrategy, details,
+ volumeObject.getMinIops(), volumeObject.getMaxIops(), storagePool.getId());
+ }
+ CloudStackVolume request = createCloudStackVolumeRequestByProtocol(
+ storagePool, details, volumeObject, qosPolicy);
+ try {
+ CloudStackVolume created = storageStrategy.createCloudStackVolume(request);
+ persistQosPolicyDetails(volumeObject.getId(), qosPolicy);
+ return created;
+ } catch (RuntimeException e) {
+ if (qosPolicy != null) {
+ storageStrategy.deleteVolumeQosPolicy(qosPolicy.getUuid());
+ }
+ throw e;
+ }
+ }
+
+ private VolumeQosPolicy createQosPolicyIfNeeded(StorageStrategy storageStrategy, Map details,
+ Long minIops, Long maxIops, Long poolId) {
+ if (!validateIops(storageStrategy, details, poolId, minIops, maxIops)) {
+ return null;
+ }
+ String policyName = getQosPolicyName(details.get(OntapStorageConstants.SVM_NAME), minIops, maxIops);
+ return storageStrategy.createVolumeQosPolicy(policyName, minIops, maxIops);
+ }
+
+ private boolean validateIops(StorageStrategy storageStrategy, Map details,
+ Long poolId, Long minIops, Long maxIops) {
+ long min = minIops == null ? 0 : minIops;
+ long max = maxIops == null ? 0 : maxIops;
+ if (min <= 0 && max <= 0) {
+ return false;
+ }
+ if (min > 0 && max > 0 && min > max) {
+ throw new CloudRuntimeException("Minimum IOPS cannot be greater than maximum IOPS");
+ }
+ if (min > 0) {
+ String isAff = details.get(OntapStorageConstants.IS_AFF);
+ if (StringUtils.isBlank(isAff)) {
+ try {
+ isAff = Boolean.toString(storageStrategy.isAff());
+ details.put(OntapStorageConstants.IS_AFF, isAff);
+ storagePoolDetailsDao.addDetail(poolId, OntapStorageConstants.IS_AFF, isAff, false);
+ } catch (Exception e) {
+ logger.error("Unable to determine whether storage pool [{}] is AFF or FAS: {}",
+ poolId, e.getMessage());
+ return true;
+ }
+ }
+ if (!Boolean.parseBoolean(isAff)) {
+ throw new CloudRuntimeException(OntapStorageConstants.MIN_IOPS_NOT_SUPPORTED_ON_FAS);
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Builds a reusable SVM-scoped QoS policy name: cs_{min}_to_{max}_iops_{svmName}.
+ * Dots in the SVM name are replaced with underscores (ONTAP QoS names cannot contain '.').
+ */
+ private String getQosPolicyName(String svmName, Long minIops, Long maxIops) {
+ String sanitizedSvmName = svmName == null ? "" : svmName.replace(".", OntapStorageConstants.UNDERSCORE);
+ long min = minIops != null && minIops > 0 ? minIops : 0;
+ long max = maxIops != null && maxIops > 0 ? maxIops : 0;
+ return OntapStorageConstants.QOS_POLICY_NAME_PREFIX + min + OntapStorageConstants.UNDERSCORE
+ + OntapStorageConstants.QOS_POLICY_NAME_TO + max + OntapStorageConstants.UNDERSCORE
+ + OntapStorageConstants.QOS_POLICY_NAME_IOPS + sanitizedSvmName;
+ }
+
+ private void persistQosPolicyDetails(long volumeId, VolumeQosPolicy qosPolicy) {
+ volumeDetailsDao.removeDetail(volumeId, OntapStorageConstants.QOS_POLICY_UUID);
+ if (qosPolicy == null) {
+ return;
+ }
+ volumeDetailsDao.addDetail(volumeId, OntapStorageConstants.QOS_POLICY_UUID, qosPolicy.getUuid(), false);
+ }
+
+ /**
+ * Creates the backend object that caches a template on this pool's FlexVolume.
+ *
+ *
Protocol-specific work is delegated to {@link StorageStrategy#createTemplateCache}.
+ * This method maps the result to {@link CreateCmdResult} and records SAN identity on
+ * {@code template_spool_ref} ({@code local_download_path} = LUN uuid).
+ */
+ private CreateCmdResult createTemplateOnPrimary(StoragePoolVO storagePool, TemplateInfo templateInfo, Map details) {
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ long sizeInBytes = getDataObjectSizeIncludingHypervisorSnapshotReserve(templateInfo, storagePool);
+ CloudStackVolume created = null;
+ try {
+ created = storageStrategy.createTemplateCache(storagePool, templateInfo, details, sizeInBytes);
+ String path = recordTemplateCacheOnSpoolRef(storagePool, templateInfo, created, sizeInBytes);
+ return new CreateCmdResult(path, new Answer(null, true, null));
+ } catch (Exception e) {
+ // Compensating delete for SAN: strategy cleans create-time LUN failures; this covers
+ // post-create failures (e.g. template_spool_ref update) after a LUN was returned.
+ if (created != null && created.getLun() != null) {
+ bestEffortDeleteTemplateCacheLun(storageStrategy, details.get(OntapStorageConstants.SVM_NAME),
+ created.getLun().getName(), created.getLun().getUuid());
+ }
+ if (e instanceof CloudRuntimeException) {
+ throw (CloudRuntimeException) e;
+ }
+ throw new CloudRuntimeException("Failed to create template cache for template [" + templateInfo.getId()
+ + "]: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Persists SAN cache identity on {@code template_spool_ref} when a LUN was created.
+ * NFS returns the template uuid as the create path; {@code install_path} is filled later.
+ */
+ private String recordTemplateCacheOnSpoolRef(StoragePoolVO storagePool, TemplateInfo templateInfo,
+ CloudStackVolume created, long sizeInBytes) {
+ if (created == null || created.getLun() == null) {
+ return templateInfo.getUuid();
+ }
+ Lun lun = created.getLun();
+ VMTemplateStoragePoolVO templatePoolRef = findTemplatePoolRef(storagePool.getId(), templateInfo.getId());
+ templatePoolRef.setLocalDownloadPath(lun.getUuid());
+ templatePoolRef.setTemplateSize(sizeInBytes);
+ vmTemplatePoolDao.update(templatePoolRef.getId(), templatePoolRef);
+ return lun.getName();
+ }
+
+ /**
+ * Clones the cached template into a new volume on the same FlexVolume.
+ *
+ *
Invoked when {@code StorageSystemDataMotionStrategy} has recorded a
+ * {@code cloneOfTemplate} detail on the volume.
{@code StorageSystemDataMotionStrategy} persists this detail immediately before calling
+ * {@code createAsync} and removes it right after, so it is only visible during creation.
+ */
+ private Long getTemplateIdForCloning(long volumeId) {
+ VolumeDetailVO detail = volumeDetailsDao.findDetail(volumeId, OntapStorageConstants.CLONE_OF_TEMPLATE);
+ if (detail == null || detail.getValue() == null || detail.getValue().isEmpty()) {
+ return null;
+ }
+ return Long.valueOf(detail.getValue());
+ }
+
+ /**
+ * Returns the CloudStack snapshot id to clone from when {@code volume_details.cloneOfSnapshot}
+ * is set, or null when this create is not a create-volume-from-snapshot.
+ *
+ *
Set by {@code StorageSystemDataMotionStrategy.handleCreateManagedVolumeFromManagedSnapshot}
+ * for the duration of {@code createAsync} only (same pattern as {@link #getTemplateIdForCloning}).
+ */
+ private Long getSnapshotIdForCloning(long volumeId) {
+ VolumeDetailVO detail = volumeDetailsDao.findDetail(volumeId, OntapStorageConstants.CLONE_OF_SNAPSHOT);
+ if (detail == null || detail.getValue() == null || detail.getValue().isEmpty()) {
+ return null;
+ }
+ return Long.valueOf(detail.getValue());
+ }
+
+ /**
+ * Creates a new volume on this pool by cloning a file/LUN from a CloudStack volume snapshot
+ * that already lives on the same FlexVolume.
+ *
+ *
Combinations (product + plugin v1):
+ *
+ *
Same pool only — {@code snapshot_details.PRIMARY_POOL_ID} must equal this
+ * {@code storagePool}. Creating the volume on a different pool is descoped; use migrate later if needed.
+ *
DATA snapshot → attachable data disk (disk offering usually inherited).
+ *
ROOT snapshot → still a data disk here (not bootable). Bootable path remains
+ * {@code createTemplate(snapshotid)} then deploy.
+ *
iSCSI — {@code POST /api/storage/luns} with
+ * {@code clone.source.name=/vol/<fv>/.snapshot/<snap>/<lun>}
+ *
NFS3 — {@code POST /api/storage/file/clone} with
+ * {@code source_path=./.snapshot/<snapshot-name>/<source-file>}
+ *
Not this path — in-place revert ({@code revertSnapshot}); VM/instance snapshots;
+ * secondary-storage archive restore.
+ *
+ *
+ *
Optional grow when the disk offering is larger than the snapshot size (same pattern as
+ * clone-from-template).
+ *
+ *
When {@code applyQos} is true, the new volume's min and max IOPS are applied the same way
+ * as a template clone: iSCSI carries the policy on the clone request, NFS attaches it after
+ * the file exists. The temporary volume used for {@code createTemplate(snapshotid)} passes
+ * false and is left without a policy.
+ */
+ private CloudStackVolume cloneCloudStackVolumeFromSnapshot(StoragePoolVO storagePool, VolumeInfo volumeInfo,
+ Map details, long csSnapshotId,
+ boolean applyQos) {
+ String snapshotName = requireSnapshotDetail(csSnapshotId, OntapStorageConstants.ONTAP_SNAP_NAME);
+ String volumePath = requireSnapshotDetail(csSnapshotId, OntapStorageConstants.VOLUME_PATH);
+ String primaryPoolId = requireSnapshotDetail(csSnapshotId, OntapStorageConstants.PRIMARY_POOL_ID);
+ String snapProtocol = requireSnapshotDetail(csSnapshotId, OntapStorageConstants.PROTOCOL);
+
+ // Same-pool / same-protocol gate (v1). Fail before any ONTAP call.
+ if (!String.valueOf(storagePool.getId()).equals(primaryPoolId)) {
+ throw new CloudRuntimeException("Create volume from snapshot [" + csSnapshotId
+ + "] requires the snapshot's primary pool [" + primaryPoolId
+ + "]; requested pool is [" + storagePool.getId() + "] (creating a volume from a snapshot on a different pool is not supported)");
+ }
+ String poolProtocol = details.get(OntapStorageConstants.PROTOCOL);
+ if (poolProtocol == null || !poolProtocol.equalsIgnoreCase(snapProtocol)) {
+ throw new CloudRuntimeException("Create volume from snapshot [" + csSnapshotId
+ + "] protocol mismatch: snapshot=[" + snapProtocol + "], pool=[" + poolProtocol + "]");
+ }
+
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ boolean iscsi = isIscsi(details);
+ VolumeQosPolicy qosPolicy = null;
+
+ logger.info("cloneCloudStackVolumeFromSnapshot: Cloning from CS snapshot [{}] (ONTAP snap [{}], path [{}]) "
+ + "for volume [{}] on pool [{}] protocol [{}]",
+ csSnapshotId, snapshotName, volumePath, volumeInfo.getId(), storagePool.getId(), poolProtocol);
+
+ try {
+ if (applyQos) {
+ verifySufficientIopsForStoragePool(storagePool, volumeInfo.getMinIops(), volumeInfo.getId());
+ Volume.Type volumeType = volumeInfo.getVolumeType();
+ if (volumeType == Volume.Type.DATADISK || volumeType == Volume.Type.ROOT) {
+ qosPolicy = createQosPolicyIfNeeded(storageStrategy, details,
+ volumeInfo.getMinIops(), volumeInfo.getMaxIops(), storagePool.getId());
+ }
+ }
+
+ CloudStackVolume request = iscsi
+ ? createCloneLunFromSnapshotRequest(storagePool, details, volumeInfo, volumePath, snapshotName, qosPolicy)
+ : createCloneFileFromSnapshotRequest(storagePool, details, volumeInfo, volumePath, snapshotName);
+ CloudStackVolume cloned = storageStrategy.cloneCloudStackVolume(request);
+ if (cloned == null) {
+ throw new CloudRuntimeException("ONTAP returned nothing when cloning snapshot [" + csSnapshotId
+ + "] for volume [" + volumeInfo.getId() + "]");
+ }
+
+ long requestedSize = getDataObjectSizeIncludingHypervisorSnapshotReserve(volumeInfo, storagePool);
+ long snapshotSize = resolveSnapshotSizeBytes(csSnapshotId);
+ if (snapshotSize > 0 && requestedSize > snapshotSize) {
+ logger.info("cloneCloudStackVolumeFromSnapshot: Growing clone of snapshot [{}] from {} to {} bytes for volume [{}]",
+ csSnapshotId, snapshotSize, requestedSize, volumeInfo.getId());
+ resizeClonedVolumeOrCleanup(storageStrategy, cloned, requestedSize,
+ "cloneCloudStackVolumeFromSnapshot snapshot [" + csSnapshotId + "] volume [" + volumeInfo.getId() + "]");
+ }
+
+ if (!iscsi && qosPolicy != null) {
+ CloudStackVolume cloudStackVolume = createCloudStackVolumeRequestByProtocol(
+ storagePool, details, volumeInfo, qosPolicy);
+ storageStrategy.updateCloudStackVolume(cloudStackVolume);
+ }
+ if (applyQos) {
+ persistQosPolicyDetails(volumeInfo.getId(), qosPolicy);
+ }
+ return cloned;
+ } catch (Exception e) {
+ if (qosPolicy != null) {
+ storageStrategy.deleteVolumeQosPolicy(qosPolicy.getUuid());
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Grows a just-created ONTAP clone to the requested size. If resize fails, deletes the clone
+ * so the CloudStack create-failure path does not leave an orphaned LUN/file on the array.
+ */
+ private void resizeClonedVolumeOrCleanup(StorageStrategy storageStrategy, CloudStackVolume cloned,
+ long requestedSize, String context) {
+ try {
+ storageStrategy.resizeCloudStackVolume(cloned, requestedSize);
+ } catch (RuntimeException resizeEx) {
+ logger.error("{}: Resize to {} bytes failed; deleting orphaned ONTAP clone: {}",
+ context, requestedSize, resizeEx.getMessage());
+ bestEffortDeleteOrphanedClone(storageStrategy, cloned);
+ throw resizeEx;
+ }
+ }
+
+ private void bestEffortDeleteOrphanedClone(StorageStrategy storageStrategy, CloudStackVolume cloned) {
+ try {
+ storageStrategy.deleteCloudStackVolume(cloned);
+ logger.info("Deleted orphaned ONTAP clone after resize failure");
+ } catch (Exception cleanupEx) {
+ logger.warn("Failed to delete orphaned ONTAP clone after resize failure: {}", cleanupEx.getMessage());
+ }
+ }
+
+ private String requireSnapshotDetail(long csSnapshotId, String key) {
+ String value = getSnapshotDetail(csSnapshotId, key);
+ if (value == null || value.isEmpty()) {
+ throw new CloudRuntimeException("Missing snapshot_details [" + key + "] for snapshot [" + csSnapshotId
+ + "]; cannot create volume from snapshot");
+ }
+ return value;
+ }
+
+ private long resolveSnapshotSizeBytes(long csSnapshotId) {
+ SnapshotVO snapshotVO = snapshotDao.findById(csSnapshotId);
+ if (snapshotVO == null || snapshotVO.getSize() <= 0) {
+ return 0L;
+ }
+ return snapshotVO.getSize();
+ }
+
+ private VMTemplateStoragePoolVO findTemplatePoolRef(long poolId, long templateId) {
+ VMTemplateStoragePoolVO templatePoolRef = vmTemplatePoolDao.findByPoolTemplate(poolId, templateId, null);
+ if (templatePoolRef == null) {
+ throw new CloudRuntimeException("No template_spool_ref row for template [" + templateId + "] on pool [" + poolId + "]");
+ }
+ return templatePoolRef;
+ }
+
+ /**
+ * Deletes the LUN caching a template on this pool, invoked by template eviction
+ * ({@code TemplateManagerImpl.evictTemplateFromStoragePool}).
+ *
+ *
Volumes previously cloned from this LUN are unaffected: an ONTAP sis-clone shares blocks
+ * with its source through reference counting rather than depending on it, so the source can be
+ * removed while its clones stay online.
+ *
+ *
{@code deleteCloudStackVolume} already unmaps as it deletes ({@code allow_delete_while_mapped})
+ * and treats a missing LUN as success.
+ */
+ private void deleteTemplateOnPrimary(DataStore store, TemplateInfo templateInfo) {
+ StoragePoolVO storagePool = storagePoolDao.findById(store.getId());
+ if (storagePool == null) {
+ throw new CloudRuntimeException("Storage Pool not found for id: " + store.getId());
+ }
+
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(store.getId());
+ VMTemplateStoragePoolVO templatePoolRef = vmTemplatePoolDao.findByPoolTemplate(storagePool.getId(), templateInfo.getId(), null);
+ if (templatePoolRef == null) {
+ logger.warn("deleteTemplateOnPrimary: No template_spool_ref for template [{}] on pool [{}]; nothing to delete",
+ templateInfo.getId(), storagePool.getId());
+ return;
+ }
+
StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
- CloudStackVolume cloudStackVolumeRequest = OntapStorageUtils.createCloudStackVolumeRequestByProtocol(storagePool, details, volumeObject);
- return storageStrategy.createCloudStackVolume(cloudStackVolumeRequest);
+ if (isIscsi(details)) {
+ deleteIscsiTemplateCache(storagePool, templateInfo, templatePoolRef, storageStrategy, details);
+ } else {
+ deleteNfsTemplateCache(details, templateInfo, templatePoolRef, storageStrategy);
+ }
+ }
+
+ private void deleteIscsiTemplateCache(StoragePoolVO storagePool, TemplateInfo templateInfo,
+ VMTemplateStoragePoolVO templatePoolRef, StorageStrategy storageStrategy,
+ Map details) {
+ String lunName = getTemplateLunName(storagePool, templateInfo.getId());
+ String lunUuid = templatePoolRef.getLocalDownloadPath();
+ deleteTemplateCacheLun(storageStrategy, details.get(OntapStorageConstants.SVM_NAME), lunName, lunUuid);
+ logger.info("deleteIscsiTemplateCache: Deleted template cache LUN [{}] for template [{}] on pool [{}]",
+ lunName, templateInfo.getId(), storagePool.getId());
+ }
+
+ /**
+ * Deletes a template-cache LUN. When {@code lunUuid} is missing, resolves the LUN by its
+ * deterministic name so eviction and create-time rollback still work.
+ */
+ private void deleteTemplateCacheLun(StorageStrategy storageStrategy, String svmName, String lunName, String lunUuid) {
+ String uuid = lunUuid;
+ if (uuid == null || uuid.isEmpty()) {
+ CloudStackVolume existing = getCloudStackVolumeByName(storageStrategy, svmName, lunName);
+ if (existing == null || existing.getLun() == null || existing.getLun().getUuid() == null) {
+ logger.warn("deleteTemplateCacheLun: LUN [{}] not found on SVM [{}]; nothing to delete", lunName, svmName);
+ return;
+ }
+ uuid = existing.getLun().getUuid();
+ }
+
+ Lun lun = new Lun();
+ lun.setUuid(uuid);
+ lun.setName(lunName);
+
+ CloudStackVolume deleteRequest = new CloudStackVolume();
+ deleteRequest.setLun(lun);
+ storageStrategy.deleteCloudStackVolume(deleteRequest);
+ }
+
+ /**
+ * Best-effort cleanup of a template-cache LUN after a failed create. Never masks the original
+ * create failure.
+ */
+ private void bestEffortDeleteTemplateCacheLun(StorageStrategy storageStrategy, String svmName, String lunName,
+ String lunUuid) {
+ try {
+ deleteTemplateCacheLun(storageStrategy, svmName, lunName, lunUuid);
+ logger.info("bestEffortDeleteTemplateCacheLun: Removed leftover template cache LUN [{}] on SVM [{}]",
+ lunName, svmName);
+ } catch (Exception cleanupEx) {
+ logger.warn("bestEffortDeleteTemplateCacheLun: Failed to remove leftover template cache LUN [{}] on SVM [{}]: {}",
+ lunName, svmName, cleanupEx.getMessage());
+ }
+ }
+
+ private void deleteNfsTemplateCache(Map details, TemplateInfo templateInfo,
+ VMTemplateStoragePoolVO templatePoolRef, StorageStrategy storageStrategy) {
+ String filePath = templatePoolRef.getInstallPath();
+ if (filePath == null || filePath.isEmpty()) {
+ logger.warn("deleteNfsTemplateCache: No install_path recorded for template [{}]; nothing to delete",
+ templateInfo.getId());
+ return;
+ }
+ String flexVolUuid = details.get(OntapStorageConstants.VOLUME_UUID);
+ if (flexVolUuid == null || flexVolUuid.isEmpty()) {
+ // Misconfigured pool detail — fail eviction rather than calling ONTAP with a null
+ // volume UUID (which would hit /api/storage/volumes/null and still look like success
+ // upstream if we swallowed the error).
+ throw new CloudRuntimeException("FlexVolume UUID (volumeUUID) is missing from storage pool details; "
+ + "cannot delete NFS template cache file [" + filePath + "] for template ["
+ + templateInfo.getId() + "]");
+ }
+ ((UnifiedNASStrategy) storageStrategy).deleteFileByPath(flexVolUuid, filePath);
+ logger.info("deleteNfsTemplateCache: Deleted template cache file [{}] for template [{}]",
+ filePath, templateInfo.getId());
+ }
+
+ /**
+ * Rejects the request when the minimum IOPS being asked for would push the pool past its
+ * configured IOPS capacity. Pools without an IOPS capacity enforce no ceiling.
+ *
+ * Used IOPS include volumes still in {@link Volume.State#Creating} so a second overlapping
+ * create sees the first reservation. The volume being checked is omitted so its own min IOPS
+ * are not counted twice (CloudStack has already moved it to Creating before this runs).
+ */
+ private void verifySufficientIopsForStoragePool(StoragePoolVO storagePool, Long requestedMinIops, long excludeVolumeId) {
+ Long capacityIops = storagePool.getCapacityIops();
+ if (capacityIops == null || requestedMinIops == null || requestedMinIops <= 0) {
+ return;
+ }
+
+ long requestedTotalIops = getAllocatedMinIops(storagePool, excludeVolumeId) + requestedMinIops;
+ if (requestedTotalIops > capacityIops) {
+ throw new CloudRuntimeException(String.format(
+ "Insufficient IOPS capacity on storage pool %s: requested total of %d IOPS exceeds the pool IOPS capacity of %d",
+ storagePool.getName(), requestedTotalIops, capacityIops));
+ }
}
/**
* 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) {
@@ -231,14 +812,39 @@ public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallbac
StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
logger.info("createCloudStackVolumeForTypeVolume: Connection to Ontap SVM [{}] successful, preparing CloudStackVolumeRequest", details.get(OntapStorageConstants.SVM_NAME));
VolumeInfo volumeInfo = (VolumeInfo) data;
- CloudStackVolume cloudStackVolumeRequest = createDeleteCloudStackVolumeRequest(storagePool, details, volumeInfo);
- storageStrategy.deleteCloudStackVolume(cloudStackVolumeRequest);
+ VolumeDetailVO qosPolicyDetail = volumeDetailsDao.findDetail(volumeInfo.getId(), OntapStorageConstants.QOS_POLICY_UUID);
+
+ // NFS file delete leaves the QoS assignment in place unless the file is set to none first.
+ // iSCSI LUN delete already drops object_count.
+ // TODO: revisit this NFS none-policy step when the file is deleted from ONTAP instead of KVM.
+ boolean qosCleared = false;
+ if (!isIscsi(details)) {
+ qosCleared = clearQosPolicyBeforeDelete(storageStrategy, storagePool, details, volumeInfo, qosPolicyDetail);
+ }
+ try {
+ CloudStackVolume cloudStackVolumeRequest = createDeleteCloudStackVolumeRequest(storagePool, details, volumeInfo);
+ storageStrategy.deleteCloudStackVolume(cloudStackVolumeRequest);
+ } catch (Exception e) {
+ if (qosCleared) {
+ restoreQosPolicyAfterFailedDelete(storageStrategy, storagePool, details, volumeInfo, qosPolicyDetail);
+ }
+ throw e;
+ }
+ if (qosPolicyDetail != null) {
+ deleteUnusedQosPolicy(storageStrategy, qosPolicyDetail.getValue());
+ }
logger.info("deleteAsync: Volume deleted: " + volumeInfo.getId());
+ removeTemporarySnapshotCopyRecord(volumeInfo.getId());
+ commandResult.setResult(null);
+ commandResult.setSuccess(true);
+ } else if (data.getType() == DataObjectType.TEMPLATE) {
+ deleteTemplateOnPrimary(store, (TemplateInfo) data);
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());
}
@@ -247,104 +853,386 @@ public void deleteAsync(DataStore store, DataObject data, AsyncCompletionCallbac
commandResult.setSuccess(false);
commandResult.setResult(e.getMessage());
} finally {
- callback.complete(commandResult);
+ // StorageSystemDataMotionStrategy deletes its temporary snapshot copy with a null callback.
+ if (callback != null) {
+ callback.complete(commandResult);
+ }
+ }
+ }
+
+ /**
+ * Sets the NFS file QoS policy to {@code none} before the file is deleted.
+ * Failure must not block volume delete.
+ *
+ * @return true when the file was moved off its policy
+ */
+ private boolean clearQosPolicyBeforeDelete(StorageStrategy storageStrategy, StoragePoolVO storagePool,
+ Map details, VolumeInfo volumeInfo,
+ VolumeDetailVO qosPolicyDetail) {
+ if (qosPolicyDetail == null || qosPolicyDetail.getValue() == null) {
+ return false;
+ }
+ VolumeQosPolicy nonePolicy = new VolumeQosPolicy();
+ nonePolicy.setName(OntapStorageConstants.QOS_POLICY_NONE);
+ try {
+ CloudStackVolume request = createCloudStackVolumeRequestByProtocol(storagePool, details, volumeInfo, nonePolicy);
+ storageStrategy.updateCloudStackVolume(request);
+ return true;
+ } catch (Exception e) {
+ logger.warn("Could not clear QoS policy [{}] on volume [{}] before delete: {}",
+ qosPolicyDetail.getValue(), volumeInfo.getId(), e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Puts the previous QoS policy back on an NFS file when delete fails after the policy was cleared.
+ */
+ private void restoreQosPolicyAfterFailedDelete(StorageStrategy storageStrategy, StoragePoolVO storagePool,
+ Map details, VolumeInfo volumeInfo,
+ VolumeDetailVO qosPolicyDetail) {
+ if (qosPolicyDetail == null || qosPolicyDetail.getValue() == null) {
+ return;
+ }
+ VolumeQosPolicy previousPolicy = new VolumeQosPolicy();
+ previousPolicy.setUuid(qosPolicyDetail.getValue());
+ try {
+ CloudStackVolume request = createCloudStackVolumeRequestByProtocol(storagePool, details, volumeInfo, previousPolicy);
+ storageStrategy.updateCloudStackVolume(request);
+ } catch (Exception e) {
+ logger.warn("Could not restore QoS policy [{}] on volume [{}] after delete failed: {}",
+ qosPolicyDetail.getValue(), volumeInfo.getId(), e.getMessage());
}
}
/**
- * 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);
+ storageStrategy.deleteFlexVolSnapshotForCloudStackVolume(flexVolUuid, ontapSnapshotUuid, snapshotName);
- 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("deleteCloudStackVolumeSnapshot: completed ONTAP delete for CloudStack volume snapshot [{}]", snapshotId);
+ commandResult.setSuccess(true);
+ commandResult.setResult(null);
+ } catch (Exception e) {
+ 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);
+ 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) {
+ copyAsync(srcData, destData, null, callback);
+ }
+
+ /**
+ * Dispatches a driver-level copy by (source type, destination type). Only the pairs accepted by
+ * {@link #canCopy} are handled; everything else is left to the data motion strategies.
+ *
+ *
Callers may pass a null callback (for example {@code StorageSystemDataMotionStrategy} for the
+ * temporary snapshot copy), in which case a failed copy is thrown.
+ */
+ @Override
+ public void copyAsync(DataObject srcData, DataObject destData, Host destHost, AsyncCompletionCallback callback) {
+ CopyCommandResult result;
+ if (isSnapshotToTemporaryVolumeCopy(srcData, destData)) {
+ result = copySnapshotToTemporaryVolume(srcData, (VolumeInfo) destData);
+ } else {
+ throw new UnsupportedOperationException("Copy from " + describeForCopy(srcData) + " to "
+ + describeForCopy(destData) + " is not supported for ONTAP primary storage.");
+ }
+
+ if (callback != null) {
+ callback.complete(result);
+ } else if (!result.isSuccess()) {
+ throw new CloudRuntimeException("Failed to copy " + srcData.getType() + " [" + srcData.getId() + "] to "
+ + destData.getType() + " [" + destData.getId() + "]: " + result.getResult());
+ }
+ }
+
+ /**
+ * Returns true only for the (source type, destination type) pairs the driver copies itself.
+ *
+ *
{@code DataMotionServiceImpl} consults {@code canCopy} before choosing a data motion strategy,
+ * so any pair accepted here bypasses {@code StorageSystemDataMotionStrategy} (volume migration,
+ * createVolume(snapshotid), template caching). Add a pair only together with its handler in
+ * {@link #copyAsync}.
+ */
+ @Override
+ public boolean canCopy(DataObject srcData, DataObject destData) {
+ if (srcData == null || destData == null || srcData.getDataStore() == null || destData.getDataStore() == null) {
+ return false;
+ }
+ return isSnapshotToTemporaryVolumeCopy(srcData, destData);
+ }
+
+ private String describeForCopy(DataObject data) {
+ return data == null ? "null" : data.getType() + " [" + data.getId() + "]";
+ }
+
+ /**
+ * Matches the temporary volume {@code StorageSystemDataMotionStrategy} creates on the snapshot's own
+ * pool while copying the snapshot to secondary storage for {@code createTemplate(snapshotid)}.
+ */
+ private boolean isSnapshotToTemporaryVolumeCopy(DataObject srcData, DataObject destData) {
+ if (srcData == null || destData == null || srcData.getDataStore() == null || destData.getDataStore() == null) {
+ return false;
+ }
+ if (srcData.getType() != DataObjectType.SNAPSHOT || destData.getType() != DataObjectType.VOLUME) {
+ return false;
+ }
+ if (srcData.getDataStore().getRole() != DataStoreRole.Primary
+ || srcData.getDataStore().getId() != destData.getDataStore().getId()) {
+ return false;
+ }
+ return isTemporarySnapshotCopyVolume(volumeDao.findById(destData.getId()));
+ }
+
+ /**
+ * Clones a CloudStack volume snapshot into the temporary volume used for
+ * {@code createTemplate(snapshotid)}. The strategy then maps that volume to a KVM host, copies it
+ * into the template and deletes it again through {@link #deleteAsync}.
+ */
+ private CopyCommandResult copySnapshotToTemporaryVolume(DataObject srcData, VolumeInfo volInfo) {
+ try {
+ StoragePoolVO storagePool = storagePoolDao.findById(volInfo.getDataStore().getId());
+ if (storagePool == null) {
+ throw new CloudRuntimeException("Storage Pool not found for id: " + volInfo.getDataStore().getId());
+ }
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId());
+ validateProtocol(details, volInfo.getDataStore());
+ VolumeVO volumeVO = volumeDao.findById(volInfo.getId());
+
+ renameTemporarySnapshotCopy(volInfo, volumeVO, srcData.getId());
+ logger.info("copySnapshotToTemporaryVolume: Cloning CS snapshot [{}] into temporary volume [{}] on pool [{}] for template creation",
+ srcData.getId(), volInfo.getId(), storagePool.getId());
+ CloudStackVolume cloned = cloneCloudStackVolumeFromSnapshot(storagePool, volInfo, details, srcData.getId(), false);
+ if (ProtocolType.NFS3.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ volumeVO.setPath(volInfo.getUuid());
}
+ recordCreatedVolume(storagePool, volInfo, volumeVO, details, cloned);
+ return new CopyCommandResult(null, new CopyCmdAnswer(volInfo.getTO()));
+ } catch (Exception e) {
+ logger.error("copySnapshotToTemporaryVolume: Failed to clone snapshot [{}] into volume [{}]: {}",
+ srcData.getId(), volInfo.getId(), e.getMessage());
+ CopyCommandResult result = new CopyCommandResult(null, new CopyCmdAnswer(e.getMessage()));
+ result.setResult(e.getMessage());
+ return result;
+ }
+ }
+
+ /**
+ * The temporary volume created by {@code StorageSystemDataMotionStrategy} for a snapshot copy is
+ * persisted in Allocated state without a disk offering; user volumes always carry a disk offering.
+ */
+ private boolean isTemporarySnapshotCopyVolume(VolumeVO volumeVO) {
+ if (volumeVO == null || volumeVO.getState() != Volume.State.Allocated) {
+ return false;
+ }
+ Long diskOfferingId = volumeVO.getDiskOfferingId();
+ return diskOfferingId == null || diskOfferingId == 0L;
+ }
+
+ /**
+ * The framework names the temporary volume {@code _.TMP}, which is not a valid
+ * ONTAP LUN name, so it is renamed before cloning. The in-memory object is updated as well because the
+ * SAN clone derives the LUN name from {@link VolumeInfo#getName()}.
+ */
+ private void renameTemporarySnapshotCopy(VolumeInfo volInfo, VolumeVO volumeVO, long csSnapshotId) {
+ String name = OntapStorageConstants.TEMP_SNAPSHOT_COPY_NAME_PREFIX + csSnapshotId + OntapStorageConstants.UNDERSCORE + volInfo.getId();
+ volumeVO.setName(name);
+ if (volInfo.getVolume() instanceof VolumeVO) {
+ ((VolumeVO) volInfo.getVolume()).setName(name);
+ }
+ }
+
+ /**
+ * {@code StorageSystemDataMotionStrategy} deletes the temporary snapshot copy on the array but leaves its
+ * volume row behind on success, so it is removed here once the backend object is gone.
+ */
+ private void removeTemporarySnapshotCopyRecord(long volumeId) {
+ VolumeVO volumeVO = volumeDao.findById(volumeId);
+ if (!isTemporarySnapshotCopyVolume(volumeVO)
+ || volumeVO.getName() == null || !volumeVO.getName().startsWith(OntapStorageConstants.TEMP_SNAPSHOT_COPY_NAME_PREFIX)) {
+ return;
+ }
+ volumeDetailsDao.removeDetails(volumeId);
+ volumeDao.remove(volumeId);
+ logger.info("deleteAsync: Removed temporary snapshot copy volume record [{}]", volumeId);
+ }
+
+ /**
+ * Resizes a volume. Size and QoS from the resize window are applied together by
+ * {@link StorageStrategy#resizeCloudStackVolume}.
+ */
+ @Override
+ public void resize(DataObject data, AsyncCompletionCallback callback) {
+ CreateCmdResult result = null;
+ try {
+ if (data != null && data.getType() == DataObjectType.VOLUME) {
+ if (data instanceof VolumeInfo) {
+ VolumeQosPolicy policyToApply = null;
+ VolumeInfo volumeInfo = (VolumeInfo) data;
+ Object rawPayload = volumeInfo.getpayload();
+ ResizeVolumePayload payload = (rawPayload instanceof ResizeVolumePayload)
+ ? (ResizeVolumePayload) rawPayload : null;
+ if (payload == null || payload.newSize == null) {
+ throw new CloudRuntimeException("Invalid resize payload for volume " + volumeInfo.getId());
+ }
+ if (volumeInfo.getDataStore() == null) {
+ throw new CloudRuntimeException("Data store not found for volume " + volumeInfo.getId());
+ }
+
+ StoragePoolVO storagePool = storagePoolDao.findById(volumeInfo.getDataStore().getId());
+ if (storagePool == null) {
+ throw new CloudRuntimeException("Storage pool not found for volume " + volumeInfo.getId());
+ }
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId());
+
+ VolumeVO volumeVO = volumeDao.findById(volumeInfo.getId());
+ if (volumeVO == null) {
+ throw new CloudRuntimeException("Volume not found for id " + volumeInfo.getId());
+ }
+ if (payload.newSize < volumeVO.getSize()) {
+ throw new CloudRuntimeException("Unable to shrink volume.");
+ }
- logger.info("deleteOntapSnapshot: Successfully deleted ONTAP snapshot [{}] (uuid={}) for CloudStack snapshot [{}]",
- snapshotName, ontapSnapshotUuid, snapshotId);
+ verifySufficientIopsForStoragePool(storagePool, payload.newMinIops, volumeVO.getId());
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ VolumeDetailVO qosDetail = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.QOS_POLICY_UUID);
+ String previousUuid = qosDetail != null ? qosDetail.getValue() : null;
+ VolumeQosPolicy qosPolicy = createQosPolicyIfNeeded(storageStrategy, details,
+ payload.newMinIops, payload.newMaxIops, storagePool.getId());
+
+ if (qosPolicy != null && !Objects.equals(previousUuid, qosPolicy.getUuid())) {
+ policyToApply = qosPolicy;
+ } else if (qosPolicy == null && previousUuid != null) {
+ policyToApply = new VolumeQosPolicy();
+ policyToApply.setName(OntapStorageConstants.QOS_POLICY_NONE);
+ }
- commandResult.setSuccess(true);
- commandResult.setResult(null);
+ try {
+ CloudStackVolume cloudStackVolume = resizeCloudStackVolumeRequest(details, volumeInfo, policyToApply);
+ storageStrategy.resizeCloudStackVolume(cloudStackVolume, payload.newSize);
+ } catch (RuntimeException e) {
+ if (qosPolicy != null && !Objects.equals(previousUuid, qosPolicy.getUuid())) {
+ deleteUnusedQosPolicy(storageStrategy, qosPolicy.getUuid());
+ }
+ throw e;
+ }
+ if (policyToApply != null) {
+ persistQosPolicyDetails(volumeVO.getId(), qosPolicy);
+ deleteUnusedQosPolicy(storageStrategy, previousUuid);
+ }
- } 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);
- commandResult.setSuccess(true);
- commandResult.setResult(null);
+ volumeVO.setSize(payload.newSize);
+ if (!volumeDao.update(volumeVO.getId(), volumeVO)) {
+ throw new CloudRuntimeException("Failed to update volume " + volumeVO.getId()
+ + " after resizing the ONTAP backing object");
+ }
+ result = new CreateCmdResult(volumeVO.getPath(), new Answer(null, true, null));
+ logger.info("resize: Successfully resized volume [{}] to [{}] bytes", volumeInfo.getId(), payload.newSize);
+ }else{
+ String errorMessage = "Invalid DataObjectType (" + data.getType() + ") passed to resize";
+ result = new CreateCmdResult(null, new Answer(null, false, errorMessage));
+ result.setResult(errorMessage);
+ return;
+ }
} else {
- logger.error("deleteOntapSnapshot: Failed to delete ONTAP snapshot for CloudStack snapshot [{}]: {}",
- snapshotId, e.getMessage(), e);
- commandResult.setSuccess(false);
- commandResult.setResult(e.getMessage());
+ throw new CloudRuntimeException("Expected a VOLUME DataObject but received " +
+ (data != null ? data.getType() : "null"));
}
+ } catch (Exception e) {
+ String errMsg = e.getMessage();
+ logger.error("resize: Failed for volume [{}]: {}", data != null ? data.getId() : null, errMsg, e);
+ result = new CreateCmdResult(null, new Answer(null, false, errMsg));
+ result.setResult(errMsg);
+ } finally {
+ callback.complete(result);
}
}
- @Override
- public void copyAsync(DataObject srcData, DataObject destData, AsyncCompletionCallback callback) {
- throw new UnsupportedOperationException("Copy operation is not supported for ONTAP primary storage.");
- }
-
- @Override
- public void copyAsync(DataObject srcData, DataObject destData, Host destHost, AsyncCompletionCallback callback) {
- throw new UnsupportedOperationException("Copy operation is not supported for ONTAP primary storage.");
- }
-
- @Override
- public boolean canCopy(DataObject srcData, DataObject destData) {
- return false;
+ private void deleteUnusedQosPolicy(StorageStrategy storageStrategy, String policyUuid) {
+ if (policyUuid == null || policyUuid.isEmpty()) {
+ return;
+ }
+ try {
+ storageStrategy.deleteVolumeQosPolicy(policyUuid);
+ } catch (Exception e) {
+ logger.error("Unused QoS policy [{}] was not deleted: {}", policyUuid, e.getMessage());
+ }
}
- @Override
- public void resize(DataObject data, AsyncCompletionCallback callback) {}
-
@Override
public ChapInfo getChapInfo(DataObject dataObject) {
return null;
@@ -399,6 +1287,9 @@ public boolean grantAccess(DataObject dataObject, Host host, DataStore dataStore
volumeVO.setPoolType(storagePool.getPoolType());
volumeVO.setPoolId(storagePool.getId());
volumeDao.update(volumeVO.getId(), volumeVO);
+ syncTemporarySnapshotCopyPath(dataObject, volumeVO);
+ } else if (dataObject.getType() == DataObjectType.TEMPLATE) {
+ grantAccessTemplate((TemplateInfo) dataObject, host, dataStore, storagePool);
} else {
logger.error("Invalid DataObjectType (" + dataObject.getType() + ") passed to grantAccess");
throw new CloudRuntimeException("Invalid DataObjectType (" + dataObject.getType() + ") passed to grantAccess");
@@ -410,28 +1301,122 @@ public boolean grantAccess(DataObject dataObject, Host host, DataStore dataStore
}
}
+ /**
+ * For the temporary snapshot copy, {@code StorageSystemDataMotionStrategy} builds the CopyCommand from
+ * the same in-memory volume object it passed to grantAccess, so the iSCSI path resolved by the LUN
+ * mapping must be reflected on that object too.
+ */
+ private void syncTemporarySnapshotCopyPath(DataObject dataObject, VolumeVO volumeVO) {
+ if (!isTemporarySnapshotCopyVolume(volumeVO) || !(dataObject instanceof VolumeInfo)) {
+ return;
+ }
+ Volume inMemoryVolume = ((VolumeInfo) dataObject).getVolume();
+ if (inMemoryVolume instanceof VolumeVO) {
+ ((VolumeVO) inMemoryVolume).setPath(volumeVO.getPath());
+ ((VolumeVO) inMemoryVolume).set_iScsiName(volumeVO.get_iScsiName());
+ }
+ }
+
private void grantAccessIscsi(Host host, VolumeVO volumeVO, Map details, String svmName, StoragePoolVO storagePool) {
String cloudStackVolumeName = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_NAME).getValue();
UnifiedSANStrategy sanStrategy = (UnifiedSANStrategy) OntapStorageUtils.getStrategyByStoragePoolDetails(details);
String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
- // Validate if Igroup exist ONTAP for this host as we may be using delete_on_unmap= true and igroup may be deleted by ONTAP automatically
+ ensureAccessGroupForHost(sanStrategy, host, storagePool, svmName, accessGroupName);
+
+ // Create or retrieve existing LUN mapping.
+ String lunNumber = sanStrategy.ensureLunMapped(svmName, cloudStackVolumeName, accessGroupName);
+ String lunWwid = resolveVolumeLunWwid(sanStrategy, svmName, cloudStackVolumeName, volumeVO.getId());
+ logger.debug("grantAccessIscsi: LUN [{}] mapped to igroup [{}] as logical unit number [{}], WWID [{}]",
+ cloudStackVolumeName, accessGroupName, lunNumber, lunWwid);
+
+ // Update volume path if changed (e.g. the WWID was not yet known for a pre-existing volume)
+ String iscsiPath = buildIscsiPath(storagePool, lunWwid);
+ if (volumeVO.getPath() == null || !volumeVO.getPath().equals(iscsiPath)) {
+ volumeVO.set_iScsiName(iscsiPath);
+ volumeVO.setPath(iscsiPath);
+ }
+ }
+
+ /**
+ * Maps the cached template LUN to the host so the KVM agent can write the image into it.
+ *
+ *
Called by the framework from {@code copyTemplateToManagedTemplateVolume} just before it
+ * issues the {@code CopyCommand}. That method reads {@code managedStoreTarget} from the pool
+ * details before this call, when the LUN number does not exist yet, so the stale
+ * value is corrected here. The datastore details are re-read when the command is built, so the
+ * update lands in time.
+ */
+ private void grantAccessTemplate(TemplateInfo templateInfo, Host host, DataStore dataStore, StoragePoolVO storagePool) {
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId());
+ if (!ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ logger.debug("grantAccessTemplate: NFS template [{}], no igroup mapping required", templateInfo.getUuid());
+ return;
+ }
+
+ String svmName = details.get(OntapStorageConstants.SVM_NAME);
+ VMTemplateStoragePoolVO templatePoolRef = findTemplatePoolRef(storagePool.getId(), templateInfo.getId());
+ String lunName = getTemplateLunName(storagePool, templateInfo.getId());
+
+ UnifiedSANStrategy sanStrategy = (UnifiedSANStrategy) OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
+
+ ensureAccessGroupForHost(sanStrategy, host, storagePool, svmName, accessGroupName);
+
+ boolean lunMapped = false;
+ try {
+ String lunNumber = sanStrategy.ensureLunMapped(svmName, lunName, accessGroupName);
+ lunMapped = true;
+ // The template cache LUN has no volume_details row to cache the WWID in, and this runs
+ // once per template per pool, so it is fetched from the array each time.
+ String lunWwid = fetchLunWwid(sanStrategy, svmName, lunName);
+ logger.debug("grantAccessTemplate: template cache LUN [{}] mapped as logical unit number [{}], WWID [{}]",
+ lunName, lunNumber, lunWwid);
+ String iscsiPath = buildIscsiPath(storagePool, lunWwid);
+
+ templatePoolRef.setInstallPath(iscsiPath);
+ vmTemplatePoolDao.update(templatePoolRef.getId(), templatePoolRef);
+ refreshManagedStoreTarget(dataStore, iscsiPath);
+
+ logger.info("grantAccessTemplate: Mapped template cache LUN [{}] to igroup [{}] as [{}] for template [{}]",
+ lunName, accessGroupName, iscsiPath, templateInfo.getId());
+ } catch (RuntimeException e) {
+ // Undo only this call's side effect (map). Do not delete the cache LUN — create already succeeded.
+ if (lunMapped) {
+ try {
+ unmapLunFromHost(sanStrategy, svmName, lunName, accessGroupName, host);
+ } catch (Exception rollbackEx) {
+ logger.warn("grantAccessTemplate: Failed to unmap template cache LUN [{}] from igroup [{}] after grant failure: {}",
+ lunName, accessGroupName, rollbackEx.getMessage());
+ }
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Ensures an igroup containing this host's initiator exists on the SVM.
+ *
+ *
The igroup may be absent even for a host that used the pool before, because LUN maps are
+ * created with {@code delete_on_unmap}, which lets ONTAP remove the igroup on its own.
+ */
+ private void ensureAccessGroupForHost(UnifiedSANStrategy sanStrategy, Host host, StoragePoolVO storagePool,
+ String svmName, String accessGroupName) {
Map getAccessGroupMap = Map.of(
OntapStorageConstants.NAME, accessGroupName,
OntapStorageConstants.SVM_DOT_NAME, svmName
);
AccessGroup accessGroup = sanStrategy.getAccessGroup(getAccessGroupMap);
- if(accessGroup == null || accessGroup.getIgroup() == null) {
- logger.info("grantAccess: Igroup {} does not exist for the host {} : Need to create Igroup for the host ", accessGroupName, host.getName());
- // create the igroup for the host and perform lun-mapping
+ if (accessGroup == null || accessGroup.getIgroup() == null) {
+ logger.info("ensureAccessGroupForHost: Igroup {} does not exist for the host {} : Need to create Igroup for the host ", accessGroupName, host.getName());
accessGroup = new AccessGroup();
List hosts = new ArrayList<>();
hosts.add((HostVO) host);
accessGroup.setHostsToConnect(hosts);
accessGroup.setStoragePoolId(storagePool.getId());
accessGroup = sanStrategy.createAccessGroup(accessGroup);
- }else{
- logger.info("grantAccess: Igroup {} already exist for the host {}: ", accessGroup.getIgroup().getName() , host.getName());
+ } else {
+ logger.info("ensureAccessGroupForHost: Igroup {} already exist for the host {}: ", accessGroup.getIgroup().getName(), host.getName());
/* TODO Below cases will be covered later, for now they will be a pre-requisite on customer side
1. Igroup exist with the same name but host initiator has been removed
2. Igroup exist with the same name but host initiator has been changed may be due to new NIC or new adapter
@@ -439,16 +1424,79 @@ private void grantAccessIscsi(Host host, VolumeVO volumeVO, Map
Incase it is not , add it and proceed for lun-mapping
*/
}
- logger.info("grantAccess: Igroup {} is present now with initiators {} ", accessGroup.getIgroup().getName(), accessGroup.getIgroup().getInitiators());
- // Create or retrieve existing LUN mapping
- String lunNumber = sanStrategy.ensureLunMapped(svmName, cloudStackVolumeName, accessGroupName);
+ logger.info("ensureAccessGroupForHost: Igroup {} is present now with initiators {} ", accessGroup.getIgroup().getName(), accessGroup.getIgroup().getInitiators());
+ }
- // Update volume path if changed (e.g., after migration or re-mapping)
- String iscsiPath = OntapStorageConstants.SLASH + storagePool.getPath() + OntapStorageConstants.SLASH + lunNumber;
- if (volumeVO.getPath() == null || !volumeVO.getPath().equals(iscsiPath)) {
- volumeVO.set_iScsiName(iscsiPath);
- volumeVO.setPath(iscsiPath);
+ /**
+ * Builds the volume path the KVM agent expects for managed iSCSI: {@code //}.
+ */
+ private String buildIscsiPath(StoragePoolVO storagePool, String lunWwid) {
+ return OntapStorageConstants.SLASH + storagePool.getPath() + OntapStorageConstants.SLASH + lunWwid;
+ }
+
+ /**
+ * Resolves a volume's LUN WWID, caching it as a volume detail after the first lookup.
+ *
+ *
Resolved at grant time rather than at create time so that volumes created before this
+ * became the path format pick the WWID up on their next start.
NetApp builds the NAA IEEE Registered Extended identifier as the format designator ({@code 6}),
+ * its OUI ({@code 00a098}), a 4-bit pad nibble ({@code 0}), and then the ASCII bytes of the 12-char
+ * serial number in hex. For serial {@code x0M-8?/kEsJT} this yields
+ * {@code 600a098078304d2d383f2f6b45734a54}, which the host exposes as
+ * {@code /dev/disk/by-id/wwn-0x600a098078304d2d383f2f6b45734a54} and vSphere shows as
+ * {@code naa.600a098078304d2d383f2f6b45734a54}.
+ */
+ private String toNaaWwid(String serialNumber) {
+ StringBuilder wwid = new StringBuilder(OntapStorageConstants.NETAPP_NAA_PREFIX);
+ for (byte b : serialNumber.getBytes(StandardCharsets.US_ASCII)) {
+ wwid.append(String.format("%02x", b));
+ }
+ return wwid.toString();
+ }
+
+ private void refreshManagedStoreTarget(DataStore dataStore, String iscsiPath) {
+ if (!(dataStore instanceof PrimaryDataStore)) {
+ return;
}
+ PrimaryDataStore primaryDataStore = (PrimaryDataStore) dataStore;
+ Map storeDetails = primaryDataStore.getDetails();
+ if (storeDetails == null) {
+ return;
+ }
+ Map updated = new HashMap<>(storeDetails);
+ updated.put(PrimaryDataStore.MANAGED_STORE_TARGET, iscsiPath);
+ primaryDataStore.setDetails(updated);
}
/**
@@ -485,6 +1533,8 @@ public void revokeAccess(DataObject dataObject, Host host, DataStore dataStore)
throw new CloudRuntimeException("CloudStack Volume not found for id: " + dataObject.getId());
}
revokeAccessForVolume(storagePool, volumeVO, host);
+ } else if (dataObject.getType() == DataObjectType.TEMPLATE) {
+ revokeAccessForTemplate(storagePool, (TemplateInfo) dataObject, host);
} else {
logger.error("revokeAccess: Invalid DataObjectType (" + dataObject.getType() + ") passed to revokeAccess");
throw new CloudRuntimeException("Invalid DataObjectType (" + dataObject.getType() + ") passed to revokeAccess");
@@ -510,46 +1560,73 @@ private void revokeAccessForVolume(StoragePoolVO storagePool, VolumeVO volumeVO,
// Retrieve LUN name from volume details; if missing, volume may not have been fully created
VolumeDetailVO lunDetail = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_NAME);
- ValidateRevoke result = getValidateRevoke(volumeVO, host, lunDetail, storageStrategy, svmName, accessGroupName);
- if (result == null) return;
-
- // Remove the LUN mapping from the igroup
- Map disableLogicalAccessMap = new HashMap<>();
- disableLogicalAccessMap.put(OntapStorageConstants.LUN_DOT_UUID, result.cloudStackVolume.getLun().getUuid());
- disableLogicalAccessMap.put(OntapStorageConstants.IGROUP_DOT_UUID, result.accessGroup.getIgroup().getUuid());
- storageStrategy.disableLogicalAccess(disableLogicalAccessMap);
+ String lunName = lunDetail != null ? lunDetail.getValue() : null;
+ if (lunName == null) {
+ logger.warn("revokeAccessForVolume: No LUN name found for volume [{}]; skipping revoke", volumeVO.getId());
+ return;
+ }
+ unmapLunFromHost(storageStrategy, svmName, lunName, accessGroupName, host);
+ }
+ }
- logger.info("revokeAccessForVolume: Successfully revoked access to LUN [{}] for host [{}]",
- result.lunName, host.getName());
+ /**
+ * Unmaps the cached template LUN once the framework has finished writing the image into it.
+ */
+ private void revokeAccessForTemplate(StoragePoolVO storagePool, TemplateInfo templateInfo, Host host) {
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId());
+ if (!ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
+ logger.debug("revokeAccessForTemplate: NFS template [{}], no igroup mapping to remove", templateInfo.getUuid());
+ return;
}
+
+ String svmName = details.get(OntapStorageConstants.SVM_NAME);
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+ String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
+ String lunName = getTemplateLunName(storagePool, templateInfo.getId());
+
+ logger.info("revokeAccessForTemplate: Revoking access to template cache LUN [{}] for host [{}]", lunName, host.getName());
+ unmapLunFromHost(storageStrategy, svmName, lunName, accessGroupName, host);
}
- @Nullable
- private ValidateRevoke getValidateRevoke(VolumeVO volumeVO, Host host, VolumeDetailVO lunDetail, StorageStrategy storageStrategy, String svmName, String accessGroupName) {
- String lunName = lunDetail != null ? lunDetail.getValue() : null;
- if (lunName == null) {
- logger.warn("revokeAccessForVolume: No LUN name found for volume [{}]; skipping revoke", volumeVO.getId());
- return null;
+ /**
+ * Removes a LUN-to-igroup mapping, skipping quietly when the LUN, the igroup or the host
+ * initiator is already gone.
+ */
+ private void unmapLunFromHost(StorageStrategy storageStrategy, String svmName, String lunName,
+ String accessGroupName, Host host) {
+ ValidateRevoke result = getValidateRevoke(lunName, host, storageStrategy, svmName, accessGroupName);
+ if (result == null) {
+ return;
}
+ Map disableLogicalAccessMap = new HashMap<>();
+ disableLogicalAccessMap.put(OntapStorageConstants.LUN_DOT_UUID, result.cloudStackVolume.getLun().getUuid());
+ disableLogicalAccessMap.put(OntapStorageConstants.IGROUP_DOT_UUID, result.accessGroup.getIgroup().getUuid());
+ storageStrategy.disableLogicalAccess(disableLogicalAccessMap);
+
+ logger.info("unmapLunFromHost: Successfully revoked access to LUN [{}] for host [{}]", result.lunName, host.getName());
+ }
+
+ @Nullable
+ private ValidateRevoke getValidateRevoke(String lunName, Host host, StorageStrategy storageStrategy, String svmName, String accessGroupName) {
// Verify LUN still exists on ONTAP (may have been manually deleted)
CloudStackVolume cloudStackVolume = getCloudStackVolumeByName(storageStrategy, svmName, lunName);
if (cloudStackVolume == null || cloudStackVolume.getLun() == null || cloudStackVolume.getLun().getUuid() == null) {
- logger.warn("revokeAccessForVolume: LUN for volume [{}] not found on ONTAP, skipping revoke", volumeVO.getId());
+ logger.warn("getValidateRevoke: LUN [{}] not found on ONTAP, skipping revoke", lunName);
return null;
}
// Verify igroup still exists on ONTAP
AccessGroup accessGroup = getAccessGroupByName(storageStrategy, svmName, accessGroupName);
if (accessGroup == null || accessGroup.getIgroup() == null || accessGroup.getIgroup().getUuid() == null) {
- logger.warn("revokeAccessForVolume: iGroup [{}] not found on ONTAP, skipping revoke", accessGroupName);
+ logger.warn("getValidateRevoke: iGroup [{}] not found on ONTAP, skipping revoke", accessGroupName);
return null;
}
// Verify host initiator is in the igroup before attempting to remove mapping
SANStrategy sanStrategy = (UnifiedSANStrategy) storageStrategy;
if (!sanStrategy.validateInitiatorInAccessGroup(host.getStorageUrl(), svmName, accessGroup.getIgroup())) {
- logger.warn("revokeAccessForVolume: Initiator [{}] is not in iGroup [{}], skipping revoke",
+ logger.warn("getValidateRevoke: Initiator [{}] is not in iGroup [{}], skipping revoke",
host.getStorageUrl(), accessGroupName);
return null;
}
@@ -600,24 +1677,138 @@ private AccessGroup getAccessGroupByName(StorageStrategy storageStrategy, String
return accessGroup;
}
+ /**
+ * ONTAP is only supported with KVM, which does not take hypervisor-side snapshots into the
+ * volume itself, so no reserve is added on top of the requested size.
+ *
+ *
For a template this returns the virtual size ({@code VMTemplateVO.size}), not the
+ * compressed size on secondary storage. The cached template LUN is written by the KVM agent
+ * with {@code qemu-img convert} from QCOW2 to RAW, so it must be able to hold the fully
+ * expanded image.
+ */
@Override
public long getDataObjectSizeIncludingHypervisorSnapshotReserve(DataObject dataObject, StoragePool storagePool) {
- return 0;
+ if (dataObject == null) {
+ return 0;
+ }
+ Long size = dataObject.getSize();
+ return size != null && size > 0 ? size : 0;
}
@Override
public long getBytesRequiredForTemplate(TemplateInfo templateInfo, StoragePool storagePool) {
- return 0;
+ if (templateInfo == null || storagePool == null) {
+ return 0;
+ }
+ // template_spool_ref is inserted in Allocated before the cache exists;
+ // only skip reservation when the template is Ready and has a backend identity.
+ VMTemplateStoragePoolVO templatePoolRef =
+ vmTemplatePoolDao.findByPoolTemplate(storagePool.getId(), templateInfo.getId(), null);
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId());
+ if (isTemplateCachedOnPool(templatePoolRef, details)) {
+ return 0;
+ }
+ return getDataObjectSizeIncludingHypervisorSnapshotReserve(templateInfo, storagePool);
+ }
+
+ /**
+ * Returns true when the primary template cache is present and usable for clone/deploy.
+ * A spool_ref row alone is not enough: CloudStack creates it in Allocated before the LUN/file exists.
+ * Ready is sufficient; downloadState is set alongside Ready on the managed-cache success path.
+ */
+ private boolean isTemplateCachedOnPool(VMTemplateStoragePoolVO templatePoolRef, Map details) {
+ if (templatePoolRef == null) {
+ return false;
+ }
+ if (templatePoolRef.getState() != ObjectInDataStoreStateMachine.State.Ready) {
+ return false;
+ }
+ if (details != null && isIscsi(details)) {
+ return StringUtils.isNotBlank(templatePoolRef.getLocalDownloadPath());
+ }
+ return StringUtils.isNotBlank(templatePoolRef.getInstallPath());
}
+ /**
+ * Returns the bytes used on the FlexVolume backing this pool, read directly from ONTAP
+ * ({@code space.used}).
+ *
+ *
Fails closed when ONTAP cannot provide trustworthy usage data. Returning zero for an
+ * unreachable or incomplete backend would make capacity checks treat an unknown pool as empty
+ * and could incorrectly authorize a volume grow.
+ *
+ * @throws InvalidParameterValueException if {@code storagePool} is null
+ * @throws CloudRuntimeException if the pool has no FlexVolume UUID in its details, ONTAP
+ * cannot be queried, or used-space data is missing
+ */
@Override
public long getUsedBytes(StoragePool storagePool) {
- return 0;
+ if (storagePool == null) {
+ throw new InvalidParameterValueException("storagePool is null, ensure the pool exists and is fully initialised before querying used bytes");
+ }
+
+ Map poolDetails = storagePoolDetailsDao.listDetailsKeyPairs(storagePool.getId());
+ String flexVolUuid = poolDetails != null ? poolDetails.get(OntapStorageConstants.VOLUME_UUID) : null;
+
+ if (StringUtils.isBlank(flexVolUuid)) {
+ throw new CloudRuntimeException("FlexVolume UUID not found in pool details for pool " + storagePool.getId());
+ }
+
+ try {
+ StorageStrategy strategy = OntapStorageUtils.getStrategyByStoragePoolDetails(poolDetails);
+ Map queryParams = new HashMap<>();
+ queryParams.put(OntapStorageConstants.FIELDS, OntapStorageConstants.SPACE_USED);
+ var flexVol = strategy.getStorageVolume(flexVolUuid, queryParams);
+
+ if (flexVol == null) {
+ throw new CloudRuntimeException(String.format(
+ "FlexVolume [%s] backing pool [%s] was not found on ONTAP",
+ flexVolUuid, storagePool.getId()));
+ }
+ if (flexVol.getSpace() == null) {
+ throw new CloudRuntimeException(String.format(
+ "ONTAP returned no space information for FlexVolume [%s] backing pool [%s]",
+ flexVolUuid, storagePool.getId()));
+ }
+
+ logger.debug("getUsedBytes: FlexVolume [{}] backing pool [{}] reports {} bytes used",
+ flexVolUuid, storagePool.getId(), flexVol.getSpace().getUsed());
+ return flexVol.getSpace().getUsed();
+ } catch (CloudRuntimeException e) {
+ logger.error("getUsedBytes: Failed to get used bytes for pool [{}]", storagePool.getId(), e);
+ throw e;
+ } catch (Exception e) {
+ logger.error("getUsedBytes: Failed to get used bytes for pool [{}]", storagePool.getId(), e);
+ throw new CloudRuntimeException(
+ String.format("Could not read used space for pool [%s]: %s", storagePool.getId(), e.getMessage()), e);
+ }
}
+ /**
+ * Returns min IOPS reserved on the pool, including volumes still being created.
+ * Destroyed and expunged volumes are omitted by the DAO query.
+ */
@Override
public long getUsedIops(StoragePool storagePool) {
- return 0;
+ return getAllocatedMinIops(storagePool, null);
+ }
+
+ private long getAllocatedMinIops(StoragePool storagePool, Long excludeVolumeId) {
+ long usedIops = 0;
+
+ List volumes = volumeDao.findNonDestroyedVolumesByPoolId(storagePool.getId(), null);
+ if (volumes != null) {
+ for (VolumeVO volume : volumes) {
+ if (excludeVolumeId != null && excludeVolumeId.equals(volume.getId())) {
+ continue;
+ }
+ if (volume.getMinIops() != null) {
+ usedIops += volume.getMinIops();
+ }
+ }
+ }
+
+ return usedIops;
}
/**
@@ -647,7 +1838,7 @@ public void takeSnapshot(SnapshotInfo snapshot, AsyncCompletionCallback snapshotDetails = new LinkedHashMap<>();
+ snapshotDetails.put(OntapStorageConstants.SRC_CS_VOLUME_ID, String.valueOf(volumeInfo.getId()));
+ snapshotDetails.put(OntapStorageConstants.BASE_ONTAP_FV_ID, flexVolUuid);
+ snapshotDetails.put(OntapStorageConstants.ONTAP_SNAP_ID, ontapSnapshotUuid);
+ snapshotDetails.put(OntapStorageConstants.ONTAP_SNAP_NAME, snapshotName);
+ snapshotDetails.put(OntapStorageConstants.VOLUME_PATH, volumePath);
+ snapshotDetails.put(OntapStorageConstants.PRIMARY_POOL_ID, String.valueOf(volumeVO.getPoolId()));
+ snapshotDetails.put(OntapStorageConstants.PROTOCOL, protocol);
+ // iSCSI only — needed for LUN restore / identity; null (skipped) for NFS.
+ snapshotDetails.put(OntapStorageConstants.LUN_DOT_UUID, lunUuid);
+ // Source-disk IOPS at snapshot time. Create-volume-from-snapshot applies the new volume's
+ // min/max (these values, or a root disk offering) rather than reading this detail back.
+ putPositiveIopsDetail(snapshotDetails, OntapStorageConstants.MIN_IOPS, volumeVO.getMinIops());
+ putPositiveIopsDetail(snapshotDetails, OntapStorageConstants.MAX_IOPS, volumeVO.getMaxIops());
+ updateSnapshotDetails(snapshot.getId(), snapshotDetails);
CreateObjectAnswer createObjectAnswer = new CreateObjectAnswer(snapshotObjectTo);
result = new CreateCmdResult(null, createObjectAnswer);
@@ -789,15 +1995,8 @@ private String resolveSnapshotUuid(SnapshotFeignClient snapshotClient, String au
* specific file (NFS) or LUN (iSCSI) from the FlexVolume snapshot directly
* via ONTAP REST API, without involving the hypervisor agent.
*
- *
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 +2046,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",
@@ -970,76 +2159,332 @@ private CloudStackVolume createDeleteCloudStackVolumeRequest(StoragePool storage
}
+ private boolean isIscsi(Map details) {
+ return ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL));
+ }
+
+ /**
+ * Resize request. Size is passed separately to {@code resizeCloudStackVolume}.
+ * When QoS changes, iSCSI carries the LUN uuid and policy, and NFS carries the FlexVol uuid and file policy.
+ */
+ private CloudStackVolume resizeCloudStackVolumeRequest(Map details, VolumeInfo volumeInfo,
+ VolumeQosPolicy qosPolicy) {
+ CloudStackVolume resizeRequest = new CloudStackVolume();
+ resizeRequest.setVolumeInfo(volumeInfo);
+ if (qosPolicy == null) {
+ return resizeRequest;
+ }
+ VolumeQosPolicy qosPolicyReference = new VolumeQosPolicy();
+ qosPolicyReference.setName(qosPolicy.getName());
+ qosPolicyReference.setUuid(qosPolicy.getUuid());
+
+ String protocol = details.get(OntapStorageConstants.PROTOCOL);
+ ProtocolType protocolType = ProtocolType.valueOf(protocol);
+ switch (protocolType) {
+ case NFS3:
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setQosPolicy(qosPolicyReference);
+ resizeRequest.setFile(fileInfo);
+ resizeRequest.setFlexVolumeUuid(details.get(OntapStorageConstants.VOLUME_UUID));
+ return resizeRequest;
+ case ISCSI:
+ VolumeDetailVO lunUuid = volumeDetailsDao.findDetail(volumeInfo.getId(), OntapStorageConstants.LUN_DOT_UUID);
+ if (lunUuid == null || lunUuid.getValue() == null) {
+ throw new CloudRuntimeException("LUN UUID is missing for volume " + volumeInfo.getId());
+ }
+ Lun lun = new Lun();
+ lun.setUuid(lunUuid.getValue());
+ lun.setQosPolicy(qosPolicyReference);
+ resizeRequest.setLun(lun);
+ return resizeRequest;
+ default:
+ throw new CloudRuntimeException("Unsupported protocol " + protocol);
+ }
+ }
+
+ /**
+ * Builds the request that creates or updates a volume (LUN for iSCSI, qcow2 file for NFS),
+ * attaching a QoS policy reference when one is provided.
+ */
+ private CloudStackVolume createCloudStackVolumeRequestByProtocol(StoragePoolVO storagePool, Map details,
+ DataObject volumeObject, VolumeQosPolicy qosPolicy) {
+ VolumeQosPolicy qosPolicyReference = null;
+ if (qosPolicy != null) {
+ qosPolicyReference = new VolumeQosPolicy();
+ qosPolicyReference.setName(qosPolicy.getName());
+ qosPolicyReference.setUuid(qosPolicy.getUuid());
+ }
+
+ String protocol = details.get(OntapStorageConstants.PROTOCOL);
+ ProtocolType protocolType = ProtocolType.valueOf(protocol);
+ switch (protocolType) {
+ case NFS3:
+ CloudStackVolume nfsRequest = new CloudStackVolume();
+ nfsRequest.setDatastoreId(String.valueOf(storagePool.getId()));
+ nfsRequest.setFlexVolumeUuid(details.get(OntapStorageConstants.VOLUME_UUID));
+ nfsRequest.setVolumeInfo(volumeObject);
+ if (qosPolicyReference != null) {
+ FileInfo fileInfo = new FileInfo();
+ fileInfo.setQosPolicy(qosPolicyReference);
+ nfsRequest.setFile(fileInfo);
+ }
+ return nfsRequest;
+ case ISCSI:
+ Svm svm = new Svm();
+ svm.setName(details.get(OntapStorageConstants.SVM_NAME));
+ CloudStackVolume iscsiRequest = new CloudStackVolume();
+ Lun lunRequest = new Lun();
+ lunRequest.setSvm(svm);
+
+ LunSpace lunSpace = new LunSpace();
+ lunSpace.setSize(volumeObject.getSize());
+ lunRequest.setSpace(lunSpace);
+ String lunName = volumeObject.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE);
+ if (!OntapStorageUtils.isValidName(lunName)) {
+ throw new InvalidParameterValueException("Invalid dataObject name [" + lunName
+ + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long.");
+ }
+ lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), lunName));
+ lunRequest.setOsType(Lun.OsTypeEnum.valueOf(
+ OntapStorageUtils.getOSTypeFromHypervisor(storagePool.getHypervisor().name())));
+ lunRequest.setQosPolicy(qosPolicyReference);
+ iscsiRequest.setLun(lunRequest);
+ return iscsiRequest;
+ default:
+ throw new CloudRuntimeException("Unsupported protocol " + protocol);
+ }
+ }
+
+ /**
+ * LUN path used to cache a template on this pool: {@code /vol//cs_tmpl_}.
+ */
+ private String getTemplateLunName(StoragePoolVO storagePool, long templateId) {
+ return OntapStorageUtils.getLunName(storagePool.getName(), OntapStorageConstants.TEMPLATE_LUN_PREFIX + templateId);
+ }
+
+ /**
+ * Builds the request that clones the cached template LUN into a new volume LUN.
+ *
+ *
Source identity mirrors the NFS file-clone path workflow: {@code clone.source.name} is
+ * the same deterministic ONTAP path used at template create
+ * ({@code /vol//cs_tmpl_}). {@code local_download_path} (LUN uuid) is
+ * still sent as a secondary identity.
+ *
+ *
Size is omitted: ONTAP rejects a size on a clone create, and the clone inherits the
+ * source size. Growing to the requested volume size is a separate PATCH.
+ */
+ private CloudStackVolume createCloneLunRequest(StoragePoolVO storagePool, Map details,
+ VolumeInfo volumeObject, VMTemplateStoragePoolVO templatePoolRef,
+ long templateId, VolumeQosPolicy qosPolicy) {
+ String sourceLunUuid = templatePoolRef.getLocalDownloadPath();
+ if (sourceLunUuid == null || sourceLunUuid.isEmpty()) {
+ throw new CloudRuntimeException("Template [" + templateId + "] has no cached LUN on pool ["
+ + storagePool.getId() + "]; cannot clone volume [" + volumeObject.getId() + "]");
+ }
+
+ Svm svm = new Svm();
+ svm.setName(details.get(OntapStorageConstants.SVM_NAME));
+
+ String lunName = volumeObject.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE);
+ if (!OntapStorageUtils.isValidName(lunName)) {
+ throw new InvalidParameterValueException("Invalid dataObject name [" + lunName
+ + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long.");
+ }
+
+ Lun.Source source = new Lun.Source();
+ source.setName(getTemplateLunName(storagePool, templateId));
+ source.setUuid(sourceLunUuid);
+ Lun.Clone clone = new Lun.Clone();
+ clone.setSource(source);
+
+ Lun lunRequest = new Lun();
+ lunRequest.setSvm(svm);
+ lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), lunName));
+ lunRequest.setClone(clone);
+ if (qosPolicy != null) {
+ VolumeQosPolicy qosPolicyReference = new VolumeQosPolicy();
+ qosPolicyReference.setName(qosPolicy.getName());
+ qosPolicyReference.setUuid(qosPolicy.getUuid());
+ lunRequest.setQosPolicy(qosPolicyReference);
+ }
+
+ CloudStackVolume request = new CloudStackVolume();
+ request.setLun(lunRequest);
+ request.setVolumeInfo(volumeObject);
+ return request;
+ }
+
+ /**
+ * Builds the request that clones the cached qcow2 ({@code install_path}) into a new file
+ * named after the volume uuid, inside the same FlexVolume.
+ */
+ private CloudStackVolume createCloneFileRequest(StoragePoolVO storagePool, VolumeInfo volumeInfo,
+ VMTemplateStoragePoolVO templatePoolRef, long templateId) {
+ String sourcePath = templatePoolRef.getInstallPath();
+ if (sourcePath == null || sourcePath.isEmpty()) {
+ throw new CloudRuntimeException("Template [" + templateId + "] has no cached file on pool ["
+ + storagePool.getId() + "]; cannot clone volume [" + volumeInfo.getId() + "]");
+ }
+
+ FileInfo file = new FileInfo();
+ file.setPath(sourcePath);
+
+ CloudStackVolume request = new CloudStackVolume();
+ request.setDatastoreId(String.valueOf(storagePool.getId()));
+ request.setVolumeInfo(volumeInfo);
+ request.setFile(file);
+ request.setDestinationPath(volumeInfo.getUuid());
+ return request;
+ }
+
+ /**
+ * Builds the request that clones a LUN out of a FlexVolume snapshot into a new volume LUN.
+ *
+ *
{@code clone.source.name} is {@code /vol//.snapshot//}; ONTAP cannot
+ * resolve a snapshot-resident LUN by uuid, so no uuid is sent.
+ */
+ private CloudStackVolume createCloneLunFromSnapshotRequest(StoragePoolVO storagePool, Map details,
+ VolumeInfo volumeInfo, String sourceVolumePath,
+ String snapshotName, VolumeQosPolicy qosPolicy) {
+ String lunName = volumeInfo.getName().replace(OntapStorageConstants.HYPHEN, OntapStorageConstants.UNDERSCORE);
+ if (!OntapStorageUtils.isValidName(lunName)) {
+ throw new CloudRuntimeException("Invalid dataObject name [" + lunName
+ + "]. It must start with a letter and can only contain letters, digits, and underscores, and be up to 200 characters long.");
+ }
+
+ String flexVolName = details.get(OntapStorageConstants.VOLUME_NAME);
+ if (flexVolName == null || flexVolName.isEmpty()) {
+ flexVolName = storagePool.getName();
+ }
+
+ Svm svm = new Svm();
+ svm.setName(details.get(OntapStorageConstants.SVM_NAME));
+
+ Lun.Source source = new Lun.Source();
+ source.setName(OntapStorageUtils.toLunCloneSourcePathInSnapshot(sourceVolumePath, flexVolName, snapshotName));
+ Lun.Clone clone = new Lun.Clone();
+ clone.setSource(source);
+
+ Lun lunRequest = new Lun();
+ lunRequest.setSvm(svm);
+ lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(), lunName));
+ lunRequest.setClone(clone);
+ if (qosPolicy != null) {
+ VolumeQosPolicy qosPolicyReference = new VolumeQosPolicy();
+ qosPolicyReference.setName(qosPolicy.getName());
+ qosPolicyReference.setUuid(qosPolicy.getUuid());
+ lunRequest.setQosPolicy(qosPolicyReference);
+ }
+
+ CloudStackVolume request = new CloudStackVolume();
+ request.setLun(lunRequest);
+ request.setVolumeInfo(volumeInfo);
+ return request;
+ }
+
+ /**
+ * Builds the request that clones a file out of a FlexVolume snapshot into a new file named after
+ * the volume uuid, inside the same FlexVolume.
+ */
+ private CloudStackVolume createCloneFileFromSnapshotRequest(StoragePoolVO storagePool, Map details,
+ VolumeInfo volumeInfo, String sourceVolumePath,
+ String snapshotName) {
+ String flexVolName = details.get(OntapStorageConstants.VOLUME_NAME);
+
+ FileInfo file = new FileInfo();
+ // FlexVol-relative source path, for example "".
+ String relativeSourcePath = OntapStorageUtils.toFlexVolRelativePath(sourceVolumePath, flexVolName);
+ // Path assigned to file.setPath(), for example "./.snapshot//".
+ file.setPath(OntapStorageConstants.DOT + OntapStorageConstants.SNAPSHOT_PATH_SEGMENT + snapshotName
+ + OntapStorageConstants.SLASH + relativeSourcePath);
+
+ CloudStackVolume request = new CloudStackVolume();
+ request.setDatastoreId(String.valueOf(storagePool.getId()));
+ request.setVolumeInfo(volumeInfo);
+ request.setFile(file);
+ request.setDestinationPath(OntapStorageUtils.toFlexVolRelativePath(volumeInfo.getUuid(), flexVolName));
+ return request;
+ }
+
// ──────────────────────────────────────────────────────────────────────────
// Snapshot Helper Methods
// ──────────────────────────────────────────────────────────────────────────
/**
- * 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 cloudStackSnapshotName, long snapshotId) {
+ return OntapStorageUtils.buildOntapSnapshotName(cloudStackSnapshotName, OntapStorageConstants.CS + snapshotId);
+ }
+
+ /**
+ * Only ISCSI and NFS3 pools can be provisioned; any other protocol would fall through
+ * createAsync without producing a result, leaving the caller with a null callback value.
*/
- private String buildSnapshotName(String volumeName, String snapshotUuid) {
- String name = volumeName + "-" + snapshotUuid;
- int maxLength = OntapStorageConstants.MAX_SNAPSHOT_NAME_LENGTH;
- int trimRequired = name.length() - maxLength;
+ private void validateProtocol(Map details, DataStore dataStore) {
+ String protocol = details == null ? null : details.get(OntapStorageConstants.PROTOCOL);
+ boolean supported = protocol != null && Arrays.stream(ProtocolType.values())
+ .anyMatch(type -> type.name().equalsIgnoreCase(protocol));
+ if (!supported) {
+ throw new CloudRuntimeException("Unsupported protocol [" + protocol + "] on storage pool ["
+ + dataStore.getName() + "]; supported protocols are " + Arrays.toString(ProtocolType.values()));
+ }
+ }
- if (trimRequired > 0) {
- name = StringUtils.left(volumeName, volumeName.length() - trimRequired) + "-" + snapshotUuid;
+ private Storage.ImageFormat getImageFormat(StoragePoolVO storagePool) {
+ HypervisorType hypervisorType = storagePool.getHypervisor();
+ if (!HypervisorType.KVM.equals(hypervisorType)) {
+ throw new CloudRuntimeException("Unsupported hypervisor [" + hypervisorType + "] for ONTAP image format resolution");
+ }
+ Storage.StoragePoolType spType = storagePool.getPoolType();
+ switch (spType) {
+ case OntapiSCSI:
+ return Storage.ImageFormat.RAW;
+ case NetworkFilesystem:
+ return Storage.ImageFormat.QCOW2;
+ default:
+ throw new CloudRuntimeException("Unsupported pool type [" + spType + "] for ONTAP image format resolution");
}
- return name;
}
/**
- * Persists snapshot metadata in snapshot_details table.
+ * Persists ONTAP snapshot metadata in {@code snapshot_details} for revert, delete, and
+ * create-volume-from-snapshot.
*
- * @param csSnapshotId CloudStack snapshot ID
- * @param csVolumeId Source CloudStack volume ID
- * @param flexVolUuid ONTAP FlexVolume UUID
- * @param ontapSnapshotUuid ONTAP FlexVolume snapshot UUID
- * @param snapshotName ONTAP snapshot name
- * @param volumePath Path of the volume file/LUN within the FlexVolume (for restore)
- * @param storagePoolId Primary storage pool ID
- * @param protocol Storage protocol (NFS3 or ISCSI)
- * @param lunUuid LUN UUID (only for iSCSI, null for NFS)
+ *
Volume-snapshot delete reads {@code base_ontap_fv_id} and {@code ontap_snap_id} here
+ * during {@link #deleteCloudStackVolumeSnapshot}; missing rows prevent ONTAP cleanup.
+ *
+ *
All rows go through {@link #persistSnapshotDetail} so DAO writes stay consistent;
+ * optional fields (LUN uuid, IOPS) are skipped when unset.
+ *
+ * @param csSnapshotId CloudStack snapshot ID
+ * @param details {@code snapshot_details} name to value, keyed by {@link OntapStorageConstants};
+ * entries with null or blank values are skipped
+ */
+ private void updateSnapshotDetails(long csSnapshotId, Map details) {
+ for (Map.Entry detail : details.entrySet()) {
+ persistSnapshotDetail(csSnapshotId, detail.getKey(), detail.getValue());
+ }
+ }
+
+ /**
+ * Adds an IOPS entry to {@code details} only when {@code iops} is non-null and positive.
+ */
+ private static void putPositiveIopsDetail(Map details, String detailName, Long iops) {
+ if (iops != null && iops > 0) {
+ details.put(detailName, String.valueOf(iops));
+ }
+ }
+
+ /**
+ * Persists one {@code snapshot_details} row. No-op when {@code value} is null or blank so
+ * optional keys (e.g. LUN uuid on NFS) share the same DAO path as required keys.
*/
- private void updateSnapshotDetails(long csSnapshotId, long csVolumeId, String flexVolUuid,
- String ontapSnapshotUuid, String snapshotName,
- String volumePath, long storagePoolId, String protocol,
- String lunUuid) {
- SnapshotDetailsVO snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.SRC_CS_VOLUME_ID, String.valueOf(csVolumeId), false);
- snapshotDetailsDao.persist(snapshotDetail);
-
- snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.BASE_ONTAP_FV_ID, flexVolUuid, false);
- snapshotDetailsDao.persist(snapshotDetail);
-
- snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.ONTAP_SNAP_ID, ontapSnapshotUuid, false);
- snapshotDetailsDao.persist(snapshotDetail);
-
- snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.ONTAP_SNAP_NAME, snapshotName, false);
- snapshotDetailsDao.persist(snapshotDetail);
-
- snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.VOLUME_PATH, volumePath, false);
- snapshotDetailsDao.persist(snapshotDetail);
-
- snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.PRIMARY_POOL_ID, String.valueOf(storagePoolId), false);
- snapshotDetailsDao.persist(snapshotDetail);
-
- snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.PROTOCOL, protocol, false);
- snapshotDetailsDao.persist(snapshotDetail);
-
- // Store LUN UUID for iSCSI volumes (required for LUN restore API)
- if (lunUuid != null && !lunUuid.isEmpty()) {
- snapshotDetail = new SnapshotDetailsVO(csSnapshotId,
- OntapStorageConstants.LUN_DOT_UUID, lunUuid, false);
- snapshotDetailsDao.persist(snapshotDetail);
+ private void persistSnapshotDetail(long csSnapshotId, String detailName, String value) {
+ if (value == null || value.isEmpty()) {
+ return;
}
+ snapshotDetailsDao.persist(new SnapshotDetailsVO(csSnapshotId, detailName, value, false));
}
}
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/ClusterFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/ClusterFeignClient.java
index 582fb58e6f3b..476ef2e392fc 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/ClusterFeignClient.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/ClusterFeignClient.java
@@ -19,14 +19,24 @@
package org.apache.cloudstack.storage.feign.client;
+import feign.QueryMap;
import org.apache.cloudstack.storage.feign.model.Cluster;
+import org.apache.cloudstack.storage.feign.model.ClusterNode;
+import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
import feign.Headers;
import feign.Param;
import feign.RequestLine;
+import java.util.Map;
+
public interface ClusterFeignClient {
@RequestLine("GET /api/cluster")
@Headers({"Authorization: {authHeader}", "return_records: {returnRecords}"})
Cluster getCluster(@Param("authHeader") String authHeader, @Param("returnRecords") boolean returnRecords);
+
+ @RequestLine("GET /api/cluster/nodes")
+ @Headers({"Authorization: {authHeader}"})
+ OntapResponse getClusterNodes(@Param("authHeader") String authHeader,
+ @QueryMap Map queryMap);
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/EmsFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/EmsFeignClient.java
new file mode 100644
index 000000000000..c3e4c73d6165
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/EmsFeignClient.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cloudstack.storage.feign.client;
+
+import feign.Headers;
+import feign.Param;
+import feign.RequestLine;
+import org.apache.cloudstack.storage.feign.model.EmsApplicationLog;
+
+public interface EmsFeignClient {
+
+ @RequestLine("POST /api/support/ems/application-logs")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ void sendEmsApplicationLog(@Param("authHeader") String authHeader, EmsApplicationLog emsApplicationLog);
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java
index 8cf21b94b2f1..ba38f57ef1c7 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/NASFeignClient.java
@@ -21,7 +21,9 @@
import feign.QueryMap;
import org.apache.cloudstack.storage.feign.model.ExportPolicy;
+import org.apache.cloudstack.storage.feign.model.FileCloneRequest;
import org.apache.cloudstack.storage.feign.model.FileInfo;
+import org.apache.cloudstack.storage.feign.model.response.JobResponse;
import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
import feign.Headers;
import feign.Param;
@@ -58,6 +60,15 @@ void createFile(@Param("authHeader") String authHeader,
@Param("path") String filePath,
FileInfo file);
+ /**
+ * Creates a space-efficient clone of a file within a FlexVolume.
+ *
+ *
ONTAP REST: {@code POST /api/storage/file/clone}
+ */
+ @RequestLine("POST /api/storage/file/clone")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse cloneFile(@Param("authHeader") String authHeader, FileCloneRequest request);
+
// Export Policy Operations
@RequestLine("POST /api/protocols/nfs/export-policies")
@Headers({"Authorization: {authHeader}"})
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/QosFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/QosFeignClient.java
new file mode 100644
index 000000000000..669ea935124d
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/QosFeignClient.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.feign.client;
+
+import java.util.Map;
+
+import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy;
+import org.apache.cloudstack.storage.feign.model.response.JobResponse;
+import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
+
+import feign.Headers;
+import feign.Param;
+import feign.QueryMap;
+import feign.RequestLine;
+
+public interface QosFeignClient {
+
+ @RequestLine("POST /api/storage/qos/policies")
+ @Headers({"Authorization: {authHeader}"})
+ JobResponse createPolicy(@Param("authHeader") String authHeader, VolumeQosPolicy policy);
+
+ @RequestLine("GET /api/storage/qos/policies")
+ @Headers({"Authorization: {authHeader}"})
+ OntapResponse getPolicies(@Param("authHeader") String authHeader,
+ @QueryMap Map queryParams);
+
+ @RequestLine("GET /api/storage/qos/policies/{uuid}")
+ @Headers({"Authorization: {authHeader}"})
+ VolumeQosPolicy getPolicy(@Param("authHeader") String authHeader, @Param("uuid") String uuid,
+ @QueryMap Map queryParams);
+
+ @RequestLine("DELETE /api/storage/qos/policies/{uuid}")
+ @Headers({"Authorization: {authHeader}"})
+ JobResponse deletePolicy(@Param("authHeader") String authHeader, @Param("uuid") String uuid);
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java
index 7281dc2ecbeb..8a60a9a3e2bb 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/SANFeignClient.java
@@ -50,13 +50,13 @@ public interface SANFeignClient {
@Headers({"Authorization: {authHeader}"})
Lun getLunByUUID(@Param("authHeader") String authHeader, @Param("uuid") String uuid);
- @RequestLine("PATCH /{uuid}")
- @Headers({"Authorization: {authHeader}"})
- void updateLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Lun lun);
+ @RequestLine("PATCH /api/storage/luns/{uuid}")
+ @Headers({"Authorization: {authHeader}", "Content-Type: application/json"})
+ JobResponse updateLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Lun lun);
@RequestLine("DELETE /api/storage/luns/{uuid}")
@Headers({"Authorization: {authHeader}"})
- void deleteLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, @QueryMap Map queryMap);
+ JobResponse deleteLun(@Param("authHeader") String authHeader, @Param("uuid") String uuid, @QueryMap Map queryMap);
// iGroup Operation APIs
@RequestLine("POST /api/protocols/san/igroups?return_records={returnRecords}")
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 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/client/VolumeFeignClient.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/VolumeFeignClient.java
index 6384566487d4..d272ee6df13c 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/VolumeFeignClient.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/client/VolumeFeignClient.java
@@ -44,7 +44,7 @@ public interface VolumeFeignClient {
@RequestLine("GET /api/storage/volumes/{uuid}")
@Headers({"Authorization: {authHeader}"})
- Volume getVolumeByUUID(@Param("authHeader") String authHeader, @Param("uuid") String uuid);
+ Volume getVolumeByUUID(@Param("authHeader") String authHeader, @Param("uuid") String uuid, @QueryMap Map queryParams);
@RequestLine("GET /api/storage/volumes")
@Headers({"Authorization: {authHeader}"})
@@ -52,5 +52,5 @@ public interface VolumeFeignClient {
@RequestLine("PATCH /api/storage/volumes/{uuid}")
@Headers({ "Authorization: {authHeader}"})
- JobResponse updateVolumeRebalancing(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Volume volumeRequest);
+ JobResponse updateVolume(@Param("authHeader") String authHeader, @Param("uuid") String uuid, Volume volumeRequest);
}
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/Cluster.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Cluster.java
index 061372756175..f0b870f25423 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Cluster.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Cluster.java
@@ -19,6 +19,7 @@
package org.apache.cloudstack.storage.feign.model;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -48,6 +49,12 @@ public class Cluster {
@JsonProperty("disaggregated")
private Boolean disaggregated = null;
+ @JsonIgnore
+ private String model;
+
+ @JsonIgnore
+ private String platformType;
+
public String getHealth() {
return health;
@@ -104,6 +111,22 @@ public void setDisaggregated(Boolean disaggregated) {
this.disaggregated = disaggregated;
}
+ public String getModel() {
+ return model;
+ }
+
+ public void setModel(String model) {
+ this.model = model;
+ }
+
+ public String getPlatformType() {
+ return platformType;
+ }
+
+ public void setPlatformType(String platformType) {
+ this.platformType = platformType;
+ }
+
@Override
public int hashCode() {
return Objects.hash(getName(), getUuid());
@@ -129,6 +152,8 @@ public String toString() {
", version=" + version +
", sanOptimized=" + sanOptimized +
", disaggregated=" + disaggregated +
+ ", model='" + model + '\'' +
+ ", platformType='" + platformType + '\'' +
'}';
}
}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ClusterNode.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ClusterNode.java
new file mode 100644
index 000000000000..8d067c05790c
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/ClusterNode.java
@@ -0,0 +1,76 @@
+/*
+ * 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;
+
+/**
+ * Subset of {@code GET /api/cluster/nodes} used for ASUP
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ClusterNode {
+
+ @JsonProperty("model")
+ private String model;
+
+ @JsonProperty("is_all_flash_optimized")
+ private Boolean allFlashOptimized;
+
+ @JsonProperty("is_perf_optimized")
+ private Boolean performanceOptimized;
+
+ @JsonProperty("is_capacity_optimized")
+ private Boolean capacityOptimized;
+
+ public String getModel() {
+ return model;
+ }
+
+ public void setModel(String model) {
+ this.model = model;
+ }
+
+ public Boolean getAllFlashOptimized() {
+ return allFlashOptimized;
+ }
+
+ public void setAllFlashOptimized(Boolean allFlashOptimized) {
+ this.allFlashOptimized = allFlashOptimized;
+ }
+
+ public Boolean getPerformanceOptimized() {
+ return performanceOptimized;
+ }
+
+ public void setPerformanceOptimized(Boolean performanceOptimized) {
+ this.performanceOptimized = performanceOptimized;
+ }
+
+ public Boolean getCapacityOptimized() {
+ return capacityOptimized;
+ }
+
+ public void setCapacityOptimized(Boolean capacityOptimized) {
+ this.capacityOptimized = capacityOptimized;
+ }
+}
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/EmsApplicationLog.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/EmsApplicationLog.java
new file mode 100644
index 000000000000..24289c8d33b2
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/EmsApplicationLog.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cloudstack.storage.feign.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class EmsApplicationLog {
+
+ @JsonProperty("computer_name")
+ private String computerName;
+
+ @JsonProperty("event_source")
+ private String eventSource;
+
+ @JsonProperty("app_version")
+ private String appVersion;
+
+ @JsonProperty("category")
+ private String category;
+
+ @JsonProperty("severity")
+ private String severity;
+
+ @JsonProperty("autosupport_required")
+ private Boolean autosupportRequired;
+
+ @JsonProperty("event_id")
+ private String eventId;
+
+ @JsonProperty("event_description")
+ private String eventDescription;
+
+ public EmsApplicationLog() {
+ }
+
+ public String getComputerName() {
+ return computerName;
+ }
+
+ public void setComputerName(String computerName) {
+ this.computerName = computerName;
+ }
+
+ public String getEventSource() {
+ return eventSource;
+ }
+
+ public void setEventSource(String eventSource) {
+ this.eventSource = eventSource;
+ }
+
+ public String getAppVersion() {
+ return appVersion;
+ }
+
+ public void setAppVersion(String appVersion) {
+ this.appVersion = appVersion;
+ }
+
+ public String getCategory() {
+ return category;
+ }
+
+ public void setCategory(String category) {
+ this.category = category;
+ }
+
+ public String getSeverity() {
+ return severity;
+ }
+
+ public void setSeverity(String severity) {
+ this.severity = severity;
+ }
+
+ public Boolean getAutosupportRequired() {
+ return autosupportRequired;
+ }
+
+ public void setAutosupportRequired(Boolean autosupportRequired) {
+ this.autosupportRequired = autosupportRequired;
+ }
+
+ public String getEventId() {
+ return eventId;
+ }
+
+ public void setEventId(String eventId) {
+ this.eventId = eventId;
+ }
+
+ public String getEventDescription() {
+ return eventDescription;
+ }
+
+ public void setEventDescription(String eventDescription) {
+ this.eventDescription = eventDescription;
+ }
+
+ @Override
+ public String toString() {
+ return "EmsApplicationLog{" +
+ "computerName='" + computerName + '\'' +
+ ", eventSource='" + eventSource + '\'' +
+ ", appVersion='" + appVersion + '\'' +
+ ", category='" + category + '\'' +
+ ", severity='" + severity + '\'' +
+ ", autosupportRequired=" + autosupportRequired +
+ ", eventId='" + eventId + '\'' +
+ ", eventDescription='" + eventDescription + '\'' +
+ '}';
+ }
+}
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/FileCloneRequest.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileCloneRequest.java
new file mode 100644
index 000000000000..129643470895
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileCloneRequest.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.cloudstack.storage.feign.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Request body for the ONTAP file clone API.
+ *
+ *
ONTAP REST endpoint: {@code POST /api/storage/file/clone}
+ *
+ *
Creates a space-efficient copy of a file. Source and destination paths are relative to the
+ * root of {@code volume}, and both must live in that same FlexVolume.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class FileCloneRequest {
+
+ @JsonProperty("volume")
+ private VolumeRef volume;
+
+ @JsonProperty("source_path")
+ private String sourcePath;
+
+ @JsonProperty("destination_path")
+ private String destinationPath;
+
+ @JsonProperty("overwrite_destination")
+ private Boolean overwriteDestination;
+
+ /**
+ * Optional FlexVolume snapshot to clone from. When set, ONTAP clones {@code source_path}
+ * as it existed in that snapshot rather than from the live file/LUN.
+ *
+ *
Used by create-volume-from-snapshot (same FlexVol). Omitted for live template-cache clones.
+ */
+ @JsonProperty("snapshot")
+ private SnapshotRef snapshot;
+
+ public FileCloneRequest() {
+ }
+
+ public FileCloneRequest(String flexVolUuid, String flexVolName, String sourcePath, String destinationPath) {
+ this.volume = new VolumeRef(flexVolUuid, flexVolName);
+ this.sourcePath = sourcePath;
+ this.destinationPath = destinationPath;
+ }
+
+ public VolumeRef getVolume() {
+ return volume;
+ }
+
+ public void setVolume(VolumeRef volume) {
+ this.volume = volume;
+ }
+
+ public String getSourcePath() {
+ return sourcePath;
+ }
+
+ public void setSourcePath(String sourcePath) {
+ this.sourcePath = sourcePath;
+ }
+
+ public String getDestinationPath() {
+ return destinationPath;
+ }
+
+ public void setDestinationPath(String destinationPath) {
+ this.destinationPath = destinationPath;
+ }
+
+ public Boolean getOverwriteDestination() {
+ return overwriteDestination;
+ }
+
+ public void setOverwriteDestination(Boolean overwriteDestination) {
+ this.overwriteDestination = overwriteDestination;
+ }
+
+ public SnapshotRef getSnapshot() {
+ return snapshot;
+ }
+
+ public void setSnapshot(SnapshotRef snapshot) {
+ this.snapshot = snapshot;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class VolumeRef {
+
+ @JsonProperty("uuid")
+ private String uuid;
+
+ @JsonProperty("name")
+ private String name;
+
+ public VolumeRef() {
+ }
+
+ public VolumeRef(String uuid, String name) {
+ this.uuid = uuid;
+ 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;
+ }
+ }
+
+ /**
+ * Snapshot identity for {@code POST /api/storage/file/clone} when cloning from a FlexVol snapshot.
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class SnapshotRef {
+
+ @JsonProperty("name")
+ private String name;
+
+ public SnapshotRef() {
+ }
+
+ public SnapshotRef(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "FileCloneRequest{volume=" + (volume != null ? volume.getUuid() : null)
+ + ", sourcePath=" + sourcePath
+ + ", destinationPath=" + destinationPath + "}";
+ }
+}
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java
index a5dd24a3a286..71bf4c980bd4 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/FileInfo.java
@@ -49,6 +49,8 @@ public class FileInfo {
private Boolean overwriteEnabled = null;
@JsonProperty("path")
private String path = null;
+ @JsonProperty("qos_policy")
+ private VolumeQosPolicy qosPolicy = null;
@JsonProperty("size")
private Long size = null;
@JsonProperty("target")
@@ -178,6 +180,15 @@ public String getPath() {
public void setPath(String path) {
this.path = path;
}
+
+ public VolumeQosPolicy getQosPolicy() {
+ return qosPolicy;
+ }
+
+ public void setQosPolicy(VolumeQosPolicy qosPolicy) {
+ this.qosPolicy = qosPolicy;
+ }
+
public Long getSize() {
return size;
}
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/feign/model/Lun.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java
index 364790958c8a..0b2e09dbec45 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/Lun.java
@@ -83,6 +83,9 @@ public static PropertyClassEnum fromValue(String value) {
@JsonProperty("name")
private String name = null;
+ @JsonProperty("qos_policy")
+ private VolumeQosPolicy qosPolicy = null;
+
@JsonProperty("clone")
private Clone clone = null;
@@ -202,6 +205,14 @@ public void setName(String name) {
this.name = name;
}
+ public VolumeQosPolicy getQosPolicy() {
+ return qosPolicy;
+ }
+
+ public void setQosPolicy(VolumeQosPolicy qosPolicy) {
+ this.qosPolicy = qosPolicy;
+ }
+
public Lun osType(OsTypeEnum osType) {
this.osType = osType;
return this;
@@ -308,6 +319,7 @@ private String toIndentedString(Object o) {
}
+ @JsonInclude(JsonInclude.Include.NON_NULL)
public static class Clone {
@JsonProperty("source")
private Source source = null;
@@ -319,6 +331,7 @@ public void setSource(Source source) {
}
}
+ @JsonInclude(JsonInclude.Include.NON_NULL)
public static class Source {
@JsonProperty("name")
private String name = null;
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java
index 7a9a4307ab1a..2e6ce1b6fa3c 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/VolumeQosPolicy.java
@@ -26,43 +26,23 @@
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class VolumeQosPolicy {
- @JsonProperty("max_throughput_iops")
- private Integer maxThroughputIops = null;
-
- @JsonProperty("max_throughput_mbps")
- private Integer maxThroughputMbps = null;
-
- @JsonProperty("min_throughput_iops")
- private Integer minThroughputIops = null;
-
+ @JsonProperty("fixed")
+ private Fixed fixed;
@JsonProperty("name")
private String name = null;
-
@JsonProperty("uuid")
private String uuid = null;
+ @JsonProperty("svm")
+ private Svm svm;
+ @JsonProperty("object_count")
+ private Integer objectCount;
- public Integer getMaxThroughputIops() {
- return maxThroughputIops;
- }
-
- public void setMaxThroughputIops(Integer maxThroughputIops) {
- this.maxThroughputIops = maxThroughputIops;
- }
-
- public Integer getMaxThroughputMbps() {
- return maxThroughputMbps;
+ public Fixed getFixed() {
+ return fixed;
}
- public void setMaxThroughputMbps(Integer maxThroughputMbps) {
- this.maxThroughputMbps = maxThroughputMbps;
- }
-
- public Integer getMinThroughputIops() {
- return minThroughputIops;
- }
-
- public void setMinThroughputIops(Integer minThroughputIops) {
- this.minThroughputIops = minThroughputIops;
+ public void setFixed(Fixed fixed) {
+ this.fixed = fixed;
}
public String getName() {
@@ -80,4 +60,57 @@ public String getUuid() {
public void setUuid(String uuid) {
this.uuid = uuid;
}
+
+ public Svm getSvm() {
+ return svm;
+ }
+
+ public void setSvm(Svm svm) {
+ this.svm = svm;
+ }
+
+ public Integer getObjectCount() {
+ return objectCount;
+ }
+
+ public void setObjectCount(Integer objectCount) {
+ this.objectCount = objectCount;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Fixed {
+ @JsonProperty("capacity_shared")
+ private Boolean capacityShared;
+
+ @JsonProperty("min_throughput_iops")
+ private Long minThroughputIops;
+
+ @JsonProperty("max_throughput_iops")
+ private Long maxThroughputIops;
+
+ public Boolean getCapacityShared() {
+ return capacityShared;
+ }
+
+ public void setCapacityShared(Boolean capacityShared) {
+ this.capacityShared = capacityShared;
+ }
+
+ public Long getMinThroughputIops() {
+ return minThroughputIops;
+ }
+
+ public void setMinThroughputIops(Long minThroughputIops) {
+ this.minThroughputIops = minThroughputIops;
+ }
+
+ public Long getMaxThroughputIops() {
+ return maxThroughputIops;
+ }
+
+ public void setMaxThroughputIops(Long maxThroughputIops) {
+ this.maxThroughputIops = maxThroughputIops;
+ }
+ }
}
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..83b0c0223ea0 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,12 +35,14 @@
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.Aggregate;
import org.apache.cloudstack.storage.feign.model.OntapStorage;
import org.apache.cloudstack.storage.feign.model.Volume;
import org.apache.cloudstack.storage.provider.StorageProviderFactory;
@@ -59,13 +56,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.capacity.CapacityManager;
+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;
public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycleImpl implements PrimaryDataStoreLifeCycle {
@Inject private ClusterDao _clusterDao;
@@ -76,9 +81,11 @@ public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycl
@Inject private StoragePoolAutomation _storagePoolAutomation;
@Inject private PrimaryDataStoreDao storagePoolDao;
@Inject private StoragePoolDetailsDao storagePoolDetailsDao;
+ @Inject private AlertManager _alertMgr;
+ @Inject private CapacityManager _capacityMgr;
private static final Logger logger = LogManager.getLogger(OntapPrimaryDatastoreLifecycle.class);
- private static final long ONTAP_MIN_VOLUME_SIZE_IN_BYTES = 1677721600L;
+ private static final long ONTAP_MIN_VOLUME_SIZE_IN_BYTES = 20971520L;
/**
* Creates primary storage on NetApp storage
@@ -96,6 +103,7 @@ public DataStore initialize(Map dsInfos) {
String storagePoolName = (String) dsInfos.get("name");
String providerName = (String) dsInfos.get("providerName");
Long capacityBytes = (Long) dsInfos.get("capacityBytes");
+ Long capacityIops = (Long) dsInfos.get("capacityIops");
boolean managed = (boolean) dsInfos.get("managed");
String tags = (String) dsInfos.get("tags");
Boolean isTagARule = (Boolean) dsInfos.get("isTagARule");
@@ -108,6 +116,8 @@ public DataStore initialize(Map dsInfos) {
@SuppressWarnings("unchecked")
Map details = (Map) dsInfos.get("details");
+ validateInitializeInputs(capacityBytes, capacityIops, podId, clusterId, zoneId, storagePoolName, providerName, managed, details);
+
PrimaryDataStoreParameters parameters = new PrimaryDataStoreParameters();
if (clusterId != null) {
ClusterVO clusterVO = _clusterDao.findById(clusterId);
@@ -118,8 +128,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,17 +143,34 @@ 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");
+ details.put(OntapStorageConstants.IS_AFF, Boolean.toString(storageStrategy.isAff()));
+ if (storageStrategy.getResolvedSvmUuid() != null && !storageStrategy.getResolvedSvmUuid().isEmpty()) {
+ details.put(OntapStorageConstants.SVM_UUID, storageStrategy.getResolvedSvmUuid());
+ }
+ Aggregate aggregate;
+ try {
+ aggregate = storageStrategy.chooseAggregate(storageStrategy.getAggregates(), capacityBytes);
+ } catch (Exception e) {
+ logger.error("Exception occurred while choosing aggregate for pool: " + storagePoolName, e);
+ throw new CloudRuntimeException("Failed to choose ONTAP aggregate for pool: " + storagePoolName
+ + ". Error: " + e.getMessage(), e);
+ }
+
+ Map lifResult;
+ try {
+ lifResult = storageStrategy.getNetworkInterface(aggregate);
+ } 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);
}
- logger.info("Using Data LIF for storage access: " + dataLIF);
- details.put(OntapStorageConstants.DATA_LIF, dataLIF);
+ String dataLif = lifResult.get(OntapStorageConstants.DATA_LIF);
+ String lifWarning = lifResult.get(OntapStorageConstants.LIF_WARNING);
+ processDataLifSelection(dataLif, lifWarning, details, storagePoolName, zoneId, podId);
+
logger.info("Creating ONTAP volume '" + storagePoolName + "' with size: " + capacityBytes + " bytes (" +
(capacityBytes / (1024 * 1024 * 1024)) + " GB)");
try {
- Volume volume = storageStrategy.createStorageVolume(storagePoolName, capacityBytes);
+ Volume volume = storageStrategy.createStorageVolume(storagePoolName, capacityBytes, aggregate);
if (volume == null) {
logger.error("createStorageVolume returned null for volume: " + storagePoolName);
throw new CloudRuntimeException("Failed to create ONTAP volume: " + storagePoolName);
@@ -174,7 +199,7 @@ public DataStore initialize(Map dsInfos) {
logger.info("Setting NFS path for storage pool: " + path + ", port: " + port + " with mount option: vers=3");
break;
case ISCSI:
- parameters.setType(Storage.StoragePoolType.Iscsi);
+ parameters.setType(Storage.StoragePoolType.OntapiSCSI);
path = storageStrategy.getStoragePath();
port = OntapStorageConstants.ISCSI_PORT;
logger.info("Setting iSCSI path for storage pool: " + path + ", port: " + port);
@@ -197,21 +222,26 @@ public DataStore initialize(Map dsInfos) {
parameters.setProviderName(providerName);
parameters.setManaged(managed);
parameters.setCapacityBytes(capacityBytes);
+ parameters.setCapacityIops(capacityIops);
parameters.setUsedBytes(0);
return _dataStoreHelper.createPrimaryDataStore(parameters);
}
- private long validateInitializeInputs(Long capacityBytes, Long podId, Long clusterId, Long zoneId,
+ private void validateInitializeInputs(Long capacityBytes, Long capacityIops, Long podId, Long clusterId, Long zoneId,
String storagePoolName, String providerName, boolean managed, Map details) {
- // Validate and set capacity
if (capacityBytes == null || capacityBytes <= 0) {
- logger.warn("capacityBytes not provided or invalid (" + capacityBytes + "), using ONTAP minimum size: " + ONTAP_MIN_VOLUME_SIZE_IN_BYTES);
- capacityBytes = ONTAP_MIN_VOLUME_SIZE_IN_BYTES;
- } else if (capacityBytes < ONTAP_MIN_VOLUME_SIZE_IN_BYTES) {
- logger.warn("capacityBytes (" + capacityBytes + ") is below ONTAP minimum (" + ONTAP_MIN_VOLUME_SIZE_IN_BYTES + "), adjusting to minimum");
- capacityBytes = ONTAP_MIN_VOLUME_SIZE_IN_BYTES;
+ throw new InvalidParameterValueException("Storage pool capacity is required for ONTAP primary storage and must be at least "
+ + ONTAP_MIN_VOLUME_SIZE_IN_BYTES + " bytes (20 MB)");
+ }
+ if (capacityBytes < ONTAP_MIN_VOLUME_SIZE_IN_BYTES) {
+ throw new InvalidParameterValueException("Storage pool capacity " + capacityBytes + " bytes is below the ONTAP minimum volume size of "
+ + ONTAP_MIN_VOLUME_SIZE_IN_BYTES + " bytes (20 MB)");
+ }
+ // IOPS capacity is optional; when left blank no pool-level IOPS ceiling is enforced.
+ if (capacityIops != null && capacityIops <= 0) {
+ throw new InvalidParameterValueException("Storage pool IOPS capacity must be greater than 0");
}
// Validate scope
@@ -268,8 +298,24 @@ private long validateInitializeInputs(Long capacityBytes, Long podId, Long clust
missing.removeAll(providedKeys);
throw new CloudRuntimeException("ONTAP primary storage creation failed, missing detail(s): " + missing);
}
+ }
+
+ private void processDataLifSelection(String dataLIF, String lifWarning, Map details,
+ String storagePoolName, Long zoneId, Long podId) {
+ 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);
- return capacityBytes;
+ // Persist LIF warning as a pool detail and fire a storage alert so the user is informed
+ if (lifWarning != null) {
+ 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
@@ -523,7 +569,62 @@ public boolean migrateToObjectStore(DataStore store) {
@Override
public void updateStoragePool(StoragePool storagePool, Map details) {
+ String newCapacityIopsStr = details.get(PrimaryDataStoreLifeCycle.CAPACITY_IOPS);
+ if (newCapacityIopsStr != null) {
+ validateUpdatedCapacityIops(storagePool, newCapacityIopsStr);
+ }
+
+ String newCapacityStr = details.get(PrimaryDataStoreLifeCycle.CAPACITY_BYTES);
+ if (newCapacityStr == null) {
+ logger.debug("No capacity change requested for pool: {}, skipping FlexVolume resize", storagePool.getName());
+ return;
+ }
+
+ long currentCapacityBytes = storagePool.getCapacityBytes();
+ long newCapacityBytes = Long.parseLong(newCapacityStr);
+ StorageStrategy storageStrategy = OntapStorageUtils.getStrategyByStoragePoolDetails(details);
+
+ String volumeUuid = details.get(OntapStorageConstants.VOLUME_UUID);
+ if (volumeUuid == null || volumeUuid.isEmpty()) {
+ logger.error("Volume UUID or name not found in details for pool: {}, cannot resize", storagePool.getName());
+ throw new CloudRuntimeException("Volume UUID or name not found in details, cannot resize ONTAP FlexVolume");
+ }
+
+ Volume volume = new Volume();
+ volume.setUuid(volumeUuid);
+ volume.setName(details.get(OntapStorageConstants.VOLUME_NAME));
+ volume.setSize(newCapacityBytes);
+ try {
+ storageStrategy.updateStorageVolume(volume);
+ logger.info("Successfully resized ONTAP FlexVolume '{}' (UUID: {}) for pool '{}' from {} bytes to {} bytes",
+ volume.getName(), volume.getUuid(), storagePool.getName(), currentCapacityBytes, newCapacityBytes);
+ } catch (Exception e) {
+ logger.error("Exception while resizing FlexVolume for pool: {}. Error: {}", storagePool.getName(), e.getMessage(), e);
+ throw new CloudRuntimeException("Failed to resize ONTAP FlexVolume for pool: " + storagePool.getName() + ". " + e.getMessage(), e);
+ }
+ }
+
+ private void validateUpdatedCapacityIops(StoragePool storagePool, String newCapacityIopsStr) {
+ long newCapacityIops;
+ try {
+ newCapacityIops = Long.parseLong(newCapacityIopsStr);
+ } catch (NumberFormatException e) {
+ throw new InvalidParameterValueException("Invalid storage pool IOPS capacity: " + newCapacityIopsStr);
+ }
+ if (newCapacityIops <= 0) {
+ throw new InvalidParameterValueException("Storage pool IOPS capacity must be greater than 0");
+ }
+ StoragePoolVO storagePoolVO = storagePoolDao.findById(storagePool.getId());
+ if (storagePoolVO == null) {
+ throw new InvalidParameterValueException("Storage pool not found for id: " + storagePool.getId());
+ }
+ long allocatedIops = _capacityMgr.getUsedIops(storagePoolVO);
+ if (newCapacityIops < allocatedIops) {
+ throw new InvalidParameterValueException(String.format(
+ "Cannot set IOPS capacity of storage pool %s to %d IOPS because %d IOPS are already allocated",
+ storagePool.getName(), newCapacityIops, allocatedIops));
+ }
}
@Override
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..a19a90c59641 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,25 +19,41 @@
package org.apache.cloudstack.storage.service;
-import com.cloud.utils.exception.CloudRuntimeException;
-import feign.FeignException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
import org.apache.cloudstack.storage.feign.FeignClientFactory;
import org.apache.cloudstack.storage.feign.client.AggregateFeignClient;
+import org.apache.cloudstack.storage.feign.client.ClusterFeignClient;
+import org.apache.cloudstack.storage.feign.client.EmsFeignClient;
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.QosFeignClient;
import org.apache.cloudstack.storage.feign.client.SANFeignClient;
import org.apache.cloudstack.storage.feign.client.SnapshotFeignClient;
import org.apache.cloudstack.storage.feign.client.SvmFeignClient;
import org.apache.cloudstack.storage.feign.client.VolumeFeignClient;
import org.apache.cloudstack.storage.feign.model.Aggregate;
+import org.apache.cloudstack.storage.feign.model.Cluster;
+import org.apache.cloudstack.storage.feign.model.ClusterNode;
+import org.apache.cloudstack.storage.feign.model.EmsApplicationLog;
import org.apache.cloudstack.storage.feign.model.IpInterface;
import org.apache.cloudstack.storage.feign.model.IscsiService;
import org.apache.cloudstack.storage.feign.model.Job;
import org.apache.cloudstack.storage.feign.model.Nas;
import org.apache.cloudstack.storage.feign.model.OntapStorage;
import org.apache.cloudstack.storage.feign.model.Svm;
+import org.apache.cloudstack.storage.feign.model.Version;
import org.apache.cloudstack.storage.feign.model.Volume;
+import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy;
import org.apache.cloudstack.storage.feign.model.response.JobResponse;
import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
import org.apache.cloudstack.storage.service.model.AccessGroup;
@@ -45,14 +61,13 @@
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 java.util.HashMap;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import feign.FeignException;
/**
* Storage Strategy represents the communication path for all the ONTAP storage options
@@ -69,9 +84,12 @@ public abstract class StorageStrategy {
protected SvmFeignClient svmFeignClient;
protected JobFeignClient jobFeignClient;
protected NetworkFeignClient networkFeignClient;
+ protected QosFeignClient qosFeignClient;
protected SANFeignClient sanFeignClient;
protected NASFeignClient nasFeignClient;
protected SnapshotFeignClient snapshotFeignClient;
+ protected ClusterFeignClient clusterFeignClient;
+ protected EmsFeignClient emsFeignClient;
protected OntapStorage storage;
@@ -79,6 +97,7 @@ public abstract class StorageStrategy {
* 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);
@@ -93,15 +112,194 @@ public StorageStrategy(OntapStorage ontapStorage) {
this.svmFeignClient = feignClientFactory.createClient(SvmFeignClient.class, baseURL);
this.jobFeignClient = feignClientFactory.createClient(JobFeignClient.class, baseURL);
this.networkFeignClient = feignClientFactory.createClient(NetworkFeignClient.class, baseURL);
+ this.qosFeignClient = feignClientFactory.createClient(QosFeignClient.class, baseURL);
this.sanFeignClient = feignClientFactory.createClient(SANFeignClient.class, baseURL);
this.nasFeignClient = feignClientFactory.createClient(NASFeignClient.class, baseURL);
this.snapshotFeignClient = feignClientFactory.createClient(SnapshotFeignClient.class, baseURL);
+ this.clusterFeignClient = feignClientFactory.createClient(ClusterFeignClient.class, baseURL);
+ this.emsFeignClient = feignClientFactory.createClient(EmsFeignClient.class, baseURL);
+ }
+
+ /**
+ * Fetches the ONTAP {@link Cluster} (name, uuid, version) and, for ASUP, the rolled-up node
+ * {@code model} and {@code platformType} from {@code GET /api/cluster/nodes}. Best-effort:
+ * returns {@code null} if the cluster GET fails. Node fields are left unset if the nodes GET
+ * fails. Callers must never fail a storage operation because of this.
+ *
+ * @return the ONTAP {@link Cluster}, or {@code null} if it cannot be resolved
+ */
+ public Cluster getClusterInfo() {
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ Cluster cluster = clusterFeignClient.getCluster(authHeader, true);
+ if (cluster != null) {
+ populateNodeAsupFields(cluster, authHeader);
+ }
+ return cluster;
+ } catch (Exception e) {
+ logger.warn("getClusterInfo: failed to fetch ONTAP cluster info for storage IP {}: {}",
+ storage.getStorageIP(), e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Sets {@link Cluster#setModel(String)} and {@link Cluster#setPlatformType(String)} from one
+ * {@code GET /api/cluster/nodes}. Distinct models are joined with a comma. Platform type is
+ * {@code performance}, {@code capacity}, {@code fas}, or {@code composite} when personalities mix.
+ * A node that omits {@code is_all_flash_optimized} is left out of the rollup so a partial
+ * response is not reported as {@code fas}.
+ */
+ private void populateNodeAsupFields(Cluster cluster, String authHeader) {
+ try {
+ Map query = new HashMap<>();
+ query.put(OntapStorageConstants.FIELDS, OntapStorageConstants.CLUSTER_NODE_ASUP_FIELDS);
+ OntapResponse response = clusterFeignClient.getClusterNodes(authHeader, query);
+ if (response == null || response.getRecords() == null || response.getRecords().isEmpty()) {
+ return;
+ }
+ LinkedHashSet models = new LinkedHashSet<>();
+ LinkedHashSet platformTypes = new LinkedHashSet<>();
+ for (ClusterNode node : response.getRecords()) {
+ if (node == null) {
+ continue;
+ }
+ if (node.getModel() != null && !node.getModel().isBlank()) {
+ models.add(node.getModel().trim());
+ }
+ String platformType = classifyNodePlatformType(node);
+ if (platformType != null) {
+ platformTypes.add(platformType);
+ }
+ }
+ if (!models.isEmpty()) {
+ cluster.setModel(String.join(OntapStorageConstants.COMMA, models));
+ }
+ cluster.setPlatformType(rollupPlatformType(platformTypes));
+ } catch (Exception e) {
+ logger.warn("getClusterInfo: failed to fetch ONTAP node hardware for storage IP {}: {}",
+ storage.getStorageIP(), e.getMessage());
+ }
+ }
+
+ /**
+ * Extracts a clean, parser-friendly ONTAP version string from a {@link Cluster}.
+ *
+ *
Prefers the compact "generation.major.minor" numeric form (e.g. "9.17.1"), which avoids
+ * the colon/date noise in {@code version.full}. Falls back to the verbose {@code version.full}
+ * banner only when the numeric fields are unavailable.
+ *
+ * @param cluster the cluster (may be {@code null})
+ * @return the ONTAP version string, or {@code null} if it cannot be resolved
+ */
+ public String getClusterVersion(Cluster cluster) {
+ if (cluster == null || cluster.getVersion() == null) {
+ return null;
+ }
+ Version version = cluster.getVersion();
+ if (version.getGeneration() != null && version.getMajor() != null && version.getMinor() != null) {
+ return version.getGeneration() + OntapStorageConstants.DOT + version.getMajor()
+ + OntapStorageConstants.DOT + version.getMinor();
+ }
+ if (version.getFull() != null && !version.getFull().isEmpty()) {
+ return version.getFull();
+ }
+ return null;
+ }
+
+ /**
+ * Classifies one node: not all-flash → {@code fas}; all-flash + capacity → {@code capacity};
+ * otherwise all-flash (including performance-optimized or classic AFF) → {@code performance}.
+ * Returns {@code null} when {@code is_all_flash_optimized} is absent, so unknown hardware is
+ * not reported as {@code fas}.
+ */
+ private static String classifyNodePlatformType(ClusterNode node) {
+ if (node.getAllFlashOptimized() == null) {
+ return null;
+ }
+ if (!node.getAllFlashOptimized()) {
+ return OntapStorageConstants.ASUP_PLATFORM_TYPE_FAS;
+ }
+ if (Boolean.TRUE.equals(node.getCapacityOptimized())) {
+ return OntapStorageConstants.ASUP_PLATFORM_TYPE_CAPACITY;
+ }
+ return OntapStorageConstants.ASUP_PLATFORM_TYPE_PERFORMANCE;
+ }
+
+ private static String rollupPlatformType(LinkedHashSet platformTypes) {
+ if (platformTypes == null || platformTypes.isEmpty()) {
+ return null;
+ }
+ if (platformTypes.size() == 1) {
+ return platformTypes.iterator().next();
+ }
+ return OntapStorageConstants.ASUP_PLATFORM_TYPE_COMPOSITE;
+ }
+
+ /**
+ * True when every cluster node reports {@code is_all_flash_optimized} (AFF, including C-series).
+ * A FAS node, or a node that omits the flag, makes this false. Used for min-throughput QoS support.
+ */
+ public boolean isAff() {
+ Map query = new HashMap<>();
+ query.put(OntapStorageConstants.FIELDS, OntapStorageConstants.CLUSTER_NODE_ASUP_FIELDS);
+ OntapResponse response = clusterFeignClient.getClusterNodes(getAuthHeader(), query);
+ if (response == null || response.getRecords() == null
+ || response.getRecords().stream().allMatch(node -> node == null)) {
+ throw new CloudRuntimeException(
+ "Unable to determine whether the ONTAP cluster is AFF or FAS");
+ }
+ for (ClusterNode node : response.getRecords()) {
+ if (node == null || !Boolean.TRUE.equals(node.getAllFlashOptimized())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Pushes a single ASUP (AutoSupport) EMS application-log message to the ONTAP cluster.
+ *
+ *
This is strictly best-effort telemetry: any failure is logged and swallowed so that
+ * it can never affect a storage operation or the periodic scheduler.
+ *
+ * @param message the EMS message to send
+ */
+ public void sendAsupMessage(EmsApplicationLog message) {
+ if (message == null) {
+ return;
+ }
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ emsFeignClient.sendEmsApplicationLog(authHeader, message);
+ logger.debug("sendAsupMessage: ASUP EMS message [event-id={}] sent to ONTAP cluster at {}",
+ message.getEventId(), storage.getStorageIP());
+ } catch (Exception e) {
+ logger.error("sendAsupMessage: failed to send ASUP EMS message [event-id={}] to ONTAP cluster at {}: {}",
+ message.getEventId(), storage.getStorageIP(), e.getMessage());
+ }
}
- // 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 +329,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,20 +351,71 @@ public boolean connect() {
return true;
}
+ /**
+ * ONTAP SVM UUID resolved during the last successful {@link #connect(boolean)} call.
+ */
+ public String getResolvedSvmUuid() {
+ return resolvedSvmUuid;
+ }
+
+ /**
+ * Aggregates eligible for new FlexVol creation, populated by {@link #connect(boolean)} with aggregate validation enabled.
+ */
+ public List getAggregates() {
+ return aggregates;
+ }
+
+ public void setAggregates(List aggregates) {
+ this.aggregates = aggregates;
+ }
+
+ 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);
+ }
+ 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(),
+ 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.");
+ eligibleAggregates.add(aggr);
+ }
+ if (eligibleAggregates.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.");
+ }
+ setAggregates(eligibleAggregates);
+ }
+
// Common methods like create/delete etc., should be here
/**
- * Creates ONTAP Flex-Volume
- * Eligible only for Unified ONTAP storage
- * throw exception in case of disaggregated ONTAP storage
+ * Selects the best aggregate for a volume of the given size from the given candidate aggregates.
*
- * @param volumeName the name of the volume to create
- * @param size the size of the volume in bytes
- * @return the created Volume object
+ *
Picks the online aggregate with the largest available block space that can fit
+ * {@code size}. The returned aggregate includes node information for LIF affinity.
+ *
+ * @param aggregates candidate aggregates, for example {@link #getAggregates()}
+ * @param size requested volume size in bytes
+ * @return the chosen aggregate detail response
*/
- public Volume createStorageVolume(String volumeName, Long size) {
- logger.info("Creating volume: " + volumeName + " of size: " + size + " bytes");
-
+ public Aggregate chooseAggregate(List aggregates, Long size) {
String svmName = storage.getSvmName();
if (aggregates == null || aggregates.isEmpty()) {
logger.error("No aggregates available to create volume on SVM " + svmName);
@@ -203,23 +426,14 @@ public Volume createStorageVolume(String volumeName, Long size) {
}
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
-
- // Generate the Create Volume Request
- Volume volumeRequest = new Volume();
- Svm svm = new Svm();
- svm.setName(svmName);
- Nas nas = new Nas();
- nas.setPath(OntapStorageConstants.SLASH + volumeName);
-
- volumeRequest.setName(volumeName);
- volumeRequest.setSvm(svm);
-
- // Pick the best aggregate for this specific request (largest available, online, and sufficient space).
long maxAvailableAggregateSpaceBytes = -1L;
Aggregate aggrChosen = null;
for (Aggregate aggr : aggregates) {
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.");
@@ -239,7 +453,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
final long availableBytes = aggrResp.getAvailableBlockStorageSpace().longValue();
logger.debug("Aggregate " + aggr.getName() + " available bytes=" + availableBytes + ", requested=" + size);
- if (availableBytes < size) {
+ if (availableBytes <= size) {
logger.warn("Aggregate " + aggr.getName() + " does not have sufficient available space. Required=" +
size + " bytes, available=" + availableBytes + " bytes. Skipping this aggregate.");
continue;
@@ -247,7 +461,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
if (availableBytes > maxAvailableAggregateSpaceBytes) {
maxAvailableAggregateSpaceBytes = availableBytes;
- aggrChosen = aggr;
+ aggrChosen = aggrResp;
}
}
@@ -255,11 +469,55 @@ public Volume createStorageVolume(String volumeName, Long size) {
logger.error("No suitable aggregates found on SVM " + svmName + " for volume creation.");
throw new CloudRuntimeException("No suitable aggregates found on SVM " + svmName + " for volume operations.");
}
- logger.info("Selected aggregate: " + aggrChosen.getName() + " for volume operations.");
+ if (aggrChosen.getNode() == null || aggrChosen.getNode().getName() == null
+ || aggrChosen.getNode().getName().isEmpty()) {
+ logger.error("Selected aggregate " + aggrChosen.getName() + " does not have a node name.");
+ throw new CloudRuntimeException("Selected aggregate " + aggrChosen.getName()
+ + " does not have a node name required for LIF affinity.");
+ }
+ logger.info("Selected aggregate: " + aggrChosen.getName() + " on node "
+ + aggrChosen.getNode().getName() + " for volume operations.");
+ return aggrChosen;
+ }
+
+ /**
+ * Creates ONTAP Flex-Volume on the given aggregate.
+ * Eligible only for Unified ONTAP storage
+ * throw exception in case of disaggregated ONTAP storage
+ *
+ * @param volumeName the name of the volume to create
+ * @param size the size of the volume in bytes
+ * @param aggregate the aggregate previously selected via {@link #chooseAggregate(Long)}
+ * @return the created Volume object
+ */
+ public Volume createStorageVolume(String volumeName, Long size, Aggregate aggregate) {
+ logger.info("Creating volume: " + volumeName + " of size: " + size + " bytes");
+
+ String svmName = storage.getSvmName();
+ if (size == null || size <= 0) {
+ throw new CloudRuntimeException("Invalid volume size provided: " + size);
+ }
+ if (aggregate == null || aggregate.getName() == null || aggregate.getUuid() == null) {
+ throw new CloudRuntimeException("Aggregate is required to create volume on SVM " + svmName);
+ }
+
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+
+ // Generate the Create Volume Request
+ Volume volumeRequest = new Volume();
+ Svm svm = new Svm();
+ svm.setName(svmName);
+ Nas nas = new Nas();
+ nas.setPath(OntapStorageConstants.SLASH + volumeName);
+
+ volumeRequest.setName(volumeName);
+ volumeRequest.setSvm(svm);
+
+ logger.info("Creating volume on aggregate: " + aggregate.getName() + " for volume operations.");
Aggregate aggr = new Aggregate();
- aggr.setName(aggrChosen.getName());
- aggr.setUuid(aggrChosen.getUuid());
+ aggr.setName(aggregate.getName());
+ aggr.setUuid(aggregate.getUuid());
volumeRequest.setAggregates(List.of(aggr));
volumeRequest.setSize(size);
volumeRequest.setNas(nas);
@@ -271,7 +529,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
}
String jobUUID = jobResponse.getJob().getUuid();
- Boolean jobSucceeded = jobPollForSuccess(jobUUID,10, 1000);
+ Boolean jobSucceeded = jobPollForSuccess(jobUUID,OntapStorageConstants.ONTAP_FLEXVOL_RESOLVE_MAX_RETRIES, OntapStorageConstants.ONTAP_FLEXVOL_JOB_POLL_INTERVAL_MS);
if (!jobSucceeded) {
logger.error("Volume creation job failed for volume: " + volumeName);
throw new CloudRuntimeException("Volume creation job failed for volume: " + volumeName);
@@ -339,7 +597,25 @@ public Volume createStorageVolume(String volumeName, Long size) {
* @return the updated Volume object
*/
public Volume updateStorageVolume(Volume volume) {
- return null;
+ logger.info("Resizing ONTAP FlexVolume '{}' (UUID: {}) to {} bytes", volume.getName(), volume.getUuid(), volume.getSize());
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ try {
+ Volume resizeRequest = new Volume();
+ resizeRequest.setSize(volume.getSize());
+ JobResponse jobResponse = volumeFeignClient.updateVolume(authHeader, volume.getUuid(), resizeRequest);
+ pollJobIfPresent(jobResponse, "resize FlexVolume [" + volume.getUuid() + "]",
+ OntapStorageConstants.ONTAP_VOLUME_JOB_MAX_RETRIES, OntapStorageConstants.ONTAP_VOLUME_JOB_POLL_INTERVAL_MS);
+ logger.info("FlexVolume '{}' (UUID: {}) resized successfully to {} bytes", volume.getName(), volume.getUuid(), volume.getSize());
+ } catch (FeignException e) {
+ if (OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ String msg = String.format("Cannot resize FlexVolume '%s' (UUID: %s): volume not found on ONTAP (404). ", volume.getName(), volume.getUuid());
+ logger.error(msg);
+ throw new CloudRuntimeException(msg, e);
+ }
+ logger.error("Exception while resizing FlexVolume '{}' (UUID: {}): {}", volume.getName(), volume.getUuid(), e.getMessage(), e);
+ throw new CloudRuntimeException("Failed to resize ONTAP FlexVolume: " + e.getMessage(), e);
+ }
+ return volume;
}
/**
@@ -354,13 +630,17 @@ public void deleteStorageVolume(Volume volume) {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
try {
JobResponse jobResponse = volumeFeignClient.deleteVolume(authHeader, volume.getUuid());
- Boolean jobSucceeded = jobPollForSuccess(jobResponse.getJob().getUuid(), 10, 1000);
+ Boolean jobSucceeded = jobPollForSuccess(jobResponse.getJob().getUuid(), OntapStorageConstants.ONTAP_FLEXVOL_RESOLVE_MAX_RETRIES, OntapStorageConstants.ONTAP_FLEXVOL_JOB_POLL_INTERVAL_MS);
if (!jobSucceeded) {
logger.error("Volume deletion job failed for volume: " + volume.getName());
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());
}
@@ -368,15 +648,38 @@ public void deleteStorageVolume(Volume volume) {
}
/**
- * Gets ONTAP Flex-Volume
- * Eligible only for Unified ONTAP storage
- * throw exception in case of disaggregated ONTAP storage
+ * Gets an ONTAP FlexVolume by UUID.
+ * Eligible only for Unified ONTAP storage.
+ * Throws exception in case of disaggregated ONTAP storage.
+ *
+ *
Callers pass ONTAP query parameters when they need a partial record. For example,
+ * {@code fields=space.used} returns only used space instead of the full volume.
+ * A null or empty map returns the default volume representation.
*
- * @param volume the volume to retrieve
- * @return the retrieved Volume object
+ * @param uuid the UUID of the volume to retrieve
+ * @param queryParams ONTAP query parameters applied to {@code GET /storage/volumes/{uuid}};
+ * may be null
+ * @return the retrieved Volume object, or null if not found
*/
- public Volume getStorageVolume(Volume volume) {
- return null;
+ public Volume getStorageVolume(String uuid, Map queryParams) {
+ if (StringUtils.isBlank(uuid)) {
+ throw new CloudRuntimeException("Cannot fetch ONTAP volume: UUID is null or empty");
+ }
+ Map params = queryParams != null ? queryParams : Collections.emptyMap();
+ logger.info("getStorageVolume: Fetching ONTAP volume [{}] with query params {}", uuid, params);
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ try {
+ Volume fetchedVolume = volumeFeignClient.getVolumeByUUID(authHeader, uuid, params);
+ logger.info("getStorageVolume: Volume [{}] fetched successfully", uuid);
+ return fetchedVolume;
+ } catch (FeignException e) {
+ if (OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ logger.warn("getStorageVolume: Volume [{}] not found in ONTAP", uuid);
+ return null;
+ }
+ logger.error("getStorageVolume: Exception while fetching volume [{}]: ", uuid, e);
+ throw new CloudRuntimeException("Failed to fetch volume: " + e.getMessage());
+ }
}
/**
@@ -430,12 +733,27 @@ 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 given aggregate to avoid inter-node traffic.
+ *
+ *
Selection order:
+ *
+ *
LIF whose {@code location.home_node} matches the 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
+ *
*
- * @return the network interface ip as a String
+ * @param aggregate the aggregate previously selected via {@link #chooseAggregate(Long)};
+ * must include a node name for LIF affinity
+ * @return map with {@link OntapStorageConstants#DATA_LIF} set to the LIF IP address, and
+ * optionally {@link OntapStorageConstants#LIF_WARNING} when a non-ideal LIF was selected
*/
-
- public String getNetworkInterface() {
+ public Map getNetworkInterface(Aggregate aggregate) {
+ if (aggregate == null || aggregate.getNode() == null || aggregate.getNode().getName() == null
+ || aggregate.getNode().getName().isEmpty()) {
+ throw new CloudRuntimeException("Aggregate with a node name is required to select a network interface");
+ }
+ String aggregateNode = aggregate.getNode().getName();
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
try {
Map queryParams = new HashMap<>();
@@ -453,36 +771,99 @@ 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 (iface == null || iface.getIp() == null) {
+ continue;
+ }
+ if (!Boolean.TRUE.equals(iface.getEnabled()) || !OntapStorageConstants.LIF_STATE_UP.equals(iface.getState())) {
+ continue;
+ }
+ if (!isIPv4Address(iface.getIp().getAddress())) {
+ continue;
+ }
+ // LIF is homed on the aggregate's node
+ String homeNode = iface.getLocation() != null && iface.getLocation().getHomeNode() != null
+ ? iface.getLocation().getHomeNode().getName() : null;
+ if (aggregateNode.equals(homeNode)) {
+ return networkInterfaceResult(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 (aggregateNode.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 '" + aggregateNode
+ + "'; using LIF '" + ip + "' currently running on that node (home node LIF may be down).";
+ logger.warn(warning);
+ return networkInterfaceResult(ip, warning);
+ }
+
+ String ip = fallbackInterface.getIp().getAddress();
+ String warning = "No operational LIF found on aggregate's home node '" + aggregateNode
+ + "'; using fallback LIF '" + ip + "' on a different node."
+ + " I/O will traverse an inter-node path, increasing latency.";
+ logger.warn(warning);
+ return networkInterfaceResult(ip, warning);
+ } catch (CloudRuntimeException e) {
+ throw e;
+ } catch (Exception e) {
logger.error("Exception while retrieving network interfaces: ", e);
throw new CloudRuntimeException("Failed to retrieve network interfaces: " + e.getMessage());
}
}
+ private Map networkInterfaceResult(String address, String warning) {
+ Map result = new HashMap<>();
+ result.put(OntapStorageConstants.DATA_LIF, address);
+ if (warning != null) {
+ result.put(OntapStorageConstants.LIF_WARNING, warning);
+ }
+ return result;
+ }
+
+ /**
+ * 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
@@ -495,6 +876,27 @@ public String getNetworkInterface() {
*/
abstract public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume);
+ /**
+ * Creates the protocol-specific backend object that caches a template on this pool.
+ *
+ *
iSCSI creates an empty LUN ({@code /vol/<flexVol>/cs_tmpl_<id>}) sized to
+ * {@code sizeInBytes}. NFS is a no-op on the array: the KVM agent later writes the qcow2
+ * into the mounted FlexVolume.
+ *
+ *
Returns a {@link CloudStackVolume} so the driver can map it to {@code CreateCmdResult}
+ * and update {@code template_spool_ref}. SAN populates {@code lun}; NAS returns an empty
+ * volume (no LUN / file yet).
+ *
+ * @param storagePool CloudStack primary storage pool (one FlexVolume)
+ * @param templateInfo template being cached
+ * @param details pool details (SVM, protocol, etc.)
+ * @param sizeInBytes virtual size for the cache object (required for SAN; ignored for NAS)
+ * @return created cache identity, or an empty {@link CloudStackVolume} when nothing is
+ * pre-created on the array
+ */
+ abstract public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, TemplateInfo templateInfo,
+ Map details, long sizeInBytes);
+
/**
* Method encapsulates the behavior based on the opted protocol in subclasses.
* it is going to mimic
@@ -505,7 +907,7 @@ public String getNetworkInterface() {
* @param cloudstackVolume the CloudStack volume to update
* @return the updated CloudStackVolume object
*/
- abstract CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume);
+ public abstract CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume);
/**
* Method encapsulates the behavior based on the opted protocol in subclasses.
@@ -519,14 +921,42 @@ public String getNetworkInterface() {
abstract public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume);
/**
- * Method encapsulates the behavior based on the opted protocol in subclasses.
+ * Creates a space-efficient clone of an existing object inside the same FlexVolume.
* it is going to mimic
* cloneLun for iSCSI, FC protocols
* cloneFile for NFS3.0 and NFS4.1 protocols
* cloneNameSpace for Nvme/TCP and Nvme/FC protocol
- * @param cloudstackVolume the CloudStack volume to copy
+ *
+ *
ONTAP requires the source and the destination to live in the same FlexVolume, which
+ * holds because a CloudStack primary storage pool maps one-to-one onto a FlexVolume.
+ *
+ *
The source may be an active object or one inside a FlexVolume snapshot:
+ *
+ *
SAN — {@code lun.clone.source.name} set to
+ * {@code /vol/<fv>/.snapshot/<snap>/<lun>}
+ *
NAS — {@code file.path} set to
+ * {@code ./.snapshot/<snapshot-name>/<source-file>} and sent as
+ * {@code source_path} on {@code POST /api/storage/file/clone}
+ *
+ *
+ * @param cloudstackVolume describes the clone to create; the source is carried in the
+ * protocol-specific clone reference (for SAN, {@code lun.clone.source})
+ * @return the created CloudStackVolume, populated with the backend identity of the clone
*/
- abstract public void copyCloudStackVolume(CloudStackVolume cloudstackVolume);
+ abstract public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume);
+
+ /**
+ * Grows an existing backend object to {@code sizeInBytes}.
+ *
+ *
Needed after cloning a cached template, because a clone inherits the size of its source
+ * while the service offering may ask for a larger disk.
+ *
+ *
When the request already carries a QoS policy on the LUN or file, that policy is applied
+ * as part of this resize. iSCSI sends one LUN update with the new size and the policy.
+ * NFS grows the file on the host, then updates the file policy. A request with no policy
+ * changes size only.
+ */
+ abstract public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes);
/**
* Method encapsulates the behavior based on the opted protocol in subclasses.
@@ -540,15 +970,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 +1018,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
@@ -650,16 +1078,128 @@ public String getAuthHeader() {
return OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
}
+ public VolumeQosPolicy createVolumeQosPolicy(String policyName, Long minIops, Long maxIops) {
+ VolumeQosPolicy policy = buildVolumeQosPolicy(policyName, minIops, maxIops);
+ Svm svm = new Svm();
+ svm.setName(storage.getSvmName());
+ policy.setSvm(svm);
+ try {
+ JobResponse response = qosFeignClient.createPolicy(getAuthHeader(), policy);
+ pollJobIfPresent(response, "create QoS policy [" + policyName + "]");
+ } catch (FeignException e) {
+ if (e.status() != 409) {
+ throw new CloudRuntimeException("Failed to create ONTAP QoS policy [" + policyName + "]: "
+ + e.getMessage(), e);
+ }
+ logger.info("QoS policy [{}] already exists; using the existing policy", policyName);
+ }
+
+ VolumeQosPolicy createdPolicy = getVolumeQosPolicy(policyName);
+ return createdPolicy;
+ }
+
+ public void deleteVolumeQosPolicy(String policyUuid) {
+ if (policyUuid == null || policyUuid.isEmpty()) {
+ return;
+ }
+ VolumeQosPolicy policy = getVolumeQosPolicyByUuid(policyUuid);
+ if (policy == null) {
+ logger.info("QoS policy [{}] not found on ONTAP; skipping delete", policyUuid);
+ return;
+ }
+ if (policy.getObjectCount() != null && policy.getObjectCount() > 0) {
+ logger.info("QoS policy [{}] still has object_count={}; skipping delete",
+ policyUuid, policy.getObjectCount());
+ return;
+ }
+ try {
+ JobResponse response = qosFeignClient.deletePolicy(getAuthHeader(), policyUuid);
+ pollJobIfPresent(response, "delete QoS policy [" + policyUuid + "]");
+ } catch (Exception e) {
+ if ((e instanceof FeignException && ((FeignException) e).status() == 409)
+ || OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ logger.info("QoS policy [{}] was not deleted on ONTAP (already absent or conflict): {}",
+ policyUuid, e.getMessage());
+ return;
+ }
+ throw new CloudRuntimeException("Failed to delete ONTAP QoS policy [" + policyUuid + "]: "
+ + e.getMessage(), e);
+ }
+ }
+
+ private VolumeQosPolicy getVolumeQosPolicyByUuid(String policyUuid) {
+ Map queryParams = new HashMap<>();
+ queryParams.put(OntapStorageConstants.FIELDS, OntapStorageConstants.QOS_POLICY_OBJECT_COUNT_FIELDS);
+ try {
+ VolumeQosPolicy policy = qosFeignClient.getPolicy(getAuthHeader(), policyUuid, queryParams);
+ if (policy == null || StringUtils.isEmpty(policy.getUuid())) {
+ throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyUuid
+ + "]: empty response");
+ }
+ return policy;
+ } catch (FeignException e) {
+ if (OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ return null;
+ }
+ throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyUuid + "]: "
+ + e.getMessage(), e);
+ }
+ }
+
+ private VolumeQosPolicy getVolumeQosPolicy(String policyName) {
+ Map queryParams = new HashMap<>();
+ queryParams.put(OntapStorageConstants.NAME, policyName);
+ queryParams.put(OntapStorageConstants.SVM_DOT_NAME, storage.getSvmName());
+ try {
+ OntapResponse response = qosFeignClient.getPolicies(getAuthHeader(), queryParams);
+ if (response == null || response.getRecords() == null || response.getRecords().size() <= 0) {
+ throw new CloudRuntimeException("Unable to get ONTAP QoS policy [" + policyName + "] after creation");
+ }
+ VolumeQosPolicy policy = response.getRecords().get(0);
+ if (policy == null || StringUtils.isEmpty(policy.getUuid())) {
+ throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyName
+ + "]: empty response");
+ }
+ return policy;
+ } catch (FeignException e) {
+ throw new CloudRuntimeException("Failed to fetch ONTAP QoS policy [" + policyName + "]: "
+ + e.getMessage(), e);
+ }
+ }
+
+ private VolumeQosPolicy buildVolumeQosPolicy(String policyName, Long minIops, Long maxIops) {
+ VolumeQosPolicy.Fixed fixed = new VolumeQosPolicy.Fixed();
+ fixed.setCapacityShared(false);
+ // ONTAP rejects a policy whose throughput limit is zero, so only unlimited-side values are omitted.
+ if (minIops != null && minIops > 0) {
+ fixed.setMinThroughputIops(minIops);
+ }
+ if (maxIops != null && maxIops > 0) {
+ fixed.setMaxThroughputIops(maxIops);
+ }
+
+ VolumeQosPolicy policy = new VolumeQosPolicy();
+ policy.setName(policyName);
+ policy.setFixed(fixed);
+ return policy;
+ }
+
/**
* Polls an ONTAP async job for successful completion.
*
* @param jobUUID UUID of the ONTAP job to poll
* @param maxRetries maximum number of poll attempts
- * @param sleepTimeInMilliSecs 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 +1224,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.
The source is taken from {@code file.path} and the destination from
+ * {@code destinationPath}, both relative to the root of the FlexVolume backing the pool.
+ */
@Override
- public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ if (cloudstackVolume == null || cloudstackVolume.getFile() == null
+ || cloudstackVolume.getFile().getPath() == null || cloudstackVolume.getDestinationPath() == null) {
+ logger.error("cloneCloudStackVolume: File clone failed. Invalid request: {}", cloudstackVolume);
+ throw new CloudRuntimeException("Failed to clone file, invalid request");
+ }
+ if (cloudstackVolume.getDatastoreId() == null) {
+ throw new CloudRuntimeException("Failed to clone file, no datastore id in the request");
+ }
+
+ Map details = storagePoolDetailsDao.listDetailsKeyPairs(Long.parseLong(cloudstackVolume.getDatastoreId()));
+ String flexVolUuid = details.get(OntapStorageConstants.VOLUME_UUID);
+ String flexVolName = details.get(OntapStorageConstants.VOLUME_NAME);
+ if (flexVolUuid == null || flexVolUuid.isEmpty()) {
+ throw new CloudRuntimeException("Failed to clone file, FlexVolume uuid is missing from pool poolDetails");
+ }
+ String sourcePath = cloudstackVolume.getFile().getPath();
+ String destinationPath = cloudstackVolume.getDestinationPath();
+
+ logger.info("cloneCloudStackVolume: Cloning file [{}] to [{}] in FlexVol [{}]",
+ sourcePath, destinationPath, flexVolName);
+ try {
+ FileCloneRequest request = new FileCloneRequest(flexVolUuid, flexVolName, sourcePath, destinationPath);
+ JobResponse jobResponse = nasFeignClient.cloneFile(getAuthHeader(), request);
+ pollJobIfPresent(jobResponse, "clone file [" + sourcePath + "] to [" + destinationPath + "]");
+
+ updateCloudStackVolumeMetadata(cloudstackVolume.getDatastoreId(), cloudstackVolume.getVolumeInfo());
+
+ FileInfo clonedFile = new FileInfo();
+ clonedFile.setPath(destinationPath);
+ CloudStackVolume clonedCloudStackVolume = new CloudStackVolume();
+ clonedCloudStackVolume.setFile(clonedFile);
+ clonedCloudStackVolume.setDatastoreId(cloudstackVolume.getDatastoreId());
+ clonedCloudStackVolume.setVolumeInfo(cloudstackVolume.getVolumeInfo());
+ return clonedCloudStackVolume;
+ } catch (FeignException e) {
+ logger.error("FeignException occurred while cloning file [{}], Status: {}, Exception: {}",
+ sourcePath, e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to clone file: " + e.getMessage());
+ } catch (Exception e) {
+ logger.error("Exception occurred while cloning file [{}], Exception: {}", sourcePath, e.getMessage());
+ throw new CloudRuntimeException("Failed to clone file: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Grows the cloned qcow2 to the requested size via a host-side {@code qemu-img resize}.
+ */
+ @Override
+ public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes) {
+ if (cloudstackVolume == null || cloudstackVolume.getVolumeInfo() == null) {
+ logger.error("resizeCloudStackVolume: Resize failed. Invalid request: {}", cloudstackVolume);
+ throw new CloudRuntimeException("Failed to resize file, invalid request");
+ }
+ if (sizeInBytes <= 0) {
+ throw new CloudRuntimeException("Failed to resize file, invalid size " + sizeInBytes);
+ }
+
+ DataObject volumeInfo = cloudstackVolume.getVolumeInfo();
+ Answer answer = resizeVolumeOnKVMHost(volumeInfo, sizeInBytes);
+ if (answer == null || !answer.getResult()) {
+ String errMsg = answer != null ? answer.getDetails() : "Failed to resize qcow2 on KVM host";
+ logger.error("resizeCloudStackVolume: " + errMsg);
+ throw new CloudRuntimeException(errMsg);
+ }
+ if (cloudstackVolume.getFile() != null && cloudstackVolume.getFile().getQosPolicy() != null) {
+ updateCloudStackVolume(cloudstackVolume);
+ }
+ logger.info("resizeCloudStackVolume: Resized volume [{}] to {} bytes", volumeInfo.getUuid(), sizeInBytes);
+ }
+
+ private Answer resizeVolumeOnKVMHost(DataObject volumeInfo, long sizeInBytes) {
+ VolumeObject volumeObject = (VolumeObject) volumeInfo;
+ VolumeVO volume = volumeDao.findById(volumeObject.getId());
+ if (volume == null) {
+ throw new CloudRuntimeException("Volume not found with id: " + volumeObject.getId());
+ }
+
+ StoragePoolVO storagePool = primaryDataStoreDao.findById(volume.getPoolId());
+ if (storagePool == null) {
+ throw new CloudRuntimeException("Storage Pool not found for id: " + volume.getPoolId());
+ }
+
+ // instanceName is set by VolumeApiServiceImpl.orchestrateResizeVolume() before calling the
+ // driver — it is the VM instance name when attached, or "none" when the volume is detached.
+ ResizeVolumePayload resizePayload = volumeObject.getpayload() instanceof ResizeVolumePayload
+ ? (ResizeVolumePayload) volumeObject.getpayload()
+ : null;
+ String instanceName = resizePayload != null ? resizePayload.instanceName : "none";
+ ResizeVolumeCommand cmd = new ResizeVolumeCommand(volume.getPath(), new StorageFilerTO(storagePool),
+ volume.getSize(), sizeInBytes, false, instanceName);
+
+ EndPoint ep = epSelector.select(volumeInfo);
+ if (ep == null) {
+ String errMsg = "No remote endpoint to send ResizeVolumeCommand, check if host is up";
+ logger.error(errMsg);
+ return new Answer(cmd, false, errMsg);
+ }
+ logger.info("resizeVolumeOnKVMHost: Sending command to endpoint: {}", ep.getHostAddr());
+ return ep.sendMessage(cmd);
}
@Override
@@ -148,7 +318,7 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
logger.info("createAccessGroup: ExportPolicy created: {}, now attaching this policy to storage pool volume", createdPolicy.getName());
// attach export policy to volume of storage pool
assignExportPolicyToVolume(volumeUUID,createdPolicy.getName());
- // save the export policy details in storage pool details
+ // save the export policy poolDetails in storage pool poolDetails
storagePoolDetailsDao.addDetail(accessGroup.getStoragePoolId(), OntapStorageConstants.EXPORT_POLICY_ID, String.valueOf(createdPolicy.getId()), true);
storagePoolDetailsDao.addDetail(accessGroup.getStoragePoolId(), OntapStorageConstants.EXPORT_POLICY_NAME, createdPolicy.getName(), true);
logger.info("Successfully assigned exportPolicy {} to volume {}", policyRequest.getName(), volumeName);
@@ -176,12 +346,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 +364,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 poolDetails 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
@@ -256,7 +556,7 @@ private void assignExportPolicyToVolume(String volumeUuid, String policyName) {
volumeUpdate.setNas(nas);
try {
- JobResponse jobResponse = volumeFeignClient.updateVolumeRebalancing(authHeader, volumeUuid, volumeUpdate);
+ JobResponse jobResponse = volumeFeignClient.updateVolume(authHeader, volumeUuid, volumeUpdate);
if (jobResponse == null || jobResponse.getJob() == null) {
throw new CloudRuntimeException("Failed to attach policy " + policyName + "to volume " + volumeUuid);
}
@@ -307,10 +607,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);
@@ -409,6 +709,28 @@ private Answer deleteVolumeOnKVMHost(DataObject volumeInfo) {
}
}
+ /**
+ * Deletes a file from a FlexVolume, treating an already-absent file as success.
+ */
+ public void deleteFileByPath(String flexVolUuid, String filePath) {
+ logger.info("deleteFileByPath: Deleting file [{}] from FlexVol [{}]", filePath, flexVolUuid);
+ try {
+ nasFeignClient.deleteFile(getAuthHeader(), flexVolUuid, filePath);
+ logger.debug("deleteFileByPath: Deleted file [{}]", filePath);
+ } catch (FeignException e) {
+ if (e.status() == 404) {
+ logger.warn("deleteFileByPath: File [{}] does not exist (status 404), skipping deletion", filePath);
+ return;
+ }
+ logger.error("FeignException occurred while deleting file [{}], Status: {}, Exception: {}",
+ filePath, e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to delete file: " + e.getMessage());
+ } catch (Exception e) {
+ logger.error("Exception occurred while deleting file [{}], Exception: {}", filePath, e.getMessage());
+ throw new CloudRuntimeException("Failed to delete file: " + e.getMessage());
+ }
+ }
+
private FileInfo getFile(String volumeUuid, String filePath) {
logger.info("Get File: {} for volume: {}", filePath, volumeUuid);
diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java
index 2b0e65f9f7ea..c6310352b468 100644
--- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/UnifiedSANStrategy.java
@@ -19,17 +19,24 @@
package org.apache.cloudstack.storage.service;
-import com.cloud.host.HostVO;
-import com.cloud.utils.exception.CloudRuntimeException;
-import feign.FeignException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.engine.subsystem.api.storage.TemplateInfo;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
+import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
+import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest;
import org.apache.cloudstack.storage.feign.model.Igroup;
import org.apache.cloudstack.storage.feign.model.Initiator;
-import org.apache.cloudstack.storage.feign.model.Svm;
-import org.apache.cloudstack.storage.feign.model.OntapStorage;
import org.apache.cloudstack.storage.feign.model.Lun;
import org.apache.cloudstack.storage.feign.model.LunMap;
-import org.apache.cloudstack.storage.feign.model.CliSnapshotRestoreRequest;
+import org.apache.cloudstack.storage.feign.model.LunSpace;
+import org.apache.cloudstack.storage.feign.model.OntapStorage;
+import org.apache.cloudstack.storage.feign.model.Svm;
+import org.apache.cloudstack.storage.feign.model.VolumeQosPolicy;
import org.apache.cloudstack.storage.feign.model.response.JobResponse;
import org.apache.cloudstack.storage.feign.model.response.OntapResponse;
import org.apache.cloudstack.storage.service.model.AccessGroup;
@@ -38,18 +45,24 @@
import org.apache.cloudstack.storage.utils.OntapStorageConstants;
import org.apache.cloudstack.storage.utils.OntapStorageUtils;
import org.apache.commons.collections.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
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.host.HostVO;
+import com.cloud.storage.VolumeDetailVO;
+import com.cloud.storage.dao.VolumeDetailsDao;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import feign.FeignException;
public class UnifiedSANStrategy extends SANStrategy {
private static final Logger logger = LogManager.getLogger(UnifiedSANStrategy.class);
@Inject
private StoragePoolDetailsDao storagePoolDetailsDao;
+ @Inject
+ private VolumeDetailsDao volumeDetailsDao;
public UnifiedSANStrategy(OntapStorage ontapStorage) {
super(ontapStorage);
@@ -78,6 +91,7 @@ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume
throw new CloudRuntimeException("Failed to create Lun: " + cloudstackVolume.getLun().getName());
}
Lun lun = createdLun.getRecords().get(0);
+ validateCreatedLun(lun, cloudstackVolume.getLun().getName(), "createCloudStackVolume");
logger.debug("createCloudStackVolume: LUN created successfully. Lun: {}", lun);
CloudStackVolume createdCloudStackVolume = new CloudStackVolume();
@@ -86,16 +100,124 @@ public CloudStackVolume createCloudStackVolume(CloudStackVolume cloudstackVolume
} catch (FeignException e) {
logger.error("FeignException occurred while creating LUN: {}, Status: {}, Exception: {}",
cloudstackVolume.getLun().getName(), e.status(), e.getMessage());
- throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage(), e);
+ } catch (CloudRuntimeException e) {
+ throw e;
} catch (Exception e) {
logger.error("Exception occurred while creating LUN: {}, Exception: {}", cloudstackVolume.getLun().getName(), e.getMessage());
- throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to create Lun: " + e.getMessage(), e);
}
}
+ /**
+ * Creates an empty LUN that caches a template on this pool's FlexVolume.
+ *
+ *
Sized to the template virtual disk size (what KVM writes after {@code qemu-img convert}
+ * to RAW). On create failure, best-effort deletes any leftover LUN before rethrowing.
+ */
@Override
- CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) {
- return null;
+ public CloudStackVolume createTemplateCache(StoragePoolVO storagePool, TemplateInfo templateInfo,
+ Map details, long sizeInBytes) {
+ if (sizeInBytes <= 0) {
+ throw new CloudRuntimeException("Unknown virtual size for template [" + templateInfo.getId()
+ + "]; cannot size the template LUN on pool [" + storagePool.getId() + "]. The template size in vm_template"
+ + " is unset; verify the template was registered/seeded with its virtual size (virtualsize in"
+ + " template.properties on secondary storage).");
+ }
+
+ CloudStackVolume request = buildTemplateLunRequest(storagePool, details, templateInfo.getId(), sizeInBytes);
+ String lunName = request.getLun().getName();
+ CloudStackVolume created = null;
+ try {
+ created = createCloudStackVolume(request);
+ Lun lun = created.getLun();
+ logger.info("createTemplateCache: Created template cache LUN [{}] (uuid [{}], {} bytes) on pool [{}] for template [{}]",
+ lun.getName(), lun.getUuid(), sizeInBytes, storagePool.getId(), templateInfo.getId());
+ return created;
+ } catch (Exception e) {
+ bestEffortDeleteTemplateCacheLun(details.get(OntapStorageConstants.SVM_NAME), lunName,
+ created != null && created.getLun() != null ? created.getLun().getUuid() : null);
+ if (e instanceof CloudRuntimeException) {
+ throw (CloudRuntimeException) e;
+ }
+ throw new CloudRuntimeException("Failed to create template cache LUN for template [" + templateInfo.getId()
+ + "]: " + e.getMessage(), e);
+ }
+ }
+
+ private CloudStackVolume buildTemplateLunRequest(StoragePoolVO storagePool, Map details,
+ long templateId, long sizeInBytes) {
+ Svm svm = new Svm();
+ svm.setName(details.get(OntapStorageConstants.SVM_NAME));
+
+ Lun lunRequest = new Lun();
+ lunRequest.setSvm(svm);
+ lunRequest.setName(OntapStorageUtils.getLunName(storagePool.getName(),
+ OntapStorageConstants.TEMPLATE_LUN_PREFIX + templateId));
+ lunRequest.setOsType(Lun.OsTypeEnum.valueOf(
+ OntapStorageUtils.getOSTypeFromHypervisor(storagePool.getHypervisor().name())));
+ LunSpace lunSpace = new LunSpace();
+ lunSpace.setSize(sizeInBytes);
+ lunRequest.setSpace(lunSpace);
+
+ CloudStackVolume request = new CloudStackVolume();
+ request.setLun(lunRequest);
+ return request;
+ }
+
+ private void bestEffortDeleteTemplateCacheLun(String svmName, String lunName, String lunUuid) {
+ try {
+ String uuid = lunUuid;
+ if (uuid == null || uuid.isEmpty()) {
+ Map lookup = Map.of(
+ OntapStorageConstants.NAME, lunName,
+ OntapStorageConstants.SVM_DOT_NAME, svmName);
+ CloudStackVolume existing = getCloudStackVolume(lookup);
+ if (existing == null || existing.getLun() == null || existing.getLun().getUuid() == null) {
+ logger.warn("bestEffortDeleteTemplateCacheLun: LUN [{}] not found on SVM [{}]; nothing to delete",
+ lunName, svmName);
+ return;
+ }
+ uuid = existing.getLun().getUuid();
+ }
+ Lun lun = new Lun();
+ lun.setUuid(uuid);
+ lun.setName(lunName);
+ CloudStackVolume deleteRequest = new CloudStackVolume();
+ deleteRequest.setLun(lun);
+ deleteCloudStackVolume(deleteRequest);
+ logger.info("bestEffortDeleteTemplateCacheLun: Removed leftover template cache LUN [{}] on SVM [{}]",
+ lunName, svmName);
+ } catch (Exception cleanupEx) {
+ logger.warn("bestEffortDeleteTemplateCacheLun: Failed to remove leftover template cache LUN [{}] on SVM [{}]: {}",
+ lunName, svmName, cleanupEx.getMessage());
+ }
+ }
+
+ @Override
+ public CloudStackVolume updateCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ if (cloudstackVolume == null || cloudstackVolume.getLun() == null
+ || cloudstackVolume.getLun().getUuid() == null) {
+ throw new CloudRuntimeException("Invalid iSCSI volume request");
+ }
+ VolumeQosPolicy qosPolicy = cloudstackVolume.getLun().getQosPolicy();
+ if (qosPolicy == null) {
+ return cloudstackVolume;
+ }
+ Lun lunUpdate = new Lun();
+ lunUpdate.setQosPolicy(qosPolicy);
+ try {
+ JobResponse response = sanFeignClient.updateLun(
+ getAuthHeader(), cloudstackVolume.getLun().getUuid(), lunUpdate);
+ pollJobIfPresent(response, "update LUN [" + cloudstackVolume.getLun().getUuid() + "]");
+ } catch (FeignException e) {
+ throw new CloudRuntimeException("Failed to update LUN: " + e.getMessage(), e);
+ } catch (Exception e) {
+ throw new CloudRuntimeException("Failed to update LUN: " + e.getMessage(), e);
+ }
+ logger.info("Updated LUN [{}] with QoS policy [{}]",
+ cloudstackVolume.getLun().getUuid(), qosPolicy.getName());
+ return cloudstackVolume;
}
@Override
@@ -109,7 +231,8 @@ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
Map queryParams = Map.of("allow_delete_while_mapped", "true");
try {
- sanFeignClient.deleteLun(authHeader, cloudstackVolume.getLun().getUuid(), queryParams);
+ JobResponse response = sanFeignClient.deleteLun(authHeader, cloudstackVolume.getLun().getUuid(), queryParams);
+ pollJobIfPresent(response, "delete Lun [" + cloudstackVolume.getLun().getName() + "]");
} catch (FeignException feignEx) {
if (feignEx.status() == 404) {
logger.warn("deleteCloudStackVolume: Lun {} does not exist (status 404), skipping deletion", cloudstackVolume.getLun().getName());
@@ -124,8 +247,128 @@ public void deleteCloudStackVolume(CloudStackVolume cloudstackVolume) {
}
}
+ /**
+ * Clones a LUN inside the FlexVolume using ONTAP's {@code clone.source} form of LUN create.
+ *
+ *
The resulting LUN shares blocks with its source and is created in constant time.
+ * It is a sis-clone, so it stays readable after the source LUN is deleted.
+ */
@Override
- public void copyCloudStackVolume(CloudStackVolume cloudstackVolume) {}
+ public CloudStackVolume cloneCloudStackVolume(CloudStackVolume cloudstackVolume) {
+ if (cloudstackVolume == null || cloudstackVolume.getLun() == null) {
+ logger.error("cloneCloudStackVolume: LUN clone failed. Invalid request: {}", cloudstackVolume);
+ throw new CloudRuntimeException("Failed to clone Lun, invalid request");
+ }
+ Lun lunRequest = cloudstackVolume.getLun();
+ if (lunRequest.getClone() == null || lunRequest.getClone().getSource() == null) {
+ logger.error("cloneCloudStackVolume: LUN clone failed. No clone source in request for Lun {}", lunRequest.getName());
+ throw new CloudRuntimeException("Failed to clone Lun, no clone source provided");
+ }
+ logger.trace("cloneCloudStackVolume: Cloning Lun {} from source name={} uuid={}",
+ lunRequest.getName(),
+ lunRequest.getClone().getSource().getName(),
+ lunRequest.getClone().getSource().getUuid());
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ OntapResponse clonedLun = sanFeignClient.createLun(authHeader, true, lunRequest);
+ if (clonedLun == null || CollectionUtils.isEmpty(clonedLun.getRecords())) {
+ logger.error("cloneCloudStackVolume: LUN clone returned no records for Lun {}", lunRequest.getName());
+ throw new CloudRuntimeException("Failed to clone Lun: " + lunRequest.getName());
+ }
+ Lun lun = clonedLun.getRecords().get(0);
+ validateCreatedLun(lun, lunRequest.getName(), "cloneCloudStackVolume");
+ logger.debug("cloneCloudStackVolume: LUN cloned successfully. Lun: {}", lun);
+
+ CloudStackVolume clonedCloudStackVolume = new CloudStackVolume();
+ clonedCloudStackVolume.setLun(lun);
+ clonedCloudStackVolume.setVolumeInfo(cloudstackVolume.getVolumeInfo());
+ return clonedCloudStackVolume;
+ } catch (FeignException e) {
+ logger.error("FeignException occurred while cloning LUN: {}, Status: {}, Exception: {}",
+ lunRequest.getName(), e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to clone Lun: " + e.getMessage());
+ } catch (CloudRuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ logger.error("Exception occurred while cloning LUN: {}, Exception: {}", lunRequest.getName(), e.getMessage());
+ throw new CloudRuntimeException("Failed to clone Lun: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Ensures ONTAP returned a usable LUN identity from create/clone. Callers in the datastore
+ * driver rely on non-null name and uuid, so reject incomplete records at the Feign boundary.
+ */
+ private void validateCreatedLun(Lun lun, String requestName, String operation) {
+ if (lun == null || lun.getName() == null || lun.getUuid() == null) {
+ logger.error("{}: ONTAP returned incomplete LUN for {}", operation, requestName);
+ throw new CloudRuntimeException("ONTAP returned incomplete LUN for: " + requestName);
+ }
+ }
+
+ /**
+ * Grows an existing LUN to {@code sizeInBytes}.
+ *
+ *
Needed after cloning a cached template, because a clone inherits the size of its source
+ * while the service offering may ask for a larger disk.
+ */
+ @Override
+ public void resizeCloudStackVolume(CloudStackVolume cloudstackVolume, long sizeInBytes) {
+ if (cloudstackVolume == null || cloudstackVolume.getVolumeInfo() == null) {
+ logger.error("resizeCloudStackVolume: Lun resize failed. Invalid request: {}", cloudstackVolume);
+ throw new CloudRuntimeException("Failed to resize Lun, invalid request");
+ }
+ if (sizeInBytes <= 0) {
+ throw new CloudRuntimeException("Failed to resize Lun, invalid size " + sizeInBytes);
+ }
+
+ // Resolve LUN UUID from volume details when not pre-populated on the cloudstackVolume
+ // Keep a QoS policy already set on the request so size and QoS go out in one LUN update.
+ VolumeQosPolicy qosPolicy = cloudstackVolume.getLun() != null ? cloudstackVolume.getLun().getQosPolicy() : null;
+ if (cloudstackVolume.getLun() == null || cloudstackVolume.getLun().getUuid() == null) {
+ logger.debug("LUN details not present on cloudstackVolume, resolving UUID from volume details");
+ long volumeId = cloudstackVolume.getVolumeInfo().getId();
+ VolumeDetailVO lunUuidDetail = volumeDetailsDao.findDetail(volumeId, OntapStorageConstants.LUN_DOT_UUID);
+ if (lunUuidDetail == null || StringUtils.isBlank(lunUuidDetail.getValue())) {
+ throw new CloudRuntimeException("LUN UUID not found in volume details for volume " + volumeId);
+ }
+ Lun resolvedLun = new Lun();
+ resolvedLun.setUuid(lunUuidDetail.getValue());
+ cloudstackVolume.setLun(resolvedLun);
+ } else {
+ logger.debug("resizeCloudStackVolume: LUN UUID [{}] already present on cloudstackVolume, skipping volume details lookup", cloudstackVolume.getLun().getUuid());
+ }
+
+ String lunUuid = cloudstackVolume.getLun().getUuid();
+ logger.trace("resizeCloudStackVolume: Resizing Lun {} to {} bytes", lunUuid, sizeInBytes);
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ LunSpace lunSpace = new LunSpace();
+ lunSpace.setSize(sizeInBytes);
+ Lun lunPatch = new Lun();
+ lunPatch.setSpace(lunSpace);
+ if (qosPolicy != null) {
+ lunPatch.setQosPolicy(qosPolicy);
+ }
+ JobResponse response = sanFeignClient.updateLun(authHeader, lunUuid, lunPatch);
+ pollJobIfPresent(response, "resize Lun [" + lunUuid + "]");
+ logger.debug("resizeCloudStackVolume: Lun {} resized to {} bytes", lunUuid, sizeInBytes);
+ } catch (FeignException e) {
+ logger.error("FeignException occurred while resizing LUN [{}], Status: {}, Exception: {}",
+ lunUuid, e.status(), e.getMessage());
+ if (OntapStorageUtils.isOntapObjectNotFoundError(e)) {
+ throw new CloudRuntimeException(String.format(
+ "LUN [%s] no longer exists on ONTAP; it may have been deleted externally. " +
+ "Verify the LUN is present before retrying the resize.", lunUuid));
+ }
+ throw new CloudRuntimeException(String.format(
+ "Failed to resize LUN [%s]: %s",lunUuid, e.getMessage()));
+ } catch (Exception e) {
+ logger.error("Exception occurred while resizing LUN [{}]: {}", lunUuid, e.getMessage());
+ throw new CloudRuntimeException(String.format(
+ "Unexpected error while resizing LUN [%s]: %s", lunUuid, e.getMessage()));
+ }
+ }
@Override
public CloudStackVolume getCloudStackVolume(Map values) {
@@ -160,10 +403,41 @@ public CloudStackVolume getCloudStackVolume(Map values) {
return null;
}
logger.error("FeignException occurred while fetching Lun, Status: {}, Exception: {}", e.status(), e.getMessage());
- throw new CloudRuntimeException("Failed to fetch Lun details: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Lun poolDetails: " + e.getMessage());
} catch (Exception e) {
logger.error("Exception occurred while fetching Lun, Exception: {}", e.getMessage());
- throw new CloudRuntimeException("Failed to fetch Lun details: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Lun poolDetails: " + e.getMessage());
+ }
+ }
+
+ public String getLunSerialNumber(String svmName, String lunName) {
+ logger.trace("getLunSerialNumber: fetching serial number of LUN [{}] on SVM [{}]", lunName, svmName);
+ if (svmName == null || svmName.isEmpty() || lunName == null || lunName.isEmpty()) {
+ throw new CloudRuntimeException("Failed to get LUN serial number, invalid svm or LUN name");
+ }
+ try {
+ String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
+ Map queryParams = Map.of(
+ OntapStorageConstants.SVM_DOT_NAME, svmName,
+ OntapStorageConstants.NAME, lunName,
+ OntapStorageConstants.FIELDS, OntapStorageConstants.SERIAL_NUMBER);
+ OntapResponse lunResponse = sanFeignClient.getLunResponse(authHeader, queryParams);
+ if (lunResponse == null || lunResponse.getRecords() == null || lunResponse.getRecords().isEmpty()) {
+ logger.warn("getLunSerialNumber: LUN [{}] on SVM [{}] not found", lunName, svmName);
+ return null;
+ }
+ return lunResponse.getRecords().get(0).getSerialNumber();
+ } catch (FeignException e) {
+ if (e.status() == 404) {
+ logger.warn("getLunSerialNumber: LUN [{}] on SVM [{}] not found (status 404)", lunName, svmName);
+ return null;
+ }
+ logger.error("FeignException while fetching serial number of LUN [{}], Status: {}, Exception: {}",
+ lunName, e.status(), e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch LUN serial number: " + e.getMessage());
+ } catch (Exception e) {
+ logger.error("Exception while fetching serial number of LUN [{}]: {}", lunName, e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch LUN serial number: " + e.getMessage());
}
}
@@ -174,9 +448,9 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
logger.error("createAccessGroup: Igroup creation failed. Invalid request: {}", accessGroup);
throw new CloudRuntimeException("Failed to create Igroup, invalid request");
}
- // Get StoragePool details
+ // Get StoragePool poolDetails
if (accessGroup.getStoragePoolId() == null) {
- throw new CloudRuntimeException("Failed to create Igroup, invalid datastore details in the request");
+ throw new CloudRuntimeException("Failed to create Igroup, invalid datastore poolDetails in the request");
}
if (accessGroup.getHostsToConnect() == null || accessGroup.getHostsToConnect().isEmpty()) {
throw new CloudRuntimeException("Failed to create Igroup, no hosts to connect provided in the request");
@@ -185,7 +459,7 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
String igroupName = null;
try {
Map dataStoreDetails = storagePoolDetailsDao.listDetailsKeyPairs(accessGroup.getStoragePoolId());
- logger.trace("createAccessGroup: Successfully fetched datastore details.");
+ logger.trace("createAccessGroup: Successfully fetched datastore poolDetails.");
// Generate Igroup request
Igroup igroupRequest = new Igroup();
@@ -261,9 +535,9 @@ public void deleteAccessGroup(AccessGroup accessGroup) {
logger.error("deleteAccessGroup: Igroup deletion failed. Invalid request: {}", accessGroup);
throw new CloudRuntimeException("Failed to delete Igroup, invalid request");
}
- // Get StoragePool details
+ // Get StoragePool poolDetails
if (accessGroup.getStoragePoolId() == null) {
- throw new CloudRuntimeException("Failed to delete Igroup, invalid datastore details in the request");
+ throw new CloudRuntimeException("Failed to delete Igroup, invalid datastore poolDetails in the request");
}
try {
String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword());
@@ -370,10 +644,10 @@ public AccessGroup getAccessGroup(Map values) {
return null;
}
logger.error("FeignException occurred while fetching Igroup, Status: {}, Exception: {}", e.status(), e.getMessage());
- throw new CloudRuntimeException("Failed to fetch Igroup details: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Igroup poolDetails: " + e.getMessage());
} catch (Exception e) {
logger.error("Exception occurred while fetching Igroup, Exception: {}", e.getMessage());
- throw new CloudRuntimeException("Failed to fetch Igroup details: " + e.getMessage());
+ throw new CloudRuntimeException("Failed to fetch Igroup poolDetails: " + e.getMessage());
}
}
@@ -419,7 +693,7 @@ public String enableLogicalAccess(Map values) {
throw feignEx;
}
}
- // Get the LunMap details
+ // Get the LunMap poolDetails
OntapResponse lunMapResponse = null;
try {
lunMapResponse = sanFeignClient.getLunMapResponse(authHeader,
@@ -434,12 +708,12 @@ public String enableLogicalAccess(Map values) {
lunNumber = lunMapResponse.getRecords().get(0).getLogicalUnitNumber().toString();
} else {
- logger.error("enableLogicalAccess: Failed to fetch LunMap details for Lun: {} and igroup: {}. LunMap response is null or empty.", lunName, igroupName);
- throw new CloudRuntimeException("Failed to fetch LunMap details for Lun: " + lunName + " and igroup: " + igroupName);
+ logger.error("enableLogicalAccess: Failed to fetch LunMap poolDetails for Lun: {} and igroup: {}. LunMap response is null or empty.", lunName, igroupName);
+ throw new CloudRuntimeException("Failed to fetch LunMap poolDetails for Lun: " + lunName + " and igroup: " + igroupName);
}
} catch (Exception e) {
- logger.error("enableLogicalAccess: Failed to fetch LunMap details for Lun: {} and igroup: {}, Exception: {}", lunName, igroupName, e);
- throw new CloudRuntimeException("Failed to fetch LunMap details for Lun: " + lunName + " and igroup: " + igroupName);
+ logger.error("enableLogicalAccess: Failed to fetch LunMap poolDetails for Lun: {} and igroup: {}, Exception: {}", lunName, igroupName, e);
+ throw new CloudRuntimeException("Failed to fetch LunMap poolDetails for Lun: " + lunName + " and igroup: " + igroupName);
}
logger.trace("enableLogicalAccess: LunMap created successfully, LunMap: {}", lunMapResponse.getRecords().get(0));
} catch (Exception e) {
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/OntapConfigurationManager.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapConfigurationManager.java
new file mode 100644
index 000000000000..e93a1757de2f
--- /dev/null
+++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapConfigurationManager.java
@@ -0,0 +1,104 @@
+/*
+ * 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.utils;
+
+import com.cloud.exception.InvalidParameterValueException;
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.config.Configurable;
+import org.apache.cloudstack.framework.config.ValidatedConfigKey;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.function.Consumer;
+
+/**
+ * Single registration point for ONTAP plugin {@link ConfigKey}s (same pattern as
+ * Linstor/StorPool). Callers read live values via {@code Key.value()}; they must not
+ * construct new {@code ConfigKey} instances.
+ */
+public class OntapConfigurationManager implements Configurable {
+ public static final ValidatedConfigKey AsupIntervalHours = new ValidatedConfigKey<>(
+ OntapStorageConstants.ADVANCED_CONFIG_KEY_CATEGORY, Integer.class,
+ OntapStorageConstants.ASUP_INTERVAL_CONFIG_KEY,
+ String.valueOf(OntapStorageConstants.ASUP_DEFAULT_INTERVAL_HOURS),
+ OntapStorageConstants.ASUP_INTERVAL_DESCRIPTION,
+ true, ConfigKey.Scope.Global, null, asupIntervalValidator());
+
+ public static final ConfigKey>[] CONFIG_KEYS = new ConfigKey>[] {
+ AsupIntervalHours
+ };
+
+ /**
+ * {@link ValidatedConfigKey#validateValue(String)} always passes the raw config string,
+ * even when the key type is {@link Integer}. Adapt that to {@code Consumer}.
+ */
+ @SuppressWarnings("unchecked")
+ private static Consumer asupIntervalValidator() {
+ Consumer