Merge "[netapp-cinder] Added support for san active active capability"

This commit is contained in:
Zuul authored and Gerrit Code Review committed 2026-10-03 13:31:15 +00:00
commit 68e204a457
7 files changed
+495 -1

No files matched your search

@@ -4536,3 +4536,154 @@ class NetAppCmodeClientTestCase(test.TestCase):
self.client.check_api_permissions)
self.assertEqual(0, mock_log.call_count)
def test_get_rest_connection_creates_once(self):
mock_rest = mock.Mock()
self.mock_object(netapp_api, 'RestNaServer',
return_value=mock_rest)
self.connection._host = 'fake_host'
self.connection._protocol = 'https'
self.connection._ssl_cert_path = '/fake/ca.pem'
self.connection._username = 'admin'
self.connection._password = 'pass'
self.connection._port = '443'
self.connection._private_key_file = None
self.connection._certificate_file = None
self.connection._ca_certificate_file = None
self.connection._certificate_host_validation = False
conn1 = self.client._get_rest_connection()
conn2 = self.client._get_rest_connection()
self.assertIs(conn1, conn2)
netapp_api.RestNaServer.assert_called_once()
def test_get_rest_connection_ssl_cert_path_string(self):
self.mock_object(netapp_api, 'RestNaServer',
return_value=mock.Mock())
self.connection._host = 'fake_host'
self.connection._protocol = 'https'
self.connection._ssl_cert_path = '/fake/ca.pem'
self.connection._username = 'admin'
self.connection._password = 'pass'
self.connection._port = '443'
self.connection._private_key_file = None
self.connection._certificate_file = None
self.connection._ca_certificate_file = None
self.connection._certificate_host_validation = False
self.client._get_rest_connection()
call_kwargs = netapp_api.RestNaServer.call_args[1]
self.assertEqual('/fake/ca.pem', call_kwargs['ssl_cert_path'])
def test_get_rest_connection_ssl_cert_path_none(self):
self.mock_object(netapp_api, 'RestNaServer',
return_value=mock.Mock())
self.connection._host = 'fake_host'
self.connection._protocol = 'https'
self.connection._ssl_cert_path = None
self.connection._username = 'admin'
self.connection._password = 'pass'
self.connection._port = '443'
self.connection._private_key_file = None
self.connection._certificate_file = None
self.connection._ca_certificate_file = None
self.connection._certificate_host_validation = False
self.client._get_rest_connection()
call_kwargs = netapp_api.RestNaServer.call_args[1]
self.assertFalse(call_kwargs['ssl_cert_path'])
def test_get_rest_connection_ssl_cert_path_missing_attr(self):
self.mock_object(netapp_api, 'RestNaServer',
return_value=mock.Mock())
self.connection._host = 'fake_host'
self.connection._protocol = 'https'
self.connection._username = 'admin'
self.connection._password = 'pass'
self.connection._port = '443'
self.connection._private_key_file = None
self.connection._certificate_file = None
self.connection._ca_certificate_file = None
self.connection._certificate_host_validation = False
if hasattr(self.connection, '_ssl_cert_path'):
delattr(self.connection, '_ssl_cert_path')
self.client._get_rest_connection()
call_kwargs = netapp_api.RestNaServer.call_args[1]
self.assertFalse(call_kwargs['ssl_cert_path'])
def test_get_svm_san_multipathing_active_active(self):
mock_rest_conn = mock.Mock()
mock_rest_conn.invoke_successfully.return_value = (
200,
{'records': [{'san_multipathing': 'active_active'}]})
self.mock_object(self.client, '_get_rest_connection',
return_value=mock_rest_conn)
result = self.client.get_svm_san_multipathing()
self.assertEqual('active_active', result)
mock_rest_conn.invoke_successfully.assert_called_once_with(
'svm/svms', 'get',
query={'name': self.client.vserver,
'fields': 'san_multipathing'})
def test_get_svm_san_multipathing_local_active(self):
mock_rest_conn = mock.Mock()
mock_rest_conn.invoke_successfully.return_value = (
200,
{'records': [{'san_multipathing': 'local_active'}]})
self.mock_object(self.client, '_get_rest_connection',
return_value=mock_rest_conn)
result = self.client.get_svm_san_multipathing()
self.assertEqual('local_active', result)
def test_get_svm_san_multipathing_empty_records(self):
mock_rest_conn = mock.Mock()
mock_rest_conn.invoke_successfully.return_value = (
200, {'records': []})
self.mock_object(self.client, '_get_rest_connection',
return_value=mock_rest_conn)
result = self.client.get_svm_san_multipathing()
self.assertIsNone(result)
def test_get_svm_san_multipathing_no_records_key(self):
mock_rest_conn = mock.Mock()
mock_rest_conn.invoke_successfully.return_value = (200, {})
self.mock_object(self.client, '_get_rest_connection',
return_value=mock_rest_conn)
result = self.client.get_svm_san_multipathing()
self.assertIsNone(result)
def test_get_svm_san_multipathing_exception(self):
mock_rest_conn = mock.Mock()
mock_rest_conn.invoke_successfully.side_effect = (
netapp_api.NaApiError(code=0, message='connection failed'))
self.mock_object(self.client, '_get_rest_connection',
return_value=mock_rest_conn)
mock_log = self.mock_object(client_cmode.LOG, 'exception')
result = self.client.get_svm_san_multipathing()
self.assertIsNone(result)
mock_log.assert_called_once()
def test_get_svm_san_multipathing_unexpected_exception(self):
mock_rest_conn = mock.Mock()
mock_rest_conn.invoke_successfully.side_effect = ValueError(
'connection failed')
self.mock_object(self.client, '_get_rest_connection',
return_value=mock_rest_conn)
self.assertRaises(
ValueError, self.client.get_svm_san_multipathing)
@@ -5887,3 +5887,74 @@ class NetAppRestCmodeClientTestCase(test.TestCase):
result = self.client.get_svm_uuid_by_name()
self.assertIsNone(result)
def test_get_svm_san_multipathing_active_active(self):
self.mock_object(
self.client, 'send_request',
return_value={'records': [
{'san_multipathing': 'active_active'}]})
result = self.client.get_svm_san_multipathing()
self.assertEqual('active_active', result)
self.client.send_request.assert_called_once_with(
'/svm/svms', 'get',
query={'name': self.client.vserver,
'fields': 'san_multipathing'})
def test_get_svm_san_multipathing_local_active(self):
self.mock_object(
self.client, 'send_request',
return_value={'records': [
{'san_multipathing': 'local_active'}]})
result = self.client.get_svm_san_multipathing()
self.assertEqual('local_active', result)
def test_get_svm_san_multipathing_empty_records(self):
self.mock_object(
self.client, 'send_request',
return_value={'records': []})
result = self.client.get_svm_san_multipathing()
self.assertIsNone(result)
def test_get_svm_san_multipathing_no_records_key(self):
self.mock_object(
self.client, 'send_request',
return_value={})
result = self.client.get_svm_san_multipathing()
self.assertIsNone(result)
def test_get_svm_san_multipathing_exception(self):
self.mock_object(
self.client, 'send_request',
side_effect=netapp_api.NaApiError(
code=0, message='connection failed'))
mock_log = self.mock_object(client_cmode_rest.LOG, 'exception')
result = self.client.get_svm_san_multipathing()
self.assertIsNone(result)
mock_log.assert_called_once()
def test_get_svm_san_multipathing_unexpected_exception(self):
self.mock_object(
self.client, 'send_request',
side_effect=ValueError('connection failed'))
self.assertRaises(
ValueError, self.client.get_svm_san_multipathing)
def test_get_svm_san_multipathing_missing_field(self):
self.mock_object(
self.client, 'send_request',
return_value={'records': [{'name': 'vs0'}]})
result = self.client.get_svm_san_multipathing()
self.assertIsNone(result)
@@ -192,6 +192,9 @@ class CapabilitiesLibraryTestCase(test.TestCase):
self.ssc_library, '_get_ssc_qos_min_info',
side_effect=[SSC_QOS_MIN_INFO['volume1'],
SSC_QOS_MIN_INFO['volume2']])
mock_get_ssc_san_mp_info = self.mock_object(
self.ssc_library, '_get_ssc_san_multipathing_info',
return_value={})
if protocol != 'nfs':
mock_get_ssc_volume_count_info = self.mock_object(
self.ssc_library, '_get_ssc_volume_count_info',
@@ -230,6 +233,75 @@ class CapabilitiesLibraryTestCase(test.TestCase):
mock.call('volume1'), mock.call('volume2')])
mock_get_ssc_qos_min_info.assert_has_calls([
mock.call('node1'), mock.call('node2')])
mock_get_ssc_san_mp_info.assert_called_once_with()
@ddt.data('nfs', 'iscsi', 'fc', 'nvme')
def test_update_ssc_san_multipathing_protocol_gating(self, protocol):
self.ssc_library.protocol = protocol
self.mock_object(
self.ssc_library, '_get_ssc_flexvol_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_dedupe_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_mirror_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_encryption_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_aggregate_info',
return_value={'netapp_node_name': 'node1'})
self.mock_object(
self.ssc_library, '_get_ssc_qos_min_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_volume_count_info', return_value={})
mock_san_mp_info = self.mock_object(
self.ssc_library, '_get_ssc_san_multipathing_info',
return_value={'netapp_san_active_active': 'true'})
flexvol_map = {'volume1': {'pool_name': 'volume1'}}
self.ssc_library.update_ssc(flexvol_map)
if protocol == 'nfs':
mock_san_mp_info.assert_not_called()
self.assertNotIn(
'netapp_san_active_active',
self.ssc_library.ssc['volume1'])
else:
mock_san_mp_info.assert_called_once_with()
self.assertEqual(
'true',
self.ssc_library.ssc['volume1']['netapp_san_active_active'])
def test_update_ssc_san_multipathing_info_empty_not_applied(self):
self.ssc_library.protocol = 'iscsi'
self.mock_object(
self.ssc_library, '_get_ssc_flexvol_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_dedupe_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_mirror_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_encryption_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_aggregate_info',
return_value={'netapp_node_name': 'node1'})
self.mock_object(
self.ssc_library, '_get_ssc_qos_min_info', return_value={})
self.mock_object(
self.ssc_library, '_get_ssc_volume_count_info', return_value={})
mock_san_mp_info = self.mock_object(
self.ssc_library, '_get_ssc_san_multipathing_info',
return_value={})
flexvol_map = {'volume1': {'pool_name': 'volume1'}}
self.ssc_library.update_ssc(flexvol_map)
mock_san_mp_info.assert_called_once_with()
self.assertNotIn(
'netapp_san_active_active', self.ssc_library.ssc['volume1'])
def test__update_for_failover(self):
self.mock_object(self.ssc_library, 'update_ssc')
@@ -725,3 +797,76 @@ class CapabilitiesLibraryTestCase(test.TestCase):
contains_fg_returned = self.ssc_library.contains_flexgroup_pool()
self.assertEqual(contains_fg_returned, contains_fg)
def test_get_ssc_san_multipathing_info_version_none(self):
self.mock_object(
self.zapi_client, 'get_ontap_version', return_value=None)
result = self.ssc_library._get_ssc_san_multipathing_info()
self.assertEqual({}, result)
self.zapi_client.get_ontap_version.assert_called_once_with(
cached=True)
@ddt.data((9, 17, 1), (9, 14, 0), (9, 18, 0))
def test_get_ssc_san_multipathing_info_old_ontap(self, ontap_version):
self.mock_object(
self.zapi_client, 'get_ontap_version',
return_value=ontap_version)
result = self.ssc_library._get_ssc_san_multipathing_info()
self.assertEqual({}, result)
self.zapi_client.get_svm_san_multipathing.assert_not_called()
def test_get_ssc_san_multipathing_info_active_active(self):
self.mock_object(
self.zapi_client, 'get_ontap_version',
return_value=(9, 19, 1))
self.mock_object(
self.zapi_client, 'get_svm_san_multipathing',
return_value='active_active')
result = self.ssc_library._get_ssc_san_multipathing_info()
self.assertEqual({'netapp_san_active_active': 'true'}, result)
self.zapi_client.get_svm_san_multipathing.assert_called_once_with()
def test_get_ssc_san_multipathing_info_local_active(self):
self.mock_object(
self.zapi_client, 'get_ontap_version',
return_value=(9, 19, 1))
self.mock_object(
self.zapi_client, 'get_svm_san_multipathing',
return_value='local_active')
result = self.ssc_library._get_ssc_san_multipathing_info()
self.assertEqual({'netapp_san_active_active': 'false'}, result)
@ddt.data((9, 19, 1), (9, 20, 0), (10, 0, 0))
def test_get_ssc_san_multipathing_info_new_ontap(self, ontap_version):
self.mock_object(
self.zapi_client, 'get_ontap_version',
return_value=ontap_version)
self.mock_object(
self.zapi_client, 'get_svm_san_multipathing',
return_value='local_active')
result = self.ssc_library._get_ssc_san_multipathing_info()
self.assertEqual({'netapp_san_active_active': 'false'}, result)
self.zapi_client.get_svm_san_multipathing.assert_called_once_with()
def test_get_ssc_san_multipathing_info_none_response(self):
self.mock_object(
self.zapi_client, 'get_ontap_version',
return_value=(9, 19, 1))
self.mock_object(
self.zapi_client, 'get_svm_san_multipathing',
return_value=None)
result = self.ssc_library._get_ssc_san_multipathing_info()
self.assertEqual({}, result)
self.zapi_client.get_svm_san_multipathing.assert_called_once_with()
@@ -2819,3 +2819,54 @@ class Client(client_base.Client, metaclass=volume_utils.TraceWrapperMetaclass):
}
return provisioning_opts
def _get_rest_connection(self):
"""Lazily create a minimal REST connection for REST-only queries."""
if not hasattr(self, '_rest_connection'):
zapi_cert_path = getattr(
self.connection, '_ssl_cert_path', None)
# When the ZAPI connection has no explicit CA cert path it
# silently disables SSL verification (unverified context).
# Mirror that behaviour for the REST session by passing
# False so that requests also skips certificate checks.
ssl_cert_path = zapi_cert_path if (
isinstance(zapi_cert_path, str)) else False
self._rest_connection = netapp_api.RestNaServer(
host=self.connection._host,
transport_type=self.connection._protocol,
ssl_cert_path=ssl_cert_path,
username=self.connection._username,
password=self.connection._password,
port=self.connection._port,
private_key_file=self.connection._private_key_file,
certificate_file=self.connection._certificate_file,
ca_certificate_file=(
self.connection._ca_certificate_file),
ssl_cert_verify=self.connection._ssl_cert_verify,
certificate_host_validation=(
self.connection._certificate_host_validation),
)
return self._rest_connection
def get_svm_san_multipathing(self):
"""Query SVM san-multipathing via REST (no ZAPI equivalent).
Returns the san_multipathing value ('active_active',
'local_active') or None if the query fails or the field
is not available (e.g. ONTAP < 9.19.1).
"""
try:
rest_conn = self._get_rest_connection()
query = {
'name': self.vserver,
'fields': 'san_multipathing',
}
_, response = rest_conn.invoke_successfully(
'svm/svms', 'get', query=query)
records = response.get('records', [])
if records:
return records[0].get('san_multipathing')
except netapp_api.NaApiError:
LOG.exception('Failed to query san_multipathing via REST. '
'Defaulting to None.')
return None
@@ -3834,3 +3834,24 @@ class RestClient(object, metaclass=volume_utils.TraceWrapperMetaclass):
if r.get('name') == self.vserver:
return r.get('uuid')
return None
def get_svm_san_multipathing(self):
"""Query SVM san-multipathing property via REST.
Returns the san_multipathing value ('active_active',
'local_active') or None if the query fails or the field
is not available (e.g. ONTAP < 9.19.1).
"""
try:
query = {
'name': self.vserver,
'fields': 'san_multipathing',
}
response = self.send_request('/svm/svms', 'get', query=query)
records = response.get('records', [])
if records:
return records[0].get('san_multipathing')
except netapp_api.NaApiError:
LOG.exception('Failed to query san_multipathing via REST. '
'Defaulting to None.')
return None
@@ -20,10 +20,12 @@ import copy
import re
from oslo_log import log as logging
from packaging import version
LOG = logging.getLogger(__name__)
MINIMUM_SAN_AA_VERSION = '9.19.1'
class CapabilitiesLibrary(object):
@@ -92,6 +94,11 @@ class CapabilitiesLibrary(object):
ssc = {}
san_mp_info = {}
# SVM-level property -- query once, apply to all pools
if self.protocol.casefold() != 'nfs':
san_mp_info = self._get_ssc_san_multipathing_info()
for flexvol_name, flexvol_info in flexvol_map.items():
ssc_volume = {}
@@ -115,6 +122,10 @@ class CapabilitiesLibrary(object):
ssc_volume.update(self._get_ssc_qos_min_info(node_name))
if san_mp_info:
# SVM-level SAN multipathing capability
ssc_volume.update(san_mp_info)
if self.protocol.casefold() != 'nfs':
ssc_volume.update
(self._get_ssc_volume_count_info(flexvol_name))
@@ -319,6 +330,33 @@ class CapabilitiesLibrary(object):
'total_volumes': volume_count,
}
def _get_ssc_san_multipathing_info(self):
"""Gather SVM san-multipathing info for the SSC.
This is an SVM-level property indicating whether the SVM has
active-active SAN multipathing enabled (ONTAP 9.19.1+).
The capability is queried via REST; when using the legacy ZAPI
client the ZAPI Client performs a lightweight REST fallback.
Returns an empty dict on ONTAP < 9.19.1 so that the key is
never advertised on clusters that do not support it.
"""
ontap_version = self.zapi_client.get_ontap_version(cached=True)
if ontap_version is None:
return {}
ontap_version_str = ".".join(map(str, ontap_version))
if (version.parse(ontap_version_str) <
version.parse(MINIMUM_SAN_AA_VERSION)):
return {}
san_multipathing = self.zapi_client.get_svm_san_multipathing()
if san_multipathing is None:
return {}
is_active_active = (san_multipathing == 'active_active')
return {
'netapp_san_active_active': str(is_active_active).lower(),
}
def get_matching_flexvols_for_extra_specs(self, extra_specs):
"""Return a list of flexvol names that match a set of extra specs."""
@@ -0,0 +1,17 @@
---
features:
- |
NetApp ONTAP driver: Added pool-level capability
``netapp_san_active_active`` that exposes the ONTAP 9.19.1 SVM-level
``san_multipathing`` property (``active_active`` or ``local_active``)
as a schedulable extra spec. Operators can steer volume placement
toward Active-Active or Local-Active backends via volume types using
``netapp_san_active_active=<is> True`` or
``netapp_san_active_active=<is> False``. The ZAPI client uses a lazy,
cached ``RestNaServer`` to query this REST-only property without
requiring migration to the full REST client. The REST client queries
it natively. The capability is injected into each pool's SSC data
during ``update_ssc()`` and excluded from ASAr2 backends where
Active-Active is inherent. A version gate ensures the capability is
only advertised on clusters running ONTAP 9.19.1 or later; older
releases omit the key entirely.