NetApp ONTAP: Add REST Client for ONTAP
As the result of adoption of a REST API by NetApp ONTAP, some client requests are now being gradually moved to the new format. A fallback mechanism will be used to switch back to ZAPI if a client is using an old version with an unsupported rest endpoint. A new module was created to handle the REST requests, but the old ZAPI client was not removed yet to keep the compatibility with old ONTAP systems. In this patch the functions needed for the NetApp driver intialization were also added. Co-authored-by: Fábio Oliveira <fabioaurelio1269@gmail.com> Co-authored-by: Felipe Rodrigues <felipefuty01@gmail.com> Co-authored-by: Fernando Ferraz <sfernand@netapp.com> Co-authored-by: Luisa Amaral <luisaa@netapp.com> Change-Id: I2df082fc79152ade713fea2474eefe639b43b4b8 partially-implements: blueprint netapp-ontap-rest-api-client
This commit is contained in:
@@ -826,7 +826,7 @@ VOLUME_GET_ITER_SSC_RESPONSE_STR = """
|
||||
<snapshot-policy>default</snapshot-policy>
|
||||
</volume-snapshot-attributes>
|
||||
<volume-language-attributes>
|
||||
<language-code>en_US</language-code>
|
||||
<language-code>c.utf_8</language-code>
|
||||
</volume-language-attributes>
|
||||
</volume-attributes>
|
||||
""" % {
|
||||
@@ -876,7 +876,7 @@ VOLUME_GET_ITER_SSC_RESPONSE_STR_FLEXGROUP = """
|
||||
<snapshot-policy>default</snapshot-policy>
|
||||
</volume-snapshot-attributes>
|
||||
<volume-language-attributes>
|
||||
<language-code>en_US</language-code>
|
||||
<language-code>c.utf_8</language-code>
|
||||
</volume-language-attributes>
|
||||
</volume-attributes>
|
||||
""" % {
|
||||
@@ -903,7 +903,7 @@ VOLUME_INFO_SSC = {
|
||||
'junction-path': '/%s' % VOLUME_NAMES[0],
|
||||
'aggregate': VOLUME_AGGREGATE_NAMES[0],
|
||||
'space-guarantee-enabled': True,
|
||||
'language': 'en_US',
|
||||
'language': 'c.utf_8',
|
||||
'percentage-snapshot-reserve': '5',
|
||||
'snapshot-policy': 'default',
|
||||
'type': 'rw',
|
||||
@@ -919,7 +919,7 @@ VOLUME_INFO_SSC_FLEXGROUP = {
|
||||
'junction-path': '/%s' % VOLUME_NAMES[0],
|
||||
'aggregate': [VOLUME_AGGREGATE_NAMES[0]],
|
||||
'space-guarantee-enabled': True,
|
||||
'language': 'en_US',
|
||||
'language': 'c.utf_8',
|
||||
'percentage-snapshot-reserve': '5',
|
||||
'snapshot-policy': 'default',
|
||||
'type': 'rw',
|
||||
@@ -1019,7 +1019,7 @@ VOLUME_GET_ITER_ENCRYPTION_SSC_RESPONSE = etree.XML("""
|
||||
<snapshot-policy>default</snapshot-policy>
|
||||
</volume-snapshot-attributes>
|
||||
<volume-language-attributes>
|
||||
<language-code>en_US</language-code>
|
||||
<language-code>c.utf_8</language-code>
|
||||
</volume-language-attributes>
|
||||
</volume-attributes>
|
||||
</attributes-list>
|
||||
@@ -1581,3 +1581,121 @@ GET_FILE_COPY_STATUS_RESPONSE = etree.XML("""
|
||||
DESTROY_FILE_COPY_RESPONSE = etree.XML("""
|
||||
<results status="passed" />
|
||||
""")
|
||||
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST = [
|
||||
{
|
||||
"uuid": "2407b637-119c-11ec-a4fb-00a0b89c9a78",
|
||||
"name": VOLUME_NAMES[0],
|
||||
"state": "online",
|
||||
"style": "flexvol",
|
||||
"is_svm_root": False,
|
||||
"type": "rw",
|
||||
"error_state": {
|
||||
"is_inconsistent": False
|
||||
},
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/api/storage/volumes/2407b637-119c-11ec-a4fb"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"uuid": "2c190609-d51c-11eb-b83a",
|
||||
"name": VOLUME_NAMES[1],
|
||||
"state": "online",
|
||||
"style": "flexvol",
|
||||
"is_svm_root": False,
|
||||
"type": "rw",
|
||||
"error_state": {
|
||||
"is_inconsistent": False
|
||||
},
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/api/storage/volumes/2c190609-d51c-11eb-b83a"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
VOLUME_GET_ITER_RESPONSE_REST_PAGE = {
|
||||
"records": [
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
],
|
||||
"num_records": 10,
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/api/storage/volumes?fields=name&max_records=2"
|
||||
},
|
||||
"next": {
|
||||
"href": "/api/storage/volumes?"
|
||||
f"start.uuid={VOLUME_GET_ITER_RESPONSE_LIST_REST[0]['uuid']}"
|
||||
"&fields=name&max_records=2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VOLUME_GET_ITER_RESPONSE_REST_LAST_PAGE = {
|
||||
"records": [
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
VOLUME_GET_ITER_RESPONSE_LIST_REST[0],
|
||||
],
|
||||
"num_records": 8,
|
||||
}
|
||||
|
||||
INVALID_GET_ITER_RESPONSE_NO_RECORDS_REST = {
|
||||
"num_records": 1,
|
||||
}
|
||||
|
||||
INVALID_GET_ITER_RESPONSE_NO_NUM_RECORDS_REST = {
|
||||
"records": [],
|
||||
}
|
||||
|
||||
NO_RECORDS_RESPONSE_REST = {
|
||||
"records": [],
|
||||
"num_records": 0,
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/api/cluster/nodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ERROR_RESPONSE_REST = {
|
||||
"error": {
|
||||
"code": 1100,
|
||||
"message": "fake error",
|
||||
}
|
||||
}
|
||||
|
||||
FAKE_ACTION_ENDPOINT = '/fake_endpoint'
|
||||
FAKE_BASE_ENDPOINT = '/fake_api'
|
||||
FAKE_HEADERS = {'header': 'fake_header'}
|
||||
FAKE_BODY = {'body': 'fake_body'}
|
||||
FAKE_HTTP_QUERY = {'type': 'fake_type'}
|
||||
FAKE_FORMATTED_HTTP_QUERY = '?type=fake_type'
|
||||
|
||||
JOB_RESPONSE_REST = {
|
||||
"job": {
|
||||
"uuid": "uuid-12345",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/api/cluster/jobs/uuid-12345"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ from unittest import mock
|
||||
|
||||
import ddt
|
||||
from lxml import etree
|
||||
from oslo_serialization import jsonutils
|
||||
from oslo_utils import netutils
|
||||
import paramiko
|
||||
import requests
|
||||
from requests import auth
|
||||
import six
|
||||
from six.moves import urllib
|
||||
|
||||
@@ -565,3 +568,321 @@ class SSHUtilTests(test.TestCase):
|
||||
stderr = mock.Mock()
|
||||
stderr.channel = mock.Mock(channel)
|
||||
return stdin, stdout, stderr
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NetAppRestApiServerTests(test.TestCase):
|
||||
"""Test case for NetApp REST API server methods."""
|
||||
def setUp(self):
|
||||
self.rest_client = netapp_api.RestNaServer('127.0.0.1')
|
||||
super(NetAppRestApiServerTests, self).setUp()
|
||||
|
||||
@ddt.data(None, 'my_cert')
|
||||
def test__init__ssl_verify(self, ssl_cert_path):
|
||||
client = netapp_api.RestNaServer('127.0.0.1',
|
||||
ssl_cert_path=ssl_cert_path)
|
||||
|
||||
if ssl_cert_path:
|
||||
self.assertEqual(ssl_cert_path, client._ssl_verify)
|
||||
else:
|
||||
self.assertTrue(client._ssl_verify)
|
||||
|
||||
@ddt.data(None, 'ftp')
|
||||
def test_set_transport_type_value_error(self, transport_type):
|
||||
self.assertRaises(ValueError, self.rest_client.set_transport_type,
|
||||
transport_type)
|
||||
|
||||
@ddt.data('http', 'https')
|
||||
def test_set_transport_type_valid(self, transport_type):
|
||||
"""Tests setting a valid transport type"""
|
||||
self.rest_client.set_transport_type(transport_type)
|
||||
self.assertEqual(self.rest_client._protocol, transport_type)
|
||||
|
||||
@ddt.data('!&', '80na', '')
|
||||
def test_set_port__value_error(self, port):
|
||||
self.assertRaises(ValueError, self.rest_client.set_port, port)
|
||||
|
||||
@ddt.data(
|
||||
{'port': None, 'protocol': 'http', 'expected_port': '80'},
|
||||
{'port': None, 'protocol': 'https', 'expected_port': '443'},
|
||||
{'port': '111', 'protocol': None, 'expected_port': '111'}
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_set_port(self, port, protocol, expected_port):
|
||||
self.rest_client._protocol = protocol
|
||||
|
||||
self.rest_client.set_port(port=port)
|
||||
|
||||
self.assertEqual(expected_port, self.rest_client._port)
|
||||
|
||||
@ddt.data('!&', '80na', '')
|
||||
def test_set_timeout_value_error(self, timeout):
|
||||
self.assertRaises(ValueError, self.rest_client.set_timeout, timeout)
|
||||
|
||||
@ddt.data({'params': {'major': 1, 'minor': '20a'}},
|
||||
{'params': {'major': '20a', 'minor': 1}},
|
||||
{'params': {'major': '!*', 'minor': '20a'}})
|
||||
@ddt.unpack
|
||||
def test_set_api_version_value_error(self, params):
|
||||
self.assertRaises(ValueError, self.rest_client.set_api_version,
|
||||
**params)
|
||||
|
||||
def test_set_api_version_valid(self):
|
||||
args = {'major': '20', 'minor': 1}
|
||||
|
||||
self.rest_client.set_api_version(**args)
|
||||
|
||||
self.assertEqual(self.rest_client._api_major_version, 20)
|
||||
self.assertEqual(self.rest_client._api_minor_version, 1)
|
||||
self.assertEqual(self.rest_client._api_version, "20.1")
|
||||
|
||||
def test_invoke_successfully_naapi_error(self):
|
||||
self.mock_object(self.rest_client, '_build_headers', return_value={})
|
||||
self.mock_object(self.rest_client, '_get_base_url', return_value='')
|
||||
self.mock_object(self.rest_client, 'send_http_request',
|
||||
return_value=(10, zapi_fakes.ERROR_RESPONSE_REST))
|
||||
|
||||
self.assertRaises(netapp_api.NaApiError,
|
||||
self.rest_client.invoke_successfully,
|
||||
zapi_fakes.FAKE_ACTION_ENDPOINT, 'get')
|
||||
|
||||
@ddt.data(None, {'fields': 'fake_fields'})
|
||||
def test_invoke_successfully(self, query):
|
||||
mock_build_header = self.mock_object(
|
||||
self.rest_client, '_build_headers',
|
||||
return_value=zapi_fakes.FAKE_HEADERS)
|
||||
mock_base = self.mock_object(
|
||||
self.rest_client, '_get_base_url',
|
||||
return_value=zapi_fakes.FAKE_BASE_ENDPOINT)
|
||||
mock_add_query = self.mock_object(
|
||||
self.rest_client, '_add_query_params_to_url',
|
||||
return_value=zapi_fakes.FAKE_ACTION_ENDPOINT)
|
||||
http_code = 200
|
||||
mock_send_http = self.mock_object(
|
||||
self.rest_client, 'send_http_request',
|
||||
return_value=(http_code, zapi_fakes.NO_RECORDS_RESPONSE_REST))
|
||||
|
||||
code, response = self.rest_client.invoke_successfully(
|
||||
zapi_fakes.FAKE_ACTION_ENDPOINT, 'get', body=zapi_fakes.FAKE_BODY,
|
||||
query=query, enable_tunneling=True)
|
||||
|
||||
self.assertEqual(response, zapi_fakes.NO_RECORDS_RESPONSE_REST)
|
||||
self.assertEqual(code, http_code)
|
||||
mock_build_header.assert_called_once_with(True)
|
||||
mock_base.assert_called_once_with()
|
||||
self.assertEqual(bool(query), mock_add_query.called)
|
||||
mock_send_http.assert_called_once_with(
|
||||
'get',
|
||||
zapi_fakes.FAKE_BASE_ENDPOINT + zapi_fakes.FAKE_ACTION_ENDPOINT,
|
||||
zapi_fakes.FAKE_BODY, zapi_fakes.FAKE_HEADERS)
|
||||
|
||||
@ddt.data(
|
||||
{'error': requests.HTTPError(), 'raised': netapp_api.NaApiError},
|
||||
{'error': Exception, 'raised': netapp_api.NaApiError})
|
||||
@ddt.unpack
|
||||
def test_send_http_request_http_error(self, error, raised):
|
||||
self.mock_object(netapp_api, 'LOG')
|
||||
self.mock_object(self.rest_client, '_build_session')
|
||||
self.rest_client._session = mock.Mock()
|
||||
self.mock_object(
|
||||
self.rest_client, '_get_request_method', mock.Mock(
|
||||
return_value=mock.Mock(side_effect=error)))
|
||||
|
||||
self.assertRaises(raised, self.rest_client.send_http_request,
|
||||
'get', zapi_fakes.FAKE_ACTION_ENDPOINT,
|
||||
zapi_fakes.FAKE_BODY, zapi_fakes.FAKE_HEADERS)
|
||||
|
||||
@ddt.data(
|
||||
{
|
||||
'resp_content': zapi_fakes.NO_RECORDS_RESPONSE_REST,
|
||||
'body': zapi_fakes.FAKE_BODY,
|
||||
'timeout': 10,
|
||||
},
|
||||
{
|
||||
'resp_content': zapi_fakes.NO_RECORDS_RESPONSE_REST,
|
||||
'body': zapi_fakes.FAKE_BODY,
|
||||
'timeout': None,
|
||||
},
|
||||
{
|
||||
'resp_content': zapi_fakes.NO_RECORDS_RESPONSE_REST,
|
||||
'body': None,
|
||||
'timeout': None,
|
||||
},
|
||||
{
|
||||
'resp_content': None,
|
||||
'body': None,
|
||||
'timeout': None,
|
||||
}
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_send_http_request(self, resp_content, body, timeout):
|
||||
if timeout:
|
||||
self.rest_client._timeout = timeout
|
||||
self.mock_object(netapp_api, 'LOG')
|
||||
mock_json_dumps = self.mock_object(
|
||||
jsonutils, 'dumps', mock.Mock(return_value='fake_dump_body'))
|
||||
mock_build_session = self.mock_object(
|
||||
self.rest_client, '_build_session')
|
||||
_mock_session = mock.Mock()
|
||||
self.rest_client._session = _mock_session
|
||||
response = mock.Mock()
|
||||
response.content = resp_content
|
||||
response.status_code = 10
|
||||
mock_post = mock.Mock(return_value=response)
|
||||
mock_get_request_method = self.mock_object(
|
||||
self.rest_client, '_get_request_method', mock.Mock(
|
||||
return_value=mock_post))
|
||||
mock_json_loads = self.mock_object(
|
||||
jsonutils, 'loads',
|
||||
mock.Mock(return_value='fake_loads_response'))
|
||||
|
||||
code, res = self.rest_client.send_http_request(
|
||||
'post', zapi_fakes.FAKE_ACTION_ENDPOINT,
|
||||
body, zapi_fakes.FAKE_HEADERS)
|
||||
|
||||
expected_res = 'fake_loads_response' if resp_content else {}
|
||||
self.assertEqual(expected_res, res)
|
||||
self.assertEqual(10, code)
|
||||
self.assertEqual(bool(body), mock_json_dumps.called)
|
||||
self.assertEqual(bool(resp_content), mock_json_loads.called)
|
||||
mock_build_session.assert_called_once_with(zapi_fakes.FAKE_HEADERS)
|
||||
mock_get_request_method.assert_called_once_with('post', _mock_session)
|
||||
expected_data = 'fake_dump_body' if body else {}
|
||||
if timeout:
|
||||
mock_post.assert_called_once_with(
|
||||
zapi_fakes.FAKE_ACTION_ENDPOINT, data=expected_data,
|
||||
timeout=timeout)
|
||||
else:
|
||||
mock_post.assert_called_once_with(zapi_fakes.FAKE_ACTION_ENDPOINT,
|
||||
data=expected_data)
|
||||
|
||||
@ddt.data(
|
||||
{'host': '192.168.1.0', 'port': '80', 'protocol': 'http'},
|
||||
{'host': '0.0.0.0', 'port': '443', 'protocol': 'https'},
|
||||
{'host': '::ffff:8', 'port': '80', 'protocol': 'http'},
|
||||
{'host': 'fdf8:f53b:82e4::53', 'port': '443', 'protocol': 'https'})
|
||||
@ddt.unpack
|
||||
def test__get_base_url(self, host, port, protocol):
|
||||
client = netapp_api.RestNaServer(host, port=port,
|
||||
transport_type=protocol)
|
||||
expected_host = f'[{host}]' if ':' in host else host
|
||||
expected_url = '%s://%s:%s/api/' % (protocol, expected_host, port)
|
||||
|
||||
url = client._get_base_url()
|
||||
|
||||
self.assertEqual(expected_url, url)
|
||||
|
||||
def test__add_query_params_to_url(self):
|
||||
formatted_url = self.rest_client._add_query_params_to_url(
|
||||
zapi_fakes.FAKE_ACTION_ENDPOINT, zapi_fakes.FAKE_HTTP_QUERY)
|
||||
|
||||
expected_formatted_url = zapi_fakes.FAKE_ACTION_ENDPOINT
|
||||
expected_formatted_url += zapi_fakes.FAKE_FORMATTED_HTTP_QUERY
|
||||
self.assertEqual(expected_formatted_url, formatted_url)
|
||||
|
||||
@ddt.data('post', 'get', 'put', 'delete', 'patch')
|
||||
def test_get_request_method(self, method):
|
||||
_mock_session = mock.Mock()
|
||||
_mock_session.post = mock.Mock()
|
||||
_mock_session.get = mock.Mock()
|
||||
_mock_session.put = mock.Mock()
|
||||
_mock_session.delete = mock.Mock()
|
||||
_mock_session.patch = mock.Mock()
|
||||
|
||||
res = self.rest_client._get_request_method(method, _mock_session)
|
||||
|
||||
expected_method = getattr(_mock_session, method)
|
||||
self.assertEqual(expected_method, res)
|
||||
|
||||
def test__str__(self):
|
||||
fake_host = 'fake_host'
|
||||
client = netapp_api.RestNaServer(fake_host)
|
||||
|
||||
expected_str = "server: %s" % fake_host
|
||||
self.assertEqual(expected_str, str(client))
|
||||
|
||||
def test_get_transport_type(self):
|
||||
expected_protocol = 'fake_protocol'
|
||||
self.rest_client._protocol = expected_protocol
|
||||
|
||||
res = self.rest_client.get_transport_type()
|
||||
|
||||
self.assertEqual(expected_protocol, res)
|
||||
|
||||
@ddt.data(None, ('1', '0'))
|
||||
def test_get_api_version(self, api_version):
|
||||
if api_version:
|
||||
self.rest_client._api_version = str(api_version)
|
||||
(self.rest_client._api_major_version, _) = api_version
|
||||
(_, self.rest_client._api_minor_version) = api_version
|
||||
|
||||
res = self.rest_client.get_api_version()
|
||||
|
||||
self.assertEqual(api_version, res)
|
||||
|
||||
@ddt.data(None, '9.10')
|
||||
def test_get_ontap_version(self, ontap_version):
|
||||
if ontap_version:
|
||||
self.rest_client._ontap_version = ontap_version
|
||||
|
||||
res = self.rest_client.get_ontap_version()
|
||||
|
||||
self.assertEqual(ontap_version, res)
|
||||
|
||||
def test_set_vserver(self):
|
||||
expected_vserver = 'fake_vserver'
|
||||
self.rest_client.set_vserver(expected_vserver)
|
||||
|
||||
self.assertEqual(expected_vserver, self.rest_client._vserver)
|
||||
|
||||
def test_get_vserver(self):
|
||||
expected_vserver = 'fake_vserver'
|
||||
self.rest_client._vserver = expected_vserver
|
||||
|
||||
res = self.rest_client.get_vserver()
|
||||
|
||||
self.assertEqual(expected_vserver, res)
|
||||
|
||||
def test__build_session(self):
|
||||
fake_session = mock.Mock()
|
||||
mock_requests_session = self.mock_object(
|
||||
requests, 'Session', mock.Mock(return_value=fake_session))
|
||||
mock_auth = self.mock_object(
|
||||
self.rest_client, '_create_basic_auth_handler',
|
||||
mock.Mock(return_value='fake_auth'))
|
||||
self.rest_client._ssl_verify = 'fake_ssl'
|
||||
|
||||
self.rest_client._build_session(zapi_fakes.FAKE_HEADERS)
|
||||
|
||||
self.assertEqual(fake_session, self.rest_client._session)
|
||||
self.assertEqual('fake_auth', self.rest_client._session.auth)
|
||||
self.assertEqual('fake_ssl', self.rest_client._session.verify)
|
||||
self.assertEqual(zapi_fakes.FAKE_HEADERS,
|
||||
self.rest_client._session.headers)
|
||||
mock_requests_session.assert_called_once_with()
|
||||
mock_auth.assert_called_once_with()
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test__build_headers(self, enable_tunneling):
|
||||
self.rest_client._vserver = zapi_fakes.VSERVER_NAME
|
||||
|
||||
res = self.rest_client._build_headers(enable_tunneling)
|
||||
|
||||
expected = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if enable_tunneling:
|
||||
expected["X-Dot-SVM-Name"] = zapi_fakes.VSERVER_NAME
|
||||
self.assertEqual(expected, res)
|
||||
|
||||
def test__create_basic_auth_handler(self):
|
||||
username = 'fake_username'
|
||||
password = 'fake_password'
|
||||
client = netapp_api.RestNaServer('10.1.1.1', username=username,
|
||||
password=password)
|
||||
|
||||
res = client._create_basic_auth_handler()
|
||||
|
||||
expected = auth.HTTPBasicAuth(username, password)
|
||||
self.assertEqual(expected.__dict__, res.__dict__)
|
||||
|
||||
@@ -4097,7 +4097,7 @@ class NetAppCmodeClientTestCase(test.TestCase):
|
||||
'aggregate': 'fake_aggr1',
|
||||
'compression_enabled': False,
|
||||
'dedupe_enabled': True,
|
||||
'language': 'en_US',
|
||||
'language': 'c.utf_8',
|
||||
'size': 1,
|
||||
'snapshot_policy': 'default',
|
||||
'snapshot_reserve': '5',
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# Copyright (c) 2014 Alex Meade. All rights reserved.
|
||||
# Copyright (c) 2015 Dustin Schoenbrun. All rights reserved.
|
||||
# Copyright (c) 2015 Tom Barron. All rights reserved.
|
||||
# Copyright (c) 2016 Mike Rooney. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
from unittest import mock
|
||||
import uuid
|
||||
|
||||
import ddt
|
||||
import six
|
||||
|
||||
from cinder.tests.unit import test
|
||||
from cinder.tests.unit.volume.drivers.netapp.dataontap.client import (
|
||||
fakes as fake_client)
|
||||
from cinder.tests.unit.volume.drivers.netapp.dataontap import fakes as fake
|
||||
from cinder.volume.drivers.netapp.dataontap.client import api as netapp_api
|
||||
from cinder.volume.drivers.netapp.dataontap.client import client_cmode
|
||||
from cinder.volume.drivers.netapp.dataontap.client import client_cmode_rest
|
||||
|
||||
|
||||
CONNECTION_INFO = {'hostname': 'hostname',
|
||||
'transport_type': 'https',
|
||||
'port': 443,
|
||||
'username': 'admin',
|
||||
'password': 'passw0rd',
|
||||
'vserver': 'fake_vserver',
|
||||
'ssl_cert_path': 'fake_ca',
|
||||
'api_trace_pattern': 'fake_regex'}
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NetAppRestCmodeClientTestCase(test.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(NetAppRestCmodeClientTestCase, self).setUp()
|
||||
|
||||
# Setup Client mocks
|
||||
self.mock_object(client_cmode.Client, '_init_ssh_client')
|
||||
# store the original reference so we can call it later in
|
||||
# test__get_cluster_nodes_info
|
||||
self.original_get_cluster_nodes_info = (
|
||||
client_cmode.Client._get_cluster_nodes_info)
|
||||
self.mock_object(client_cmode.Client, '_get_cluster_nodes_info',
|
||||
return_value=fake.HYBRID_SYSTEM_NODES_INFO)
|
||||
self.mock_object(client_cmode.Client, 'get_ontap_version',
|
||||
return_value=(9, 11, 1))
|
||||
self.mock_object(client_cmode.Client,
|
||||
'get_ontapi_version',
|
||||
return_value=(1, 20))
|
||||
|
||||
# Setup RestClient mocks
|
||||
self.mock_object(client_cmode_rest.RestClient, '_init_ssh_client')
|
||||
# store the original reference so we can call it later in
|
||||
# test__get_cluster_nodes_info
|
||||
self.original_get_cluster_nodes_info = (
|
||||
client_cmode_rest.RestClient._get_cluster_nodes_info)
|
||||
|
||||
# Temporary fix because the function is under implementation
|
||||
if not hasattr(client_cmode_rest.RestClient,
|
||||
'_get_cluster_nodes_info'):
|
||||
setattr(client_cmode_rest.RestClient,
|
||||
'_get_cluster_nodes_info',
|
||||
None)
|
||||
self.original_get_cluster_nodes_info = (
|
||||
client_cmode_rest.RestClient._get_cluster_nodes_info)
|
||||
|
||||
self.mock_object(client_cmode_rest.RestClient,
|
||||
'_get_cluster_nodes_info',
|
||||
return_value=fake.HYBRID_SYSTEM_NODES_INFO)
|
||||
self.mock_object(client_cmode_rest.RestClient, 'get_ontap_version',
|
||||
return_value=(9, 11, 1))
|
||||
with mock.patch.object(client_cmode_rest.RestClient,
|
||||
'get_ontap_version',
|
||||
return_value=(9, 11, 1)):
|
||||
self.client = client_cmode_rest.RestClient(**CONNECTION_INFO)
|
||||
|
||||
self.client.ssh_client = mock.MagicMock()
|
||||
self.client.connection = mock.MagicMock()
|
||||
self.connection = self.client.connection
|
||||
|
||||
self.vserver = CONNECTION_INFO['vserver']
|
||||
self.fake_volume = six.text_type(uuid.uuid4())
|
||||
self.fake_lun = six.text_type(uuid.uuid4())
|
||||
# this line interferes in test__get_cluster_nodes_info
|
||||
# self.mock_send_request = self.mock_object(
|
||||
# self.client, 'send_request')
|
||||
|
||||
def _mock_api_error(self, code='fake'):
|
||||
return mock.Mock(side_effect=netapp_api.NaApiError(code=code))
|
||||
|
||||
def test_send_request(self):
|
||||
expected = 'fake_response'
|
||||
mock_get_records = self.mock_object(
|
||||
self.client, 'get_records',
|
||||
mock.Mock(return_value=expected))
|
||||
|
||||
res = self.client.send_request(
|
||||
fake_client.FAKE_ACTION_ENDPOINT, 'get',
|
||||
body=fake_client.FAKE_BODY,
|
||||
query=fake_client.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
|
||||
self.assertEqual(expected, res)
|
||||
mock_get_records.assert_called_once_with(
|
||||
fake_client.FAKE_ACTION_ENDPOINT,
|
||||
fake_client.FAKE_HTTP_QUERY, False, 10000)
|
||||
|
||||
def test_send_request_post(self):
|
||||
expected = (201, 'fake_response')
|
||||
mock_invoke = self.mock_object(
|
||||
self.client.connection, 'invoke_successfully',
|
||||
mock.Mock(return_value=expected))
|
||||
|
||||
res = self.client.send_request(
|
||||
fake_client.FAKE_ACTION_ENDPOINT, 'post',
|
||||
body=fake_client.FAKE_BODY,
|
||||
query=fake_client.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
|
||||
self.assertEqual(expected[1], res)
|
||||
mock_invoke.assert_called_once_with(
|
||||
fake_client.FAKE_ACTION_ENDPOINT, 'post',
|
||||
body=fake_client.FAKE_BODY,
|
||||
query=fake_client.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
|
||||
def test_send_request_wait(self):
|
||||
expected = (202, fake_client.JOB_RESPONSE_REST)
|
||||
mock_invoke = self.mock_object(
|
||||
self.client.connection, 'invoke_successfully',
|
||||
mock.Mock(return_value=expected))
|
||||
|
||||
mock_wait = self.mock_object(
|
||||
self.client, '_wait_job_result',
|
||||
mock.Mock(return_value=expected[1]))
|
||||
|
||||
res = self.client.send_request(
|
||||
fake_client.FAKE_ACTION_ENDPOINT, 'post',
|
||||
body=fake_client.FAKE_BODY,
|
||||
query=fake_client.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
|
||||
self.assertEqual(expected[1], res)
|
||||
mock_invoke.assert_called_once_with(
|
||||
fake_client.FAKE_ACTION_ENDPOINT, 'post',
|
||||
body=fake_client.FAKE_BODY,
|
||||
query=fake_client.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
mock_wait.assert_called_once_with(
|
||||
expected[1]['job']['_links']['self']['href'][4:])
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_get_records(self, enable_tunneling):
|
||||
api_responses = [
|
||||
(200, fake_client.VOLUME_GET_ITER_RESPONSE_REST_PAGE),
|
||||
(200, fake_client.VOLUME_GET_ITER_RESPONSE_REST_PAGE),
|
||||
(200, fake_client.VOLUME_GET_ITER_RESPONSE_REST_LAST_PAGE),
|
||||
]
|
||||
|
||||
mock_invoke = self.mock_object(
|
||||
self.client.connection, 'invoke_successfully',
|
||||
side_effect=copy.deepcopy(api_responses))
|
||||
|
||||
query = {
|
||||
'fields': 'name'
|
||||
}
|
||||
|
||||
result = self.client.get_records(
|
||||
'/storage/volumes/', query=query,
|
||||
enable_tunneling=enable_tunneling,
|
||||
max_page_length=10)
|
||||
|
||||
num_records = result['num_records']
|
||||
self.assertEqual(28, num_records)
|
||||
self.assertEqual(28, len(result['records']))
|
||||
|
||||
expected_records = []
|
||||
expected_records.extend(api_responses[0][1]['records'])
|
||||
expected_records.extend(api_responses[1][1]['records'])
|
||||
expected_records.extend(api_responses[2][1]['records'])
|
||||
|
||||
self.assertEqual(expected_records, result['records'])
|
||||
|
||||
next_tag = result.get('next')
|
||||
self.assertIsNone(next_tag)
|
||||
|
||||
expected_query = copy.deepcopy(query)
|
||||
expected_query['max_records'] = 10
|
||||
|
||||
next_url_1 = api_responses[0][1]['_links']['next']['href'][4:]
|
||||
next_url_2 = api_responses[1][1]['_links']['next']['href'][4:]
|
||||
|
||||
mock_invoke.assert_has_calls([
|
||||
mock.call('/storage/volumes/', 'get', query=expected_query,
|
||||
enable_tunneling=enable_tunneling),
|
||||
mock.call(next_url_1, 'get', query=None,
|
||||
enable_tunneling=enable_tunneling),
|
||||
mock.call(next_url_2, 'get', query=None,
|
||||
enable_tunneling=enable_tunneling),
|
||||
])
|
||||
|
||||
def test_get_records_single_page(self):
|
||||
|
||||
api_response = (
|
||||
200, fake_client.VOLUME_GET_ITER_RESPONSE_REST_LAST_PAGE)
|
||||
mock_invoke = self.mock_object(self.client.connection,
|
||||
'invoke_successfully',
|
||||
return_value=api_response)
|
||||
|
||||
query = {
|
||||
'fields': 'name'
|
||||
}
|
||||
|
||||
result = self.client.get_records(
|
||||
'/storage/volumes/', query=query, max_page_length=10)
|
||||
|
||||
num_records = result['num_records']
|
||||
self.assertEqual(8, num_records)
|
||||
self.assertEqual(8, len(result['records']))
|
||||
|
||||
next_tag = result.get('next')
|
||||
self.assertIsNone(next_tag)
|
||||
|
||||
args = copy.deepcopy(query)
|
||||
args['max_records'] = 10
|
||||
|
||||
mock_invoke.assert_has_calls([
|
||||
mock.call('/storage/volumes/', 'get', query=args,
|
||||
enable_tunneling=True),
|
||||
])
|
||||
|
||||
def test_get_records_not_found(self):
|
||||
|
||||
api_response = (200, fake_client.NO_RECORDS_RESPONSE_REST)
|
||||
mock_invoke = self.mock_object(self.client.connection,
|
||||
'invoke_successfully',
|
||||
return_value=api_response)
|
||||
|
||||
result = self.client.get_records('/storage/volumes/')
|
||||
|
||||
num_records = result['num_records']
|
||||
self.assertEqual(0, num_records)
|
||||
self.assertEqual(0, len(result['records']))
|
||||
|
||||
args = {
|
||||
'max_records': client_cmode_rest.DEFAULT_MAX_PAGE_LENGTH
|
||||
}
|
||||
|
||||
mock_invoke.assert_has_calls([
|
||||
mock.call('/storage/volumes/', 'get', query=args,
|
||||
enable_tunneling=True),
|
||||
])
|
||||
|
||||
def test_get_records_timeout(self):
|
||||
# To simulate timeout, max_records is 30, but the API returns less
|
||||
# records and fill the 'next url' pointing to the next page.
|
||||
max_records = 30
|
||||
api_responses = [
|
||||
(200, fake_client.VOLUME_GET_ITER_RESPONSE_REST_PAGE),
|
||||
(200, fake_client.VOLUME_GET_ITER_RESPONSE_REST_PAGE),
|
||||
(200, fake_client.VOLUME_GET_ITER_RESPONSE_REST_LAST_PAGE),
|
||||
]
|
||||
|
||||
mock_invoke = self.mock_object(
|
||||
self.client.connection, 'invoke_successfully',
|
||||
side_effect=copy.deepcopy(api_responses))
|
||||
|
||||
query = {
|
||||
'fields': 'name'
|
||||
}
|
||||
|
||||
result = self.client.get_records(
|
||||
'/storage/volumes/', query=query, max_page_length=max_records)
|
||||
|
||||
num_records = result['num_records']
|
||||
self.assertEqual(28, num_records)
|
||||
self.assertEqual(28, len(result['records']))
|
||||
|
||||
expected_records = []
|
||||
expected_records.extend(api_responses[0][1]['records'])
|
||||
expected_records.extend(api_responses[1][1]['records'])
|
||||
expected_records.extend(api_responses[2][1]['records'])
|
||||
|
||||
self.assertEqual(expected_records, result['records'])
|
||||
|
||||
next_tag = result.get('next', None)
|
||||
self.assertIsNone(next_tag)
|
||||
|
||||
args1 = copy.deepcopy(query)
|
||||
args1['max_records'] = max_records
|
||||
|
||||
next_url_1 = api_responses[0][1]['_links']['next']['href'][4:]
|
||||
next_url_2 = api_responses[1][1]['_links']['next']['href'][4:]
|
||||
|
||||
mock_invoke.assert_has_calls([
|
||||
mock.call('/storage/volumes/', 'get', query=args1,
|
||||
enable_tunneling=True),
|
||||
mock.call(next_url_1, 'get', query=None, enable_tunneling=True),
|
||||
mock.call(next_url_2, 'get', query=None, enable_tunneling=True),
|
||||
])
|
||||
@@ -21,6 +21,7 @@ from cinder import exception
|
||||
from cinder.tests.unit import test
|
||||
from cinder.tests.unit.volume.drivers.netapp.dataontap.utils import fakes
|
||||
from cinder.volume.drivers.netapp.dataontap.client import client_cmode
|
||||
from cinder.volume.drivers.netapp.dataontap.client import client_cmode_rest
|
||||
from cinder.volume.drivers.netapp.dataontap.utils import utils
|
||||
|
||||
CONF = cfg.CONF
|
||||
@@ -33,6 +34,8 @@ class NetAppCDOTDataMotionTestCase(test.TestCase):
|
||||
super(NetAppCDOTDataMotionTestCase, self).setUp()
|
||||
self.backend = 'backend1'
|
||||
self.mock_cmode_client = self.mock_object(client_cmode, 'Client')
|
||||
self.mock_cmode_rest_client = self.mock_object(
|
||||
client_cmode_rest, 'RestClient')
|
||||
self.config = fakes.get_fake_cmode_config(self.backend)
|
||||
CONF.set_override('volume_backend_name', self.backend,
|
||||
group=self.backend)
|
||||
@@ -48,6 +51,8 @@ class NetAppCDOTDataMotionTestCase(test.TestCase):
|
||||
group=self.backend)
|
||||
CONF.set_override('netapp_api_trace_pattern', "fake_regex",
|
||||
group=self.backend)
|
||||
CONF.set_override('netapp_ssl_cert_path', 'fake_ca',
|
||||
group=self.backend)
|
||||
|
||||
def test_get_backend_configuration(self):
|
||||
self.mock_object(utils, 'CONF')
|
||||
@@ -81,18 +86,31 @@ class NetAppCDOTDataMotionTestCase(test.TestCase):
|
||||
utils.get_backend_configuration,
|
||||
self.backend)
|
||||
|
||||
def test_get_client_for_backend(self):
|
||||
@ddt.data(True, False)
|
||||
def test_get_client_for_backend(self, use_legacy):
|
||||
self.config.netapp_use_legacy_client = use_legacy
|
||||
self.mock_object(utils, 'get_backend_configuration',
|
||||
return_value=self.config)
|
||||
|
||||
utils.get_client_for_backend(self.backend)
|
||||
|
||||
self.mock_cmode_client.assert_called_once_with(
|
||||
hostname='fake_hostname', password='fake_password',
|
||||
username='fake_user', transport_type='https', port=8866,
|
||||
trace=mock.ANY, vserver=None, api_trace_pattern="fake_regex")
|
||||
if use_legacy:
|
||||
self.mock_cmode_client.assert_called_once_with(
|
||||
hostname='fake_hostname', password='fake_password',
|
||||
username='fake_user', transport_type='https', port=8866,
|
||||
trace=mock.ANY, vserver=None, api_trace_pattern="fake_regex")
|
||||
self.mock_cmode_rest_client.assert_not_called()
|
||||
else:
|
||||
self.mock_cmode_rest_client.assert_called_once_with(
|
||||
hostname='fake_hostname', password='fake_password',
|
||||
username='fake_user', transport_type='https', port=8866,
|
||||
trace=mock.ANY, vserver=None, api_trace_pattern="fake_regex",
|
||||
ssl_cert_path='fake_ca', async_rest_timeout=60)
|
||||
self.mock_cmode_client.assert_not_called()
|
||||
|
||||
def test_get_client_for_backend_with_vserver(self):
|
||||
@ddt.data(True, False)
|
||||
def test_get_client_for_backend_with_vserver(self, use_legacy):
|
||||
self.config.netapp_use_legacy_client = use_legacy
|
||||
self.mock_object(utils, 'get_backend_configuration',
|
||||
return_value=self.config)
|
||||
|
||||
@@ -101,11 +119,21 @@ class NetAppCDOTDataMotionTestCase(test.TestCase):
|
||||
|
||||
utils.get_client_for_backend(self.backend)
|
||||
|
||||
self.mock_cmode_client.assert_called_once_with(
|
||||
hostname='fake_hostname', password='fake_password',
|
||||
username='fake_user', transport_type='https', port=8866,
|
||||
trace=mock.ANY, vserver='fake_vserver',
|
||||
api_trace_pattern="fake_regex")
|
||||
if use_legacy:
|
||||
self.mock_cmode_client.assert_called_once_with(
|
||||
hostname='fake_hostname', password='fake_password',
|
||||
username='fake_user', transport_type='https', port=8866,
|
||||
trace=mock.ANY, vserver='fake_vserver',
|
||||
api_trace_pattern="fake_regex")
|
||||
self.mock_cmode_rest_client.assert_not_called()
|
||||
else:
|
||||
self.mock_cmode_rest_client.assert_called_once_with(
|
||||
hostname='fake_hostname', password='fake_password',
|
||||
username='fake_user', transport_type='https', port=8866,
|
||||
trace=mock.ANY, vserver='fake_vserver',
|
||||
api_trace_pattern="fake_regex", ssl_cert_path='fake_ca',
|
||||
async_rest_timeout = 60)
|
||||
self.mock_cmode_client.assert_not_called()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
|
||||
@@ -25,7 +25,12 @@ from eventlet import greenthread
|
||||
from eventlet import semaphore
|
||||
from lxml import etree
|
||||
from oslo_log import log as logging
|
||||
from oslo_serialization import jsonutils
|
||||
from oslo_utils import netutils
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests import auth
|
||||
from requests.packages.urllib3.util.retry import Retry
|
||||
import six
|
||||
from six.moves import urllib
|
||||
|
||||
@@ -37,6 +42,7 @@ from cinder.volume import volume_utils
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# ZAPI API error codes.
|
||||
EAPIERROR = '13001'
|
||||
EAPIPRIVILEGE = '13003'
|
||||
EAPINOTFOUND = '13005'
|
||||
@@ -549,6 +555,12 @@ class NaApiError(Exception):
|
||||
return 'NetApp API failed. Reason - %s:%s' % (self.code, self.message)
|
||||
|
||||
|
||||
class NaRetryableError(NaApiError):
|
||||
def __str__(self, *args, **kwargs):
|
||||
return 'NetApp API failed. Try again. Reason - %s:%s' % (
|
||||
self.code, self.message)
|
||||
|
||||
|
||||
class SSHUtil(object):
|
||||
"""Encapsulates connection logic and command execution for SSH client."""
|
||||
|
||||
@@ -628,3 +640,227 @@ class SSHUtil(object):
|
||||
if wait_time > timeout:
|
||||
LOG.debug("Timeout exceeded while waiting for exit status.")
|
||||
break
|
||||
|
||||
|
||||
# REST API error codes.
|
||||
REST_UNAUTHORIZED = '6'
|
||||
|
||||
|
||||
class RestNaServer(object):
|
||||
|
||||
TRANSPORT_TYPE_HTTP = 'http'
|
||||
TRANSPORT_TYPE_HTTPS = 'https'
|
||||
HTTP_PORT = '80'
|
||||
HTTPS_PORT = '443'
|
||||
|
||||
TRANSPORT_PORT = {
|
||||
TRANSPORT_TYPE_HTTP: HTTP_PORT,
|
||||
TRANSPORT_TYPE_HTTPS: HTTPS_PORT
|
||||
}
|
||||
|
||||
def __init__(self, host, transport_type=TRANSPORT_TYPE_HTTP,
|
||||
ssl_cert_path=None, username=None, password=None, port=None,
|
||||
api_trace_pattern=None):
|
||||
self._host = host
|
||||
self.set_transport_type(transport_type)
|
||||
self.set_port(port=port)
|
||||
self._username = username
|
||||
self._password = password
|
||||
|
||||
if api_trace_pattern is not None:
|
||||
na_utils.setup_api_trace_pattern(api_trace_pattern)
|
||||
|
||||
if ssl_cert_path is not None:
|
||||
self._ssl_verify = ssl_cert_path
|
||||
else:
|
||||
# Note(felipe_rodrigues): it will verify with the Mozila CA roots,
|
||||
# given by certifi package.
|
||||
self._ssl_verify = True
|
||||
|
||||
self._api_version = None
|
||||
self._api_major_version = None
|
||||
self._api_minor_version = None
|
||||
self._ontap_version = None
|
||||
self._timeout = None
|
||||
|
||||
LOG.debug('Using REST with NetApp controller: %s', self._host)
|
||||
|
||||
def set_transport_type(self, transport_type):
|
||||
"""Set the transport type protocol for API.
|
||||
|
||||
Supports http and https transport types.
|
||||
"""
|
||||
if transport_type is None or transport_type.lower() not in (
|
||||
RestNaServer.TRANSPORT_TYPE_HTTP,
|
||||
RestNaServer.TRANSPORT_TYPE_HTTPS):
|
||||
raise ValueError('Unsupported transport type')
|
||||
self._protocol = transport_type.lower()
|
||||
|
||||
def get_transport_type(self):
|
||||
"""Get the transport type protocol."""
|
||||
return self._protocol
|
||||
|
||||
def set_api_version(self, major, minor):
|
||||
"""Set the API version."""
|
||||
try:
|
||||
self._api_major_version = int(major)
|
||||
self._api_minor_version = int(minor)
|
||||
self._api_version = str(major) + "." + str(minor)
|
||||
except ValueError:
|
||||
raise ValueError('Major and minor versions must be integers')
|
||||
|
||||
def get_api_version(self):
|
||||
"""Gets the API version tuple."""
|
||||
if not self._api_version:
|
||||
return None
|
||||
return (self._api_major_version, self._api_minor_version)
|
||||
|
||||
def set_ontap_version(self, ontap_version):
|
||||
"""Set the ONTAP version."""
|
||||
self._ontap_version = ontap_version
|
||||
|
||||
def get_ontap_version(self):
|
||||
"""Gets the ONTAP version."""
|
||||
return self._ontap_version
|
||||
|
||||
def set_port(self, port=None):
|
||||
"""Set the ONTAP port, if not informed, set with default one."""
|
||||
if port is None and self._protocol in RestNaServer.TRANSPORT_PORT:
|
||||
self._port = RestNaServer.TRANSPORT_PORT[self._protocol]
|
||||
else:
|
||||
try:
|
||||
int(port)
|
||||
except ValueError:
|
||||
raise ValueError('Port must be integer')
|
||||
self._port = str(port)
|
||||
|
||||
def get_port(self):
|
||||
"""Get the server communication port."""
|
||||
return self._port
|
||||
|
||||
def set_timeout(self, seconds):
|
||||
"""Sets the timeout in seconds."""
|
||||
try:
|
||||
self._timeout = int(seconds)
|
||||
except ValueError:
|
||||
raise ValueError('timeout in seconds must be integer')
|
||||
|
||||
def get_timeout(self):
|
||||
"""Gets the timeout in seconds if set."""
|
||||
return self._timeout
|
||||
|
||||
def set_vserver(self, vserver):
|
||||
"""Set the vserver to use if tunneling gets enabled."""
|
||||
self._vserver = vserver
|
||||
|
||||
def get_vserver(self):
|
||||
"""Get the vserver to use in tunneling."""
|
||||
return self._vserver
|
||||
|
||||
def __str__(self):
|
||||
"""Gets a representation of the client."""
|
||||
return "server: %s" % (self._host)
|
||||
|
||||
def _get_request_method(self, method, session):
|
||||
"""Returns the request method to be used in the REST call."""
|
||||
|
||||
request_methods = {
|
||||
'post': session.post,
|
||||
'get': session.get,
|
||||
'put': session.put,
|
||||
'delete': session.delete,
|
||||
'patch': session.patch,
|
||||
}
|
||||
return request_methods[method]
|
||||
|
||||
def _add_query_params_to_url(self, url, query):
|
||||
"""Populates the URL with specified filters."""
|
||||
filters = '&'.join([f"{k}={v}" for k, v in query.items()])
|
||||
url += "?" + filters
|
||||
return url
|
||||
|
||||
def _get_base_url(self):
|
||||
"""Get the base URL for REST requests."""
|
||||
host = self._host
|
||||
if ':' in host:
|
||||
host = '[%s]' % host
|
||||
return '%s://%s:%s/api/' % (self._protocol, host, self._port)
|
||||
|
||||
def _build_session(self, headers):
|
||||
"""Builds a session in the client."""
|
||||
self._session = requests.Session()
|
||||
|
||||
# NOTE(felipe_rodrigues): request resilient of temporary network
|
||||
# failures (like name resolution failure), retrying until 5 times.
|
||||
max_retries = Retry(total=5, connect=5, read=2, backoff_factor=1)
|
||||
adapter = HTTPAdapter(max_retries=max_retries)
|
||||
self._session.mount('%s://' % self._protocol, adapter)
|
||||
|
||||
self._session.auth = self._create_basic_auth_handler()
|
||||
self._session.verify = self._ssl_verify
|
||||
self._session.headers = headers
|
||||
|
||||
def _build_headers(self, enable_tunneling):
|
||||
"""Build and return headers for a REST request."""
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if enable_tunneling:
|
||||
headers["X-Dot-SVM-Name"] = self.get_vserver()
|
||||
|
||||
return headers
|
||||
|
||||
def _create_basic_auth_handler(self):
|
||||
"""Creates and returns a basic HTTP auth handler."""
|
||||
return auth.HTTPBasicAuth(self._username, self._password)
|
||||
|
||||
@volume_utils.trace_api(
|
||||
filter_function=na_utils.trace_filter_func_rest_api)
|
||||
def send_http_request(self, method, url, body, headers):
|
||||
"""Invoke the API on the server.
|
||||
|
||||
The passed parameters and returned parameters will be logged if trace
|
||||
feature is on. They are important for debugging purpose.
|
||||
"""
|
||||
data = jsonutils.dumps(body) if body else {}
|
||||
|
||||
self._build_session(headers)
|
||||
request_method = self._get_request_method(method, self._session)
|
||||
|
||||
try:
|
||||
if self._timeout is not None:
|
||||
response = request_method(
|
||||
url, data=data, timeout=self._timeout)
|
||||
else:
|
||||
response = request_method(url, data=data)
|
||||
except requests.HTTPError as e:
|
||||
raise NaApiError(e.errno, e.strerror)
|
||||
except Exception as e:
|
||||
raise NaApiError(message=e)
|
||||
|
||||
code = response.status_code
|
||||
body = jsonutils.loads(response.content) if response.content else {}
|
||||
return code, body
|
||||
|
||||
def invoke_successfully(self, action_url, method, body=None, query=None,
|
||||
enable_tunneling=False):
|
||||
"""Invokes REST API and checks execution status as success."""
|
||||
headers = self._build_headers(enable_tunneling)
|
||||
if query:
|
||||
action_url = self._add_query_params_to_url(action_url, query)
|
||||
url = self._get_base_url() + action_url
|
||||
code, response = self.send_http_request(method, url, body, headers)
|
||||
|
||||
if not response.get('error'):
|
||||
return code, response
|
||||
|
||||
result_error = response.get('error')
|
||||
code = result_error.get('code', 'ESTATUSFAILED')
|
||||
# TODO: add the correct code number for REST not licensed clone error.
|
||||
if code == ESIS_CLONE_NOT_LICENSED:
|
||||
msg = 'Clone operation failed: FlexClone not licensed.'
|
||||
else:
|
||||
msg = (result_error.get('message')
|
||||
or 'Execution status is failed due to unknown reason')
|
||||
raise NaApiError(code, msg)
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
# Copyright (c) 2022 NetApp, Inc. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from oslo_log import log as logging
|
||||
import six
|
||||
|
||||
from cinder.i18n import _
|
||||
from cinder import utils
|
||||
from cinder.volume.drivers.netapp.dataontap.client import api as netapp_api
|
||||
from cinder.volume.drivers.netapp.dataontap.client import client_cmode
|
||||
from cinder.volume.drivers.netapp import utils as na_utils
|
||||
from cinder.volume import volume_utils
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
DEFAULT_MAX_PAGE_LENGTH = 10000
|
||||
ONTAP_SELECT_MODEL = 'FDvM300'
|
||||
ONTAP_C190 = 'C190'
|
||||
HTTP_ACCEPTED = 202
|
||||
|
||||
|
||||
@six.add_metaclass(volume_utils.TraceWrapperMetaclass)
|
||||
class RestClient(object):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
host = kwargs['hostname']
|
||||
username = kwargs['username']
|
||||
password = kwargs['password']
|
||||
api_trace_pattern = kwargs['api_trace_pattern']
|
||||
self.connection = netapp_api.RestNaServer(
|
||||
host=host,
|
||||
transport_type=kwargs['transport_type'],
|
||||
ssl_cert_path=kwargs.pop('ssl_cert_path'),
|
||||
port=kwargs['port'],
|
||||
username=username,
|
||||
password=password,
|
||||
api_trace_pattern=api_trace_pattern)
|
||||
|
||||
self.async_rest_timeout = kwargs.get('async_rest_timeout', 60)
|
||||
|
||||
self.vserver = kwargs.get('vserver')
|
||||
self.connection.set_vserver(self.vserver)
|
||||
|
||||
ontap_version = self.get_ontap_version(cached=False)
|
||||
if ontap_version < (9, 11, 1):
|
||||
msg = _('REST Client can be used only with ONTAP 9.11.1 or upper.')
|
||||
raise na_utils.NetAppDriverException(msg)
|
||||
self.connection.set_ontap_version(ontap_version)
|
||||
|
||||
self.ssh_client = self._init_ssh_client(host, username, password)
|
||||
|
||||
# NOTE(nahimsouza): ZAPI Client is needed to implement the fallback
|
||||
# when a REST method is not supported.
|
||||
self.zapi_client = client_cmode.Client(**kwargs)
|
||||
|
||||
self._init_features()
|
||||
|
||||
def _init_ssh_client(self, host, username, password):
|
||||
return netapp_api.SSHUtil(
|
||||
host=host,
|
||||
username=username,
|
||||
password=password)
|
||||
|
||||
def _init_features(self):
|
||||
self.features = na_utils.Features()
|
||||
|
||||
generation, major, minor = self.get_ontap_version()
|
||||
ontap_version = (generation, major)
|
||||
|
||||
ontap_9_0 = ontap_version >= (9, 0)
|
||||
ontap_9_4 = ontap_version >= (9, 4)
|
||||
ontap_9_5 = ontap_version >= (9, 5)
|
||||
ontap_9_6 = ontap_version >= (9, 6)
|
||||
ontap_9_8 = ontap_version >= (9, 8)
|
||||
ontap_9_9 = ontap_version >= (9, 9)
|
||||
|
||||
nodes_info = self._get_cluster_nodes_info()
|
||||
for node in nodes_info:
|
||||
qos_min_block = False
|
||||
qos_min_nfs = False
|
||||
if node['model'] == ONTAP_SELECT_MODEL:
|
||||
qos_min_block = node['is_all_flash_select'] and ontap_9_6
|
||||
qos_min_nfs = qos_min_block
|
||||
elif ONTAP_C190 in node['model']:
|
||||
qos_min_block = node['is_all_flash'] and ontap_9_6
|
||||
qos_min_nfs = qos_min_block
|
||||
else:
|
||||
qos_min_block = node['is_all_flash'] and ontap_9_0
|
||||
qos_min_nfs = node['is_all_flash'] and ontap_9_0
|
||||
|
||||
qos_name = na_utils.qos_min_feature_name(True, node['name'])
|
||||
self.features.add_feature(qos_name, supported=qos_min_nfs)
|
||||
qos_name = na_utils.qos_min_feature_name(False, node['name'])
|
||||
self.features.add_feature(qos_name, supported=qos_min_block)
|
||||
|
||||
self.features.add_feature('SNAPMIRROR_V2', supported=ontap_9_0)
|
||||
self.features.add_feature('USER_CAPABILITY_LIST',
|
||||
supported=ontap_9_0)
|
||||
self.features.add_feature('SYSTEM_METRICS', supported=ontap_9_0)
|
||||
self.features.add_feature('CLONE_SPLIT_STATUS', supported=ontap_9_0)
|
||||
self.features.add_feature('FAST_CLONE_DELETE', supported=ontap_9_0)
|
||||
self.features.add_feature('SYSTEM_CONSTITUENT_METRICS',
|
||||
supported=ontap_9_0)
|
||||
self.features.add_feature('ADVANCED_DISK_PARTITIONING',
|
||||
supported=ontap_9_0)
|
||||
self.features.add_feature('BACKUP_CLONE_PARAM', supported=ontap_9_0)
|
||||
self.features.add_feature('CLUSTER_PEER_POLICY', supported=ontap_9_0)
|
||||
self.features.add_feature('FLEXVOL_ENCRYPTION', supported=ontap_9_0)
|
||||
self.features.add_feature('FLEXGROUP', supported=ontap_9_8)
|
||||
self.features.add_feature('FLEXGROUP_CLONE_FILE',
|
||||
supported=ontap_9_9)
|
||||
|
||||
self.features.add_feature('ADAPTIVE_QOS', supported=ontap_9_4)
|
||||
self.features.add_feature('ADAPTIVE_QOS_BLOCK_SIZE',
|
||||
supported=ontap_9_5)
|
||||
self.features.add_feature('ADAPTIVE_QOS_EXPECTED_IOPS_ALLOCATION',
|
||||
supported=ontap_9_5)
|
||||
|
||||
LOG.info('ONTAP Version: %(generation)s.%(major)s.%(minor)s',
|
||||
{'generation': ontap_version[0], 'major': ontap_version[1],
|
||||
'minor': minor})
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""If method is not implemented for REST, try to call the ZAPI."""
|
||||
LOG.debug("The %s call is not supported for REST, falling back to "
|
||||
"ZAPI.", name)
|
||||
# Don't use self.zapi_client to avoid reentrant call to __getattr__()
|
||||
zapi_client = object.__getattribute__(self, 'zapi_client')
|
||||
return getattr(zapi_client, name)
|
||||
|
||||
def _wait_job_result(self, job_url):
|
||||
"""Waits for a job to finish."""
|
||||
|
||||
interval = 2
|
||||
retries = (self.async_rest_timeout / interval)
|
||||
|
||||
@utils.retry(netapp_api.NaRetryableError, interval=interval,
|
||||
retries=retries, backoff_rate=1)
|
||||
def _waiter():
|
||||
response = self.send_request(job_url, 'get',
|
||||
enable_tunneling=False)
|
||||
|
||||
job_state = response.get('state')
|
||||
if job_state == 'success':
|
||||
return response
|
||||
elif job_state == 'failure':
|
||||
message = response['error']['message']
|
||||
code = response['error']['code']
|
||||
raise netapp_api.NaApiError(message=message, code=code)
|
||||
|
||||
msg_args = {'job': job_url, 'state': job_state}
|
||||
LOG.debug("Job %(job)s has not finished: %(state)s", msg_args)
|
||||
raise netapp_api.NaRetryableError(message='Job is running.')
|
||||
|
||||
try:
|
||||
return _waiter()
|
||||
except netapp_api.NaRetryableError:
|
||||
msg = _("Job %s did not reach the expected state. Retries "
|
||||
"exhausted. Aborting.") % job_url
|
||||
raise na_utils.NetAppDriverException(msg)
|
||||
|
||||
def send_request(self, action_url, method, body=None, query=None,
|
||||
enable_tunneling=True,
|
||||
max_page_length=DEFAULT_MAX_PAGE_LENGTH,
|
||||
wait_on_accepted=True):
|
||||
|
||||
"""Sends REST request to ONTAP.
|
||||
|
||||
:param action_url: action URL for the request
|
||||
:param method: HTTP method for the request ('get', 'post', 'put',
|
||||
'delete' or 'patch')
|
||||
:param body: dict of arguments to be passed as request body
|
||||
:param query: dict of arguments to be passed as query string
|
||||
:param enable_tunneling: enable tunneling to the ONTAP host
|
||||
:param max_page_length: size of the page during pagination
|
||||
:param wait_on_accepted: if True, wait until the job finishes when
|
||||
HTTP code 202 (Accepted) is returned
|
||||
|
||||
:returns: parsed REST response
|
||||
"""
|
||||
|
||||
response = None
|
||||
|
||||
if method == 'get':
|
||||
response = self.get_records(
|
||||
action_url, query, enable_tunneling, max_page_length)
|
||||
else:
|
||||
code, response = self.connection.invoke_successfully(
|
||||
action_url, method, body=body, query=query,
|
||||
enable_tunneling=enable_tunneling)
|
||||
|
||||
if code == HTTP_ACCEPTED and wait_on_accepted:
|
||||
# get job URL and discard '/api'
|
||||
job_url = response['job']['_links']['self']['href'][4:]
|
||||
response = self._wait_job_result(job_url)
|
||||
|
||||
return response
|
||||
|
||||
def get_records(self, action_url, query=None, enable_tunneling=True,
|
||||
max_page_length=DEFAULT_MAX_PAGE_LENGTH):
|
||||
"""Retrieves ONTAP resources using pagination REST request.
|
||||
|
||||
:param action_url: action URL for the request
|
||||
:param query: dict of arguments to be passed as query string
|
||||
:param enable_tunneling: enable tunneling to the ONTAP host
|
||||
:param max_page_length: size of the page during pagination
|
||||
|
||||
:returns: dict containing records and num_records
|
||||
"""
|
||||
|
||||
# Initialize query variable if it is None
|
||||
query = query if query else {}
|
||||
query['max_records'] = max_page_length
|
||||
|
||||
_, response = self.connection.invoke_successfully(
|
||||
action_url, 'get', query=query,
|
||||
enable_tunneling=enable_tunneling)
|
||||
|
||||
# NOTE(nahimsouza): if all records are returned in the first call,
|
||||
# 'next_url' will be None.
|
||||
next_url = response.get('_links', {}).get('next', {}).get('href')
|
||||
next_url = next_url[4:] if next_url else None # discard '/api'
|
||||
|
||||
# Get remaining pages, saving data into first page
|
||||
while next_url:
|
||||
# NOTE(nahimsouza): clean the 'query', because the parameters are
|
||||
# already included in 'next_url'.
|
||||
_, next_response = self.connection.invoke_successfully(
|
||||
next_url, 'get', query=None,
|
||||
enable_tunneling=enable_tunneling)
|
||||
|
||||
response['num_records'] += next_response.get('num_records', 0)
|
||||
response['records'].extend(next_response.get('records'))
|
||||
|
||||
next_url = (
|
||||
next_response.get('_links', {}).get('next', {}).get('href'))
|
||||
next_url = next_url[4:] if next_url else None # discard '/api'
|
||||
|
||||
return response
|
||||
|
||||
def get_ontap_version(self, cached=True):
|
||||
"""Gets the ONTAP version as tuple."""
|
||||
|
||||
if cached:
|
||||
return self.connection.get_ontap_version()
|
||||
|
||||
query = {
|
||||
'fields': 'version'
|
||||
}
|
||||
|
||||
response = self.send_request('/cluster/', 'get', query=query)
|
||||
|
||||
version = (response['version']['generation'],
|
||||
response['version']['major'],
|
||||
response['version']['minor'])
|
||||
|
||||
return version
|
||||
|
||||
def _get_cluster_nodes_info(self):
|
||||
"""Return a list of models of the nodes in the cluster."""
|
||||
query_args = {'fields': 'model,'
|
||||
'name,'
|
||||
'is_all_flash_optimized,'
|
||||
'is_all_flash_select_optimized'}
|
||||
|
||||
nodes = []
|
||||
try:
|
||||
result = self.send_request('cluster/nodes', 'get',
|
||||
query=query_args,
|
||||
enable_tunneling=False)
|
||||
|
||||
for record in result['records']:
|
||||
node = {
|
||||
'model': record['model'],
|
||||
'name': record['name'],
|
||||
'is_all_flash':
|
||||
record['is_all_flash_optimized'],
|
||||
'is_all_flash_select':
|
||||
record['is_all_flash_select_optimized']
|
||||
}
|
||||
nodes.append(node)
|
||||
except netapp_api.NaApiError as e:
|
||||
if e.code == netapp_api.REST_UNAUTHORIZED:
|
||||
LOG.debug('Cluster nodes can only be collected with '
|
||||
'cluster scoped credentials.')
|
||||
else:
|
||||
LOG.exception('Failed to get the cluster nodes.')
|
||||
|
||||
return nodes
|
||||
@@ -26,6 +26,7 @@ from cinder.i18n import _
|
||||
from cinder.volume import configuration
|
||||
from cinder.volume import driver
|
||||
from cinder.volume.drivers.netapp.dataontap.client import client_cmode
|
||||
from cinder.volume.drivers.netapp.dataontap.client import client_cmode_rest
|
||||
from cinder.volume.drivers.netapp import options as na_opts
|
||||
from cinder.volume import volume_utils
|
||||
|
||||
@@ -65,15 +66,28 @@ def get_client_for_backend(backend_name, vserver_name=None):
|
||||
"""Get a cDOT API client for a specific backend."""
|
||||
|
||||
config = get_backend_configuration(backend_name)
|
||||
client = client_cmode.Client(
|
||||
transport_type=config.netapp_transport_type,
|
||||
username=config.netapp_login,
|
||||
password=config.netapp_password,
|
||||
hostname=config.netapp_server_hostname,
|
||||
port=config.netapp_server_port,
|
||||
vserver=vserver_name or config.netapp_vserver,
|
||||
trace=volume_utils.TRACE_API,
|
||||
api_trace_pattern=config.netapp_api_trace_pattern)
|
||||
if config.netapp_use_legacy_client:
|
||||
client = client_cmode.Client(
|
||||
transport_type=config.netapp_transport_type,
|
||||
username=config.netapp_login,
|
||||
password=config.netapp_password,
|
||||
hostname=config.netapp_server_hostname,
|
||||
port=config.netapp_server_port,
|
||||
vserver=vserver_name or config.netapp_vserver,
|
||||
trace=volume_utils.TRACE_API,
|
||||
api_trace_pattern=config.netapp_api_trace_pattern)
|
||||
else:
|
||||
client = client_cmode_rest.RestClient(
|
||||
transport_type=config.netapp_transport_type,
|
||||
ssl_cert_path=config.netapp_ssl_cert_path,
|
||||
username=config.netapp_login,
|
||||
password=config.netapp_password,
|
||||
hostname=config.netapp_server_hostname,
|
||||
port=config.netapp_server_port,
|
||||
vserver=vserver_name or config.netapp_vserver,
|
||||
trace=volume_utils.TRACE_API,
|
||||
api_trace_pattern=config.netapp_api_trace_pattern,
|
||||
async_rest_timeout=config.netapp_async_rest_timeout)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@@ -50,14 +50,34 @@ netapp_connection_opts = [
|
||||
cfg.IntOpt('netapp_server_port',
|
||||
help=('The TCP port to use for communication with the storage '
|
||||
'system or proxy server. If not specified, Data ONTAP '
|
||||
'drivers will use 80 for HTTP and 443 for HTTPS.')), ]
|
||||
'drivers will use 80 for HTTP and 443 for HTTPS.')),
|
||||
cfg.BoolOpt('netapp_use_legacy_client',
|
||||
default=True,
|
||||
help=('Select which ONTAP client to use for retrieving and '
|
||||
'modifying data on the storage. The legacy client '
|
||||
'relies on ZAPI calls. If set to False, the new REST '
|
||||
'client is used, which runs REST calls if supported, '
|
||||
'otherwise falls back to the equivalent ZAPI call.')),
|
||||
cfg.IntOpt('netapp_async_rest_timeout',
|
||||
min=60,
|
||||
default=60, # One minute
|
||||
help='The maximum time in seconds to wait for completing a '
|
||||
'REST asynchronous operation.'), ]
|
||||
|
||||
netapp_transport_opts = [
|
||||
cfg.StrOpt('netapp_transport_type',
|
||||
default='http',
|
||||
choices=['http', 'https'],
|
||||
help=('The transport protocol used when communicating with '
|
||||
'the storage system or proxy server.')), ]
|
||||
'the storage system or proxy server.')),
|
||||
cfg.StrOpt('netapp_ssl_cert_path',
|
||||
help=("The path to a CA_BUNDLE file or directory with "
|
||||
"certificates of trusted CA. If set to a directory, it "
|
||||
"must have been processed using the c_rehash utility "
|
||||
"supplied with OpenSSL. If not informed, it will use the "
|
||||
"Mozilla's carefully curated collection of Root "
|
||||
"Certificates for validating the trustworthiness of SSL "
|
||||
"certificates. Only applies with new REST client.")), ]
|
||||
|
||||
netapp_basicauth_opts = [
|
||||
cfg.StrOpt('netapp_login',
|
||||
|
||||
@@ -184,6 +184,13 @@ def trace_filter_func_api(all_args):
|
||||
return re.match(API_TRACE_PATTERN, api_name) is not None
|
||||
|
||||
|
||||
def trace_filter_func_rest_api(all_args):
|
||||
url = all_args.get('url')
|
||||
if url is None:
|
||||
return True
|
||||
return re.match(API_TRACE_PATTERN, url) is not None
|
||||
|
||||
|
||||
def round_down(value, precision='0.00'):
|
||||
return float(decimal.Decimal(str(value)).quantize(
|
||||
decimal.Decimal(precision), rounding=decimal.ROUND_DOWN))
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
NetApp drivers: NFS, iSCSI and FCP drivers have now the option to request
|
||||
ONTAP operations through REST API. The new option `netapp_use_legacy_client`
|
||||
switch between the old ZAPI client approach and new REST client. It is
|
||||
default to `True`, meaning that the drivers will keep working as before
|
||||
using ZAPI operations. If desired, this option can be set to `False` connecting
|
||||
with new REST client that performs REST API operations if it is available,
|
||||
otherwise falls back to ZAPI.
|
||||
Reference in New Issue
Block a user