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/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/IpInterface.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/IpInterface.java index c15798a42b70..8070763285c8 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/IpInterface.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/feign/model/IpInterface.java @@ -19,13 +19,13 @@ package org.apache.cloudstack.storage.feign.model; +import java.util.List; +import java.util.Objects; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import java.util.Objects; - @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) public class IpInterface { @@ -44,6 +44,15 @@ public class IpInterface { @JsonProperty("services") private List services; + @JsonProperty("state") + private String state; + + @JsonProperty("enabled") + private Boolean enabled; + + @JsonProperty("location") + private Location location; + // Getters and setters public String getUuid() { return uuid; @@ -85,6 +94,30 @@ public void setServices(List services) { this.services = services; } + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public Location getLocation() { + return location; + } + + public void setLocation(Location location) { + this.location = location; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -98,12 +131,14 @@ public boolean equals(Object o) { Objects.equals(name, that.name) && Objects.equals(ip, that.ip) && Objects.equals(svm, that.svm) && - Objects.equals(services, that.services); + Objects.equals(services, that.services) && + Objects.equals(state, that.state) && + Objects.equals(enabled, that.enabled); } @Override public int hashCode() { - return Objects.hash(uuid, name, ip, svm, services); + return Objects.hash(uuid, name, ip, svm, services, state, enabled); } @Override @@ -114,9 +149,52 @@ public String toString() { ", ip=" + ip + ", svm=" + svm + ", services=" + services + + ", state='" + state + '\'' + + ", enabled=" + enabled + '}'; } + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Node { + @JsonProperty("name") + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Location { + @JsonProperty("home_node") + private Node homeNode; + + @JsonProperty("node") + private Node node; + + public Node getHomeNode() { + return homeNode; + } + + public void setHomeNode(Node homeNode) { + this.homeNode = homeNode; + } + + public Node getNode() { + return node; + } + + public void setNode(Node node) { + this.node = node; + } + } + // Nested class for IP information @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java index 3734ce25bc93..1134a410576f 100755 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycle.java @@ -19,20 +19,15 @@ package org.apache.cloudstack.storage.lifecycle; -import org.apache.cloudstack.engine.subsystem.api.storage.Scope; -import com.cloud.agent.api.StoragePoolInfo; -import com.cloud.dc.ClusterVO; -import com.cloud.dc.dao.ClusterDao; -import com.cloud.exception.InvalidParameterValueException; -import com.cloud.host.HostVO; -import com.cloud.hypervisor.Hypervisor; -import com.cloud.resource.ResourceManager; -import com.cloud.storage.Storage; -import com.cloud.storage.StorageManager; -import com.cloud.storage.StoragePool; -import com.cloud.storage.StoragePoolAutomation; -import com.cloud.utils.exception.CloudRuntimeException; -import com.google.common.base.Preconditions; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import javax.inject.Inject; + import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; @@ -40,10 +35,11 @@ import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreInfo; import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreLifeCycle; import org.apache.cloudstack.engine.subsystem.api.storage.PrimaryDataStoreParameters; +import org.apache.cloudstack.engine.subsystem.api.storage.Scope; import org.apache.cloudstack.engine.subsystem.api.storage.ZoneScope; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; -import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDetailsDao; +import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.datastore.lifecycle.BasePrimaryDataStoreLifeCycleImpl; import org.apache.cloudstack.storage.feign.model.OntapStorage; @@ -59,13 +55,21 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import javax.inject.Inject; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import com.cloud.agent.api.StoragePoolInfo; +import com.cloud.alert.AlertManager; +import com.cloud.dc.ClusterVO; +import com.cloud.dc.dao.ClusterDao; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.host.HostVO; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.resource.ResourceManager; +import com.cloud.storage.Storage; +import com.cloud.storage.StorageManager; +import com.cloud.storage.StoragePool; +import com.cloud.storage.StoragePoolAutomation; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.CloudRuntimeException; +import com.google.common.base.Preconditions; public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycleImpl implements PrimaryDataStoreLifeCycle { @Inject private ClusterDao _clusterDao; @@ -76,6 +80,7 @@ public class OntapPrimaryDatastoreLifecycle extends BasePrimaryDataStoreLifeCycl @Inject private StoragePoolAutomation _storagePoolAutomation; @Inject private PrimaryDataStoreDao storagePoolDao; @Inject private StoragePoolDetailsDao storagePoolDetailsDao; + @Inject private AlertManager _alertMgr; private static final Logger logger = LogManager.getLogger(OntapPrimaryDatastoreLifecycle.class); private static final long ONTAP_MIN_VOLUME_SIZE_IN_BYTES = 1677721600L; @@ -135,13 +140,6 @@ 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"); - } - logger.info("Using Data LIF for storage access: " + dataLIF); - details.put(OntapStorageConstants.DATA_LIF, dataLIF); logger.info("Creating ONTAP volume '" + storagePoolName + "' with size: " + capacityBytes + " bytes (" + (capacityBytes / (1024 * 1024 * 1024)) + " GB)"); try { @@ -157,6 +155,30 @@ public DataStore initialize(Map dsInfos) { logger.error("Exception occurred while creating ONTAP volume: " + storagePoolName, e); throw new CloudRuntimeException("Failed to create ONTAP volume: " + storagePoolName + ". Error: " + e.getMessage(), e); } + + Pair lifResult; + try { + lifResult = storageStrategy.getNetworkInterface(); + } catch (Exception e) { + logger.error("Exception occurred while retrieving network interface for pool: " + storagePoolName, e); + throw new CloudRuntimeException("Failed to retrieve Data LIF from ONTAP: " + e.getMessage(), e); + } + String dataLIF = lifResult.first(); + if (dataLIF == null || dataLIF.isEmpty()) { + throw new CloudRuntimeException("Failed to retrieve Data LIF from ONTAP, cannot create primary storage"); + } + logger.info("Using Data LIF for storage access: " + dataLIF); + details.put(OntapStorageConstants.DATA_LIF, dataLIF); + + // Persist LIF warning as a pool detail and fire a storage alert so the user is informed + if (lifResult.second() != null) { + String lifWarning = lifResult.second(); + details.put(OntapStorageConstants.LIF_WARNING, lifWarning); + logger.warn("LIF selection warning for pool '" + storagePoolName + "': " + lifWarning); + String alertSubject = "ONTAP Storage Pool '" + storagePoolName + "': " + + lifWarning.split(OntapStorageConstants.SEMICOLON)[0].trim(); + OntapStorageUtils.sendStorageAlert(_alertMgr, zoneId, podId, alertSubject, lifWarning); + } } else { throw new CloudRuntimeException("ONTAP details validation failed, cannot create primary storage"); } 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 f4ab806d6885..fe085720f5a1 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/StorageStrategy.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/service/StorageStrategy.java @@ -19,13 +19,17 @@ package org.apache.cloudstack.storage.service; -import com.cloud.utils.exception.CloudRuntimeException; -import feign.FeignException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + import org.apache.cloudstack.storage.feign.FeignClientFactory; import org.apache.cloudstack.storage.feign.client.AggregateFeignClient; import org.apache.cloudstack.storage.feign.client.JobFeignClient; -import org.apache.cloudstack.storage.feign.client.NetworkFeignClient; import org.apache.cloudstack.storage.feign.client.NASFeignClient; +import org.apache.cloudstack.storage.feign.client.NetworkFeignClient; import org.apache.cloudstack.storage.feign.client.SANFeignClient; import org.apache.cloudstack.storage.feign.client.SnapshotFeignClient; import org.apache.cloudstack.storage.feign.client.SvmFeignClient; @@ -48,11 +52,10 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.util.HashMap; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.CloudRuntimeException; + +import feign.FeignException; /** * Storage Strategy represents the communication path for all the ONTAP storage options @@ -75,6 +78,12 @@ public abstract class StorageStrategy { protected OntapStorage storage; + /** + * Holds the node name of the aggregate chosen during createStorageVolume(). + * Used by getNetworkInterface() to prefer a LIF homed on the same node. + */ + private String chosenAggregateNode; + /** * Presents aggregate object for the unified storage, not eligible for disaggregated */ @@ -142,7 +151,10 @@ public boolean connect() { 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()); + 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; @@ -219,7 +231,10 @@ public Volume createStorageVolume(String volumeName, Long size) { Aggregate aggrChosen = null; for (Aggregate aggr : aggregates) { logger.debug("Found aggregate: " + aggr.getName() + " with UUID: " + aggr.getUuid()); - Aggregate aggrResp = aggregateFeignClient.getAggregateByUUID(authHeader, aggr.getUuid()); + Aggregate aggrResp = aggregateFeignClient.getAggregateByUUID(authHeader, aggr.getUuid(), + Map.of(OntapStorageConstants.FIELDS, OntapStorageConstants.AGGREGATE_NODE + + OntapStorageConstants.COMMA + OntapStorageConstants.AGGREGATE_SPACE + + OntapStorageConstants.COMMA + OntapStorageConstants.STATE)); if (aggrResp == null) { logger.warn("Aggregate details response is null for aggregate " + aggr.getName() + ". Skipping."); @@ -247,7 +262,7 @@ public Volume createStorageVolume(String volumeName, Long size) { if (availableBytes > maxAvailableAggregateSpaceBytes) { maxAvailableAggregateSpaceBytes = availableBytes; - aggrChosen = aggr; + aggrChosen = aggrResp; } } @@ -257,6 +272,10 @@ public Volume createStorageVolume(String volumeName, Long size) { } logger.info("Selected aggregate: " + aggrChosen.getName() + " for volume operations."); + if (aggrChosen.getNode() != null) { + this.chosenAggregateNode = aggrChosen.getNode().getName(); + } + Aggregate aggr = new Aggregate(); aggr.setName(aggrChosen.getName()); aggr.setUuid(aggrChosen.getUuid()); @@ -430,12 +449,20 @@ public String getStoragePath() { /** - * Get the network ip interface + * Selects the best available data LIF for storage I/O, preferring one homed on the same node + * as the chosen aggregate to avoid inter-node traffic. + * + *

Selection order:

+ *
    + *
  1. LIF whose {@code location.home_node} matches the chosen aggregate's node — no warning
  2. + *
  3. LIF currently running on that node (e.g. after failover) — returned with a warning
  4. + *
  5. Any UP and enabled LIF — returned with a warning when aggregate node is known
  6. + *
* - * @return the network interface ip as a String + * @return {@link Pair} where {@code first()} is the LIF's IP address and {@code second()} is + * a warning message (null when no warning) */ - - public String getNetworkInterface() { + public Pair getNetworkInterface() { String authHeader = OntapStorageUtils.generateAuthHeader(storage.getUsername(), storage.getPassword()); try { Map queryParams = new HashMap<>(); @@ -453,36 +480,87 @@ public String getNetworkInterface() { throw new CloudRuntimeException("Unsupported protocol: " + storage.getProtocol()); } } - queryParams.put(OntapStorageConstants.FIELDS, OntapStorageConstants.IP_ADDRESS); + queryParams.put(OntapStorageConstants.FIELDS, + OntapStorageConstants.IP_ADDRESS + OntapStorageConstants.COMMA + + OntapStorageConstants.STATE + OntapStorageConstants.COMMA + + OntapStorageConstants.LIF_ENABLED + OntapStorageConstants.COMMA + + OntapStorageConstants.LIF_LOCATION_HOME_NODE + OntapStorageConstants.COMMA + + OntapStorageConstants.LIF_LOCATION_NODE); queryParams.put(OntapStorageConstants.RETURN_RECORDS, OntapStorageConstants.TRUE); OntapResponse response = networkFeignClient.getNetworkIpInterfaces(authHeader, queryParams); - if (response != null && response.getRecords() != null && !response.getRecords().isEmpty()) { - IpInterface ipInterface = null; - // For simplicity, return the first interface's name (Of IPv4 type for NFS3) - if (storage.getProtocol() == ProtocolType.ISCSI) { - ipInterface = response.getRecords().get(0); - } else if (storage.getProtocol() == ProtocolType.NFS3) { - for (IpInterface iface : response.getRecords()) { - if (iface.getIp().getAddress().contains(".")) { - ipInterface = iface; - break; + if (response == null || response.getRecords() == null || response.getRecords().isEmpty()) { + throw new CloudRuntimeException("No network interfaces found for SVM " + storage.getSvmName() + + " for protocol " + storage.getProtocol()); + } + + IpInterface currentNodeInterface = null; + IpInterface fallbackInterface = null; + + for (IpInterface iface : response.getRecords()) { + if (!Boolean.TRUE.equals(iface.getEnabled()) || !OntapStorageConstants.LIF_STATE_UP.equals(iface.getState())) { + continue; + } + if (!isIPv4Address(iface.getIp().getAddress())) { + continue; + } + if (chosenAggregateNode != null) { + String homeNode = iface.getLocation() != null && iface.getLocation().getHomeNode() != null + ? iface.getLocation().getHomeNode().getName() : null; + if (chosenAggregateNode.equals(homeNode)) { + return new Pair<>(iface.getIp().getAddress(), null); + } + if (currentNodeInterface == null) { + String currentNode = iface.getLocation() != null && iface.getLocation().getNode() != null + ? iface.getLocation().getNode().getName() : null; + if (chosenAggregateNode.equals(currentNode)) { + currentNodeInterface = iface; } } } + if (fallbackInterface == null) { + fallbackInterface = iface; + } + } - logger.info("Retrieved network interface: " + ipInterface.getIp().getAddress()); - return ipInterface.getIp().getAddress(); - } else { - throw new CloudRuntimeException("No network interfaces found for SVM " + storage.getSvmName() + - " for protocol " + storage.getProtocol()); + if (currentNodeInterface == null && fallbackInterface == null) { + throw new CloudRuntimeException("No operationally UP and enabled LIF found for SVM '" + + storage.getSvmName() + "' with protocol " + storage.getProtocol() + + " — all " + response.getRecords().size() + " LIF(s) are either administratively disabled or operationally down"); } - } catch (FeignException.FeignClientException e) { + + if (currentNodeInterface != null) { + String ip = currentNodeInterface.getIp().getAddress(); + String warning = "No home-node LIF found for aggregate node '" + chosenAggregateNode + + "'; using LIF '" + ip + "' currently running on that node (home node LIF may be down)."; + logger.warn(warning); + return new Pair<>(ip, warning); + } + + String ip = fallbackInterface.getIp().getAddress(); + if (chosenAggregateNode == null) { + return new Pair<>(ip, null); + } + String warning = "No operational LIF found on aggregate's home node '" + chosenAggregateNode + + "'; using fallback LIF '" + ip + "' on a different node." + + " I/O will traverse an inter-node path, increasing latency."; + logger.warn(warning); + return new Pair<>(ip, warning); + } catch (Exception e) { logger.error("Exception while retrieving network interfaces: ", e); throw new CloudRuntimeException("Failed to retrieve network interfaces: " + e.getMessage()); } } + /** + * Returns true if the given IP address string is an IPv4 address. + * IPv6 addresses contain colons; IPv4 addresses do not. + * To extend LIF selection to support IPv6, update this method and its call site in getNetworkInterface(). + */ + private boolean isIPv4Address(String address) { + return address != null && !address.contains(":"); + } + /** * Method encapsulates the behavior based on the opted protocol in subclasses. * it is going to mimic diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java index e5224237e526..79d4f472125b 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageConstants.java @@ -66,9 +66,16 @@ public class OntapStorageConstants { public static final String INITIATORS = "initiators"; public static final String AGGREGATES = "aggregates"; public static final String STATE = "state"; + public static final String AGGREGATE_NODE = "node"; + public static final String AGGREGATE_SPACE = "space"; public static final String DATA_NFS = "data_nfs"; public static final String DATA_ISCSI = "data_iscsi"; public static final String IP_ADDRESS = "ip.address"; + public static final String LIF_ENABLED = "enabled"; + public static final String LIF_STATE_UP = "up"; + public static final String LIF_LOCATION_HOME_NODE = "location.home_node.name"; + public static final String LIF_LOCATION_NODE = "location.node.name"; + public static final String LIF_WARNING = "ONTAP_LIF_WARNING"; public static final String SERVICES = "services"; public static final String RETURN_RECORDS = "return_records"; diff --git a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java index 66fd41d5123d..908d220525ad 100644 --- a/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java +++ b/plugins/storage/volume/ontap/src/main/java/org/apache/cloudstack/storage/utils/OntapStorageUtils.java @@ -19,9 +19,9 @@ package org.apache.cloudstack.storage.utils; -import com.cloud.exception.InvalidParameterValueException; -import com.cloud.utils.StringUtils; -import com.cloud.utils.exception.CloudRuntimeException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.feign.model.Lun; @@ -36,8 +36,10 @@ import org.apache.logging.log4j.Logger; import org.springframework.util.Base64Utils; -import java.nio.charset.StandardCharsets; -import java.util.Map; +import com.cloud.alert.AlertManager; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.StringUtils; +import com.cloud.utils.exception.CloudRuntimeException; public class OntapStorageUtils { @@ -119,6 +121,12 @@ public static String getOSTypeFromHypervisor(String hypervisorType) { } } + public static void sendStorageAlert(AlertManager alertMgr, Long zoneId, Long podId, String subject, String body) { + if (alertMgr != null) { + alertMgr.sendAlert(AlertManager.AlertType.ALERT_TYPE_STORAGE_MISC, zoneId != null ? zoneId : 0L, podId, subject, body); + } + } + public static StorageStrategy getStrategyByStoragePoolDetails(Map details) { if (details == null || details.isEmpty()) { logger.error("getStrategyByStoragePoolDetails: Storage pool details are null or empty"); diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java index 3b576df97e37..47780a29ae7f 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/lifecycle/OntapPrimaryDatastoreLifecycleTest.java @@ -47,6 +47,7 @@ import java.util.Map; import java.util.List; import java.util.ArrayList; +import com.cloud.utils.Pair; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; @@ -116,7 +117,7 @@ void setUp() { when(_clusterDao.findById(1L)).thenReturn(clusterVO); when(storageStrategy.connect()).thenReturn(true); - when(storageStrategy.getNetworkInterface()).thenReturn("testNetworkInterface"); + when(storageStrategy.getNetworkInterface()).thenReturn(new Pair<>("testNetworkInterface", null)); Volume volume = new Volume(); volume.setUuid("test-volume-uuid"); @@ -153,6 +154,7 @@ void setUp() { poolDetails.put("svmName", "svm1"); poolDetails.put("protocol", "NFS3"); poolDetails.put("storageIP", "192.168.1.100"); + when(zoneScope.getScopeId()).thenReturn(1L); } @Test @@ -412,12 +414,12 @@ public void testAttachCluster_positive() throws Exception { when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any())) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); // Mock successful host connections when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); @@ -446,12 +448,12 @@ public void testAttachCluster_withSingleHost() throws Exception { when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any())) .thenReturn(singleHost); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); // Execute @@ -477,12 +479,12 @@ public void testAttachCluster_withMultipleHosts() throws Exception { when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any())) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); // Execute @@ -507,6 +509,7 @@ public void testAttachCluster_hostConnectionFailure() throws Exception { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); // Mock host connection failure for first host when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())) @@ -533,12 +536,12 @@ public void testAttachCluster_emptyHostList() throws Exception { when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any())) .thenReturn(emptyHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); // Execute boolean result = ontapPrimaryDatastoreLifecycle.attachCluster( @@ -562,6 +565,7 @@ public void testAttachCluster_secondHostConnectionFails() throws Exception { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); // Mock: first host succeeds, second host fails when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())) @@ -585,12 +589,12 @@ public void testAttachCluster_createAccessGroupCalled() throws Exception { when(_resourceMgr.getEligibleUpAndEnabledHostsInClusterForStorageConnection(any())) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachCluster(any(DataStore.class))).thenReturn(dataStore); when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); // Execute @@ -608,16 +612,15 @@ public void testAttachCluster_createAccessGroupCalled() throws Exception { @Test public void testAttachZone_positive() throws Exception { // Setup - when(zoneScope.getScopeId()).thenReturn(1L); when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM))) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); // Mock successful host connections when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); @@ -643,16 +646,15 @@ public void testAttachZone_withSingleHost() throws Exception { List singleHost = new ArrayList<>(); singleHost.add(mockHosts.get(0)); - when(zoneScope.getScopeId()).thenReturn(1L); when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM))) .thenReturn(singleHost); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); // Execute @@ -675,16 +677,15 @@ public void testAttachZone_withMultipleHosts() throws Exception { host3.setClusterId(1L); mockHosts.add(host3); - when(zoneScope.getScopeId()).thenReturn(1L); when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM))) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); // Execute @@ -701,7 +702,6 @@ public void testAttachZone_withMultipleHosts() throws Exception { @Test public void testAttachZone_hostConnectionFailure() throws Exception { // Setup - when(zoneScope.getScopeId()).thenReturn(1L); when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM))) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); @@ -710,6 +710,7 @@ public void testAttachZone_hostConnectionFailure() throws Exception { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); // Mock host connection failure for first host when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())) @@ -733,16 +734,15 @@ public void testAttachZone_emptyHostList() throws Exception { // Setup - no hosts in zone List emptyHosts = new ArrayList<>(); - when(zoneScope.getScopeId()).thenReturn(1L); when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM))) .thenReturn(emptyHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); // Execute boolean result = ontapPrimaryDatastoreLifecycle.attachZone( @@ -758,7 +758,6 @@ public void testAttachZone_emptyHostList() throws Exception { @Test public void testAttachZone_secondHostConnectionFails() throws Exception { // Setup - when(zoneScope.getScopeId()).thenReturn(1L); when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM))) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); @@ -767,6 +766,7 @@ public void testAttachZone_secondHostConnectionFails() throws Exception { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); // Mock: first host succeeds, second host fails when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())) @@ -787,16 +787,15 @@ public void testAttachZone_secondHostConnectionFails() throws Exception { @Test public void testAttachZone_createAccessGroupCalled() throws Exception { // Setup - when(zoneScope.getScopeId()).thenReturn(1L); when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM))) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); + when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); // Execute @@ -834,16 +833,13 @@ public void testAttachZone_nullHypervisorThrowsException() { @Test public void testAttachZone_kvmHypervisorSetsAndUpdatesPool() throws Exception { // KVM hypervisorType should be set on the pool and persisted via storagePoolDao.update - when(zoneScope.getScopeId()).thenReturn(1L); when(_resourceMgr.getEligibleUpAndEnabledHostsInZoneForStorageConnection(any(), eq(1L), eq(Hypervisor.HypervisorType.KVM))) .thenReturn(mockHosts); when(storagePoolDetailsDao.listDetailsKeyPairs(1L)).thenReturn(poolDetails); - when(_dataStoreHelper.attachZone(any(DataStore.class))).thenReturn(dataStore); try (MockedStatic utilityMock = Mockito.mockStatic(OntapStorageUtils.class)) { utilityMock.when(() -> OntapStorageUtils.getStrategyByStoragePoolDetails(any())) .thenReturn(storageStrategy); - when(storageStrategy.createAccessGroup(any(AccessGroup.class))).thenReturn(null); when(_storageMgr.connectHostToSharedPool(any(HostVO.class), anyLong())).thenReturn(true); boolean result = ontapPrimaryDatastoreLifecycle.attachZone( diff --git a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java index df9afe2542f9..7923a888c781 100644 --- a/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java +++ b/plugins/storage/volume/ontap/src/test/java/org/apache/cloudstack/storage/service/StorageStrategyTest.java @@ -18,8 +18,14 @@ */ package org.apache.cloudstack.storage.service; -import com.cloud.utils.exception.CloudRuntimeException; -import feign.FeignException; +import java.lang.reflect.Field; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + import org.apache.cloudstack.storage.feign.client.AggregateFeignClient; import org.apache.cloudstack.storage.feign.client.JobFeignClient; import org.apache.cloudstack.storage.feign.client.NetworkFeignClient; @@ -39,32 +45,31 @@ import org.apache.cloudstack.storage.service.model.CloudStackVolume; import org.apache.cloudstack.storage.service.model.ProtocolType; import org.apache.cloudstack.storage.utils.OntapStorageConstants; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; - -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import org.mockito.Mock; import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.cloud.utils.Pair; +import com.cloud.utils.exception.CloudRuntimeException; + +import feign.FeignException; +import feign.Request; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -214,13 +219,8 @@ public void testConnect_positive() { svmResponse.setRecords(List.of(svm)); when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse); - Aggregate aggregateDetail = mock(Aggregate.class); - when(aggregateDetail.getName()).thenReturn("aggr1"); - when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1"); - when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE); - when(aggregateDetail.getSpace()).thenReturn(mock(Aggregate.AggregateSpace.class)); - when(aggregateDetail.getAvailableBlockStorageSpace()).thenReturn(10000000000.0); - when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail); + Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 10000000000.0); + when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())).thenReturn(aggregateDetail); // Execute boolean result = storageStrategy.connect(); @@ -253,11 +253,8 @@ public void testConnect_succeedsWhenAggregateSpaceBelowPoolCapacity() { when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse); // Aggregate is ONLINE but has far less free space than the configured pool size (5GB). - Aggregate aggregateDetail = mock(Aggregate.class); - when(aggregateDetail.getName()).thenReturn("aggr1"); - when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1"); - when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE); - when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail); + Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 1000000.0); // only 1MB free + when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())).thenReturn(aggregateDetail); // Execute & Verify - connect() should succeed regardless of available space. boolean result = storageStrategy.connect(); @@ -282,11 +279,11 @@ public void testConnect_noOnlineAggregates() { when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse); - Aggregate aggregateDetail = mock(Aggregate.class); - when(aggregateDetail.getName()).thenReturn("aggr1"); - when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1"); - when(aggregateDetail.getState()).thenReturn(null); // not online - when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail); + Aggregate aggregateDetail = new Aggregate(); + aggregateDetail.setName("aggr1"); + aggregateDetail.setUuid("aggr-uuid-1"); + aggregateDetail.setState(null); // not online + when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())).thenReturn(aggregateDetail); // Execute & Verify CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect()); @@ -408,8 +405,10 @@ public void testConnect_nullSvmResponse() { @Test public void testConnect_invalidCredentials() { // Setup - ONTAP rejects the supplied username/password with HTTP 401 Unauthorized. + Map> emptyHeaders = Collections.emptyMap(); + Request dummyReq = Request.create(Request.HttpMethod.GET, "http://test", emptyHeaders, (byte[]) null, (Charset) null); when(svmFeignClient.getSvmResponse(anyMap(), anyString())) - .thenThrow(mock(FeignException.Unauthorized.class)); + .thenThrow(new FeignException.Unauthorized("Unauthorized", dummyReq, null)); // Execute & Verify - connect() must surface a clear "invalid credentials" error. CloudRuntimeException ex = assertThrows(CloudRuntimeException.class, () -> storageStrategy.connect()); @@ -428,14 +427,8 @@ public void testCreateStorageVolume_positive() { storageStrategy.connect(); // Setup aggregate details - Aggregate aggregateDetail = mock(Aggregate.class); - when(aggregateDetail.getName()).thenReturn("aggr1"); - when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1"); - when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE); - when(aggregateDetail.getSpace()).thenReturn(mock(Aggregate.AggregateSpace.class)); // Mock non-null space - when(aggregateDetail.getAvailableBlockStorageSpace()).thenReturn(10000000000.0); - - when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))) + Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 10000000000.0); + when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())) .thenReturn(aggregateDetail); // Setup job response @@ -515,12 +508,12 @@ public void testCreateStorageVolume_aggregateNotOnline() { setupSuccessfulConnect(); storageStrategy.connect(); - Aggregate aggregateDetail = mock(Aggregate.class); - when(aggregateDetail.getName()).thenReturn("aggr1"); - when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1"); - when(aggregateDetail.getState()).thenReturn(null); // null state to simulate offline + Aggregate aggregateDetail = new Aggregate(); + aggregateDetail.setName("aggr1"); + aggregateDetail.setUuid("aggr-uuid-1"); + aggregateDetail.setState(null); // null state to simulate offline - when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))) + when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())) .thenReturn(aggregateDetail); // Execute & Verify @@ -535,13 +528,9 @@ public void testCreateStorageVolume_insufficientSpace() { setupSuccessfulConnect(); storageStrategy.connect(); - Aggregate aggregateDetail = mock(Aggregate.class); - when(aggregateDetail.getName()).thenReturn("aggr1"); - when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1"); - when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE); - when(aggregateDetail.getAvailableBlockStorageSpace()).thenReturn(1000000.0); // Only 1MB available + Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 1000000.0); // Only 1MB available - when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))) + when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())) .thenReturn(aggregateDetail); // Execute & Verify @@ -667,8 +656,10 @@ public void testDeleteStorageVolume_feignException() { volume.setName("test-volume"); volume.setUuid("vol-uuid-1"); + Map> emptyHeaders = Collections.emptyMap(); + Request dummyReq = Request.create(Request.HttpMethod.DELETE, "http://test", emptyHeaders, (byte[]) null, (Charset) null); when(volumeFeignClient.deleteVolume(anyString(), eq("vol-uuid-1"))) - .thenThrow(mock(FeignException.FeignClientException.class)); + .thenThrow(new FeignException.FeignClientException(500, "error", dummyReq, null)); // Execute & Verify Exception ex = assertThrows(CloudRuntimeException.class, @@ -763,6 +754,8 @@ public void testGetNetworkInterface_nfs() { IpInterface ipInterface = new IpInterface(); ipInterface.setIp(ipInfo); + ipInterface.setState(OntapStorageConstants.LIF_STATE_UP); + ipInterface.setEnabled(true); OntapResponse interfaceResponse = new OntapResponse<>(); interfaceResponse.setRecords(List.of(ipInterface)); @@ -771,11 +764,12 @@ public void testGetNetworkInterface_nfs() { .thenReturn(interfaceResponse); // Execute - String result = storageStrategy.getNetworkInterface(); + Pair result = storageStrategy.getNetworkInterface(); // Verify assertNotNull(result); - assertEquals("192.168.1.50", result); + assertEquals("192.168.1.50", result.first()); + assertTrue(result.second() == null, "Expect no warning when a suitable LIF is found"); verify(networkFeignClient, times(1)).getNetworkIpInterfaces(anyString(), anyMap()); } @@ -793,6 +787,8 @@ public void testGetNetworkInterface_iscsi() { IpInterface ipInterface = new IpInterface(); ipInterface.setIp(ipInfo); + ipInterface.setState(OntapStorageConstants.LIF_STATE_UP); + ipInterface.setEnabled(true); OntapResponse interfaceResponse = new OntapResponse<>(); interfaceResponse.setRecords(List.of(ipInterface)); @@ -801,11 +797,84 @@ public void testGetNetworkInterface_iscsi() { .thenReturn(interfaceResponse); // Execute - String result = storageStrategy.getNetworkInterface(); + Pair result = storageStrategy.getNetworkInterface(); // Verify assertNotNull(result); - assertEquals("192.168.1.51", result); + assertEquals("192.168.1.51", result.first()); + assertTrue(result.second() == null, "Expect no warning when a suitable LIF is found"); + } + + @Test + public void testGetNetworkInterface_nfs_lifDown() { + // LIF exists but is operationally down — should fail + IpInterface.IpInfo ipInfo = new IpInterface.IpInfo(); + ipInfo.setAddress("192.168.1.50"); + + IpInterface ipInterface = new IpInterface(); + ipInterface.setIp(ipInfo); + ipInterface.setState("down"); + ipInterface.setEnabled(true); + + OntapResponse interfaceResponse = new OntapResponse<>(); + interfaceResponse.setRecords(List.of(ipInterface)); + + when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) + .thenReturn(interfaceResponse); + + Exception ex = assertThrows(CloudRuntimeException.class, + () -> storageStrategy.getNetworkInterface()); + assertTrue(ex.getMessage().contains("operationally UP and enabled")); + } + + @Test + public void testGetNetworkInterface_nfs_lifDisabled() { + // LIF exists but is administratively disabled — should fail + IpInterface.IpInfo ipInfo = new IpInterface.IpInfo(); + ipInfo.setAddress("192.168.1.50"); + + IpInterface ipInterface = new IpInterface(); + ipInterface.setIp(ipInfo); + ipInterface.setState(OntapStorageConstants.LIF_STATE_UP); + ipInterface.setEnabled(false); + + OntapResponse interfaceResponse = new OntapResponse<>(); + interfaceResponse.setRecords(List.of(ipInterface)); + + when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) + .thenReturn(interfaceResponse); + + Exception ex = assertThrows(CloudRuntimeException.class, + () -> storageStrategy.getNetworkInterface()); + assertTrue(ex.getMessage().contains("operationally UP and enabled")); + } + + @Test + public void testGetNetworkInterface_iscsi_lifDown() { + // iSCSI LIF exists but is operationally down — should fail + OntapStorage iscsiStorage = new OntapStorage("admin", "password", "192.168.1.100", + "svm1", null, ProtocolType.ISCSI); + storageStrategy = new TestableStorageStrategy(iscsiStorage, + aggregateFeignClient, volumeFeignClient, svmFeignClient, + jobFeignClient, networkFeignClient, sanFeignClient); + + IpInterface.IpInfo ipInfo = new IpInterface.IpInfo(); + ipInfo.setAddress("192.168.1.51"); + + IpInterface ipInterface = new IpInterface(); + ipInterface.setIp(ipInfo); + ipInterface.setState("down"); + ipInterface.setEnabled(true); + + OntapResponse interfaceResponse = new OntapResponse<>(); + interfaceResponse.setRecords(List.of(ipInterface)); + + when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) + .thenReturn(interfaceResponse); + + Exception ex = assertThrows(CloudRuntimeException.class, + () -> storageStrategy.getNetworkInterface()); + assertTrue(ex.getMessage().contains("operationally UP and enabled")); } @Test @@ -826,8 +895,10 @@ public void testGetNetworkInterface_noInterfaces() { @Test public void testGetNetworkInterface_feignException() { // Setup + Map> emptyHeaders = Collections.emptyMap(); + Request dummyReq = Request.create(Request.HttpMethod.GET, "http://test", emptyHeaders, (byte[]) null, (Charset) null); when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) - .thenThrow(mock(FeignException.FeignClientException.class)); + .thenThrow(new FeignException.FeignClientException(500, "error", dummyReq, null)); // Execute & Verify Exception ex = assertThrows(CloudRuntimeException.class, @@ -835,6 +906,111 @@ public void testGetNetworkInterface_feignException() { assertTrue(ex.getMessage().contains("Failed to retrieve network interfaces")); } + // ========== getNetworkInterface() Node-Affinity Tests ========== + + /** + * Tier 1: LIF homed on the same node as the chosen aggregate — selected without warning. + */ + @Test + public void testGetNetworkInterface_nfs_tier1_homeNodeMatch() { + injectChosenAggregateNode(storageStrategy, "node-a"); + + IpInterface lif = buildLif("10.0.0.1", OntapStorageConstants.LIF_STATE_UP, true, "node-a", "node-a"); + when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) + .thenReturn(wrapLifs(List.of(lif))); + + Pair result = storageStrategy.getNetworkInterface(); + + assertEquals("10.0.0.1", result.first()); + assertTrue(result.second() == null, "Tier 1 should produce no warning"); + } + + /** + * Tier 2: No home-node match, but another UP LIF is currently running on the target node (failover). + * The result should carry a warning. + */ + @Test + public void testGetNetworkInterface_nfs_tier2_currentNodeMatch() { + injectChosenAggregateNode(storageStrategy, "node-a"); + + // home node = node-b, currently running on node-a after failover + IpInterface lif = buildLif("10.0.0.2", OntapStorageConstants.LIF_STATE_UP, true, "node-b", "node-a"); + when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) + .thenReturn(wrapLifs(List.of(lif))); + + Pair result = storageStrategy.getNetworkInterface(); + + assertEquals("10.0.0.2", result.first()); + assertTrue(result.second() != null, "Tier 2 should produce a warning"); + assertTrue(result.second().contains("node-a")); + } + + /** + * Tier 3 fallback: No LIF matches the target node in either home_node or current node. + * First UP/enabled LIF used; result carries a warning directing the user to create a + * LIF on the correct node. + */ + @Test + public void testGetNetworkInterface_nfs_tier3_crossNodeFallback() { + injectChosenAggregateNode(storageStrategy, "node-a"); + + // Both home_node and current node are node-b — no affinity to node-a + IpInterface lif = buildLif("10.0.0.3", OntapStorageConstants.LIF_STATE_UP, true, "node-b", "node-b"); + when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) + .thenReturn(wrapLifs(List.of(lif))); + + Pair result = storageStrategy.getNetworkInterface(); + + assertEquals("10.0.0.3", result.first()); + assertTrue(result.second() != null, "Tier 3 fallback should produce a warning"); + assertTrue(result.second().contains("node-a"), + "Warning should mention the expected node"); + assertTrue(result.second().contains("10.0.0.3"), + "Warning should mention the fallback LIF IP"); + } + + /** + * When chosenAggregateNode is null (volume not yet created / no aggregate info), + * any UP/enabled LIF is returned without warning. + */ + @Test + public void testGetNetworkInterface_nfs_noAggregateNode_noWarning() { + // chosenAggregateNode is null by default — no node affinity context + IpInterface lif = buildLif("10.0.0.4", OntapStorageConstants.LIF_STATE_UP, true, "node-a", "node-a"); + when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) + .thenReturn(wrapLifs(List.of(lif))); + + Pair result = storageStrategy.getNetworkInterface(); + + assertEquals("10.0.0.4", result.first()); + // With no chosenAggregateNode, tier 1/2 selection is skipped — result falls through to tier 3 + // but since there's no "expected node" in the warning message (chosenAggregateNode is null), + // the message text will still contain "null" — we simply verify no exception is thrown and IP is correct. + // (Tier 3 warning is generated when chosenAggregateNode != null; here it is null so no warning) + assertTrue(result.second() == null, "No warning when chosenAggregateNode is null"); + } + + /** + * Tier-1 LIF is down; Tier-2 LIF matches the current node and should be selected with a warning. + */ + @Test + public void testGetNetworkInterface_nfs_tier1Down_tier2Used() { + injectChosenAggregateNode(storageStrategy, "node-a"); + + // Tier 1 candidate: home_node = node-a but operationally DOWN + IpInterface lifDown = buildLif("10.0.0.5", "down", true, "node-a", "node-a"); + // Tier 2 candidate: home_node = node-b, currently on node-a + IpInterface lifFailover = buildLif("10.0.0.6", OntapStorageConstants.LIF_STATE_UP, true, "node-b", "node-a"); + + when(networkFeignClient.getNetworkIpInterfaces(anyString(), anyMap())) + .thenReturn(wrapLifs(List.of(lifDown, lifFailover))); + + Pair result = storageStrategy.getNetworkInterface(); + + assertEquals("10.0.0.6", result.first()); + assertTrue(result.second() != null, "Should warn that the home-node LIF is not in use"); + } + // ========== Helper Methods ========== private void setupSuccessfulConnect() { @@ -853,24 +1029,13 @@ private void setupSuccessfulConnect() { when(svmFeignClient.getSvmResponse(anyMap(), anyString())).thenReturn(svmResponse); - Aggregate aggregateDetail = mock(Aggregate.class); - when(aggregateDetail.getName()).thenReturn("aggr1"); - when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1"); - when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE); - when(aggregateDetail.getSpace()).thenReturn(mock(Aggregate.AggregateSpace.class)); - when(aggregateDetail.getAvailableBlockStorageSpace()).thenReturn(10000000000.0); - when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))).thenReturn(aggregateDetail); + Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 10000000000.0); + when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())).thenReturn(aggregateDetail); } private void setupAggregateForVolumeCreation() { - Aggregate aggregateDetail = mock(Aggregate.class); - when(aggregateDetail.getName()).thenReturn("aggr1"); - when(aggregateDetail.getUuid()).thenReturn("aggr-uuid-1"); - when(aggregateDetail.getState()).thenReturn(Aggregate.StateEnum.ONLINE); - when(aggregateDetail.getSpace()).thenReturn(mock(Aggregate.AggregateSpace.class)); // Mock non-null space - when(aggregateDetail.getAvailableBlockStorageSpace()).thenReturn(10000000000.0); - - when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"))) + Aggregate aggregateDetail = buildAggregate("aggr1", "aggr-uuid-1", 10000000000.0); + when(aggregateFeignClient.getAggregateByUUID(anyString(), eq("aggr-uuid-1"), anyMap())) .thenReturn(aggregateDetail); } @@ -900,4 +1065,76 @@ private void setupSuccessfulJobCreation() { when(volumeFeignClient.getVolume(anyString(), anyMap())) .thenReturn(volumeResponse); } + + /** + * Injects a value into the private {@code chosenAggregateNode} field of StorageStrategy + * so node-affinity tests can exercise all three selection tiers without having to drive + * the full {@code createStorageVolume()} flow. + */ + private static void injectChosenAggregateNode(StorageStrategy strategy, String nodeName) { + try { + Field field = StorageStrategy.class.getDeclaredField("chosenAggregateNode"); + field.setAccessible(true); + field.set(strategy, nodeName); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to inject chosenAggregateNode", e); + } + } + + /** + * Builds an {@link IpInterface} with all node-affinity fields populated. + * + * @param ip the LIF's IP address (IPv4 for NFS3 selection to work) + * @param state operational state (e.g. "up" or "down") + * @param enabled administrative state + * @param homeNode name of the node the LIF is homed to + * @param currentNode name of the node the LIF is currently running on + */ + private static IpInterface buildLif(String ip, String state, boolean enabled, + String homeNode, String currentNode) { + IpInterface.IpInfo ipInfo = new IpInterface.IpInfo(); + ipInfo.setAddress(ip); + + IpInterface.Node homeNodeObj = new IpInterface.Node(); + homeNodeObj.setName(homeNode); + + IpInterface.Node currentNodeObj = new IpInterface.Node(); + currentNodeObj.setName(currentNode); + + IpInterface.Location location = new IpInterface.Location(); + location.setHomeNode(homeNodeObj); + location.setNode(currentNodeObj); + + IpInterface lif = new IpInterface(); + lif.setIp(ipInfo); + lif.setState(state); + lif.setEnabled(enabled); + lif.setLocation(location); + return lif; + } + + private static OntapResponse wrapLifs(List lifs) { + OntapResponse response = new OntapResponse<>(); + response.setRecords(lifs); + return response; + } + + /** + * Creates a real {@link Aggregate} with nested space information so tests can avoid + * {@code mock(Aggregate.class)} which fails on JDK 26+ due to Byte Buddy limitations. + */ + private static Aggregate buildAggregate(String name, String uuid, double availableBytes) { + Aggregate.AggregateSpaceBlockStorage blockStorage = new Aggregate.AggregateSpaceBlockStorage(); + blockStorage.setAvailable(availableBytes); + + Aggregate.AggregateSpace space = new Aggregate.AggregateSpace(); + space.setBlockStorage(blockStorage); + + Aggregate agg = new Aggregate(); + agg.setName(name); + agg.setUuid(uuid); + agg.setState(Aggregate.StateEnum.ONLINE); + agg.setSpace(space); + return agg; + } }