Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ public boolean grantAccess(DataObject dataObject, Host host, DataStore dataStore
private void grantAccessIscsi(Host host, VolumeVO volumeVO, Map<String, String> 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.getName());
String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());

// Validate if Igroup exist ONTAP for this host as we may be using delete_on_unmap= true and igroup may be deleted by ONTAP automatically
Map<String, String> getAccessGroupMap = Map.of(
Expand Down Expand Up @@ -506,7 +506,7 @@ private void revokeAccessForVolume(StoragePoolVO storagePool, VolumeVO volumeVO,
String svmName = details.get(OntapStorageConstants.SVM_NAME);

if (ProtocolType.ISCSI.name().equalsIgnoreCase(details.get(OntapStorageConstants.PROTOCOL))) {
String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getName());
String accessGroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());

// Retrieve LUN name from volume details; if missing, volume may not have been fully created
VolumeDetailVO lunDetail = volumeDetailsDao.findDetail(volumeVO.getId(), OntapStorageConstants.LUN_DOT_NAME);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@

package org.apache.cloudstack.storage.feign.model;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;

import java.util.List;
import java.util.Objects;
Expand Down Expand Up @@ -50,6 +52,9 @@ public class Volume {
@JsonProperty("space")
private VolumeSpace space;

@JsonProperty("guarantee")
private Guarantee guarantee;

@JsonProperty("anti_ransomware")
private AntiRansomware antiRansomware;

Expand Down Expand Up @@ -112,6 +117,14 @@ public void setSpace(VolumeSpace space) {
this.space = space;
}

public Guarantee getGuarantee() {
return guarantee;
}

public void setGuarantee(Guarantee guarantee) {
this.guarantee = guarantee;
}

public AntiRansomware getAntiRansomware() {
return antiRansomware;
}
Expand Down Expand Up @@ -139,4 +152,66 @@ public boolean equals(Object o) {
public int hashCode() {
return Objects.hashCode(uuid);
}

public static class Guarantee {

/**
* ONTAP FlexVolume space guarantee (provisioning) type.
* <ul>
* <li>{@link #NONE} - thin provisioning (space is not reserved up front)</li>
* <li>{@link #VOLUME} - thick provisioning (full volume size is reserved on the aggregate)</li>
* </ul>
*/
public enum TypeEnum {
NONE("none"),

VOLUME("volume");

private String value;

TypeEnum(String value) {
this.value = value;
}

@JsonValue
public String getValue() {
return value;
}

@Override
public String toString() {
return String.valueOf(value);
}

@JsonCreator
public static TypeEnum fromValue(String text) {
if (text == null) return null;
for (TypeEnum b : TypeEnum.values()) {
if (text.equalsIgnoreCase(b.value)) {
return b;
}
}
return null;
}
}

@JsonProperty("type")
private TypeEnum type;

public Guarantee() {
}

public Guarantee(TypeEnum type) {
this.type = type;
}

public TypeEnum getType() {
return type;
}

public void setType(TypeEnum type) {
this.type = type;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
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;
Expand Down Expand Up @@ -135,31 +136,42 @@ public boolean connect() {
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<Aggregate> 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.");
break;
continue;
}
if (!Objects.equals(aggrResp.getState(), Aggregate.StateEnum.ONLINE)) {
logger.warn("Aggregate " + aggr.getName() + " is not in online state. Skipping this aggregate.");
continue;
} else if (aggrResp.getSpace() == null || aggrResp.getAvailableBlockStorageSpace() == null ||
aggrResp.getAvailableBlockStorageSpace() <= storage.getSize().doubleValue()) {
logger.warn("Aggregate " + aggr.getName() + " does not have sufficient available space. Skipping this aggregate.");
continue;
}
logger.info("Selected aggregate: " + aggr.getName() + " for volume operations.");
this.aggregates = List.of(aggr);
break;
logger.debug("Aggregate " + aggr.getName() + " is online and eligible for volume operations.");
eligibleAggregates.add(aggr);
}
if (this.aggregates == null || this.aggregates.isEmpty()) {
logger.error("No suitable aggregates found on SVM " + svmName + " for volume creation.");
throw new CloudRuntimeException("No suitable aggregates found on SVM " + svmName + " for volume creation.");
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.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 (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.");
} 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);
Expand Down Expand Up @@ -211,7 +223,7 @@ public Volume createStorageVolume(String volumeName, Long size) {

if (aggrResp == null) {
logger.warn("Aggregate details response is null for aggregate " + aggr.getName() + ". Skipping.");
break;
continue;
}

if (!Objects.equals(aggrResp.getState(), Aggregate.StateEnum.ONLINE)) {
Expand Down Expand Up @@ -251,6 +263,7 @@ public Volume createStorageVolume(String volumeName, Long size) {
volumeRequest.setAggregates(List.of(aggr));
volumeRequest.setSize(size);
volumeRequest.setNas(nas);
volumeRequest.setGuarantee(new Volume.Guarantee(Volume.Guarantee.TypeEnum.NONE));
try {
JobResponse jobResponse = volumeFeignClient.createVolumeWithJob(authHeader, volumeRequest);
if (jobResponse == null || jobResponse.getJob() == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ public AccessGroup createAccessGroup(AccessGroup accessGroup) {
igroupRequest.setOsType(Igroup.OsTypeEnum.Linux);

for (HostVO host : accessGroup.getHostsToConnect()) {
igroupName = OntapStorageUtils.getIgroupName(svmName, host.getName());
igroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
igroupRequest.setName(igroupName);

List<Initiator> initiators = new ArrayList<>();
Expand Down Expand Up @@ -271,7 +271,7 @@ public void deleteAccessGroup(AccessGroup accessGroup) {
//Get iGroup name per host
if(!CollectionUtils.isEmpty(accessGroup.getHostsToConnect())) {
for (HostVO host : accessGroup.getHostsToConnect()) {
String igroupName = OntapStorageUtils.getIgroupName(svmName, host.getName());
String igroupName = OntapStorageUtils.getIgroupName(svmName, host.getUuid());
logger.info("deleteAccessGroup: iGroup name '{}'", igroupName);

// Get the iGroup to retrieve its UUID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public class OntapStorageConstants {
public static final String IGROUP_DOT_UUID = "igroup.uuid";
public static final String UNDERSCORE = "_";
public static final String CS = "cs";
public static final int IGROUP_NAME_MAX_LENGTH = 96;
public static final String SRC_CS_VOLUME_ID = "src_cs_volume_id";
public static final String BASE_ONTAP_FV_ID = "base_ontap_fv_id";
public static final String ONTAP_SNAP_ID = "ontap_snap_id";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,15 @@ public static StorageStrategy getStrategyByStoragePoolDetails(Map<String, String
}
}

public static String getIgroupName(String svmName, String hostName) {
//Igroup name format: cs_svmName_hostName
String sanitizedHostName = hostName.split("\\.")[0].replaceAll("[^a-zA-Z0-9_-]", "_");
return OntapStorageConstants.CS + OntapStorageConstants.UNDERSCORE + svmName + OntapStorageConstants.UNDERSCORE + sanitizedHostName;
public static String getIgroupName(String svmName, String hostUuid) {
//Igroup name format: cs_svmName_hostUuid
String sanitizedHostUuid = hostUuid.replaceAll("[^a-zA-Z0-9_-]", "_");
String igroupName = OntapStorageConstants.CS + OntapStorageConstants.UNDERSCORE + svmName + OntapStorageConstants.UNDERSCORE + sanitizedHostUuid;
// ONTAP igroup names are limited to 96 characters; truncate if longer.
if (igroupName.length() > OntapStorageConstants.IGROUP_NAME_MAX_LENGTH) {
igroupName = igroupName.substring(0, OntapStorageConstants.IGROUP_NAME_MAX_LENGTH);
}
return igroupName;
}

public static String generateExportPolicyName(String svmName, String volumeName){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ void testGrantAccess_ClusterScope_Success() {
when(volumeVO.getId()).thenReturn(100L);

when(host.getName()).thenReturn("host1");
when(host.getUuid()).thenReturn("host-uuid-1");

VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false);
when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail);
Expand Down Expand Up @@ -384,6 +385,7 @@ void testGrantAccess_IgroupNotFound_CreatesNewIgroup() {
// Setup - use HostVO mock since production code casts Host to HostVO
HostVO hostVO = mock(HostVO.class);
when(hostVO.getName()).thenReturn("host1");
when(hostVO.getUuid()).thenReturn("host-uuid-1");

when(dataStore.getId()).thenReturn(1L);
when(volumeInfo.getType()).thenReturn(VOLUME);
Expand Down Expand Up @@ -477,6 +479,7 @@ void testRevokeAccess_ISCSIVolume_Success() {

when(host.getStorageUrl()).thenReturn("iqn.1993-08.org.debian:01:host1");
when(host.getName()).thenReturn("host1");
when(host.getUuid()).thenReturn("host-uuid-1");

VolumeDetailVO lunNameDetail = new VolumeDetailVO(100L, OntapStorageConstants.LUN_DOT_NAME, "/vol/vol1/lun1", false);
when(volumeDetailsDao.findDetail(100L, OntapStorageConstants.LUN_DOT_NAME)).thenReturn(lunNameDetail);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,69 @@ public void testConnect_positive() {
verify(svmFeignClient, times(1)).getSvmResponse(anyMap(), anyString());
}

@Test
public void testConnect_succeedsWhenAggregateSpaceBelowPoolCapacity() {
// Regression: connect() must validate connectivity/SVM/aggregate-state ONLY.
// Capacity is validated per-volume in createStorageVolume(name, size). Previously
// connect() compared aggregate free space against the whole storage pool size
// (storage.getSize()), which incorrectly failed data-path operations (volume/LUN
// create, grant/revoke access, delete) once the pool FlexVolume already existed.
Svm svm = new Svm();
svm.setName("svm1");
svm.setState(OntapStorageConstants.RUNNING);
svm.setNfsEnabled(true);

Aggregate aggregate = new Aggregate();
aggregate.setName("aggr1");
aggregate.setUuid("aggr-uuid-1");
svm.setAggregates(List.of(aggregate));

OntapResponse<Svm> svmResponse = new OntapResponse<>();
svmResponse.setRecords(List.of(svm));

when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);

// Aggregate is ONLINE but has far less free space than the configured pool size (5GB).
Aggregate aggregateDetail = mock(Aggregate.class);
when(aggregateDetail.getName()).thenReturn("aggr1");
when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1");
when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE);
when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail);

// Execute & Verify - connect() should succeed regardless of available space.
boolean result = storageStrategy.connect();
assertTrue(result, "connect() should succeed for an online aggregate even when its free space is below the pool capacity");
}

@Test
public void testConnect_noOnlineAggregates() {
// Setup - aggregate assigned to the SVM exists but is not ONLINE
Svm svm = new Svm();
svm.setName("svm1");
svm.setState(OntapStorageConstants.RUNNING);
svm.setNfsEnabled(true);

Aggregate aggregate = new Aggregate();
aggregate.setName("aggr1");
aggregate.setUuid("aggr-uuid-1");
svm.setAggregates(List.of(aggregate));

OntapResponse<Svm> svmResponse = new OntapResponse<>();
svmResponse.setRecords(List.of(svm));

when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse);

Aggregate aggregateDetail = mock(Aggregate.class);
when(aggregateDetail.getName()).thenReturn("aggr1");
when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1");
when(aggregateDetail.getState()).thenReturn(null); // not online
when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail);

// Execute & Verify
CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
assertTrue(ex.getMessage().contains("No suitable aggregates found"));
}

@Test
public void testConnect_svmNotFound() {
// Setup
Expand Down Expand Up @@ -342,6 +405,20 @@ public void testConnect_nullSvmResponse() {
assertTrue(ex.getMessage().contains("No SVM found"));
}

@Test
public void testConnect_invalidCredentials() {
// Setup - ONTAP rejects the supplied username/password with HTTP 401 Unauthorized.
when(svmFeignClient.getSvmResponse(anyMap(), anyString()))
.thenThrow(mock(FeignException.Unauthorized.class));

// Execute & Verify - connect() must surface a clear "invalid credentials" error.
CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect());
assertTrue(ex.getMessage().contains("Authentication failed: Invalid credentials"),
"Expected an authentication failure message but got: " + ex.getMessage());
assertTrue(ex.getMessage().contains("Please verify the username and password"),
"Expected the message to prompt verifying username/password but got: " + ex.getMessage());
}

// ========== createStorageVolume() Tests ==========

@Test
Expand Down
Loading
Loading