From 1fd7e881910dc1f1cd141866a86ddb873dde7295 Mon Sep 17 00:00:00 2001 From: Alexey Khodos Date: Sat, 18 Jun 2016 07:03:31 +0300 Subject: [PATCH] Nexenta: adding share drivers for NexentaStor Share drivers to support NexentaStor4 and NexentaStor5 via NFS protocol. Supports all minimal required features. Implements: blueprint nexenta-manila-driver DocImpact Change-Id: Ib8b8525397626162968458ec837f9239ff5dbb2b --- ...hare_back_ends_feature_support_mapping.rst | 16 + manila/exception.py | 4 + manila/opts.py | 4 + manila/share/drivers/nexenta/__init__.py | 0 manila/share/drivers/nexenta/ns4/__init__.py | 0 manila/share/drivers/nexenta/ns4/jsonrpc.py | 92 +++ .../share/drivers/nexenta/ns4/nexenta_nas.py | 134 ++++ .../drivers/nexenta/ns4/nexenta_nfs_helper.py | 227 +++++++ manila/share/drivers/nexenta/ns5/__init__.py | 0 manila/share/drivers/nexenta/ns5/jsonrpc.py | 145 +++++ .../share/drivers/nexenta/ns5/nexenta_nas.py | 422 ++++++++++++ manila/share/drivers/nexenta/options.py | 79 +++ manila/share/drivers/nexenta/utils.py | 54 ++ .../tests/share/drivers/nexenta/__init__.py | 0 .../share/drivers/nexenta/ns4/__init__.py | 0 .../share/drivers/nexenta/ns4/test_jsonrpc.py | 38 ++ .../drivers/nexenta/ns4/test_nexenta_nas.py | 606 ++++++++++++++++++ .../share/drivers/nexenta/ns5/__init__.py | 0 .../share/drivers/nexenta/ns5/test_jsonrpc.py | 129 ++++ .../drivers/nexenta/ns5/test_nexenta_nas.py | 378 +++++++++++ .../tests/share/drivers/nexenta/test_utils.py | 49 ++ ...xenta-manila-drivers-cbd0b376a076ec50.yaml | 3 + 22 files changed, 2380 insertions(+) create mode 100644 manila/share/drivers/nexenta/__init__.py create mode 100644 manila/share/drivers/nexenta/ns4/__init__.py create mode 100644 manila/share/drivers/nexenta/ns4/jsonrpc.py create mode 100644 manila/share/drivers/nexenta/ns4/nexenta_nas.py create mode 100644 manila/share/drivers/nexenta/ns4/nexenta_nfs_helper.py create mode 100644 manila/share/drivers/nexenta/ns5/__init__.py create mode 100644 manila/share/drivers/nexenta/ns5/jsonrpc.py create mode 100644 manila/share/drivers/nexenta/ns5/nexenta_nas.py create mode 100644 manila/share/drivers/nexenta/options.py create mode 100644 manila/share/drivers/nexenta/utils.py create mode 100644 manila/tests/share/drivers/nexenta/__init__.py create mode 100644 manila/tests/share/drivers/nexenta/ns4/__init__.py create mode 100644 manila/tests/share/drivers/nexenta/ns4/test_jsonrpc.py create mode 100644 manila/tests/share/drivers/nexenta/ns4/test_nexenta_nas.py create mode 100644 manila/tests/share/drivers/nexenta/ns5/__init__.py create mode 100644 manila/tests/share/drivers/nexenta/ns5/test_jsonrpc.py create mode 100644 manila/tests/share/drivers/nexenta/ns5/test_nexenta_nas.py create mode 100644 manila/tests/share/drivers/nexenta/test_utils.py create mode 100644 releasenotes/notes/nexenta-manila-drivers-cbd0b376a076ec50.yaml diff --git a/doc/source/devref/share_back_ends_feature_support_mapping.rst b/doc/source/devref/share_back_ends_feature_support_mapping.rst index 02ed2f6b1d..3ba7c6686f 100644 --- a/doc/source/devref/share_back_ends_feature_support_mapping.rst +++ b/doc/source/devref/share_back_ends_feature_support_mapping.rst @@ -75,6 +75,10 @@ Mapping of share drivers and share features support +----------------------------------------+-----------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ | Tegile | M | \- | M | M | M | M | \- | +----------------------------------------+-----------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| NexentaStor4 | N | \- | N | \- | N | N | \- | ++----------------------------------------+-----------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ +| NexentaStor5 | N | \- | N | N | N | N | \- | ++----------------------------------------+-----------------------+-----------------------+--------------+--------------+------------------------+----------------------------+--------------------------+ Mapping of share drivers and share access rules support ------------------------------------------------------- @@ -126,6 +130,10 @@ Mapping of share drivers and share access rules support +----------------------------------------+--------------+----------------+------------+--------------+--------------+----------------+------------+------------+ | Tegile | NFS (M) |NFS (M),CIFS (M)| \- | \- | NFS (M) |NFS (M),CIFS (M)| \- | \- | +----------------------------------------+--------------+----------------+------------+--------------+--------------+----------------+------------+------------+ +| NexentaStor4 | NFS (N) | \- | \- | \- | NFS (N) | \- | \- | \- | ++----------------------------------------+--------------+----------------+------------+--------------+--------------+----------------+------------+------------+ +| NexentaStor5 | NFS (N) | \- | \- | \- | NFS (N) | \- | \- | \- | ++----------------------------------------+--------------+----------------+------------+--------------+--------------+----------------+------------+------------+ Mapping of share drivers and security services support ------------------------------------------------------ @@ -175,6 +183,10 @@ Mapping of share drivers and security services support +----------------------------------------+------------------+-----------------+------------------+ | Tegile | \- | \- | \- | +----------------------------------------+------------------+-----------------+------------------+ +| NexentaStor4 | \- | \- | \- | ++----------------------------------------+------------------+-----------------+------------------+ +| NexentaStor5 | \- | \- | \- | ++----------------------------------------+------------------+-----------------+------------------+ Mapping of share drivers and common capabilities ------------------------------------------------ @@ -224,6 +236,10 @@ Mapping of share drivers and common capabilities +----------------------------------------+-----------+------------+--------+-------------+-------------------+--------------------+-----+ | Tegile | \- | M | M | M | M | \- | \- | +----------------------------------------+-----------+------------+--------+-------------+-------------------+--------------------+-----+ +| NexentaStor4 | \- | N | N | N | N | N | \- | ++----------------------------------------+-----------+------------+--------+-------------+-------------------+--------------------+-----+ +| NexentaStor5 | \- | N | N | N | N | N | \- | ++----------------------------------------+-----------+------------+--------+-------------+-------------------+--------------------+-----+ .. note:: diff --git a/manila/exception.py b/manila/exception.py index faca680170..f20663b7a0 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -820,3 +820,7 @@ class HSPTimeoutException(ShareBackendException): class HSPItemNotFoundException(ShareBackendException): message = _("HSP Item Not Found Exception: %(msg)s") + + +class NexentaException(ShareBackendException): + message = _("Exception due to Nexenta failure. %(reason)s") diff --git a/manila/opts.py b/manila/opts.py index 8e67afb11d..109d9493b9 100644 --- a/manila/opts.py +++ b/manila/opts.py @@ -69,6 +69,7 @@ import manila.share.drivers.huawei.huawei_nas import manila.share.drivers.ibm.gpfs import manila.share.drivers.lvm import manila.share.drivers.netapp.options +import manila.share.drivers.nexenta.options import manila.share.drivers.quobyte.quobyte import manila.share.drivers.service_instance import manila.share.drivers.tegile.tegile @@ -143,6 +144,9 @@ _global_opt_lists = [ manila.share.drivers.netapp.options.netapp_basicauth_opts, manila.share.drivers.netapp.options.netapp_provisioning_opts, manila.share.drivers.netapp.options.netapp_replication_opts, + manila.share.drivers.nexenta.options.nexenta_connection_opts, + manila.share.drivers.nexenta.options.nexenta_dataset_opts, + manila.share.drivers.nexenta.options.nexenta_nfs_opts, manila.share.drivers.quobyte.quobyte.quobyte_manila_share_opts, manila.share.drivers.service_instance.common_opts, manila.share.drivers.service_instance.no_share_servers_handling_mode_opts, diff --git a/manila/share/drivers/nexenta/__init__.py b/manila/share/drivers/nexenta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/share/drivers/nexenta/ns4/__init__.py b/manila/share/drivers/nexenta/ns4/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/share/drivers/nexenta/ns4/jsonrpc.py b/manila/share/drivers/nexenta/ns4/jsonrpc.py new file mode 100644 index 0000000000..4cf2cc0068 --- /dev/null +++ b/manila/share/drivers/nexenta/ns4/jsonrpc.py @@ -0,0 +1,92 @@ +# Copyright 2016 Nexenta Systems, 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. +""" +:mod:`nexenta.jsonrpc` -- Nexenta-specific JSON RPC client +===================================================================== + +.. automodule:: nexenta.jsonrpc +""" + +import base64 +import json +import requests + +from oslo_log import log +from oslo_serialization import jsonutils + +from manila import exception +from manila import utils + +LOG = log.getLogger(__name__) + + +class NexentaJSONProxy(object): + + retry_exc_tuple = (requests.exceptions.ConnectionError,) + + def __init__(self, scheme, host, port, path, user, password, auto=False, + obj=None, method=None): + self.scheme = scheme.lower() + self.host = host + self.port = port + self.path = path + self.user = user + self.password = password + self.auto = auto + self.obj = obj + self.method = method + + def __getattr__(self, name): + if not self.obj: + obj, method = name, None + elif not self.method: + obj, method = self.obj, name + else: + obj, method = '%s.%s' % (self.obj, self.method), name + return NexentaJSONProxy(self.scheme, self.host, self.port, self.path, + self.user, self.password, self.auto, obj, + method) + + @property + def url(self): + return '%s://%s:%s%s' % (self.scheme, self.host, self.port, self.path) + + def __hash__(self): + return self.url.__hash__() + + def __repr__(self): + return 'NMS proxy: %s' % self.url + + @utils.retry(retry_exc_tuple, retries=6) + def __call__(self, *args): + data = jsonutils.dumps({ + 'object': self.obj, + 'method': self.method, + 'params': args, + }) + auth = base64.b64encode( + ('%s:%s' % (self.user, self.password)).encode('utf-8')) + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Basic %s' % auth, + } + LOG.debug('Sending JSON data: %s', data) + r = requests.post(self.url, data=data, headers=headers) + response = json.loads(r.content) if r.content else None + LOG.debug('Got response: %s', response) + if response.get('error') is not None: + message = response['error'].get('message', '') + raise exception.NexentaException(reason=message) + return response.get('result') diff --git a/manila/share/drivers/nexenta/ns4/nexenta_nas.py b/manila/share/drivers/nexenta/ns4/nexenta_nas.py new file mode 100644 index 0000000000..5fda5e32f3 --- /dev/null +++ b/manila/share/drivers/nexenta/ns4/nexenta_nas.py @@ -0,0 +1,134 @@ +# Copyright 2016 Nexenta Systems, 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 + +from manila import exception +from manila.i18n import _, _LI +from manila.share import driver +from manila.share.drivers.nexenta.ns4 import nexenta_nfs_helper +from manila.share.drivers.nexenta import options + + +VERSION = '1.0' +LOG = log.getLogger(__name__) + + +class NexentaNasDriver(driver.ShareDriver): + """Nexenta Share Driver. + + Executes commands relating to Shares. + API version history: + 1.0 - Initial version. + """ + + def __init__(self, *args, **kwargs): + """Do initialization.""" + LOG.debug('Initializing Nexenta driver.') + super(NexentaNasDriver, self).__init__(False, *args, **kwargs) + self.configuration = kwargs.get('configuration') + if self.configuration: + self.configuration.append_config_values( + options.nexenta_connection_opts) + self.configuration.append_config_values( + options.nexenta_nfs_opts) + self.configuration.append_config_values( + options.nexenta_dataset_opts) + self.helper = nexenta_nfs_helper.NFSHelper(self.configuration) + else: + raise exception.BadConfigurationException( + reason=_('Nexenta configuration missing.')) + + @property + def share_backend_name(self): + if not hasattr(self, '_share_backend_name'): + self._share_backend_name = None + if self.configuration: + self._share_backend_name = self.configuration.safe_get( + 'share_backend_name') + if not self._share_backend_name: + self._share_backend_name = 'NexentaStor4' + return self._share_backend_name + + def do_setup(self, context): + """Any initialization the Nexenta NAS driver does while starting.""" + LOG.debug('Setting up the NexentaStor4 plugin.') + return self.helper.do_setup() + + def check_for_setup_error(self): + """Returns an error if prerequisites aren't met.""" + self.helper.check_for_setup_error() + + def create_share(self, context, share, share_server=None): + """Create a share.""" + LOG.debug('Creating share %s.', share['name']) + return self.helper.create_filesystem(share) + + def create_share_from_snapshot(self, context, share, snapshot, + share_server=None): + """Is called to create share from snapshot.""" + LOG.debug('Creating share from snapshot %s.', snapshot['name']) + return self.helper.create_share_from_snapshot(share, snapshot) + + def delete_share(self, context, share, share_server=None): + """Delete a share.""" + LOG.debug('Deleting share %s.', share['name']) + self.helper.delete_share(share['name']) + + def extend_share(self, share, new_size, share_server=None): + """Extends a share.""" + LOG.debug('Extending share %(name)s to %(size)sG.', { + 'name': share['name'], 'size': new_size}) + self.helper.set_quota(share['name'], new_size) + + def create_snapshot(self, context, snapshot, share_server=None): + """Create a snapshot.""" + LOG.debug('Creating a snapshot of share %s.', snapshot['share_name']) + snap_id = self.helper.create_snapshot( + snapshot['share_name'], snapshot['name']) + LOG.info(_LI('Created snapshot %s.'), snap_id) + + def delete_snapshot(self, context, snapshot, share_server=None): + """Delete a snapshot.""" + LOG.debug('Deleting snapshot %(shr_name)s@%(snap_name)s.', { + 'shr_name': snapshot['share_name'], + 'snap_name': snapshot['name']}) + self.helper.delete_snapshot(snapshot['share_name'], snapshot['name']) + + def update_access(self, context, share, access_rules, add_rules, + delete_rules, share_server=None): + """Update access rules for given share. + + :param context: The `context.RequestContext` object for the request + :param share: Share that will have its access rules updated. + :param access_rules: All access rules for given share. This list + is enough to update the access rules for given share. + :param add_rules: Empty List or List of access rules which should be + added. access_rules already contains these rules. Not used by this + driver. + :param delete_rules: Empty List or List of access rules which should be + removed. access_rules doesn't contain these rules. Not used by + this driver. + :param share_server: Data structure with share server information. + Not used by this driver. + """ + self.helper.update_access(share['name'], access_rules) + + def _update_share_stats(self, data=None): + super(NexentaNasDriver, self)._update_share_stats() + data = self.helper.update_share_stats() + data['driver_version'] = VERSION + data['share_backend_name'] = self.share_backend_name + self._stats.update(data) diff --git a/manila/share/drivers/nexenta/ns4/nexenta_nfs_helper.py b/manila/share/drivers/nexenta/ns4/nexenta_nfs_helper.py new file mode 100644 index 0000000000..6636ef74f9 --- /dev/null +++ b/manila/share/drivers/nexenta/ns4/nexenta_nfs_helper.py @@ -0,0 +1,227 @@ +# Copyright 2016 Nexenta Systems, 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 +from oslo_utils import excutils + +from manila.common import constants as common +from manila import exception +from manila.i18n import _, _LI +from manila.share.drivers.nexenta.ns4 import jsonrpc +from manila.share.drivers.nexenta import utils + +LOG = log.getLogger(__name__) +NOT_EXIST = 'does not exist' +DEP_CLONES = 'has dependent clones' + + +class NFSHelper(object): + + def __init__(self, configuration): + self.configuration = configuration + self.nfs_mount_point_base = ( + self.configuration.nexenta_mount_point_base) + self.dataset_compression = ( + self.configuration.nexenta_dataset_compression) + self.dataset_dedupe = self.configuration.nexenta_dataset_dedupe + self.nms = None + self.nms_protocol = self.configuration.nexenta_rest_protocol + self.nms_host = self.configuration.nexenta_host + self.volume = self.configuration.nexenta_volume + self.share = self.configuration.nexenta_nfs_share + self.nms_port = self.configuration.nexenta_rest_port + self.nms_user = self.configuration.nexenta_user + self.nfs = self.configuration.nexenta_nfs + self.nms_password = self.configuration.nexenta_password + self.storage_protocol = 'NFS' + + def do_setup(self): + if self.nms_protocol == 'auto': + protocol, auto = 'http', True + else: + protocol, auto = self.nms_protocol, False + path = '/rest/nms/' + self.nms = jsonrpc.NexentaJSONProxy( + protocol, self.nms_host, self.nms_port, path, self.nms_user, + self.nms_password, auto=auto) + + def check_for_setup_error(self): + if not self.nms.volume.object_exists(self.volume): + raise exception.NexentaException(reason=_( + "Volume %s does not exist in NexentaStor appliance.") % + self.volume) + folder = '%s/%s' % (self.volume, self.share) + create_folder_props = { + 'recordsize': '4K', + 'quota': 'none', + 'compression': self.dataset_compression, + } + if not self.nms.folder.object_exists(folder): + self.nms.folder.create_with_props( + self.volume, self.share, create_folder_props) + + def create_filesystem(self, share): + """Create file system.""" + create_folder_props = { + 'recordsize': '4K', + 'quota': '%sG' % share['size'], + 'compression': self.dataset_compression, + } + if not self.configuration.nexenta_thin_provisioning: + create_folder_props['reservation'] = '%sG' % share['size'] + + parent_path = '%s/%s' % (self.volume, self.share) + self.nms.folder.create_with_props( + parent_path, share['name'], create_folder_props) + + path = self._get_share_path(share['name']) + return [self._get_location_path(path, share['share_proto'])] + + def set_quota(self, share_name, new_size): + if self.configuration.nexenta_thin_provisioning: + quota = '%sG' % new_size + self.nms.folder.set_child_prop( + self._get_share_path(share_name), 'quota', quota) + + def _get_location_path(self, path, protocol): + location = None + if protocol == 'NFS': + location = {'path': '%s:/volumes/%s' % (self.nms_host, path)} + else: + raise exception.InvalidShare( + reason=(_('Only NFS protocol is currently supported.'))) + return location + + def delete_share(self, share_name): + """Delete share.""" + folder = self._get_share_path(share_name) + try: + self.nms.folder.destroy(folder.strip(), '-r') + except exception.NexentaException as e: + with excutils.save_and_reraise_exception() as exc: + if NOT_EXIST in e.args[0]: + LOG.info(_LI('Folder %s does not exist, it was ' + 'already deleted.'), folder) + exc.reraise = False + + def _get_share_path(self, share_name): + return '%s/%s/%s' % (self.volume, self.share, share_name) + + def _get_snapshot_name(self, snapshot_name): + return 'snapshot-%s' % snapshot_name + + def create_snapshot(self, share_name, snapshot_name): + """Create a snapshot.""" + folder = self._get_share_path(share_name) + self.nms.folder.create_snapshot(folder, snapshot_name, '-r') + model_update = {'provider_location': '%s@%s' % (folder, snapshot_name)} + return model_update + + def delete_snapshot(self, share_name, snapshot_name): + """Deletes snapshot.""" + try: + self.nms.snapshot.destroy('%s@%s' % ( + self._get_share_path(share_name), snapshot_name), '') + except exception.NexentaException as e: + with excutils.save_and_reraise_exception() as exc: + if NOT_EXIST in e.args[0]: + LOG.info(_LI('Snapshot %(folder)s@%(snapshot)s does not ' + 'exist, it was already deleted.'), + { + 'folder': share_name, + 'snapshot': snapshot_name, + }) + exc.reraise = False + elif DEP_CLONES in e.args[0]: + LOG.info(_LI( + 'Snapshot %(folder)s@%(snapshot)s has dependent ' + 'clones, it will be deleted later.'), { + 'folder': share_name, + 'snapshot': snapshot_name + }) + exc.reraise = False + + def create_share_from_snapshot(self, share, snapshot): + snapshot_name = '%s/%s/%s@%s' % ( + self.volume, self.share, snapshot['share_name'], snapshot['name']) + self.nms.folder.clone( + snapshot_name, + '%s/%s/%s' % (self.volume, self.share, share['name'])) + path = self._get_share_path(share['name']) + return [self._get_location_path(path, share['share_proto'])] + + def update_access(self, share_name, access_rules): + """Update access to the share.""" + rw_list = [] + ro_list = [] + for rule in access_rules: + if rule['access_type'].lower() != 'ip': + msg = _('Only IP access type is supported.') + raise exception.InvalidShareAccess(reason=msg) + else: + if rule['access_level'] == common.ACCESS_LEVEL_RW: + rw_list.append(rule['access_to']) + else: + ro_list.append(rule['access_to']) + + share_opts = { + 'auth_type': 'none', + 'read_write': ':'.join(rw_list), + 'read_only': ':'.join(ro_list), + 'recursive': 'true', + 'anonymous_rw': 'true', + 'anonymous': 'true', + 'extra_options': 'anon=0', + } + self.nms.netstorsvc.share_folder( + 'svc:/network/nfs/server:default', + self._get_share_path(share_name), share_opts) + + def _get_capacity_info(self): + """Calculate available space on the NFS share.""" + folder_props = self.nms.folder.get_child_props( + '%s/%s' % (self.volume, self.share), 'used|available') + free = utils.str2gib_size(folder_props['available']) + allocated = utils.str2gib_size(folder_props['used']) + return free + allocated, free, allocated + + def update_share_stats(self): + """Update driver capabilities. + + No way of tracking provisioned capacity on this appliance, + not returning any to let the scheduler estimate it. + """ + total, free, allocated = self._get_capacity_info() + compression = not self.dataset_compression == 'off' + dedupe = not self.dataset_dedupe == 'off' + return { + 'vendor_name': 'Nexenta', + 'storage_protocol': self.storage_protocol, + 'nfs_mount_point_base': self.nfs_mount_point_base, + 'pools': [{ + 'pool_name': self.volume, + 'total_capacity_gb': total, + 'free_capacity_gb': free, + 'reserved_percentage': + self.configuration.reserved_share_percentage, + 'compression': compression, + 'dedupe': dedupe, + 'max_over_subscription_ratio': ( + self.configuration.safe_get( + 'max_over_subscription_ratio')), + 'thin_provisioning': + self.configuration.nexenta_thin_provisioning, + }], + } diff --git a/manila/share/drivers/nexenta/ns5/__init__.py b/manila/share/drivers/nexenta/ns5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/share/drivers/nexenta/ns5/jsonrpc.py b/manila/share/drivers/nexenta/ns5/jsonrpc.py new file mode 100644 index 0000000000..88f698ce94 --- /dev/null +++ b/manila/share/drivers/nexenta/ns5/jsonrpc.py @@ -0,0 +1,145 @@ +# Copyright 2016 Nexenta Systems, 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. +""" +:mod:`nexenta.jsonrpc` -- Nexenta-specific JSON RPC client +===================================================================== + +.. automodule:: nexenta.jsonrpc +""" + +import base64 +import json +import requests +from requests.packages.urllib3 import exceptions +import time + +from oslo_log import log +from oslo_serialization import jsonutils + +from manila import exception +from manila.i18n import _ + +LOG = log.getLogger(__name__) +requests.packages.urllib3.disable_warnings(exceptions.InsecureRequestWarning) +requests.packages.urllib3.disable_warnings( + exceptions.InsecurePlatformWarning) +session = requests.Session() + + +class NexentaJSONProxy(object): + def __init__(self, scheme, host, port, user, + password, method='get'): + self.scheme = scheme + self.host = host + self.port = port + self.user = user + self.password = password + self.method = method + + @property + def url(self): + return '%s://%s:%s/' % (self.scheme, self.host, self.port) + + def __getattr__(self, method='get'): + if method: + return NexentaJSONProxy( + self.scheme, self.host, self.port, + self.user, self.password, method) + + def __hash__(self): + return self.url.__hash__() + + def __repr__(self): + return 'NEF proxy: %s' % self.url + + def __call__(self, path, data=None): + auth = base64.b64encode( + ('%s:%s' % (self.user, self.password)).encode('utf-8')) + url = self.url + path + + if data: + data = jsonutils.dumps(data) + + LOG.debug('Sending JSON to url: %s, data: %s, method: %s', + path, data, self.method) + session.headers.update({'Content-Type': 'application/json'}) + + response = getattr(session, self.method)( + url, data=data, verify=False) + if response.status_code in (401, 403): + LOG.debug('Login requested by NexentaStor') + if self.scheme == 'http': + session.headers.update({'Authorization': 'Basic %s' % auth}) + else: + session.headers.update( + {'Authorization': 'Bearer %s' % self.https_auth()}) + LOG.debug('Re-sending JSON to url: %s, data: %s, method: %s', + path, data, self.method) + response = getattr(session, self.method)( + url, data=data, verify=False) + self.check_error(response) + content = json.loads(response.content) if response.content else None + LOG.debug("Got response: %(code)s %(reason)s %(content)s", { + 'code': response.status_code, + 'reason': response.reason, + 'content': content}) + response.close() + + if response.status_code == 202 and content: + url = self.url + content['links'][0]['href'] + keep_going = True + while keep_going: + time.sleep(1) + response = session.get(url, verify=False) + self.check_error(response) + LOG.debug("Got response: %(code)s %(reason)s", { + 'code': response.status_code, + 'reason': response.reason}) + content = json.loads( + response.content) if response.content else None + keep_going = response.status_code == 202 + response.close() + return content + + def https_auth(self): + url = self.url + 'auth/login' + data = jsonutils.dumps( + {'username': self.user, 'password': self.password}) + response = session.post( + url, data=data, verify=False) + content = json.loads(response.content) if response.content else None + LOG.debug("Got response: %(code)s %(reason)s %(content)s", { + 'code': response.status_code, + 'reason': response.reason, + 'content': content}) + response.close() + return content['token'] + + def check_error(self, response): + code = response.status_code + if code not in (200, 201, 202): + reason = response.reason + content = json.loads( + response.content) if response.content else None + response.close() + if content and 'code' in content: + message = content.get( + 'message', 'Message is not specified by Nexenta REST') + raise exception.NexentaException( + reason=message, code=content['code']) + raise exception.NexentaException( + reason=_( + 'Got bad response: %(code)s %(reason)s %(content)s') % { + 'code': code, 'reason': reason, 'content': content}) diff --git a/manila/share/drivers/nexenta/ns5/nexenta_nas.py b/manila/share/drivers/nexenta/ns5/nexenta_nas.py new file mode 100644 index 0000000000..7cd167351a --- /dev/null +++ b/manila/share/drivers/nexenta/ns5/nexenta_nas.py @@ -0,0 +1,422 @@ +# Copyright 2016 Nexenta Systems, 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 +from oslo_utils import units + +from manila.common import constants as common +from manila import exception +from manila.i18n import _, _LW, _LE +from manila.share import driver +from manila.share.drivers.nexenta.ns5 import jsonrpc +from manila.share.drivers.nexenta import options +from manila.share.drivers.nexenta import utils + +PATH_DELIMITER = '%2F' +VERSION = '1.0' +LOG = log.getLogger(__name__) + + +class NexentaNasDriver(driver.ShareDriver): + """Nexenta Share Driver. + + Executes commands relating to Shares. + API version history: + 1.0 - Initial version. + """ + + driver_prefix = 'nexenta' + + def __init__(self, *args, **kwargs): + """Do initialization.""" + LOG.debug('Initializing Nexenta driver.') + super(NexentaNasDriver, self).__init__(False, *args, **kwargs) + self.configuration = kwargs.get('configuration') + if self.configuration: + self.configuration.append_config_values( + options.nexenta_connection_opts) + self.configuration.append_config_values( + options.nexenta_nfs_opts) + self.configuration.append_config_values( + options.nexenta_dataset_opts) + else: + raise exception.BadConfigurationException( + reason=_('Nexenta configuration missing.')) + + self.nef = None + self.nef_protocol = self.configuration.nexenta_rest_protocol + self.nef_host = self.configuration.nexenta_host + self.nef_port = self.configuration.nexenta_rest_port + self.nef_user = self.configuration.nexenta_user + self.nef_password = self.configuration.nexenta_password + + self.pool_name = self.configuration.nexenta_pool + self.fs_prefix = self.configuration.nexenta_nfs_share + + self.storage_protocol = 'NFS' + self.nfs_mount_point_base = self.configuration.nexenta_mount_point_base + self.dataset_compression = ( + self.configuration.nexenta_dataset_compression) + self.provisioned_capacity = 0 + + @property + def share_backend_name(self): + if not hasattr(self, '_share_backend_name'): + self._share_backend_name = None + if self.configuration: + self._share_backend_name = self.configuration.safe_get( + 'share_backend_name') + if not self._share_backend_name: + self._share_backend_name = 'NexentaStor5' + return self._share_backend_name + + def do_setup(self, context): + """Any initialization the nexenta nas driver does while starting.""" + if self.nef_protocol == 'auto': + protocol = 'https' + else: + protocol = self.nef_protocol + self.nef = jsonrpc.NexentaJSONProxy( + protocol, self.nef_host, self.nef_port, self.nef_user, + self.nef_password) + + def check_for_setup_error(self): + """Verify that the volume for our folder exists. + + :raise: :py:exc:`LookupError` + """ + url = 'storage/pools/{}'.format(self.pool_name) + if not self.nef.get(url): + raise LookupError( + _("Pool {} does not exist in Nexenta Store appliance").format( + self.pool_name)) + url = 'storage/pools/{}/filesystems/{}'.format(self.pool_name, + self.fs_prefix) + if not self.nef.get(url): + raise LookupError( + _("filesystem {} does not exist in Nexenta Store " + "appliance").format(self.fs_prefix)) + + path = '/'.join((self.pool_name, self.fs_prefix)) + shared = False + response = self.nef.get('nas/nfs') + for share in response['data']: + if share.get('filesystem') == path: + shared = True + break + if not shared: + raise LookupError(_( + "Dataset {} is not shared in Nexenta Store appliance").format( + path)) + self._get_provisioned_capacity() + + def _get_provisioned_capacity(self): + path = '%(pool)s/%(fs)s' % { + 'pool': self.pool_name, 'fs': self.fs_prefix} + url = 'storage/filesystems?parent=%s' % path + fs_list = self.nef.get(url)['data'] + for fs in fs_list: + if fs['path'] != path: + self.provisioned_capacity += fs['quotaSize'] / units.Gi + + def create_share(self, context, share, share_server=None): + """Create a share.""" + LOG.debug('Creating share: %s.', share['name']) + data = { + 'recordSize': 4 * units.Ki, + 'compressionMode': self.dataset_compression, + 'name': '/'.join((self.fs_prefix, share['name'])), + 'quotaSize': share['size'] * units.Gi, + } + if not self.configuration.nexenta_thin_provisioning: + data['reservationSize'] = share['size'] * units.Gi + + url = 'storage/pools/{}/filesystems'.format(self.pool_name) + self.nef.post(url, data) + location = { + 'path': '{}:/{}/{}/{}'.format(self.nef_host, self.pool_name, + self.fs_prefix, share['name']) + } + + try: + self._add_permission(share['name']) + except exception.NexentaException: + try: + self.delete_share(None, share) + except exception.NexentaException as exc: + LOG.warning(_LW( + "Cannot destroy created filesystem: %(vol)s/%(folder)s, " + "exception: %(exc)s"), + {'vol': self.pool_name, 'folder': '/'.join( + (self.fs_prefix, share['name'])), 'exc': exc}) + raise + self.provisioned_capacity += share['size'] + return [location] + + def create_share_from_snapshot(self, context, share, snapshot, + share_server=None): + """Is called to create share from snapshot.""" + LOG.debug('Creating share from snapshot %s.', snapshot['name']) + url = ('storage/pools/%(pool)s/' + 'filesystems/%(fs)s/snapshots/%(snap)s/clone') % { + 'pool': self.pool_name, + 'fs': PATH_DELIMITER.join( + (self.fs_prefix, snapshot['share_name'])), + 'snap': snapshot['name']} + location = { + 'path': '{}:/{}/{}/{}'.format(self.nef_host, self.pool_name, + self.fs_prefix, share['name']) + } + path = '/'.join((self.pool_name, self.fs_prefix, share['name'])) + data = { + 'targetPath': path, + 'quotaSize': share['size'] * units.Gi, + 'recordSize': 4 * units.Ki, + 'compressionMode': self.dataset_compression, + } + if not self.configuration.nexenta_thin_provisioning: + data['reservationSize'] = share['size'] * units.Gi + self.nef.post(url, data) + + try: + self._add_permission(share['name']) + except exception.NexentaException: + LOG.exception( + _LE('Failed to add permissions for %s'), share['name']) + try: + self.delete_share(None, share) + except exception.NexentaException: + LOG.warning(_LW("Cannot destroy cloned filesystem: " + "%(vol)s/%(filesystem)s"), + {'vol': self.pool_name, + 'filesystem': '/'.join( + (self.fs_prefix, share['name']))}) + raise + + self.provisioned_capacity += share['size'] + return [location] + + def delete_share(self, context, share, share_server=None): + """Delete a share.""" + LOG.debug('Deleting share: %s.', share['name']) + + url = 'storage/pools/%(pool)s/filesystems/%(fs)s' % { + 'pool': self.pool_name, + 'fs': PATH_DELIMITER.join([self.fs_prefix, share['name']]), + } + self.nef.delete(url) + self.provisioned_capacity -= share['size'] + + def extend_share(self, share, new_size, share_server=None): + """Extends a share.""" + LOG.debug( + 'Extending share: %(name)s to %(size)sG.', ( + {'name': share['name'], 'size': new_size})) + self._set_quota(share['name'], new_size) + self.provisioned_capacity += (new_size - share['size']) + + def shrink_share(self, share, new_size, share_server=None): + """Shrinks size of existing share.""" + LOG.debug( + 'Shrinking share: %(name)s to %(size)sG.', { + 'name': share['name'], 'size': new_size}) + url = 'storage/pools/{}/filesystems/{}%2F{}'.format(self.pool_name, + self.fs_prefix, + share['name']) + used = self.nef.get(url)['bytesUsed'] / units.Gi + if used > new_size: + raise exception.ShareShrinkingPossibleDataLoss( + share_id=share['id']) + self._set_quota(share['name'], new_size) + self.provisioned_capacity += (share['size'] - new_size) + + def create_snapshot(self, context, snapshot, share_server=None): + """Create a snapshot.""" + LOG.debug('Creating a snapshot of share: %s.', snapshot['share_name']) + url = 'storage/pools/%(pool)s/filesystems/%(fs)s/snapshots' % { + 'pool': self.pool_name, + 'fs': PATH_DELIMITER.join( + (self.fs_prefix, snapshot['share_name'])), + } + data = {'name': snapshot['name']} + self.nef.post(url, data) + + def delete_snapshot(self, context, snapshot, share_server=None): + """Delete a snapshot.""" + LOG.debug('Deleting a snapshot: %(shr_name)s@%(snap_name)s.', { + 'shr_name': snapshot['share_name'], + 'snap_name': snapshot['name']}) + + url = ('storage/pools/%(pool)s/filesystems/%(fs)s/snapshots/' + '%(snap)s') % {'pool': self.pool_name, + 'fs': PATH_DELIMITER.join( + (self.fs_prefix, snapshot['share_name'])), + 'snap': snapshot['name']} + try: + self.nef.delete(url) + except exception.NexentaException as e: + if e.kwargs['code'] == 'ENOENT': + LOG.warning( + _LW('snapshot %(name)s not found, response: %(msg)s'), { + 'name': snapshot['name'], 'msg': e.msg}) + else: + raise + + def update_access(self, context, share, access_rules, add_rules, + delete_rules, share_server=None): + """Update access rules for given share. + + Using access_rules list for both adding and deleting rules. + :param context: The `context.RequestContext` object for the request + :param share: Share that will have its access rules updated. + :param access_rules: All access rules for given share. This list + is enough to update the access rules for given share. + :param add_rules: Empty List or List of access rules which should be + added. access_rules already contains these rules. Not used by this + driver. + :param delete_rules: Empty List or List of access rules which should be + removed. access_rules doesn't contain these rules. Not used by + this driver. + :param share_server: Data structure with share server information. + Not used by this driver. + """ + LOG.debug('Updating access to share %s.', share) + rw_list = [] + ro_list = [] + security_contexts = [] + for rule in access_rules: + if rule['access_type'].lower() != 'ip': + msg = _('Only IP access type is supported.') + raise exception.InvalidShareAccess(reason=msg) + else: + if rule['access_level'] == common.ACCESS_LEVEL_RW: + rw_list.append(rule['access_to']) + else: + ro_list.append(rule['access_to']) + + def append_sc(addr_list, sc_type): + for addr in addr_list: + address_mask = addr.strip().split('/', 1) + address = address_mask[0] + ls = [{"allow": True, "etype": "network", "entity": address}] + if len(address_mask) == 2: + try: + mask = int(address_mask[1]) + if mask != 32: + ls[0]['mask'] = mask + except Exception: + raise exception.InvalidInput( + reason=_( + '<{}> is not a valid access parameter').format( + addr)) + new_sc = {"securityModes": ["sys"]} + new_sc[sc_type] = ls + security_contexts.append(new_sc) + + append_sc(rw_list, 'readWriteList') + append_sc(ro_list, 'readOnlyList') + data = {"securityContexts": security_contexts} + url = 'nas/nfs/' + PATH_DELIMITER.join( + (self.pool_name, self.fs_prefix, share['name'])) + self.nef.put(url, data) + + def _set_quota(self, share_name, new_size): + quota = new_size * units.Gi + data = {'quotaSize': quota} + if not self.configuration.nexenta_thin_provisioning: + data['reservationSize'] = quota + url = 'storage/pools/{}/filesystems/{}%2F{}'.format(self.pool_name, + self.fs_prefix, + share_name) + self.nef.put(url, data) + + def _update_share_stats(self, data=None): + super(NexentaNasDriver, self)._update_share_stats() + total, free, allocated = self._get_capacity_info() + + data = { + 'vendor_name': 'Nexenta', + 'storage_protocol': self.storage_protocol, + 'share_backend_name': self.share_backend_name, + 'nfs_mount_point_base': self.nfs_mount_point_base, + 'driver_version': VERSION, + 'pools': [{ + 'pool_name': self.pool_name, + 'total_capacity_gb': total, + 'free_capacity_gb': free, + 'reserved_percentage': ( + self.configuration.reserved_share_percentage), + 'max_over_subscription_ratio': ( + self.configuration.safe_get( + 'max_over_subscription_ratio')), + 'thin_provisioning': + self.configuration.nexenta_thin_provisioning, + 'provisioned_capacity_gb': self.provisioned_capacity, + }], + } + self._stats.update(data) + + def _get_capacity_info(self): + """Calculate available space on the NFS share.""" + url = 'storage/pools/{}/filesystems/{}'.format(self.pool_name, + self.fs_prefix) + data = self.nef.get(url) + total = utils.bytes_to_gb(data['bytesAvailable']) + allocated = utils.bytes_to_gb(data['bytesUsed']) + free = total - allocated + return total, free, allocated + + def _add_permission(self, share_name): + """Share NFS filesystem on NexentaStor Appliance. + + :param share_name: relative filesystem name to be shared + """ + LOG.debug( + 'Creating RW ACE for filesystem everyone on Nexenta Store ' + 'for <%s> filesystem.', share_name) + url = 'storage/pools/{}/filesystems/{}/acl'.format( + self.pool_name, PATH_DELIMITER.join((self.fs_prefix, share_name))) + data = { + "type": "allow", + "principal": "everyone@", + "permissions": [ + "list_directory", + "read_data", + "add_file", + "write_data", + "add_subdirectory", + "append_data", + "read_xattr", + "write_xattr", + "execute", + "delete_child", + "read_attributes", + "write_attributes", + "delete", + "read_acl", + "write_acl", + "write_owner", + "synchronize", + ], + "flags": [ + "file_inherit", + "dir_inherit", + ], + } + self.nef.post(url, data) + + LOG.debug( + 'RW ACE for filesystem <%s> on Nexenta Store has been ' + 'successfully created.', share_name) diff --git a/manila/share/drivers/nexenta/options.py b/manila/share/drivers/nexenta/options.py new file mode 100644 index 0000000000..b82b6a6c7e --- /dev/null +++ b/manila/share/drivers/nexenta/options.py @@ -0,0 +1,79 @@ +# Copyright 2016 Nexenta Systems, 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. + +""" +:mod:`nexenta.options` -- Contains configuration options for Nexenta drivers. +============================================================================= + +.. automodule:: nexenta.options +""" + +from oslo_config import cfg + +nexenta_connection_opts = [ + cfg.StrOpt('nexenta_host', + help='IP address of Nexenta storage appliance.'), + cfg.IntOpt('nexenta_rest_port', + default=8457, + help='Port to connect to Nexenta REST API server.'), + cfg.IntOpt('nexenta_retry_count', + default=6, + help='Number of retries for unsuccessful API calls.'), + cfg.StrOpt('nexenta_rest_protocol', + default='auto', + choices=['http', 'https', 'auto'], + help='Use http or https for REST connection (default auto).'), + cfg.StrOpt('nexenta_user', + default='admin', + help='User name to connect to Nexenta SA.'), + cfg.StrOpt('nexenta_password', + help='Password to connect to Nexenta SA.', + secret=True), + cfg.StrOpt('nexenta_volume', + default='volume1', + help='Volume name on NexentaStor.'), + cfg.StrOpt('nexenta_pool', + default='pool1', + help='Pool name on NexentaStor.'), + cfg.BoolOpt('nexenta_nfs', + default=True, + help='On if share over NFS is enabled.'), +] + +nexenta_nfs_opts = [ + cfg.StrOpt('nexenta_mount_point_base', + default='$state_path/mnt', + help='Base directory that contains NFS share mount points.'), +] + +nexenta_dataset_opts = [ + cfg.StrOpt('nexenta_nfs_share', + default='nfs_share', + help='Parent folder on NexentaStor.'), + cfg.StrOpt('nexenta_dataset_compression', + default='on', + choices=['on', 'off', 'gzip', 'gzip-1', 'gzip-2', 'gzip-3', + 'gzip-4', 'gzip-5', 'gzip-6', 'gzip-7', 'gzip-8', + 'gzip-9', 'lzjb', 'zle', 'lz4'], + help='Compression value for new ZFS folders.'), + cfg.StrOpt('nexenta_dataset_dedupe', + default='off', + choices=['on', 'off', 'sha256', 'verify', 'sha256, verify'], + help='Deduplication value for new ZFS folders.'), + cfg.BoolOpt('nexenta_thin_provisioning', + default=True, + help=('If True shares will not be space guaranteed and ' + 'overprovisioning will be enabled.')), +] diff --git a/manila/share/drivers/nexenta/utils.py b/manila/share/drivers/nexenta/utils.py new file mode 100644 index 0000000000..4606622d58 --- /dev/null +++ b/manila/share/drivers/nexenta/utils.py @@ -0,0 +1,54 @@ +# Copyright 2016 Nexenta Systems, 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 re +import six + +from oslo_utils import units + + +def str2size(s, scale=1024): + """Convert size-string. + + String format: [:space:] to bytes. + + :param s: size-string + :param scale: base size + """ + if not s: + return 0 + if isinstance(s, six.integer_types): + return s + + match = re.match(r'^([\.\d]+)\s*([BbKkMmGgTtPpEeZzYy]?)', s) + if match is None: + raise ValueError('Invalid value: %s' % s) + groups = match.groups() + value = float(groups[0]) + suffix = len(groups) > 1 and groups[1].upper() or 'B' + types = ('B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') + for i, t in enumerate(types): + if suffix == t: + return float(value * pow(scale, i)) + + +def str2gib_size(s): + """Covert size-string to size in gigabytes.""" + size_in_bytes = str2size(s) + return size_in_bytes // units.Gi + + +def bytes_to_gb(size): + return float(size) / units.Gi diff --git a/manila/tests/share/drivers/nexenta/__init__.py b/manila/tests/share/drivers/nexenta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/tests/share/drivers/nexenta/ns4/__init__.py b/manila/tests/share/drivers/nexenta/ns4/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/tests/share/drivers/nexenta/ns4/test_jsonrpc.py b/manila/tests/share/drivers/nexenta/ns4/test_jsonrpc.py new file mode 100644 index 0000000000..917824e136 --- /dev/null +++ b/manila/tests/share/drivers/nexenta/ns4/test_jsonrpc.py @@ -0,0 +1,38 @@ +# Copyright 2016 Nexenta Systems, 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 mock import patch +from oslo_serialization import jsonutils +import requests + +from manila import exception +from manila.share.drivers.nexenta.ns4 import jsonrpc +from manila import test + + +class TestNexentaJSONProxy(test.TestCase): + + @patch('requests.post') + def test_call(self, post): + nms_post = jsonrpc.NexentaJSONProxy( + 'http', '1.1.1.1', '8080', 'user', 'pass', + 'obj', auto=False, method='get') + data = {'error': {'message': 'some_error'}} + + post.return_value = requests.Response() + post.return_value.__setstate__({ + 'status_code': 500, '_content': jsonutils.dumps(data)}) + + self.assertRaises(exception.NexentaException, nms_post) diff --git a/manila/tests/share/drivers/nexenta/ns4/test_nexenta_nas.py b/manila/tests/share/drivers/nexenta/ns4/test_nexenta_nas.py new file mode 100644 index 0000000000..516dea5843 --- /dev/null +++ b/manila/tests/share/drivers/nexenta/ns4/test_nexenta_nas.py @@ -0,0 +1,606 @@ +# Copyright 2016 Nexenta Systems, 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 base64 +import json +import mock +from mock import patch +from mock import PropertyMock +from oslo_serialization import jsonutils +from oslo_utils import units + +from manila import context +from manila import exception +from manila.share import configuration as conf +from manila.share.drivers.nexenta.ns4 import nexenta_nas +from manila import test + +PATH_TO_RPC = 'requests.post' +CODE = PropertyMock(return_value=200) + + +class FakeResponse(object): + + def __init__(self, response={}): + self.content = json.dumps(response) + super(FakeResponse, self).__init__() + + def close(self): + pass + + +class RequestParams(object): + def __init__(self, scheme, host, port, path, user, password): + self.scheme = scheme.lower() + self.host = host + self.port = port + self.path = path + self.user = user + self.password = password + + @property + def url(self): + return '%s://%s:%s%s' % (self.scheme, self.host, self.port, self.path) + + @property + def headers(self): + auth = base64.b64encode( + ('%s:%s' % (self.user, self.password)).encode('utf-8')) + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Basic %s' % auth, + } + return headers + + def build_post_args(self, obj, method, *args): + data = jsonutils.dumps({ + 'object': obj, + 'method': method, + 'params': args, + }) + return data + + +class TestNexentaNasDriver(test.TestCase): + + def _get_share_path(self, share_name): + return '%s/%s/%s' % (self.volume, self.share, share_name) + + def setUp(self): + def _safe_get(opt): + return getattr(self.cfg, opt) + + self.cfg = mock.Mock(spec=conf.Configuration) + self.cfg.nexenta_host = '1.1.1.1' + super(TestNexentaNasDriver, self).setUp() + + self.ctx = context.get_admin_context() + self.cfg.safe_get = mock.Mock(side_effect=_safe_get) + self.cfg.nexenta_rest_port = 1000 + self.cfg.reserved_share_percentage = 0 + self.cfg.max_over_subscription_ratio = 0 + self.cfg.nexenta_rest_protocol = 'auto' + self.cfg.nexenta_volume = 'volume' + self.cfg.nexenta_nfs_share = 'nfs_share' + self.cfg.nexenta_user = 'user' + self.cfg.nexenta_password = 'password' + self.cfg.nexenta_thin_provisioning = False + self.cfg.enabled_share_protocols = 'NFS' + self.cfg.nexenta_mount_point_base = '$state_path/mnt' + self.cfg.share_backend_name = 'NexentaStor' + self.cfg.nexenta_dataset_compression = 'on' + self.cfg.nexenta_smb = 'on' + self.cfg.nexenta_nfs = 'on' + self.cfg.nexenta_dataset_dedupe = 'on' + + self.cfg.network_config_group = 'DEFAULT' + self.cfg.admin_network_config_group = ( + 'fake_admin_network_config_group') + self.cfg.driver_handles_share_servers = False + + self.request_params = RequestParams( + 'http', self.cfg.nexenta_host, self.cfg.nexenta_rest_port, + '/rest/nms/', self.cfg.nexenta_user, self.cfg.nexenta_password) + + self.drv = nexenta_nas.NexentaNasDriver(configuration=self.cfg) + self.drv.do_setup(self.ctx) + + self.volume = self.cfg.nexenta_volume + self.share = self.cfg.nexenta_nfs_share + + @patch(PATH_TO_RPC) + def test_check_for_setup_error__volume_doesnt_exist(self, post): + post.return_value = FakeResponse() + + self.assertRaises( + exception.NexentaException, self.drv.check_for_setup_error) + + @patch(PATH_TO_RPC) + def test_check_for_setup_error__folder_doesnt_exist(self, post): + folder = '%s/%s' % (self.volume, self.share) + create_folder_props = { + 'recordsize': '4K', + 'quota': '1G', + 'compression': self.cfg.nexenta_dataset_compression, + 'sharesmb': self.cfg.nexenta_smb, + 'sharenfs': self.cfg.nexenta_nfs, + } + + share_opts = { + 'read_write': '*', + 'read_only': '', + 'root': 'nobody', + 'extra_options': 'anon=0', + 'recursive': 'true', + 'anonymous_rw': 'true', + } + + def my_side_effect(*args, **kwargs): + if kwargs['data'] == self.request_params.build_post_args( + 'volume', 'object_exists', self.volume): + return FakeResponse({'result': 'OK'}) + elif kwargs['data'] == self.request_params.build_post_args( + 'folder', 'object_exists', folder): + return FakeResponse() + elif kwargs['data'] == self.request_params.build_post_args( + 'folder', 'create_with_props', self.volume, self.share, + create_folder_props): + return FakeResponse() + elif kwargs['data'] == self.request_params.build_post_args( + 'netstorsvc', 'share_folder', + 'svc:/network/nfs/server:default', folder, share_opts): + return FakeResponse() + else: + raise exception.ManilaException('Unexpected request') + post.side_effect = my_side_effect + + self.assertRaises( + exception.ManilaException, self.drv.check_for_setup_error) + post.assert_any_call( + self.request_params.url, data=self.request_params.build_post_args( + 'volume', 'object_exists', self.volume), + headers=self.request_params.headers) + post.assert_any_call( + self.request_params.url, data=self.request_params.build_post_args( + 'folder', 'object_exists', folder), + headers=self.request_params.headers) + + @patch(PATH_TO_RPC) + def test_create_share(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols + } + self.cfg.nexenta_thin_provisioning = False + path = '%s/%s/%s' % (self.volume, self.share, share['name']) + location = {'path': '%s:/volumes/%s' % (self.cfg.nexenta_host, path)} + post.return_value = FakeResponse() + + self.assertEqual([location], + self.drv.create_share(self.ctx, share)) + + @patch(PATH_TO_RPC) + def test_create_share__wrong_proto(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': 'A_VERY_WRONG_PROTO' + } + post.return_value = FakeResponse() + + self.assertRaises(exception.InvalidShare, self.drv.create_share, + self.ctx, share) + + @patch(PATH_TO_RPC) + def test_create_share__thin_provisioning(self, post): + share = {'name': 'share', 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols} + create_folder_props = { + 'recordsize': '4K', + 'quota': '1G', + 'compression': self.cfg.nexenta_dataset_compression, + } + parent_path = '%s/%s' % (self.volume, self.share) + post.return_value = FakeResponse() + self.cfg.nexenta_thin_provisioning = True + + self.drv.create_share(self.ctx, share) + + post.assert_called_with( + self.request_params.url, + data=self.request_params.build_post_args( + 'folder', + 'create_with_props', + parent_path, + share['name'], + create_folder_props), + headers=self.request_params.headers) + + @patch(PATH_TO_RPC) + def test_create_share__thick_provisioning(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols + } + quota = '%sG' % share['size'] + create_folder_props = { + 'recordsize': '4K', + 'quota': quota, + 'compression': self.cfg.nexenta_dataset_compression, + 'reservation': quota, + } + parent_path = '%s/%s' % (self.volume, self.share) + post.return_value = FakeResponse() + self.cfg.nexenta_thin_provisioning = False + + self.drv.create_share(self.ctx, share) + + post.assert_called_with( + self.request_params.url, + data=self.request_params.build_post_args( + 'folder', + 'create_with_props', + parent_path, + share['name'], + create_folder_props), + headers=self.request_params.headers) + + @patch(PATH_TO_RPC) + def test_create_share_from_snapshot(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols + } + snapshot = {'name': 'sn1', 'share_name': share['name']} + post.return_value = FakeResponse() + path = '%s/%s/%s' % (self.volume, self.share, share['name']) + location = {'path': '%s:/volumes/%s' % (self.cfg.nexenta_host, path)} + snapshot_name = '%s/%s/%s@%s' % ( + self.volume, self.share, snapshot['share_name'], snapshot['name']) + + self.assertEqual([location], self.drv.create_share_from_snapshot( + self.ctx, share, snapshot)) + post.assert_any_call( + self.request_params.url, + data=self.request_params.build_post_args( + 'folder', + 'clone', + snapshot_name, + '%s/%s/%s' % (self.volume, self.share, share['name'])), + headers=self.request_params.headers) + + @patch(PATH_TO_RPC) + def test_delete_share(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols + } + post.return_value = FakeResponse() + folder = '%s/%s/%s' % (self.volume, self.share, share['name']) + + self.drv.delete_share(self.ctx, share) + + post.assert_any_call( + self.request_params.url, + data=self.request_params.build_post_args( + 'folder', + 'destroy', + folder.strip(), + '-r'), + headers=self.request_params.headers) + + @patch(PATH_TO_RPC) + def test_delete_share__exists_error(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols + } + post.return_value = FakeResponse() + post.side_effect = exception.NexentaException('does not exist') + + self.drv.delete_share(self.ctx, share) + + @patch(PATH_TO_RPC) + def test_delete_share__some_error(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols + } + post.return_value = FakeResponse() + post.side_effect = exception.ManilaException('Some error') + + self.assertRaises( + exception.ManilaException, self.drv.delete_share, self.ctx, share) + + @patch(PATH_TO_RPC) + def test_extend_share__thin_provisoning(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols + } + new_size = 5 + quota = '%sG' % new_size + post.return_value = FakeResponse() + self.cfg.nexenta_thin_provisioning = True + + self.drv.extend_share(share, new_size) + + post.assert_called_with( + self.request_params.url, + data=self.request_params.build_post_args( + 'folder', + 'set_child_prop', + '%s/%s/%s' % (self.volume, self.share, share['name']), + 'quota', quota), + headers=self.request_params.headers) + + @patch(PATH_TO_RPC) + def test_extend_share__thick_provisoning(self, post): + share = { + 'name': 'share', + 'size': 1, + 'share_proto': self.cfg.enabled_share_protocols + } + new_size = 5 + post.return_value = FakeResponse() + self.cfg.nexenta_thin_provisioning = False + + self.drv.extend_share(share, new_size) + + post.assert_not_called() + + @patch(PATH_TO_RPC) + def test_create_snapshot(self, post): + snapshot = {'share_name': 'share', 'name': 'share@first'} + post.return_value = FakeResponse() + folder = '%s/%s/%s' % (self.volume, self.share, snapshot['share_name']) + + self.drv.create_snapshot(self.ctx, snapshot) + + post.assert_called_with( + self.request_params.url, data=self.request_params.build_post_args( + 'folder', 'create_snapshot', folder, snapshot['name'], '-r'), + headers=self.request_params.headers) + + @patch(PATH_TO_RPC) + def test_delete_snapshot(self, post): + snapshot = {'share_name': 'share', 'name': 'share@first'} + post.return_value = FakeResponse() + + self.drv.delete_snapshot(self.ctx, snapshot) + + post.assert_called_with( + self.request_params.url, data=self.request_params.build_post_args( + 'snapshot', 'destroy', '%s@%s' % ( + self._get_share_path(snapshot['share_name']), + snapshot['name']), + ''), + headers=self.request_params.headers) + + @patch(PATH_TO_RPC) + def test_delete_snapshot__nexenta_error_1(self, post): + snapshot = {'share_name': 'share', 'name': 'share@first'} + post.return_value = FakeResponse() + post.side_effect = exception.NexentaException('does not exist') + + self.drv.delete_snapshot(self.ctx, snapshot) + + @patch(PATH_TO_RPC) + def test_delete_snapshot__nexenta_error_2(self, post): + snapshot = {'share_name': 'share', 'name': 'share@first'} + post.return_value = FakeResponse() + post.side_effect = exception.NexentaException('has dependent clones') + + self.drv.delete_snapshot(self.ctx, snapshot) + + @patch(PATH_TO_RPC) + def test_delete_snapshot__some_error(self, post): + snapshot = {'share_name': 'share', 'name': 'share@first'} + post.return_value = FakeResponse() + post.side_effect = exception.ManilaException('Some error') + + self.assertRaises(exception.ManilaException, self.drv.delete_snapshot, + self.ctx, snapshot) + + @patch(PATH_TO_RPC) + def test_update_access__unsupported_access_type(self, post): + share = { + 'name': 'share', + 'share_proto': self.cfg.enabled_share_protocols + } + access = { + 'access_type': 'group', + 'access_to': 'ordinary_users', + 'access_level': 'rw' + } + + self.assertRaises(exception.InvalidShareAccess, + self.drv.update_access, + self.ctx, + share, + [access], + None, + None) + + @patch(PATH_TO_RPC) + def test_update_access__cidr(self, post): + share = { + 'name': 'share', + 'share_proto': self.cfg.enabled_share_protocols + } + access1 = { + 'access_type': 'ip', + 'access_to': '1.1.1.1/24', + 'access_level': 'rw' + } + access2 = { + 'access_type': 'ip', + 'access_to': '1.2.3.4', + 'access_level': 'rw' + } + access_rules = [access1, access2] + + share_opts = { + 'auth_type': 'none', + 'read_write': '%s:%s' % ( + access1['access_to'], access2['access_to']), + 'read_only': '', + 'recursive': 'true', + 'anonymous_rw': 'true', + 'anonymous': 'true', + 'extra_options': 'anon=0', + } + + def my_side_effect(*args, **kwargs): + if kwargs['data'] == self.request_params.build_post_args( + 'netstorsvc', 'share_folder', + 'svc:/network/nfs/server:default', + self._get_share_path(share['name']), share_opts): + return FakeResponse() + else: + raise exception.ManilaException('Unexpected request') + + post.return_value = FakeResponse() + post.side_effect = my_side_effect + + self.drv.update_access(self.ctx, share, access_rules, None, None) + + post.assert_called_with( + self.request_params.url, data=self.request_params.build_post_args( + 'netstorsvc', 'share_folder', + 'svc:/network/nfs/server:default', + self._get_share_path(share['name']), share_opts), + headers=self.request_params.headers) + self.assertRaises(exception.ManilaException, self.drv.update_access, + self.ctx, share, + [access1, {'access_type': 'ip', + 'access_to': '2.2.2.2', + 'access_level': 'rw'}], + None, None) + + @patch(PATH_TO_RPC) + def test_update_access__add_one_ip_to_empty_access_list(self, post): + share = {'name': 'share', + 'share_proto': self.cfg.enabled_share_protocols} + access = { + 'access_type': 'ip', + 'access_to': '1.1.1.1', + 'access_level': 'rw' + } + + rw_list = None + share_opts = { + 'auth_type': 'none', + 'read_write': access['access_to'], + 'read_only': '', + 'recursive': 'true', + 'anonymous_rw': 'true', + 'anonymous': 'true', + 'extra_options': 'anon=0', + } + + def my_side_effect(*args, **kwargs): + if kwargs['data'] == self.request_params.build_post_args( + 'netstorsvc', 'get_shareopts', + 'svc:/network/nfs/server:default', + self._get_share_path(share['name'])): + return FakeResponse({'result': {'read_write': rw_list}}) + elif kwargs['data'] == self.request_params.build_post_args( + 'netstorsvc', 'share_folder', + 'svc:/network/nfs/server:default', + self._get_share_path(share['name']), share_opts): + return FakeResponse() + else: + raise exception.ManilaException('Unexpected request') + post.return_value = FakeResponse() + + self.drv.update_access(self.ctx, share, [access], None, None) + + post.assert_called_with( + self.request_params.url, data=self.request_params.build_post_args( + 'netstorsvc', 'share_folder', + 'svc:/network/nfs/server:default', + self._get_share_path(share['name']), share_opts), + headers=self.request_params.headers) + + post.side_effect = my_side_effect + + self.assertRaises(exception.ManilaException, self.drv.update_access, + self.ctx, share, + [{'access_type': 'ip', + 'access_to': '1111', + 'access_level': 'rw'}], + None, None) + + @patch(PATH_TO_RPC) + def test_deny_access__unsupported_access_type(self, post): + share = {'name': 'share', + 'share_proto': self.cfg.enabled_share_protocols} + access = { + 'access_type': 'group', + 'access_to': 'ordinary_users', + 'access_level': 'rw' + } + + self.assertRaises(exception.InvalidShareAccess, self.drv.update_access, + self.ctx, share, [access], None, None) + + def test_share_backend_name(self): + self.assertEqual('NexentaStor', self.drv.share_backend_name) + + @patch(PATH_TO_RPC) + def test_get_capacity_info(self, post): + post.return_value = FakeResponse({'result': { + 'available': 9 * units.Gi, 'used': 1 * units.Gi}}) + + self.assertEqual( + (10, 9, 1), self.drv.helper._get_capacity_info()) + + @patch('manila.share.drivers.nexenta.ns4.nexenta_nfs_helper.NFSHelper.' + '_get_capacity_info') + @patch('manila.share.driver.ShareDriver._update_share_stats') + def test_update_share_stats(self, super_stats, info): + info.return_value = (100, 90, 10) + stats = { + 'vendor_name': 'Nexenta', + 'storage_protocol': 'NFS', + 'nfs_mount_point_base': self.cfg.nexenta_mount_point_base, + 'driver_version': '1.0', + 'share_backend_name': self.cfg.share_backend_name, + 'pools': [{ + 'total_capacity_gb': 100, + 'free_capacity_gb': 90, + 'pool_name': 'volume', + 'reserved_percentage': ( + self.cfg.reserved_share_percentage), + 'compression': True, + 'dedupe': True, + 'thin_provisioning': self.cfg.nexenta_thin_provisioning, + 'max_over_subscription_ratio': ( + self.cfg.safe_get( + 'max_over_subscription_ratio')), + }], + } + + self.drv._update_share_stats() + + self.assertEqual(stats, self.drv._stats) diff --git a/manila/tests/share/drivers/nexenta/ns5/__init__.py b/manila/tests/share/drivers/nexenta/ns5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/tests/share/drivers/nexenta/ns5/test_jsonrpc.py b/manila/tests/share/drivers/nexenta/ns5/test_jsonrpc.py new file mode 100644 index 0000000000..8dd0fe3864 --- /dev/null +++ b/manila/tests/share/drivers/nexenta/ns5/test_jsonrpc.py @@ -0,0 +1,129 @@ +# Copyright 2016 Nexenta Systems, 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 mock import patch +from oslo_serialization import jsonutils +import requests + +from manila import exception +from manila.share.drivers.nexenta.ns5 import jsonrpc +from manila import test + +PATH_TO_RPC = 'manila.share.drivers.nexenta.ns5.jsonrpc.NexentaJSONProxy' + + +class TestNexentaJSONProxy(test.TestCase): + + def __init__(self, method): + super(self.__class__, self).__init__(method) + + def setUp(self): + super(self.__class__, self).setUp() + self.nef_get = jsonrpc.NexentaJSONProxy( + 'http', '1.1.1.1', '8080', 'user', 'pass', 'get') + self.nef_post = jsonrpc.NexentaJSONProxy( + 'https', '1.1.1.1', '8080', 'user', 'pass', 'post') + + @patch('requests.Response.close') + @patch('requests.Session.get') + def test_call_get_data(self, get, close): + data = {'key': 'value'} + get.return_value = requests.Response() + get.return_value.__setstate__( + {'status_code': 200, '_content': jsonutils.dumps(data)}) + + self.assertEqual({'key': 'value'}, self.nef_get('url')) + + @patch('requests.Response.close') + @patch('requests.Session.get') + def test_call_get_created(self, get, close): + get.return_value = requests.Response() + get.return_value.__setstate__({ + 'status_code': 201, '_content': ''}) + + self.assertIsNone(self.nef_get('url')) + + @patch('requests.Response.close') + @patch('requests.Session.post') + def test_call_post_success(self, post, close): + data = {'key': 'value'} + post.return_value = requests.Response() + post.return_value.__setstate__({ + 'status_code': 200, '_content': ''}) + self.assertIsNone(self.nef_post('url', data)) + + @patch('time.sleep') + @patch('requests.Response.close') + @patch('requests.Session.get') + @patch('requests.Session.post') + def test_call_post_202(self, post, get, close, sleep): + data = {'key': 'value'} + data2 = {'links': [{'href': 'redirect_url'}]} + + get.return_value = requests.Response() + post.return_value = requests.Response() + post.return_value.__setstate__({ + 'status_code': 202, '_content': jsonutils.dumps(data2)}) + get.return_value.__setstate__({ + 'status_code': 200, '_content': jsonutils.dumps(data)}) + + self.assertEqual({'key': 'value'}, self.nef_post('url')) + + @patch('requests.Response.close') + @patch('requests.Session.get') + def test_call_get_not_exist(self, get, close): + get.return_value = requests.Response() + get.return_value.__setstate__({ + 'status_code': 400, + '_content': jsonutils.dumps({'code': 'ENOENT'})}) + + self.assertRaises( + exception.NexentaException, lambda: self.nef_get('url')) + + @patch('requests.Response.close') + @patch('requests.Session.get') + def test_call_get_unauthorized(self, get, close): + get.return_value = requests.Response() + get.return_value.__setstate__({ + 'status_code': 401, + '_content': jsonutils.dumps({'code': 'unauthorized'})}) + + self.assertRaises( + exception.NexentaException, lambda: self.nef_get('url')) + + @patch('%s.https_auth' % PATH_TO_RPC) + @patch('requests.Response.close') + @patch('requests.Session.post') + def test_call_post_bad_token(self, post, close, auth): + post.return_value = requests.Response() + auth.return_value = {'token': 'tok'} + post.return_value.__setstate__({ + 'status_code': 401, + '_content': jsonutils.dumps({'code': 'unauthorized'})}) + + self.assertRaises( + exception.NexentaException, lambda: self.nef_post('url')) + + @patch('requests.Response.close') + @patch('requests.Session.post') + def test_auth(self, post, close): + httpsdata = {'token': 'tok'} + post.return_value = requests.Response() + post.return_value.__setstate__({ + 'status_code': 200, '_content': jsonutils.dumps(httpsdata)}) + nef_get = jsonrpc.NexentaJSONProxy( + 'http', '1.1.1.1', '8080', 'user', 'pass', method='get') + https_auth = nef_get.https_auth() + self.assertEqual('tok', https_auth) diff --git a/manila/tests/share/drivers/nexenta/ns5/test_nexenta_nas.py b/manila/tests/share/drivers/nexenta/ns5/test_nexenta_nas.py new file mode 100644 index 0000000000..a86db5f8f7 --- /dev/null +++ b/manila/tests/share/drivers/nexenta/ns5/test_nexenta_nas.py @@ -0,0 +1,378 @@ +# Copyright 2016 Nexenta Systems, 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 ddt +import mock +from mock import patch +from oslo_utils import units + +from manila import context +from manila import exception +from manila.share import configuration as conf +from manila.share.drivers.nexenta.ns5 import nexenta_nas +from manila import test + +PATH_TO_RPC = 'manila.share.drivers.nexenta.ns5.jsonrpc.NexentaJSONProxy' +DRV_PATH = 'manila.share.drivers.nexenta.ns5.nexenta_nas.NexentaNasDriver' + + +@ddt.ddt +class TestNexentaNasDriver(test.TestCase): + + def setUp(self): + def _safe_get(opt): + return getattr(self.cfg, opt) + self.cfg = conf.Configuration(None) + self.cfg.nexenta_host = '1.1.1.1' + super(self.__class__, self).setUp() + self.ctx = context.get_admin_context() + self.mock_object( + self.cfg, 'safe_get', mock.Mock(side_effect=_safe_get)) + self.cfg.nexenta_rest_port = 8080 + self.cfg.nexenta_rest_protocol = 'auto' + self.cfg.nexenta_pool = 'pool1' + self.cfg.reserved_share_percentage = 0 + self.cfg.nexenta_nfs_share = 'nfs_share' + self.cfg.nexenta_user = 'user' + self.cfg.share_backend_name = 'NexentaStor5' + self.cfg.nexenta_password = 'password' + self.cfg.nexenta_thin_provisioning = False + self.cfg.nexenta_mount_point_base = 'mnt' + self.cfg.enabled_share_protocols = 'NFS' + self.cfg.nexenta_mount_point_base = '$state_path/mnt' + self.cfg.nexenta_dataset_compression = 'on' + self.cfg.network_config_group = 'DEFAULT' + self.cfg.admin_network_config_group = ( + 'fake_admin_network_config_group') + self.cfg.driver_handles_share_servers = False + + self.drv = nexenta_nas.NexentaNasDriver(configuration=self.cfg) + self.drv.do_setup(self.ctx) + self.mock_rpc = self.mock_class(PATH_TO_RPC) + self.pool_name = self.cfg.nexenta_pool + self.fs_prefix = self.cfg.nexenta_nfs_share + + def test_backend_name(self): + self.assertEqual('NexentaStor5', self.drv.share_backend_name) + + @patch('%s._get_provisioned_capacity' % DRV_PATH) + def test_check_for_setup_error(self, mock_provisioned): + self.drv.nef.get.return_value = None + + self.assertRaises(LookupError, self.drv.check_for_setup_error) + + @patch('%s._get_provisioned_capacity' % DRV_PATH) + def test_check_for_setup_error__none(self, mock_provisioned): + self.drv.nef.get.return_value = { + 'data': [{'filesystem': 'pool1/nfs_share', 'quotaSize': 1}] + } + + self.assertIsNone(self.drv.check_for_setup_error()) + + @patch('%s._get_provisioned_capacity' % DRV_PATH) + def test_check_for_setup_error__with_data(self, mock_provisioned): + self.drv.nef.get.return_value = { + 'data': [{'filesystem': 'asd', 'quotaSize': 1}]} + + self.assertRaises(LookupError, self.drv.check_for_setup_error) + + def test__get_provisioned_capacity(self): + self.drv.nef.get.return_value = { + 'data': [ + {'path': 'pool1/nfs_share/123', 'quotaSize': 1 * units.Gi}] + } + + self.drv._get_provisioned_capacity() + + self.assertEqual(1, self.drv.provisioned_capacity) + + def test_create_share(self): + share = {'name': 'share', 'size': 1} + + self.assertEqual( + [{ + 'path': '{}:/{}/{}/{}'.format( + self.cfg.nexenta_host, self.pool_name, + self.fs_prefix, share['name']) + }], + self.drv.create_share(self.ctx, share)) + + @patch('%s.delete_share' % DRV_PATH) + @patch('%s._add_permission' % DRV_PATH) + def test_create_share__error_on_add_permission( + self, add_permission_mock, delete_share): + share = {'name': 'share', 'size': 1} + add_permission_mock.side_effect = exception.NexentaException( + 'An error occurred while adding permission') + delete_share.side_effect = exception.NexentaException( + 'An error occurred while deleting') + + self.assertRaises( + exception.NexentaException, self.drv.create_share, self.ctx, share) + + def test_create_share_from_snapshot(self): + share = {'name': 'share', 'size': 1} + snapshot = {'name': 'share@first', 'share_name': 'share'} + + self.assertEqual( + [{ + 'path': '{}:/{}/{}/{}'.format( + self.cfg.nexenta_host, self.pool_name, + self.fs_prefix, share['name']) + }], + self.drv.create_share_from_snapshot(self.ctx, share, snapshot) + ) + + @patch('%s.delete_share' % DRV_PATH) + @patch('%s._add_permission' % DRV_PATH) + def test_create_share_from_snapshot__add_permission_error( + self, add_permission_mock, delete_share): + share = {'name': 'share', 'size': 1} + snapshot = {'share_name': 'share', 'name': 'share@first'} + delete_share.side_effect = exception.NexentaException( + 'An error occurred while deleting') + add_permission_mock.side_effect = exception.NexentaException( + 'Some exception') + + self.assertRaises( + exception.NexentaException, self.drv.create_share_from_snapshot, + self.ctx, share, snapshot) + + @patch('%s._add_permission' % DRV_PATH) + def test_create_share_from_snapshot__add_permission_error_error( + self, add_permission_mock): + share = {'name': 'share', 'size': 1} + snapshot = {'share_name': 'share', 'name': 'share@first'} + add_permission_mock.side_effect = exception.NexentaException( + 'Some exception') + self.drv.nef.delete.side_effect = exception.NexentaException( + 'Some exception 2') + + self.assertRaises( + exception.NexentaException, self.drv.create_share_from_snapshot, + self.ctx, share, snapshot) + + def test_delete_share(self): + share = {'name': 'share', 'size': 1} + + self.assertIsNone(self.drv.delete_share(self.ctx, share)) + + def test_extend_share(self): + share = {'name': 'share', 'size': 1} + new_size = 2 + quota = new_size * units.Gi + data = { + 'reservationSize': quota, + 'quotaSize': quota, + } + url = 'storage/pools/{}/filesystems/{}%2F{}'.format( + self.pool_name, self.fs_prefix, share['name']) + + self.drv.extend_share(share, new_size) + + self.drv.nef.post.assert_called_with(url, data) + + def test_shrink_share(self): + share = {'name': 'share', 'size': 2} + new_size = 1 + quota = new_size * units.Gi + data = { + 'reservationSize': quota, + 'quotaSize': quota + } + url = 'storage/pools/{}/filesystems/{}%2F{}'.format( + self.pool_name, self.fs_prefix, share['name']) + self.drv.nef.get.return_value = {'bytesUsed': 512} + + self.drv.shrink_share(share, new_size) + + self.drv.nef.post.assert_called_with(url, data) + + def test_create_snapshot(self): + snapshot = {'share_name': 'share', 'name': 'share@first'} + url = 'storage/pools/%(pool)s/filesystems/%(fs)s/snapshots' % { + 'pool': self.pool_name, + 'fs': nexenta_nas.PATH_DELIMITER.join( + [self.fs_prefix, snapshot['share_name']]) + } + data = {'name': snapshot['name']} + + self.drv.create_snapshot(self.ctx, snapshot) + + self.drv.nef.post.assert_called_with(url, data) + + def test_delete_snapshot(self): + self.mock_rpc.side_effect = exception.NexentaException( + 'err', code='ENOENT') + snapshot = {'share_name': 'share', 'name': 'share@first'} + + self.assertIsNone(self.drv.delete_snapshot(self.ctx, snapshot)) + + self.mock_rpc.side_effect = exception.NexentaException( + 'err', code='somecode') + + self.assertRaises( + exception.NexentaException, self.drv.delete_snapshot, + self.ctx, snapshot) + + def build_access_security_context(self, level, ip, mask=None): + ls = [{"allow": True, "etype": "network", "entity": ip}] + if mask is not None: + ls[0]['mask'] = mask + new_sc = { + "securityModes": ["sys"], + } + if level == 'rw': + new_sc['readWriteList'] = ls + elif level == 'ro': + new_sc['readOnlyList'] = ls + else: + raise exception.ManilaException('Wrong access level') + return new_sc + + def test_update_access__unsupported_access_type(self): + share = {'name': 'share', 'size': 1} + access = { + 'access_type': 'group', + 'access_to': 'ordinary_users', + 'access_level': 'rw' + } + + self.assertRaises(exception.InvalidShareAccess, self.drv.update_access, + self.ctx, share, [access], None, None) + + def test_update_access__cidr(self): + share = {'name': 'share', 'size': 1} + access = { + 'access_type': 'ip', + 'access_to': '1.1.1.1/24', + 'access_level': 'rw' + } + url = 'nas/nfs/' + nexenta_nas.PATH_DELIMITER.join( + (self.pool_name, self.fs_prefix, share['name'])) + self.drv.nef.get.return_value = {} + + self.drv.update_access(self.ctx, share, [access], None, None) + + self.drv.nef.put.assert_called_with( + url, {'securityContexts': [ + self.build_access_security_context('rw', '1.1.1.1', 24)]}) + + def test_update_access__ip(self): + share = {'name': 'share', 'size': 1} + access = { + 'access_type': 'ip', + 'access_to': '1.1.1.1', + 'access_level': 'rw' + } + url = 'nas/nfs/' + nexenta_nas.PATH_DELIMITER.join( + (self.pool_name, self.fs_prefix, share['name'])) + self.drv.nef.get.return_value = {} + + self.drv.update_access(self.ctx, share, [access], None, None) + + self.drv.nef.put.assert_called_with( + url, {'securityContexts': [ + self.build_access_security_context('rw', '1.1.1.1')]}) + + @ddt.data('rw', 'ro') + def test_update_access__cidr_wrong_mask(self, access_level): + share = {'name': 'share', 'size': 1} + access = { + 'access_type': 'ip', + 'access_to': '1.1.1.1/aa', + 'access_level': access_level, + } + + self.assertRaises(exception.InvalidInput, self.drv.update_access, + self.ctx, share, [access], None, None) + + def test_update_access__one_ip_ro_add_rule_to_existing(self): + share = {'name': 'share', 'size': 1} + access = [ + { + 'access_type': 'ip', + 'access_to': '5.5.5.5', + 'access_level': 'ro' + }, + { + 'access_type': 'ip', + 'access_to': '1.1.1.1/24', + 'access_level': 'rw' + } + ] + url = 'nas/nfs/' + nexenta_nas.PATH_DELIMITER.join( + (self.pool_name, self.fs_prefix, share['name'])) + sc = self.build_access_security_context('rw', '1.1.1.1', 24) + self.drv.nef.get.return_value = {'securityContexts': [sc]} + + self.drv.update_access(self.ctx, share, access, None, None) + + self.drv.nef.put.assert_called_with( + url, {'securityContexts': [ + sc, self.build_access_security_context('ro', '5.5.5.5')]}) + + def test_update_access__one_ip_ro_add_rule_to_existing_wrong_mask( + self): + share = {'name': 'share', 'size': 1} + access = [ + { + 'access_type': 'ip', + 'access_to': '5.5.5.5/aa', + 'access_level': 'ro' + }, + { + 'access_type': 'ip', + 'access_to': '1.1.1.1/24', + 'access_level': 'rw' + } + ] + sc = self.build_access_security_context('rw', '1.1.1.1', 24) + self.drv.nef.get.return_value = {'securityContexts': [sc]} + + self.assertRaises(exception.InvalidInput, self.drv.update_access, + self.ctx, share, access, None, None) + + @patch('%s._get_capacity_info' % DRV_PATH) + @patch('manila.share.driver.ShareDriver._update_share_stats') + def test_update_share_stats(self, super_stats, info): + info.return_value = (100, 90, 10) + stats = { + 'vendor_name': 'Nexenta', + 'storage_protocol': 'NFS', + 'nfs_mount_point_base': self.cfg.nexenta_mount_point_base, + 'driver_version': '1.0', + 'share_backend_name': self.cfg.share_backend_name, + 'pools': [{ + 'pool_name': 'pool1', + 'total_capacity_gb': 100, + 'free_capacity_gb': 90, + 'provisioned_capacity_gb': 0, + 'max_over_subscription_ratio': 20.0, + 'reserved_percentage': ( + self.cfg.reserved_share_percentage), + 'thin_provisioning': self.cfg.nexenta_thin_provisioning, + }], + } + + self.drv._update_share_stats() + + self.assertEqual(stats, self.drv._stats) + + def test_get_capacity_info(self): + self.drv.nef.get.return_value = { + 'bytesAvailable': 10 * units.Gi, 'bytesUsed': 1 * units.Gi} + + self.assertEqual((10, 9, 1), self.drv._get_capacity_info()) diff --git a/manila/tests/share/drivers/nexenta/test_utils.py b/manila/tests/share/drivers/nexenta/test_utils.py new file mode 100644 index 0000000000..22802b741e --- /dev/null +++ b/manila/tests/share/drivers/nexenta/test_utils.py @@ -0,0 +1,49 @@ +# Copyright 2016 Nexenta Systems, 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 ddt +from oslo_utils import units + +from manila.share.drivers.nexenta import utils +from manila import test + + +@ddt.ddt +class TestNexentaUtils(test.TestCase): + + @ddt.data( + # Test empty value + (None, 0), + ('', 0), + ('0', 0), + ('12', 12), + # Test int values + (10, 10), + # Test bytes string + ('1b', 1), + ('1B', 1), + ('1023b', 1023), + ('0B', 0), + # Test other units + ('1M', units.Mi), + ('1.0M', units.Mi), + ) + @ddt.unpack + def test_str2size(self, value, result): + self.assertEqual(result, utils.str2size(value)) + + def test_str2size_input_error(self): + # Invalid format value + self.assertRaises(ValueError, utils.str2size, 'A') diff --git a/releasenotes/notes/nexenta-manila-drivers-cbd0b376a076ec50.yaml b/releasenotes/notes/nexenta-manila-drivers-cbd0b376a076ec50.yaml new file mode 100644 index 0000000000..684fa94c44 --- /dev/null +++ b/releasenotes/notes/nexenta-manila-drivers-cbd0b376a076ec50.yaml @@ -0,0 +1,3 @@ +features: + - Added share backend drivers for NexentaStor4 and NexentaStor5 appliances. +