Merge "NetApp ONTAP: Add REST Client for ONTAP"
This commit is contained in:
commit
c80477b62b
|
@ -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.
|
Loading…
Reference in New Issue