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:
Nahim Alves de Souza
2021-11-18 17:48:50 +00:00
parent c12c690271
commit 4775ca9370
11 changed files with 1390 additions and 28 deletions

View File

@@ -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"
}
}
}
}

View File

@@ -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__)

View File

@@ -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',

View File

@@ -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),
])

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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',

View File

@@ -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))

View File

@@ -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.