Merge "NetApp ONTAP: Implemented REST transition Client"
This commit is contained in:
commit
d94d1ae7bd
@ -0,0 +1,233 @@
|
||||
# Copyright (c) 2023 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 http import client as http_client
|
||||
|
||||
from oslo_log import log
|
||||
|
||||
from manila import exception
|
||||
from manila.i18n import _
|
||||
from manila.share.drivers.netapp.dataontap.client import client_base
|
||||
from manila.share.drivers.netapp.dataontap.client import client_cmode
|
||||
from manila.share.drivers.netapp.dataontap.client import rest_api as netapp_api
|
||||
from manila.share.drivers.netapp import utils as na_utils
|
||||
from manila import utils
|
||||
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
DEFAULT_MAX_PAGE_LENGTH = 10000
|
||||
|
||||
|
||||
class NetAppRestClient(object):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
self.connection = netapp_api.RestNaServer(
|
||||
host=kwargs['hostname'],
|
||||
transport_type=kwargs['transport_type'],
|
||||
ssl_cert_path=kwargs['ssl_cert_path'],
|
||||
port=kwargs['port'],
|
||||
username=kwargs['username'],
|
||||
password=kwargs['password'],
|
||||
trace=kwargs.get('trace', False),
|
||||
api_trace_pattern=kwargs.get('api_trace_pattern',
|
||||
na_utils.API_TRACE_PATTERN))
|
||||
|
||||
self.async_rest_timeout = kwargs.get('async_rest_timeout', 60)
|
||||
|
||||
self.vserver = kwargs.get('vserver', None)
|
||||
|
||||
self.connection.set_vserver(self.vserver)
|
||||
|
||||
ontap_version = self.get_ontap_version(cached=False)
|
||||
if ontap_version['version-tuple'] < (9, 11, 1):
|
||||
msg = _('This driver can communicate with ONTAP via REST APIs '
|
||||
'exclusively only when paired with a NetApp ONTAP storage '
|
||||
'system running release 9.11.1 or newer. '
|
||||
'To use ZAPI and supported REST APIs instead, '
|
||||
'set "netapp_use_legacy_client" to True.')
|
||||
raise exception.NetAppException(msg)
|
||||
self.connection.set_ontap_version(ontap_version)
|
||||
|
||||
# NOTE(nahimsouza): ZAPI Client is needed to implement the fallback
|
||||
# when a REST method is not supported.
|
||||
self.zapi_client = client_cmode.NetAppCmodeClient(**kwargs)
|
||||
|
||||
self._init_features()
|
||||
|
||||
def _init_features(self):
|
||||
"""Initialize feature support map."""
|
||||
self.features = client_base.Features()
|
||||
|
||||
# NOTE(felipe_rodrigues): REST client only runs with ONTAP 9.11.1 or
|
||||
# upper, so all features below are supported with this client.
|
||||
self.features.add_feature('SNAPMIRROR_V2', supported=True)
|
||||
self.features.add_feature('SYSTEM_METRICS', supported=True)
|
||||
self.features.add_feature('SYSTEM_CONSTITUENT_METRICS',
|
||||
supported=True)
|
||||
self.features.add_feature('BROADCAST_DOMAINS', supported=True)
|
||||
self.features.add_feature('IPSPACES', supported=True)
|
||||
self.features.add_feature('SUBNETS', supported=True)
|
||||
self.features.add_feature('CLUSTER_PEER_POLICY', supported=True)
|
||||
self.features.add_feature('ADVANCED_DISK_PARTITIONING',
|
||||
supported=True)
|
||||
self.features.add_feature('KERBEROS_VSERVER', supported=True)
|
||||
self.features.add_feature('FLEXVOL_ENCRYPTION', supported=True)
|
||||
self.features.add_feature('SVM_DR', supported=True)
|
||||
self.features.add_feature('ADAPTIVE_QOS', supported=True)
|
||||
self.features.add_feature('TRANSFER_LIMIT_NFS_CONFIG',
|
||||
supported=True)
|
||||
self.features.add_feature('CIFS_DC_ADD_SKIP_CHECK',
|
||||
supported=True)
|
||||
self.features.add_feature('LDAP_LDAP_SERVERS',
|
||||
supported=True)
|
||||
self.features.add_feature('FLEXGROUP', supported=True)
|
||||
self.features.add_feature('FLEXGROUP_FAN_OUT', supported=True)
|
||||
self.features.add_feature('SVM_MIGRATE', supported=True)
|
||||
|
||||
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):
|
||||
|
||||
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.NaRetryableError(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_client.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):
|
||||
"""Get the current Data ONTAP version."""
|
||||
|
||||
if cached:
|
||||
return self.connection.get_ontap_version()
|
||||
|
||||
query = {
|
||||
'fields': 'version'
|
||||
}
|
||||
response = self.send_request('/cluster/nodes', 'get', query=query)
|
||||
records = response.get('records')[0]
|
||||
|
||||
return {
|
||||
'version': records['version']['full'],
|
||||
'version-tuple': (records['version']['generation'],
|
||||
records['version']['major'],
|
||||
records['version']['minor']),
|
||||
}
|
274
manila/share/drivers/netapp/dataontap/client/rest_api.py
Normal file
274
manila/share/drivers/netapp/dataontap/client/rest_api.py
Normal file
@ -0,0 +1,274 @@
|
||||
# Copyright 2023 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.
|
||||
"""
|
||||
NetApp API for REST Data ONTAP.
|
||||
|
||||
Contains classes required to issue REST API calls to Data ONTAP.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from oslo_log import log
|
||||
from oslo_serialization import jsonutils
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests import auth
|
||||
from requests.packages.urllib3.util import retry
|
||||
|
||||
from manila.share.drivers.netapp.dataontap.client import api
|
||||
from manila.share.drivers.netapp import utils
|
||||
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
ESIS_CLONE_NOT_LICENSED = '14956'
|
||||
|
||||
|
||||
class NaRetryableError(api.NaApiError):
|
||||
def __str__(self, *args, **kwargs):
|
||||
return 'NetApp API failed. Try again. Reason - %s:%s' % (
|
||||
self.code, self.message)
|
||||
|
||||
|
||||
class RestNaServer(object):
|
||||
|
||||
TRANSPORT_TYPE_HTTP = 'http'
|
||||
TRANSPORT_TYPE_HTTPS = 'https'
|
||||
HTTP_PORT = '80'
|
||||
HTTPS_PORT = '443'
|
||||
TUNNELING_HEADER_KEY = "X-Dot-SVM-Name"
|
||||
|
||||
def __init__(self, host, transport_type=TRANSPORT_TYPE_HTTP,
|
||||
ssl_cert_path=None, username=None, password=None, port=None,
|
||||
trace=False, api_trace_pattern=utils.API_TRACE_PATTERN):
|
||||
self._host = host
|
||||
self.set_transport_type(transport_type)
|
||||
self.set_port(port=port)
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._trace = trace
|
||||
self._api_trace_pattern = api_trace_pattern
|
||||
self._timeout = None
|
||||
|
||||
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
|
||||
|
||||
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 hasattr(self, '_api_version'):
|
||||
return (self._api_major_version, self._api_minor_version)
|
||||
return None
|
||||
|
||||
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."""
|
||||
if hasattr(self, '_ontap_version'):
|
||||
return self._ontap_version
|
||||
return None
|
||||
|
||||
def set_port(self, port=None):
|
||||
"""Set the ONTAP port, if not informed, set with default one."""
|
||||
if port is None and self._protocol == RestNaServer.TRANSPORT_TYPE_HTTP:
|
||||
self._port = RestNaServer.HTTP_PORT
|
||||
elif port is None:
|
||||
self._port = RestNaServer.HTTPS_PORT
|
||||
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 f'{self._protocol}://{host}:{self._port}/api'
|
||||
|
||||
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.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[RestNaServer.TUNNELING_HEADER_KEY] = 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)
|
||||
|
||||
def send_http_request(self, method, url, body, headers):
|
||||
"""Invoke the API on the server."""
|
||||
data = jsonutils.dumps(body) if body else {}
|
||||
|
||||
self._build_session(headers)
|
||||
request_method = self._get_request_method(method, self._session)
|
||||
|
||||
api_name_matches_regex = (re.match(self._api_trace_pattern, url)
|
||||
is not None)
|
||||
if self._trace and api_name_matches_regex:
|
||||
svm = headers.get(RestNaServer.TUNNELING_HEADER_KEY)
|
||||
message = ("Request: %(method)s Header=%(header)s %(url)s "
|
||||
"Body=%(body)s")
|
||||
msg_args = {
|
||||
"method": method.upper(),
|
||||
"url": url,
|
||||
"body": body,
|
||||
"header": ({RestNaServer.TUNNELING_HEADER_KEY: svm}
|
||||
if svm else {}),
|
||||
}
|
||||
LOG.debug(message, msg_args)
|
||||
|
||||
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 api.NaApiError(e.errno, e.strerror)
|
||||
except Exception as e:
|
||||
raise api.NaApiError(message=e)
|
||||
|
||||
code = response.status_code
|
||||
res = jsonutils.loads(response.content) if response.content else {}
|
||||
|
||||
if self._trace and api_name_matches_regex:
|
||||
message = "Response: %(code)s Body=%(body)s"
|
||||
msg_args = {
|
||||
"code": code,
|
||||
"body": res
|
||||
}
|
||||
LOG.debug(message, msg_args)
|
||||
|
||||
return code, res
|
||||
|
||||
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') or 'ESTATUSFAILED'
|
||||
# TODO(felipe_rodrigues): 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 failed due to unknown reason')
|
||||
raise api.NaApiError(code, msg)
|
@ -30,6 +30,7 @@ from manila.share import configuration
|
||||
from manila.share import driver
|
||||
from manila.share.drivers.netapp.dataontap.client import api as netapp_api
|
||||
from manila.share.drivers.netapp.dataontap.client import client_cmode
|
||||
from manila.share.drivers.netapp.dataontap.client import client_cmode_rest
|
||||
from manila.share.drivers.netapp import options as na_opts
|
||||
from manila.share.drivers.netapp import utils as na_utils
|
||||
from manila.share import utils as share_utils
|
||||
@ -72,15 +73,26 @@ def get_backend_configuration(backend_name):
|
||||
|
||||
def get_client_for_backend(backend_name, vserver_name=None):
|
||||
config = get_backend_configuration(backend_name)
|
||||
client = client_cmode.NetAppCmodeClient(
|
||||
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=na_utils.TRACE_API)
|
||||
if config.netapp_use_legacy_client:
|
||||
client = client_cmode.NetAppCmodeClient(
|
||||
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=na_utils.TRACE_API)
|
||||
else:
|
||||
client = client_cmode_rest.NetAppRestClient(
|
||||
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=na_utils.TRACE_API)
|
||||
|
||||
return client
|
||||
|
||||
|
@ -40,6 +40,7 @@ from manila.i18n import _
|
||||
from manila.message import api as message_api
|
||||
from manila.share.drivers.netapp.dataontap.client import api as netapp_api
|
||||
from manila.share.drivers.netapp.dataontap.client import client_cmode
|
||||
from manila.share.drivers.netapp.dataontap.client import client_cmode_rest
|
||||
from manila.share.drivers.netapp.dataontap.cluster_mode import data_motion
|
||||
from manila.share.drivers.netapp.dataontap.cluster_mode import performance
|
||||
from manila.share.drivers.netapp.dataontap.protocols import cifs_cmode
|
||||
@ -215,20 +216,32 @@ class NetAppCmodeFileStorageLibrary(object):
|
||||
@na_utils.trace
|
||||
def _get_api_client(self, vserver=None):
|
||||
|
||||
# Use cached value to prevent calls to system-get-ontapi-version.
|
||||
# Use cached value to prevent redo calls during client initialization.
|
||||
client = self._clients.get(vserver)
|
||||
|
||||
if not client:
|
||||
client = client_cmode.NetAppCmodeClient(
|
||||
transport_type=self.configuration.netapp_transport_type,
|
||||
ssl_cert_path=self.configuration.netapp_ssl_cert_path,
|
||||
username=self.configuration.netapp_login,
|
||||
password=self.configuration.netapp_password,
|
||||
hostname=self.configuration.netapp_server_hostname,
|
||||
port=self.configuration.netapp_server_port,
|
||||
vserver=vserver,
|
||||
trace=na_utils.TRACE_API,
|
||||
api_trace_pattern=na_utils.API_TRACE_PATTERN)
|
||||
if self.configuration.netapp_use_legacy_client:
|
||||
client = client_cmode.NetAppCmodeClient(
|
||||
transport_type=self.configuration.netapp_transport_type,
|
||||
ssl_cert_path=self.configuration.netapp_ssl_cert_path,
|
||||
username=self.configuration.netapp_login,
|
||||
password=self.configuration.netapp_password,
|
||||
hostname=self.configuration.netapp_server_hostname,
|
||||
port=self.configuration.netapp_server_port,
|
||||
vserver=vserver,
|
||||
trace=na_utils.TRACE_API,
|
||||
api_trace_pattern=na_utils.API_TRACE_PATTERN)
|
||||
else:
|
||||
client = client_cmode_rest.NetAppRestClient(
|
||||
transport_type=self.configuration.netapp_transport_type,
|
||||
ssl_cert_path=self.configuration.netapp_ssl_cert_path,
|
||||
username=self.configuration.netapp_login,
|
||||
password=self.configuration.netapp_password,
|
||||
hostname=self.configuration.netapp_server_hostname,
|
||||
port=self.configuration.netapp_server_port,
|
||||
vserver=vserver,
|
||||
trace=na_utils.TRACE_API,
|
||||
api_trace_pattern=na_utils.API_TRACE_PATTERN)
|
||||
self._clients[vserver] = client
|
||||
|
||||
return client
|
||||
|
@ -37,7 +37,15 @@ netapp_connection_opts = [
|
||||
cfg.PortOpt('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=('The ONTAP client used for retrieving and modifying '
|
||||
'data on the storage. The legacy client relies mostly '
|
||||
'on ZAPI calls, only using REST calls for SVM migrate '
|
||||
'feature. If set to False, the new REST client is used, '
|
||||
'which runs REST calls if supported, otherwise falls '
|
||||
'back to the equivalent ZAPI call.')), ]
|
||||
|
||||
netapp_transport_opts = [
|
||||
cfg.StrOpt('netapp_transport_type',
|
||||
|
@ -52,6 +52,10 @@ FLEXVOL_STYLE_EXTENDED = 'flexvol'
|
||||
FLEXGROUP_DEFAULT_POOL_NAME = 'flexgroup_auto'
|
||||
|
||||
|
||||
class NetAppDriverException(exception.ShareBackendException):
|
||||
message = _("NetApp Manila Driver exception.")
|
||||
|
||||
|
||||
def validate_driver_instantiation(**kwargs):
|
||||
"""Checks if a driver is instantiated other than by the unified driver.
|
||||
|
||||
|
@ -30,6 +30,7 @@ CONNECTION_INFO = {
|
||||
'api_trace_pattern': '(.*)',
|
||||
}
|
||||
|
||||
FAKE_UUID = 'b32bab78-82be-11ec-a8a3-0242ac120002'
|
||||
CLUSTER_NAME = 'fake_cluster'
|
||||
REMOTE_CLUSTER_NAME = 'fake_cluster_2'
|
||||
CLUSTER_ADDRESS_1 = 'fake_cluster_address'
|
||||
@ -48,6 +49,7 @@ NFS_VERSIONS = ['nfs3', 'nfs4.0']
|
||||
ROOT_AGGREGATE_NAMES = ('root_aggr1', 'root_aggr2')
|
||||
ROOT_VOLUME_AGGREGATE_NAME = 'fake_root_aggr'
|
||||
ROOT_VOLUME_NAME = 'fake_root_volume'
|
||||
VOLUME_NAMES = ('volume1', 'volume2')
|
||||
SHARE_AGGREGATE_NAME = 'fake_aggr1'
|
||||
SHARE_AGGREGATE_NAMES = ('fake_aggr1', 'fake_aggr2')
|
||||
SHARE_AGGREGATE_RAID_TYPES = ('raid4', 'raid_dp')
|
||||
@ -3031,6 +3033,7 @@ FAKE_ACTION_URL = '/endpoint'
|
||||
FAKE_BASE_URL = '10.0.0.3/api'
|
||||
FAKE_HTTP_BODY = {'fake_key': 'fake_value'}
|
||||
FAKE_HTTP_QUERY = {'type': 'fake_type'}
|
||||
FAKE_FORMATTED_HTTP_QUERY = "?type=fake_type"
|
||||
FAKE_HTTP_HEADER = {"fake_header_key": "fake_header_value"}
|
||||
FAKE_URL_PARAMS = {"fake_url_key": "fake_url_value_to_be_concatenated"}
|
||||
|
||||
@ -3237,3 +3240,175 @@ JOB_GET_STATE_NOT_UNIQUE_RESPONSE = etree.XML("""
|
||||
""" % {
|
||||
'state': JOB_STATE,
|
||||
})
|
||||
|
||||
NO_RECORDS_RESPONSE_REST = {
|
||||
"records": [],
|
||||
"num_records": 0,
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/api/cluster/nodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ERROR_RESPONSE_REST = {
|
||||
"error": {
|
||||
"code": 1100,
|
||||
"message": "fake error",
|
||||
}
|
||||
}
|
||||
|
||||
GET_VERSION_RESPONSE_REST = {
|
||||
"records": [
|
||||
{
|
||||
"version": {
|
||||
"generation": "9",
|
||||
"minor": "11",
|
||||
"major": "1",
|
||||
"full": "NetApp Release 9.11.1: Sun Nov 05 18:20:57 UTC 2017"
|
||||
}
|
||||
}
|
||||
],
|
||||
"_links": {
|
||||
"next": {
|
||||
"href": "/api/resourcelink"
|
||||
},
|
||||
"self": {
|
||||
"href": "/api/resourcelink"
|
||||
}
|
||||
},
|
||||
"num_records": 0
|
||||
}
|
||||
|
||||
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": [],
|
||||
}
|
||||
|
||||
JOB_RESPONSE_REST = {
|
||||
"job": {
|
||||
"uuid": "uuid-12345",
|
||||
"_links": {
|
||||
"self": {
|
||||
"href": "/api/cluster/jobs/uuid-12345"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JOB_SUCCESSFUL_REST = {
|
||||
"uuid": FAKE_UUID,
|
||||
"description": "Fake description",
|
||||
"state": "success",
|
||||
"message": "success",
|
||||
"code": 0,
|
||||
"start_time": "2022-02-18T20:08:03+00:00",
|
||||
"end_time": "2022-02-18T20:08:04+00:00",
|
||||
}
|
||||
|
||||
JOB_RUNNING_REST = {
|
||||
"uuid": FAKE_UUID,
|
||||
"description": "Fake description",
|
||||
"state": "running",
|
||||
"message": "running",
|
||||
"code": 0,
|
||||
}
|
||||
|
||||
JOB_ERROR_REST = {
|
||||
"uuid": FAKE_UUID,
|
||||
"description": "Fake description",
|
||||
"state": "failure",
|
||||
"message": "failure",
|
||||
"code": 4,
|
||||
"error": {
|
||||
"target": "uuid",
|
||||
"arguments": [
|
||||
{
|
||||
"message": "string",
|
||||
"code": "string"
|
||||
}
|
||||
],
|
||||
"message": "entry doesn't exist",
|
||||
"code": "4"
|
||||
},
|
||||
"start_time": "2022-02-18T20:08:03+00:00",
|
||||
"end_time": "2022-02-18T20:08:04+00:00",
|
||||
}
|
||||
|
@ -0,0 +1,349 @@
|
||||
# Copyright (c) 2023 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.
|
||||
|
||||
import copy
|
||||
from unittest import mock
|
||||
|
||||
import ddt
|
||||
from oslo_log import log
|
||||
|
||||
from manila.share.drivers.netapp.dataontap.client import client_cmode
|
||||
from manila.share.drivers.netapp.dataontap.client import client_cmode_rest
|
||||
from manila.share.drivers.netapp import utils as netapp_utils
|
||||
from manila import test
|
||||
from manila.tests.share.drivers.netapp.dataontap.client import fakes as fake
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class NetAppRestCmodeClientTestCase(test.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(NetAppRestCmodeClientTestCase, self).setUp()
|
||||
|
||||
# Mock loggers as themselves to allow logger arg validation
|
||||
mock_logger = log.getLogger('mock_logger')
|
||||
self.mock_object(client_cmode_rest.LOG,
|
||||
'error',
|
||||
mock.Mock(side_effect=mock_logger.error))
|
||||
self.mock_object(client_cmode_rest.LOG,
|
||||
'warning',
|
||||
mock.Mock(side_effect=mock_logger.warning))
|
||||
self.mock_object(client_cmode_rest.LOG,
|
||||
'debug',
|
||||
mock.Mock(side_effect=mock_logger.debug))
|
||||
self.mock_object(client_cmode.NetAppCmodeClient,
|
||||
'get_ontapi_version',
|
||||
mock.Mock(return_value=(1, 20)))
|
||||
# store the original reference so we can call it later in
|
||||
# test_get_ontap_version
|
||||
self.original_get_ontap_version = (
|
||||
client_cmode_rest.NetAppRestClient.get_ontap_version)
|
||||
self.mock_object(client_cmode_rest.NetAppRestClient,
|
||||
'get_ontap_version',
|
||||
mock.Mock(return_value={
|
||||
'version-tuple': (9, 11, 1),
|
||||
'version': fake.VERSION,
|
||||
}))
|
||||
self.mock_object(client_cmode.NetAppCmodeClient,
|
||||
'get_system_version',
|
||||
mock.Mock(return_value={
|
||||
'version-tuple': (9, 10, 1),
|
||||
'version': fake.VERSION,
|
||||
}))
|
||||
|
||||
self.client = client_cmode_rest.NetAppRestClient(
|
||||
**fake.CONNECTION_INFO)
|
||||
self.client.connection = mock.MagicMock()
|
||||
|
||||
self.vserver_client = client_cmode.NetAppCmodeClient(
|
||||
**fake.CONNECTION_INFO)
|
||||
self.vserver_client.set_vserver(fake.VSERVER_NAME)
|
||||
self.vserver_client.connection = mock.MagicMock()
|
||||
|
||||
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.FAKE_ACTION_URL, 'get',
|
||||
body=fake.FAKE_HTTP_BODY,
|
||||
query=fake.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
|
||||
self.assertEqual(expected, res)
|
||||
mock_get_records.assert_called_once_with(
|
||||
fake.FAKE_ACTION_URL,
|
||||
fake.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.FAKE_ACTION_URL, 'post',
|
||||
body=fake.FAKE_HTTP_BODY,
|
||||
query=fake.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
|
||||
self.assertEqual(expected[1], res)
|
||||
mock_invoke.assert_called_once_with(
|
||||
fake.FAKE_ACTION_URL, 'post',
|
||||
body=fake.FAKE_HTTP_BODY,
|
||||
query=fake.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
|
||||
def test_send_request_wait(self):
|
||||
expected = (202, fake.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.FAKE_ACTION_URL, 'post',
|
||||
body=fake.FAKE_HTTP_BODY,
|
||||
query=fake.FAKE_HTTP_QUERY, enable_tunneling=False)
|
||||
|
||||
self.assertEqual(expected[1], res)
|
||||
mock_invoke.assert_called_once_with(
|
||||
fake.FAKE_ACTION_URL, 'post',
|
||||
body=fake.FAKE_HTTP_BODY,
|
||||
query=fake.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.VOLUME_GET_ITER_RESPONSE_REST_PAGE),
|
||||
(200, fake.VOLUME_GET_ITER_RESPONSE_REST_PAGE),
|
||||
(200, fake.VOLUME_GET_ITER_RESPONSE_REST_LAST_PAGE),
|
||||
]
|
||||
|
||||
mock_invoke = self.mock_object(
|
||||
self.client.connection, 'invoke_successfully',
|
||||
mock.Mock(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.VOLUME_GET_ITER_RESPONSE_REST_LAST_PAGE)
|
||||
mock_invoke = self.mock_object(self.client.connection,
|
||||
'invoke_successfully',
|
||||
mock.Mock(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.NO_RECORDS_RESPONSE_REST)
|
||||
mock_invoke = self.mock_object(self.client.connection,
|
||||
'invoke_successfully',
|
||||
mock.Mock(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.VOLUME_GET_ITER_RESPONSE_REST_PAGE),
|
||||
(200, fake.VOLUME_GET_ITER_RESPONSE_REST_PAGE),
|
||||
(200, fake.VOLUME_GET_ITER_RESPONSE_REST_LAST_PAGE),
|
||||
]
|
||||
|
||||
mock_invoke = self.mock_object(
|
||||
self.client.connection, 'invoke_successfully',
|
||||
mock.Mock(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),
|
||||
])
|
||||
|
||||
def test__getattr__(self):
|
||||
# NOTE(nahimsouza): get_ontapi_version is implemented only in ZAPI
|
||||
# client, therefore, it will call __getattr__
|
||||
self.client.get_ontapi_version()
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_get_ontap_version(self, cached):
|
||||
self.client.get_ontap_version = (
|
||||
self.original_get_ontap_version)
|
||||
api_response = {
|
||||
'records': [
|
||||
{
|
||||
'version': {
|
||||
'generation': 9,
|
||||
'major': 11,
|
||||
'minor': 1,
|
||||
'full': 'NetApp Release 9.11.1'
|
||||
}
|
||||
}]
|
||||
|
||||
}
|
||||
return_mock = {
|
||||
'version': 'NetApp Release 9.11.1',
|
||||
'version-tuple': (9, 11, 1)
|
||||
}
|
||||
mock_connect = self.mock_object(self.client.connection,
|
||||
'get_ontap_version',
|
||||
mock.Mock(return_value=return_mock))
|
||||
mock_send_request = self.mock_object(
|
||||
self.client,
|
||||
'send_request',
|
||||
mock.Mock(return_value=api_response))
|
||||
|
||||
result = self.client.get_ontap_version(self=self.client, cached=cached)
|
||||
|
||||
if cached:
|
||||
mock_connect.assert_called_once()
|
||||
else:
|
||||
mock_send_request.assert_called_once_with(
|
||||
'/cluster/nodes', 'get', query={'fields': 'version'})
|
||||
|
||||
self.assertEqual(return_mock, result)
|
||||
|
||||
def test__wait_job_result(self):
|
||||
response = fake.JOB_SUCCESSFUL_REST
|
||||
self.mock_object(self.client,
|
||||
'send_request',
|
||||
mock.Mock(return_value=response))
|
||||
result = self.client._wait_job_result(
|
||||
f'/cluster/jobs/{fake.FAKE_UUID}')
|
||||
self.assertEqual(response, result)
|
||||
|
||||
def test__wait_job_result_failure(self):
|
||||
response = fake.JOB_ERROR_REST
|
||||
self.mock_object(self.client,
|
||||
'send_request',
|
||||
mock.Mock(return_value=response))
|
||||
self.assertRaises(netapp_utils.NetAppDriverException,
|
||||
self.client._wait_job_result,
|
||||
f'/cluster/jobs/{fake.FAKE_UUID}')
|
||||
|
||||
def test__wait_job_result_timeout(self):
|
||||
response = fake.JOB_RUNNING_REST
|
||||
self.client.async_rest_timeout = 2
|
||||
self.mock_object(self.client,
|
||||
'send_request',
|
||||
mock.Mock(return_value=response))
|
||||
self.assertRaises(netapp_utils.NetAppDriverException,
|
||||
self.client._wait_job_result,
|
||||
f'/cluster/jobs/{fake.FAKE_UUID}')
|
@ -0,0 +1,341 @@
|
||||
# Copyright 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.
|
||||
"""
|
||||
Tests for NetApp REST API layer
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import ddt
|
||||
from oslo_serialization import jsonutils
|
||||
import requests
|
||||
from requests import auth
|
||||
|
||||
from manila.share.drivers.netapp.dataontap.client import api as legacy_api
|
||||
from manila.share.drivers.netapp.dataontap.client import rest_api as netapp_api
|
||||
from manila import test
|
||||
from manila.tests.share.drivers.netapp.dataontap.client import fakes as fake
|
||||
|
||||
|
||||
@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('!&', '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')
|
||||
self.mock_object(self.rest_client, '_get_base_url',
|
||||
mock.Mock(return_value=''))
|
||||
self.mock_object(
|
||||
self.rest_client, 'send_http_request',
|
||||
mock.Mock(return_value=(10, fake.ERROR_RESPONSE_REST)))
|
||||
|
||||
self.assertRaises(legacy_api.NaApiError,
|
||||
self.rest_client.invoke_successfully,
|
||||
fake.FAKE_ACTION_URL, 'get')
|
||||
|
||||
@ddt.data(None, {'fields': 'fake_fields'})
|
||||
def test_invoke_successfully(self, query):
|
||||
mock_build_header = self.mock_object(
|
||||
self.rest_client, '_build_headers',
|
||||
mock.Mock(return_value=fake.FAKE_HTTP_HEADER))
|
||||
mock_base = self.mock_object(
|
||||
self.rest_client, '_get_base_url',
|
||||
mock.Mock(return_value=fake.FAKE_BASE_URL))
|
||||
mock_add_query = self.mock_object(
|
||||
self.rest_client, '_add_query_params_to_url',
|
||||
mock.Mock(return_value=fake.FAKE_ACTION_URL))
|
||||
http_code = 200
|
||||
mock_send_http = self.mock_object(
|
||||
self.rest_client, 'send_http_request',
|
||||
mock.Mock(return_value=(http_code, fake.NO_RECORDS_RESPONSE_REST)))
|
||||
|
||||
code, response = self.rest_client.invoke_successfully(
|
||||
fake.FAKE_ACTION_URL, 'get', body=fake.FAKE_HTTP_BODY, query=query,
|
||||
enable_tunneling=True)
|
||||
|
||||
self.assertEqual(response, fake.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',
|
||||
fake.FAKE_BASE_URL + fake.FAKE_ACTION_URL, fake.FAKE_HTTP_BODY,
|
||||
fake.FAKE_HTTP_HEADER)
|
||||
|
||||
@ddt.data(
|
||||
{'error': requests.HTTPError(), 'raised': legacy_api.NaApiError},
|
||||
{'error': Exception, 'raised': legacy_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', fake.FAKE_ACTION_URL, fake.FAKE_HTTP_BODY,
|
||||
fake.FAKE_HTTP_HEADER)
|
||||
|
||||
@ddt.data(
|
||||
{
|
||||
'resp_content': fake.NO_RECORDS_RESPONSE_REST,
|
||||
'body': fake.FAKE_HTTP_BODY,
|
||||
'timeout': 10,
|
||||
},
|
||||
{
|
||||
'resp_content': fake.NO_RECORDS_RESPONSE_REST,
|
||||
'body': fake.FAKE_HTTP_BODY,
|
||||
'timeout': None,
|
||||
},
|
||||
{
|
||||
'resp_content': fake.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', fake.FAKE_ACTION_URL, body, fake.FAKE_HTTP_HEADER)
|
||||
|
||||
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(fake.FAKE_HTTP_HEADER)
|
||||
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(
|
||||
fake.FAKE_ACTION_URL, data=expected_data, timeout=timeout)
|
||||
else:
|
||||
mock_post.assert_called_once_with(fake.FAKE_ACTION_URL,
|
||||
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(
|
||||
fake.FAKE_ACTION_URL, fake.FAKE_HTTP_QUERY)
|
||||
|
||||
expected_formatted_url = fake.FAKE_ACTION_URL
|
||||
expected_formatted_url += fake.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(fake.FAKE_HTTP_HEADER)
|
||||
|
||||
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(fake.FAKE_HTTP_HEADER,
|
||||
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 = fake.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"] = fake.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__)
|
@ -0,0 +1,10 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
NetApp driver: it has now the option to request ONTAP operations through
|
||||
REST API. The new option `netapp_use_legacy_client` allows switching
|
||||
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
Block a user