From 5fb872fed18c9b0ae1bc5741017e90591a847709 Mon Sep 17 00:00:00 2001 From: Rodrigo Barbieri Date: Fri, 31 Jul 2015 18:05:59 -0300 Subject: [PATCH] New Manila HDS HNAS Driver This patch adds new Manila HDS HNAS Driver, according to blueprint. Change-Id: I0f9ae2a940df5415b93f6a6c5c2b0fac1cb062fd Implements: blueprint hds-hnas --- manila/exception.py | 8 + manila/opts.py | 2 + manila/share/drivers/hitachi/__init__.py | 0 manila/share/drivers/hitachi/hds_hnas.py | 443 +++++++ manila/share/drivers/hitachi/ssh.py | 708 +++++++++++ .../tests/share/drivers/hitachi/__init__.py | 0 .../share/drivers/hitachi/test_hds_hnas.py | 438 +++++++ .../tests/share/drivers/hitachi/test_ssh.py | 1068 +++++++++++++++++ manila/tests/test_utils.py | 21 + manila/utils.py | 36 + 10 files changed, 2724 insertions(+) create mode 100644 manila/share/drivers/hitachi/__init__.py create mode 100644 manila/share/drivers/hitachi/hds_hnas.py create mode 100644 manila/share/drivers/hitachi/ssh.py create mode 100644 manila/tests/share/drivers/hitachi/__init__.py create mode 100644 manila/tests/share/drivers/hitachi/test_hds_hnas.py create mode 100644 manila/tests/share/drivers/hitachi/test_ssh.py diff --git a/manila/exception.py b/manila/exception.py index 82d142fc1c..4e986f5a69 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -632,3 +632,11 @@ class QBRpcException(ManilaException): message = _("Quobyte JsonRpc call to backend raised " "an exception: %(result)s, Quobyte error" " code %(qbcode)s") + + +class SSHInjectionThreat(ManilaException): + message = _("SSH command injection detected: %(command)s") + + +class HNASBackendException(ManilaException): + message = _("HNAS Backend Exception: %(msg)s") diff --git a/manila/opts.py b/manila/opts.py index 9e20aaaed0..2b0d066ce6 100644 --- a/manila/opts.py +++ b/manila/opts.py @@ -57,6 +57,7 @@ import manila.share.drivers.glusterfs import manila.share.drivers.glusterfs_native import manila.share.drivers.hdfs.hdfs_native import manila.share.drivers.hds.sop +import manila.share.drivers.hitachi.hds_hnas import manila.share.drivers.hp.hp_3par_driver import manila.share.drivers.huawei.huawei_nas import manila.share.drivers.ibm.gpfs @@ -113,6 +114,7 @@ _global_opt_lists = [ manila.share.drivers.glusterfs_native.glusterfs_native_manila_share_opts, manila.share.drivers.hdfs.hdfs_native.hdfs_native_share_opts, manila.share.drivers.hds.sop.hdssop_share_opts, + manila.share.drivers.hitachi.hds_hnas.hds_hnas_opts, manila.share.drivers.hp.hp_3par_driver.HP3PAR_OPTS, manila.share.drivers.huawei.huawei_nas.huawei_opts, manila.share.drivers.ibm.gpfs.gpfs_share_opts, diff --git a/manila/share/drivers/hitachi/__init__.py b/manila/share/drivers/hitachi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/share/drivers/hitachi/hds_hnas.py b/manila/share/drivers/hitachi/hds_hnas.py new file mode 100644 index 0000000000..03ef8feb8e --- /dev/null +++ b/manila/share/drivers/hitachi/hds_hnas.py @@ -0,0 +1,443 @@ +# Copyright (c) 2015 Hitachi Data 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_config import cfg +from oslo_log import log +from oslo_utils import strutils +import six + +from manila.common import constants as const +from manila import exception +from manila.i18n import _ +from manila.i18n import _LE +from manila.i18n import _LI +from manila.share import driver +from manila.share.drivers.hitachi import ssh +from manila.share import share_types + +LOG = log.getLogger(__name__) + +hds_hnas_opts = [ + cfg.StrOpt('hds_hnas_ip', + default=None, + help="HNAS management interface IP for communication " + "between Manila controller and HNAS."), + cfg.StrOpt('hds_hnas_user', + default=None, + help="HNAS username Base64 String in order to perform tasks " + "such as create file-systems and network interfaces."), + cfg.StrOpt('hds_hnas_password', + default=None, + secret=True, + help="HNAS user password. Required only if private key is not " + "provided."), + cfg.StrOpt('hds_hnas_evs_id', + default=None, + help="Specify which EVS this backend is assigned to."), + cfg.StrOpt('hds_hnas_evs_ip', + default=None, + help="Specify IP for mounting shares."), + cfg.StrOpt('hds_hnas_file_system_name', + default=None, + help="Specify file-system name for creating shares."), + cfg.StrOpt('hds_hnas_ssh_private_key', + default=None, + secret=True, + help="RSA/DSA private key value used to connect into HNAS. " + "Required only if password is not provided."), + cfg.StrOpt('hds_hnas_cluster_admin_ip0', + default=None, + help="The IP of the clusters admin node. Only set in HNAS " + "multinode clusters."), +] + +CONF = cfg.CONF +CONF.register_opts(hds_hnas_opts) + + +class HDSHNASDriver(driver.ShareDriver): + """Manila HNAS Driver implementation. + + 1.0 - Initial Version + """ + + def __init__(self, *args, **kwargs): + """Do initialization.""" + + LOG.debug("Invoking base constructor for Manila HDS HNAS Driver.") + super(HDSHNASDriver, self).__init__(False, *args, **kwargs) + + LOG.debug("Setting up attributes for Manila HDS HNAS Driver.") + self.configuration.append_config_values(hds_hnas_opts) + + LOG.debug("Reading config parameters for Manila HDS HNAS Driver.") + self.backend_name = self.configuration.safe_get('share_backend_name') + hnas_ip = self.configuration.safe_get('hds_hnas_ip') + hnas_username = self.configuration.safe_get('hds_hnas_user') + hnas_password = self.configuration.safe_get('hds_hnas_password') + hnas_evs_id = self.configuration.safe_get('hds_hnas_evs_id') + self.hnas_evs_ip = self.configuration.safe_get('hds_hnas_evs_ip') + fs_name = self.configuration.safe_get('hds_hnas_file_system_name') + ssh_private_key = self.configuration.safe_get( + 'hds_hnas_ssh_private_key') + cluster_admin_ip0 = self.configuration.safe_get( + 'hds_hnas_cluster_admin_ip0') + self.private_storage = kwargs.get('private_storage') + + if hnas_evs_id is None: + msg = _("The config parameter hds_hnas_evs_id is not set.") + raise exception.InvalidParameterValue(err=msg) + + if self.hnas_evs_ip is None: + msg = _("The config parameter hds_hnas_evs_ip is not set.") + raise exception.InvalidParameterValue(err=msg) + + if hnas_ip is None: + msg = _("The config parameter hds_hnas_ip is not set.") + raise exception.InvalidParameterValue(err=msg) + + if hnas_username is None: + msg = _("The config parameter hds_hnas_user is not set.") + raise exception.InvalidParameterValue(err=msg) + + if hnas_password is None and ssh_private_key is None: + msg = _("Credentials configuration parameters missing: " + "you need to set hds_hnas_password or " + "hds_hnas_ssh_private_key.") + raise exception.InvalidParameterValue(err=msg) + + LOG.debug("Initializing HNAS Layer.") + + self.hnas = ssh.HNASSSHBackend(hnas_ip, hnas_username, hnas_password, + ssh_private_key, cluster_admin_ip0, + hnas_evs_id, self.hnas_evs_ip, fs_name) + + def allow_access(self, context, share, access, share_server=None): + """Allow access to a share. + + :param context: The `context.RequestContext` object for the request + :param share: Share to which access will be allowed. + :param access: Information about the access that will be allowed, e.g. + host allowed, type of access granted. + :param share_server: Data structure with share server information. + Not used by this driver. + """ + if ('nfs', 'ip') != (share['share_proto'].lower(), + access['access_type'].lower()): + msg = _("Only NFS protocol and IP access type currently " + "supported.") + raise exception.InvalidShareAccess(reason=msg) + + LOG.debug("Sending HNAS Request to allow access to share: " + "%(shr)s.", {'shr': (share['id'])}) + + share_id = self._get_hnas_share_id(share['id']) + + self.hnas.allow_access(share_id, access['access_to'], + share['share_proto'], + access['access_level']) + + LOG.info(_LI("Access allowed successfully to share: %(shr)s."), + {'shr': six.text_type(share['id'])}) + + def deny_access(self, context, share, access, share_server=None): + """Deny access to a share. + + :param context: The `context.RequestContext` object for the request + :param share: Share to which access will be denied. + :param access: Information about the access that will be denied, e.g. + host and type of access denied. + :param share_server: Data structure with share server information. + Not used by this driver. + """ + if ('nfs', 'ip') != (share['share_proto'].lower(), + access['access_type'].lower()): + msg = _("Only NFS protocol and IP access type currently " + "supported.") + raise exception.InvalidShareAccess(reason=msg) + + LOG.debug("Sending HNAS request to deny access to share:" + " %(shr_id)s.", + {'shr_id': six.text_type(share['id'])}) + + share_id = self._get_hnas_share_id(share['id']) + + self.hnas.deny_access(share_id, access['access_to'], + share['share_proto'], access['access_level']) + + LOG.info(_LI("Access denied successfully to share: %(shr)s."), + {'shr': six.text_type(share['id'])}) + + def create_share(self, context, share, share_server=None): + """Creates share. + + :param context: The `context.RequestContext` object for the request + :param share: Share that will be created. + :param share_server: Data structure with share server information. + Not used by this driver. + :returns: Returns a path of EVS IP concatenate with the path + of share in the filesystem (e.g. ['172.24.44.10:/shares/id']). + """ + LOG.debug("Creating share in HNAS: %(shr)s.", + {'shr': six.text_type(share['id'])}) + + if share['share_proto'].lower() != 'nfs': + msg = _("Only NFS protocol is currently supported.") + raise exception.ShareBackendException(msg=msg) + + ip = self.hnas_evs_ip + + path = self.hnas.create_share(share['id'], share['size'], + share['share_proto']) + + LOG.debug("Share created successfully on path: %(ip)s:%(path)s.", + {'ip': ip, 'path': path}) + return ip + ":" + path + + def delete_share(self, context, share, share_server=None): + """Deletes share. + + :param context: The `context.RequestContext` object for the request + :param share: Share that will be deleted. + :param share_server: Data structure with share server information. + Not used by this driver. + """ + share_id = self._get_hnas_share_id(share['id']) + + LOG.debug("Deleting share in HNAS: %(shr)s.", + {'shr': six.text_type(share['id'])}) + + self.hnas.delete_share(share_id, share['share_proto']) + + def create_snapshot(self, context, snapshot, share_server=None): + """Creates snapshot. + + :param context: The `context.RequestContext` object for the request + :param snapshot: Snapshot that will be created. + :param share_server: Data structure with share server information. + Not used by this driver. + """ + share_id = self._get_hnas_share_id(snapshot['share_id']) + + LOG.debug("The snapshot of share %(ss_sid)s will be created with " + "id %(ss_id)s.", {'ss_sid': snapshot['share_id'], + 'ss_id': snapshot['id']}) + + self.hnas.create_snapshot(share_id, snapshot['id']) + LOG.info(_LI("Snapshot %(id)s successfully created."), + {'id': snapshot['id']}) + + def delete_snapshot(self, context, snapshot, share_server=None): + """Deletes snapshot. + + :param context: The `context.RequestContext` object for the request + :param snapshot: Snapshot that will be deleted. + :param share_server:Data structure with share server information. + Not used by this driver. + """ + share_id = self._get_hnas_share_id(snapshot['share_id']) + + LOG.debug("The snapshot %(ss_sid)s will be deleted. The related " + "share ID is %(ss_id)s.", + {'ss_sid': snapshot['share_id'], 'ss_id': snapshot['id']}) + + self.hnas.delete_snapshot(share_id, snapshot['id']) + LOG.info(_LI("Snapshot %(id)s successfully deleted."), + {'id': snapshot['id']}) + + def create_share_from_snapshot(self, context, share, snapshot, + share_server=None): + """Creates a new share from snapshot. + + :param context: The `context.RequestContext` object for the request + :param share: Information about the new share. + :param snapshot: Information about the snapshot that will be copied + to new share. + :param share_server: Data structure with share server information. + Not used by this driver. + :returns: Returns a path of EVS IP concatenate with the path + of new share in the filesystem (e.g. ['172.24.44.10:/shares/id']). + """ + LOG.debug("Creating a new share from snapshot: %(ss_id)s.", + {'ss_id': six.text_type(snapshot['id'])}) + + ip = self.hnas_evs_ip + path = self.hnas.create_share_from_snapshot(share, snapshot) + + LOG.debug("Share created successfully on path: %(ip)s:%(path)s.", + {'ip': ip, 'path': path}) + return ip + ":" + path + + def ensure_share(self, context, share, share_server=None): + """Ensure that share is exported. + + :param context: The `context.RequestContext` object for the request + :param share: Share that will be checked. + :param share_server: Data structure with share server information. + Not used by this driver. + :returns: Returns a list of EVS IP concatenated with the path + of share in the filesystem (e.g. ['172.24.44.10:/shares/id']). + """ + LOG.debug("Ensuring share in HNAS: %(shr)s.", + {'shr': six.text_type(share['id'])}) + + if share['share_proto'].lower() != 'nfs': + msg = _("Only NFS protocol is currently supported.") + raise exception.ShareBackendException(msg=msg) + + path = self.hnas.ensure_share(share['id'], share['share_proto']) + + export = self.hnas_evs_ip + ":" + path + export_list = [export] + + LOG.debug("Share ensured in HNAS: %(shr)s.", + {'shr': six.text_type(share['id'])}) + return export_list + + def extend_share(self, share, new_size, share_server=None): + """Extends a share to new size. + + :param share: Share that will be extended. + :param new_size: New size of share. + :param share_server: Data structure with share server information. + Not used by this driver. + """ + share_id = self._get_hnas_share_id(share['id']) + + LOG.debug("Expanding share in HNAS: %(shr_id)s.", + {'shr_id': six.text_type(share['id'])}) + + if share['share_proto'].lower() != 'nfs': + msg = _("Only NFS protocol is currently supported.") + raise exception.ShareBackendException(msg=msg) + + self.hnas.extend_share(share_id, new_size, share['share_proto']) + LOG.info(_LI("Share %(shr_id)s successfully extended to " + "%(shr_size)s."), + {'shr_id': six.text_type(share['id']), + 'shr_size': six.text_type(new_size)}) + + # TODO(alyson): Implement in DHSS = true mode + def get_network_allocations_number(self): + """Track allocations_number in DHSS = true. + + When using the setting driver_handles_share_server = false + does not require to track allocations_number because we do not handle + network stuff. + """ + return 0 + + def _update_share_stats(self): + """Updates the Capability of Backend.""" + LOG.debug("Updating Backend Capability Information - HDS HNAS.") + + total_space, free_space = self.hnas.get_stats() + + reserved = self.configuration.safe_get('reserved_share_percentage') + + data = { + 'share_backend_name': self.backend_name, + 'driver_handles_share_servers': self.driver_handles_share_servers, + 'vendor_name': 'HDS', + 'driver_version': '1.0', + 'storage_protocol': 'NFS', + 'total_capacity_gb': total_space, + 'free_capacity_gb': free_space, + 'reserved_percentage': reserved, + 'QoS_support': False, + } + + LOG.info(_LI("HNAS Capabilities: %(data)s."), + {'data': six.text_type(data)}) + + super(HDSHNASDriver, self)._update_share_stats(data) + + def manage_existing(self, share, driver_options): + """Manages a share that exists on backend. + + :param share: Share that will be managed. + :param driver_options: Empty dict or dict with 'volume_id' option. + :returns: Returns a dict with size of share managed + and its location (your path in file-system). + """ + if self.driver_handles_share_servers: + msg = (_("DHSS = %s") % self.driver_handles_share_servers) + LOG.error(_LE("Operation 'manage' for shares is supported only " + "when driver does not handle share servers.")) + raise exception.InvalidDriverMode(driver_mode=msg) + + driver_mode = share_types.get_share_type_extra_specs( + share['share_type_id'], + const.ExtraSpecs.DRIVER_HANDLES_SHARE_SERVERS) + + if strutils.bool_from_string(driver_mode): + msg = _("%(mode)s != False.") % { + 'mode': const.ExtraSpecs.DRIVER_HANDLES_SHARE_SERVERS + } + raise exception.ManageExistingShareTypeMismatch(reason=msg) + + share_id = self._get_hnas_share_id(share['id']) + + LOG.info(_LI("Share %(shr_path)s will be managed with ID %(shr_id)s."), + {'shr_path': six.text_type( + share['export_locations'][0]['path']), + 'shr_id': six.text_type(share_id)}) + + old_path_info = share['export_locations'][0]['path'].split(':') + old_path = old_path_info[1].split('/') + + if len(old_path) == 3: + evs_ip = old_path_info[0] + share_id = old_path[2] + else: + msg = _("Incorrect path. It should have the following format: " + "IP:/shares/share_id.") + raise exception.ShareBackendException(msg=msg) + + if evs_ip != self.hnas_evs_ip: + msg = _("The EVS IP %(evs)s is not " + "configured.") % {'evs': six.text_type(evs_ip)} + raise exception.ShareBackendException(msg=msg) + + if six.text_type(self.backend_name) not in share['host']: + msg = _("The backend passed in the host parameter (%(shr)s) is " + "not configured.") % {'shr': share['host']} + raise exception.ShareBackendException(msg=msg) + + output = self.hnas.manage_existing(share, share_id) + self.private_storage.update( + share['id'], {'hnas_id': share_id}) + + return output + + def unmanage(self, share): + """Unmanages a share. + + :param share: Share that will be unmanaged. + """ + self.private_storage.delete(share['id']) + + LOG.info(_LI("The share with current path %(shr_path)s and ID " + "%(shr_id)s is no longer being managed."), + {'shr_path': six.text_type( + share['export_locations'][0]['path']), + 'shr_id': six.text_type(share['id'])}) + + def _get_hnas_share_id(self, share_id): + hnas_id = self.private_storage.get(share_id, 'hnas_id') + + if hnas_id is None: + hnas_id = share_id + return hnas_id \ No newline at end of file diff --git a/manila/share/drivers/hitachi/ssh.py b/manila/share/drivers/hitachi/ssh.py new file mode 100644 index 0000000000..c0e019273c --- /dev/null +++ b/manila/share/drivers/hitachi/ssh.py @@ -0,0 +1,708 @@ +# Copyright (c) 2015 Hitachi Data 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_concurrency import processutils +from oslo_log import log +from oslo_utils import units +import paramiko +import six + +from manila import exception +from manila.i18n import _ +from manila.i18n import _LE +from manila.i18n import _LW +from manila import utils as mutils + +LOG = log.getLogger(__name__) + + +class HNASSSHBackend(object): + def __init__(self, hnas_ip, hnas_username, hnas_password, ssh_private_key, + cluster_admin_ip0, evs_id, evs_ip, fs_name): + self.ip = hnas_ip + self.port = 22 + self.user = hnas_username + self.password = hnas_password + self.priv_key = ssh_private_key + self.admin_ip0 = cluster_admin_ip0 + self.evs_id = six.text_type(evs_id) + self.fs_name = fs_name + self.evs_ip = evs_ip + self.sshpool = None + + def get_stats(self): + """Get the stats from file-system. + + The available space is calculated by total space - SUM(quotas). + :returns: + total_fs_space = Total size from filesystem in config file. + available_space = Free space currently on filesystem. + """ + total_fs_space = self._get_filesystem_capacity() + total_quota = 0 + share_list = self._get_vvol_list() + + for item in share_list: + share_quota = self._get_share_quota(item) + if share_quota is not None: + total_quota += share_quota + available_space = total_fs_space - total_quota + LOG.debug("Available space in the file system: %(space)s.", + {'space': available_space}) + + return total_fs_space, available_space + + def allow_access(self, share_id, host, share_proto, permission='rw'): + """Allow access to the share. + + :param share_id: ID of share that access will be allowed. + :param host: Host to which access will be allowed. + :param share_proto: Storage protocol of share. Currently, + only NFS storage protocol is supported. + :param permission: permission (e.g. 'rw', 'ro') that will be allowed. + """ + # check if the share exists + self.ensure_share(share_id, share_proto) + export = self._nfs_export_list(share_id) + + # get the list that contains all the hosts allowed on the share + host_list = export[0].export_configuration + + if permission in ('ro', 'rw'): + host_access = host + '(' + permission + ')' + else: + msg = (_("Permission should be 'ro' or 'rw' instead " + "of %s") % permission) + raise exception.HNASBackendException(msg=msg) + + # check if the host(s) is already allowed + if any(host in x for x in host_list): + if host_access in host_list: + LOG.debug("Host: %(host)s is already allowed.", + {'host': host}) + else: + # remove all the hosts with different permissions + host_list = [ + x for x in host_list if not x.startswith(host)] + # add the host with new permission + host_list.append(host_access) + self._update_access_rule(share_id, host_list) + else: + host_list.append(host_access) + self._update_access_rule(share_id, host_list) + + def deny_access(self, share_id, host, share_proto, permission): + """Deny access to the share. + + :param share_id: ID of share that access will be denied. + :param host: Host to which access will be denied. + :param share_proto: Storage protocol of share. Currently, + only NFS storage protocol is supported. + :param permission: permission (e.g. 'rw', 'ro') that will be denied. + """ + # check if the share exists + self.ensure_share(share_id, share_proto) + export = self._nfs_export_list(share_id) + + # get the list that contains all the hosts allowed on the share + host_list = export[0].export_configuration + + if permission in ('ro', 'rw'): + host_access = host + '(' + permission + ')' + else: + msg = (_("Permission should be 'ro' or 'rw' instead " + "of %s") % permission) + raise exception.HNASBackendException(msg=msg) + + # check if the host(s) is already not allowed + if host_access not in host_list: + LOG.debug("Host: %(host)s is already not allowed.", + {'host': host}) + else: + # remove the host on host_list + host_list.remove(host_access) + self._update_access_rule(share_id, host_list) + + def delete_share(self, share_id, share_proto): + """Deletes share. + + It uses tree-delete-job-submit to format and delete virtual-volumes. + Quota is deleted with virtual-volume. + :param share_id: ID of share that will be deleted. + :param share_proto: Storage protocol of share. Currently, + only NFS storage protocol is supported. + """ + try: + self.ensure_share(share_id, share_proto) + except exception.HNASBackendException as e: + LOG.warning(_LW("Share %s does not exist on backend anymore."), + share_id) + LOG.exception(six.text_type(e)) + + self._nfs_export_del(share_id) + self._vvol_delete(share_id) + + LOG.debug("Export and share successfully deleted: %(shr)s on Manila.", + {'shr': share_id}) + + def ensure_share(self, share_id, share_proto): + """Ensure that share is exported. + + :param share_id: ID of share that will be checked. + :param share_proto: Storage protocol of share. Currently, + only NFS storage protocol is supported. + :returns: Returns a path of /shares/share_id if the export is ok. + """ + path = '/shares/' + share_id + + if not self._check_fs_mounted(self.fs_name): + self._mount(self.fs_name) + LOG.debug("Filesystem %(fs)s is unmounted. Mounting...", + {'fs': self.fs_name}) + self._check_vvol(share_id) + self._check_quota(share_id) + self._check_export(share_id) + return path + + def create_share(self, share_id, share_size, share_proto): + """Creates share. + + Creates a virtual-volume, adds a quota limit and exports it. + :param share_id: ID of share that will be created. + :param share_size: Size limit of share. + :param share_proto: Storage protocol of share. Currently, + only NFS storage protocol is supported. + :returns: Returns a path of /shares/share_id if the export was + created successfully. + """ + path = '/shares/' + share_id + self._vvol_create(share_id, share_size) + LOG.debug("Share created with id %(shr)s, size %(size)sG.", + {'shr': share_id, 'size': share_size}) + try: + # Create NFS export + self._nfs_export_add(share_id) + LOG.debug("NFS Export created to %(shr)s.", + {'shr': share_id}) + return path + except processutils.ProcessExecutionError as e: + self._vvol_delete(share_id) + msg = six.text_type(e) + LOG.exception(msg) + raise e + + def extend_share(self, share_id, share_size, share_proto): + """Extends a share to new size. + + :param share_id: ID of share that will be extended. + :param share_size: New size of share. + :param share_proto: Storage protocol of share. Currently, + only NFS storage protocol is supported. + """ + self.ensure_share(share_id, share_proto) + + total, available_space = self.get_stats() + + LOG.debug("Available space in filesystem: %(space)s.", + {'space': available_space}) + + if share_size < available_space: + self._extend_quota(share_id, share_size) + else: + msg = (_("Failed to extend share %s.") % share_id) + raise exception.HNASBackendException(msg=msg) + + def manage_existing(self, share_proto, share_id): + """Manages a share that exists on backend. + + :param share_proto: Storage protocol of share. Currently, + only NFS storage protocol is supported. + :param share_id: ID of share that will be managed. + :returns: Returns a dict with size of share managed + and its location (your path in file-system). + """ + self.ensure_share(share_id, share_proto) + + share_size = self._get_share_quota(share_id) + if share_size is None: + msg = (_("The share %s trying to be managed does not have a " + "quota limit, please set it before manage.") % share_id) + raise exception.HNASBackendException(msg=msg) + + path = six.text_type(self.evs_ip) + ':/shares/' + share_id + + return {'size': share_size, 'export_locations': [path]} + + def create_snapshot(self, share_id, snapshot_id): + """Creates a snapshot of share. + + It copies the directory and all files to a new directory inside + /snapshots/share_id/. + :param share_id: ID of share for snapshot. + :param snapshot_id: ID of new snapshot. + """ + src_path = '/shares/' + share_id + snap_path = '/snapshots/' + share_id + '/' + snapshot_id + + try: + command = ['tree-clone-job-submit', '-e', '-f', self.fs_name, + src_path, snap_path] + output, err = self._execute(command) + if 'Request submitted successfully' in output: + LOG.debug("Request for creating snapshot submitted " + "successfully.") + except processutils.ProcessExecutionError as e: + if ('Cannot find any clonable files in the source directory' in + e.stderr): + + LOG.warning(_LW("Source directory is empty, creating an empty " + "snapshot.")) + self._locked_selectfs('create', snap_path) + else: + msg = six.text_type(e) + LOG.exception(msg) + raise exception.HNASBackendException(msg=msg) + + def delete_snapshot(self, share_id, snapshot_id): + """Deletes snapshot. + + It receives the share_id only to mount the path for snapshot. + :param share_id: ID of share that snapshot was created. + :param snapshot_id: ID of snapshot. + """ + path = '/snapshots/' + share_id + '/' + snapshot_id + command = ['tree-delete-job-submit', '--confirm', '-f', self.fs_name, + path] + try: + output, err = self._execute(command) + path = '/snapshots/' + share_id + if 'Request submitted successfully' in output: + self._locked_selectfs('delete', path) + + except processutils.ProcessExecutionError as e: + if 'Source path: Cannot access' not in e.stderr: + msg = six.text_type(e) + LOG.exception(msg) + raise e + + def create_share_from_snapshot(self, share, snapshot): + """Creates a new share from snapshot. + + It copies everything from snapshot directory to a new vvol, + set a quota limit for it and export. + :param share: a dict from new share. + :param snapshot: a dict from snapshot that will be copied to + new share. + :returns: Returns the path for new share. + """ + output = '' + dst_path = '/shares/' + share['id'] + src_path = '/snapshots/' + snapshot['share_id'] + '/' + snapshot['id'] + + # Before copying everything to new vvol, we need to create it, + # because we only can transform an empty directory into a vvol. + quota = self._get_share_quota(snapshot['share_id']) + LOG.debug("Share size: %(quota)s.", {'quota': six.text_type(quota)}) + + if quota is None: + msg = (_("The original share %s does not have a quota limit, " + "please set it before creating a new " + "share.") % share['id']) + raise exception.HNASBackendException(msg=msg) + + self._vvol_create(share['id'], quota) + + try: + # Copy the directory to new vvol + # Syntax: tree-clone-job-submit + LOG.debug("Started share create from: %(shr)s.", + {'shr': six.text_type(snapshot['share_id'])}) + command = ['tree-clone-job-submit', '-f', self.fs_name, + src_path, dst_path] + output, err = self._execute(command) + except processutils.ProcessExecutionError as e: + if ('Cannot find any clonable files in the source directory' in + e.stderr): + LOG.warning(_LW("Source directory is empty, exporting " + "directory.")) + if self._nfs_export_add(share['id']): + return dst_path + + if 'Request submitted successfully' in output: + # Create NFS export + if self._nfs_export_add(share['id']): + # Return export path + return dst_path + else: + msg = (_("Share %s was not created.") % share['id']) + raise exception.HNASBackendException(msg=msg) + + def _execute(self, commands): + command = ['ssc', '127.0.0.1'] + if self.admin_ip0 is not None: + command = ['ssc', '--smuauth', self.admin_ip0] + + command = command + ['console-context', '--evs', self.evs_id] + commands = command + commands + + mutils.check_ssh_injection(commands) + commands = ' '.join(commands) + + if not self.sshpool: + self.sshpool = mutils.SSHPool(ip=self.ip, + port=self.port, + conn_timeout=None, + login=self.user, + password=self.password, + privatekey=self.priv_key) + with self.sshpool.item() as ssh: + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + out, err = processutils.ssh_execute(ssh, commands, + check_exit_code=True) + LOG.debug("Command %(cmd)s result: out = %(out)s - err = " + "%(err)s.", {'cmd': commands, + 'out': out, 'err': err}) + return out, err + except processutils.ProcessExecutionError as e: + LOG.debug("Command %(cmd)s result: out = %(out)s - err = " + "%(err)s - exit = %(exit)s.", {'cmd': e.cmd, + 'out': e.stdout, + 'err': e.stderr, + 'exit': e.exit_code}) + LOG.error(_LE("Error running SSH command.")) + raise + + def _check_fs_mounted(self, fs_name): + self._check_fs() + fs_list = self._get_filesystem_list() + for i in range(0, len(fs_list)): + if fs_list[i].name == fs_name and fs_list[i].state == 'Mount': + return True + return False + + def _get_filesystem_list(self): + command = ['filesystem-list'] + output, err = self._execute(command) + items = output.split('\n') + filesystem_list = [] + fs_name = None + if len(items) > 2: + j = 0 + for i in range(2, len(items) - 1): + if "Filesystem " in items[i] and len(items[i].split()) == 2: + description, fs_name = items[i].split() + fs_name = fs_name[:len(fs_name) - 1] + elif "NoEVS" not in items[i]: + # Not considering FS without EVS + filesystem_list.append(FileSystem(items[i])) + if fs_name is not None: + filesystem_list[j].name = fs_name + fs_name = None + j += 1 + else: + LOG.debug("Ignoring filesystems without EVS.") + + return filesystem_list + + def _nfs_export_add(self, share_id): + path = '/shares/' + share_id + # nfs-export add -S disable -c + command = ['nfs-export', 'add', '-S', 'disable', '-c', '127.0.0.1', + path, self.fs_name, path] + output, err = self._execute(command) + return True + + def _nfs_export_del(self, share_id): + path = '/shares/' + share_id + command = ['nfs-export', 'del', path] + + try: + output, err = self._execute(command) + except exception.HNASBackendException as e: + LOG.warning(_LW("Export %s does not exist on backend anymore."), + path) + LOG.exception(six.text_type(e)) + + def _update_access_rule(self, share_id, host_list): + # mount the command line + command = ['nfs-export', 'mod', '-c'] + + if len(host_list) == 0: + command.append('127.0.0.1') + else: + string_command = '"' + six.text_type(host_list[0]) + + for i in range(1, len(host_list)): + string_command += ',' + (six.text_type(host_list[i])) + string_command += '"' + command.append(string_command) + + path = '/shares/' + share_id + command.append(path) + output, err = self._execute(command) + + if ("Export modified successfully" in output or + "Export modified successfully" in err): + return True + else: + return False + + def _nfs_export_list(self, share_id=''): + if share_id is not '': + share_id = '/shares/' + share_id + command = ['nfs-export', 'list ', six.text_type(share_id)] + output, err = self._execute(command) + nfs_export_list = [] + + if 'No exports are currently configured' not in output: + items = output.split('Export name') + + if items[0][0] == '\n': + items.pop(0) + + for i in range(0, len(items)): + nfs_export_list.append(Export(items[i])) + + return nfs_export_list + + def _mount(self, fs): + command = ['mount', fs] + try: + output, err = self._execute(command) + if 'successfully mounted' in output: + return True + except processutils.ProcessExecutionError as e: + if 'file system is already mounted' in e.stderr: + return True + else: + msg = six.text_type(e) + LOG.exception(msg) + raise e + + def _vvol_create(self, vvol_name, vvol_quota): + # create a virtual-volume inside directory + if self._check_fs(): + path = '/shares/' + vvol_name + command = ['virtual-volume', 'add', '--ensure', self.fs_name, + vvol_name, path] + output, err = self._execute(command) + + # put a quota limit in virtual-volume to deny expand abuses + self._quota_add(vvol_name, vvol_quota) + return True + else: + msg = (_("Filesystem %s does not exist or it is not available " + "in the current EVS context.") % self.fs_name) + raise exception.HNASBackendException(msg=msg) + + def _quota_add(self, vvol_name, vvol_quota): + if vvol_quota > 0: + str_quota = six.text_type(vvol_quota) + 'G' + command = ['quota', 'add', '--usage-limit', + str_quota, '--usage-hard-limit', + 'yes', self.fs_name, vvol_name] + output, err = self._execute(command) + return True + return False + + def _vvol_delete(self, vvol_name): + path = '/shares/' + vvol_name + # Virtual-volume and quota are deleted together + command = ['tree-delete-job-submit', '--confirm', '-f', + self.fs_name, path] + try: + output, err = self._execute(command) + return True + except processutils.ProcessExecutionError as e: + if 'Source path: Cannot access' in e.stderr: + LOG.debug("Share %(shr)s does not exist.", + {'shr': six.text_type(vvol_name)}) + else: + msg = six.text_type(e) + LOG.exception(msg) + raise e + + def _extend_quota(self, vvol_name, new_size): + str_quota = six.text_type(new_size) + 'G' + command = ['quota', 'mod', '--usage-limit', str_quota, + self.fs_name, vvol_name] + output, err = self._execute(command) + return True + + def _check_fs(self): + fs_list = self._get_filesystem_list() + fs_name_list = [] + for i in range(0, len(fs_list)): + fs_name_list.append(fs_list[i].name) + if fs_list[i].name == self.fs_name: + return True + return False + + def _check_vvol(self, vvol_name): + command = ['virtual-volume', 'list', '--verbose', self.fs_name, + vvol_name] + try: + output, err = self._execute(command) + return True + except processutils.ProcessExecutionError as e: + msg = six.text_type(e) + LOG.exception(msg) + msg = (_("Virtual volume %s does not exist.") % vvol_name) + raise exception.HNASBackendException(msg=msg) + + def _check_quota(self, vvol_name): + command = ['quota', 'list', '--verbose', self.fs_name, vvol_name] + output, err = self._execute(command) + + if 'No quotas matching specified filter criteria' not in output: + return True + else: + msg = (_("Virtual volume %s does not have any quota.") % vvol_name) + raise exception.HNASBackendException(msg=msg) + + def _check_export(self, vvol_name): + export = self._nfs_export_list(vvol_name) + if (vvol_name in export[0].export_name and + self.fs_name in export[0].file_system_label): + return True + else: + msg = (_("Export %s does not exist.") % export[0].export_name) + raise exception.HNASBackendException(msg=msg) + + def _get_share_quota(self, share_id): + command = ['quota', 'list', self.fs_name, six.text_type(share_id)] + output, err = self._execute(command) + items = output.split('\n') + + for i in range(0, len(items) - 1): + if ('Unset' not in items[i] and + 'No quotas matching' not in items[i]): + if 'Limit' in items[i] and 'Hard' in items[i]: + quota = float(items[i].split(' ')[12]) + + # If the quota is 1 or more TB, converts to GB + if items[i].split(' ')[13] == 'TB': + return quota * units.Ki + + return quota + else: + # Returns None if the quota is unset + return None + + def _get_vvol_list(self): + command = ['virtual-volume', 'list', self.fs_name] + output, err = self._execute(command) + + vvol_list = [] + items = output.split('\n') + + for i in range(0, len(items) - 1): + if ":" not in items[i]: + vvol_list.append(items[i]) + + return vvol_list + + def _get_filesystem_capacity(self): + command = ['filesystem-limits', self.fs_name] + output, err = self._execute(command) + + items = output.split('\n') + + for i in range(0, len(items) - 1): + if 'Current capacity' in items[i]: + fs_capacity = items[i].split(' ') + + # Gets the index of the file system capacity (EX: 20GiB) + index = [i for i, string in enumerate(fs_capacity) + if 'GiB' in string] + + fs_capacity = fs_capacity[index[0]] + fs_capacity = fs_capacity.split('GiB')[0] + + return int(fs_capacity) + + @mutils.synchronized("hds_hnas_select_fs", external=True) + def _locked_selectfs(self, op, path): + if op == 'create': + command = ['selectfs', self.fs_name, '\n', + 'ssc', '127.0.0.1', 'console-context', '--evs', + self.evs_id, 'mkdir', '-p', path] + output, err = self._execute(command) + + if op == 'delete': + command = ['selectfs', self.fs_name, '\n', + 'ssc', '127.0.0.1', 'console-context', '--evs', + self.evs_id, 'rmdir', path] + try: + output, err = self._execute(command) + except processutils.ProcessExecutionError: + LOG.debug("Share %(path)s has more snapshots.", {'path': path}) + + +class FileSystem(object): + def __init__(self, data): + if data: + items = data.split() + if len(items) >= 7: + self.name = items[0] + self.dev = items[1] + self.on_span = items[2] + self.state = items[3] + self.evs = int(items[4]) + self.capacity = int(items[5]) + self.confined = int(items[6]) + if len(items) == 8: + self.flag = items[7] + else: + self.flag = '' + + +class Export(object): + def __init__(self, data): + if data: + split_data = data.split('Export configuration:\n') + items = split_data[0].split('\n') + + self.export_name = items[0].split(':')[1].strip() + self.export_path = items[1].split(':')[1].strip() + + if '*** not available ***' in items[2]: + self.file_system_info = items[2].split(':')[1].strip() + index = 0 + + else: + self.file_system_label = items[2].split(':')[1].strip() + self.file_system_size = items[3].split(':')[1].strip() + self.file_system_free_space = items[4].split(':')[1].strip() + self.file_system_state = items[5].split(':')[1] + self.formatted = items[6].split('=')[1].strip() + self.mounted = items[7].split('=')[1].strip() + self.failed = items[8].split('=')[1].strip() + self.thin_provisioned = items[9].split('=')[1].strip() + index = 7 + + self.access_snapshots = items[3 + index].split(':')[1].strip() + self.display_snapshots = items[4 + index].split(':')[1].strip() + self.read_caching = items[5 + index].split(':')[1].strip() + self.disaster_recovery_setting = items[6 + index].split(':')[1] + self.recovered = items[7 + index].split('=')[1].strip() + self.transfer_setting = items[8 + index].split('=')[1].strip() + + self.export_configuration = [] + export_config = split_data[1].split('\n') + for i in range(0, len(export_config)): + if any(j.isdigit() or j.isalpha() for j in export_config[i]): + self.export_configuration.append(export_config[i]) \ No newline at end of file diff --git a/manila/tests/share/drivers/hitachi/__init__.py b/manila/tests/share/drivers/hitachi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/tests/share/drivers/hitachi/test_hds_hnas.py b/manila/tests/share/drivers/hitachi/test_hds_hnas.py new file mode 100644 index 0000000000..be0b04d77a --- /dev/null +++ b/manila/tests/share/drivers/hitachi/test_hds_hnas.py @@ -0,0 +1,438 @@ +# Copyright (c) 2015 Hitachi Data 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 oslo_config import cfg + +from manila import context +from manila import exception +import manila.share.configuration +import manila.share.driver +from manila.share.drivers.hitachi import hds_hnas +from manila.share.drivers.hitachi import ssh +from manila.share import share_types +from manila import test +from manila.tests.db import fakes as db_fakes + +CONF = cfg.CONF + + +def fake_share(**kwargs): + share = { + 'id': 'fake_id', + 'size': 1, + 'share_type_id': '7450f16e-4c7f-42ab-90f1-c1cfb2a6bc70', + 'share_proto': 'nfs', + 'share_network_id': 'fake_network_id', + 'share_server_id': 'fake_server_id', + 'host': ['None'], + 'export_locations': [{'path': '172.24.44.10:/nfs/volume-00002'}], + } + share.update(kwargs) + return db_fakes.FakeModel(share) + + +@ddt.ddt +class HDSHNASTestCase(test.TestCase): + + def setUp(self): + super(HDSHNASTestCase, self).setUp() + + self._context = context.get_admin_context() + self._execute = mock.Mock(return_value=('', '')) + CONF.set_default('driver_handles_share_servers', False) + CONF.hds_hnas_evs_id = '2' + CONF.hds_hnas_evs_ip = '172.24.44.10' + CONF.hds_hnas_ip = '172.24.44.1' + CONF.hds_hnas_ip_port = 'hds_hnas_ip_port' + CONF.hds_hnas_user = 'hds_hnas_user' + CONF.hds_hnas_password = 'hds_hnas_password' + CONF.hds_hnas_file_system = 'file_system' + CONF.hds_hnas_ssh_private_key = 'private_key' + CONF.hds_hnas_cluster_admin_ip0 = None + self.const_dhss = 'driver_handles_share_servers' + self.fake_conf = manila.share.configuration.Configuration(None) + self._db = mock.Mock() + + self.fake_private_storage = mock.Mock() + self.mock_object(self.fake_private_storage, 'get', + mock.Mock(return_value=None)) + self.mock_object(self.fake_private_storage, 'delete', + mock.Mock(return_value=None)) + + self.mock_log = self.mock_object(manila.share.drivers.hitachi.hds_hnas, + 'LOG') + + self._driver = hds_hnas.HDSHNASDriver( + private_storage=self.fake_private_storage, + configuration=self.fake_conf) + + self.server = { + 'instance_id': 'fake_instance_id', + 'ip': 'fake_ip', + 'username': 'fake_username', + 'password': 'fake_password', + 'pk_path': 'fake_pk_path', + 'backend_details': { + 'public_address': '1.2.3.4', + 'instance_id': 'fake', + }, + } + + self.invalid_server = { + 'backend_details': { + 'ip': '1.1.1.1', + 'instance_id': 'fake', + }, + } + + self.nfs_export_list = {'export_configuration': 'fake_export'} + + self.share = fake_share() + + self.invalid_share = { + 'id': 'fakeid', + 'name': 'fakename', + 'size': 1, + 'host': 'hnas', + 'share_proto': 'CIFS', + 'share_type_id': 1, + 'share_network_id': 'fake share network id', + 'share_server_id': 'fake share server id', + 'export_locations': [{'path': '172.24.44.110:' + '/mnt/nfs/volume-00002'}], + } + + self.access = { + 'id': 'fakeaccid', + 'access_type': 'ip', + 'access_to': '10.0.0.2', + 'access_level': 'fake_level', + 'state': 'active', + } + + self.snapshot = { + 'id': 'snap_name', + 'share_id': 'fake_name', + } + + @ddt.data('hds_hnas_evs_id', 'hds_hnas_evs_ip', + 'hds_hnas_ip', 'hds_hnas_user') + def test_init_invalid_conf_parameters(self, attr_name): + self.mock_object(manila.share.driver.ShareDriver, + '__init__') + setattr(CONF, attr_name, None) + + self.assertRaises(exception.InvalidParameterValue, + self._driver.__init__) + + def test_init_invalid_credentials(self): + self.mock_object(manila.share.driver.ShareDriver, + '__init__') + CONF.hds_hnas_password = None + CONF.hds_hnas_ssh_private_key = None + + self.assertRaises(exception.InvalidParameterValue, + self._driver.__init__) + + def test_allow_access(self): + self.mock_object(ssh.HNASSSHBackend, 'allow_access') + + self._driver.allow_access(self._context, self.share, + self.access, self.server) + + ssh.HNASSSHBackend.allow_access.assert_called_once_with('fake_id', + '10.0.0.2', + 'nfs', + 'fake_level') + self.assertTrue(self.mock_log.debug.called) + self.assertTrue(self.mock_log.info.called) + + def test_allow_access_invalid_access_type(self): + access = {'access_type': 'user', 'access_to': 'fake_dest'} + + self.assertRaises(exception.InvalidShareAccess, + self._driver.allow_access, self._context, + self.share, access, self.server) + + def test_allow_access_invalid_share_protocol(self): + self.assertRaises(exception.InvalidShareAccess, + self._driver.allow_access, self._context, + self.invalid_share, self.access, self.server) + + def test_deny_access(self): + self.mock_object(ssh.HNASSSHBackend, 'deny_access') + + self._driver.deny_access(self._context, self.share, + self.access, self.server) + + ssh.HNASSSHBackend.deny_access.assert_called_once_with('fake_id', + '10.0.0.2', + 'nfs', + 'fake_level') + self.assertTrue(self.mock_log.debug.called) + self.assertTrue(self.mock_log.info.called) + + def test_deny_access_invalid_share_protocol(self): + self.assertRaises(exception.InvalidShareAccess, + self._driver.deny_access, self._context, + self.invalid_share, self.access, self.server) + + def test_create_share(self): + # share server none + path = '/' + self.share['id'] + + self.mock_object(ssh.HNASSSHBackend, 'create_share', + mock.Mock(return_value=path)) + + result = self._driver.create_share(self._context, + self.share) + + ssh.HNASSSHBackend.create_share.assert_called_once_with('fake_id', 1, + 'nfs') + self.assertEqual('172.24.44.10:/fake_id', result) + self.assertTrue(self.mock_log.debug.called) + + def test_create_share_invalid_share_protocol(self): + self.assertRaises(exception.ShareBackendException, + self._driver.create_share, + self._context, self.invalid_share) + self.assertTrue(self.mock_log.debug.called) + + def test_delete_share(self): + self.mock_object(ssh.HNASSSHBackend, 'delete_share') + + self._driver.delete_share(self._context, self.share) + + ssh.HNASSSHBackend.delete_share.assert_called_once_with('fake_id', + 'nfs') + self.assertTrue(self.mock_log.debug.called) + + def test_ensure_share(self): + export_list = ['172.24.44.10:/shares/fake_id'] + path = '/shares/fake_id' + + self.mock_object(ssh.HNASSSHBackend, 'ensure_share', + mock.Mock(return_value=path)) + + out = self._driver.ensure_share(self._context, self.share) + + ssh.HNASSSHBackend.ensure_share.assert_called_once_with('fake_id', + 'nfs') + self.assertTrue(self.mock_log.debug.called) + self.assertEqual(export_list, out) + + def test_ensure_share_invalid_share_protocol(self): + # invalid share proto + self.assertRaises(exception.ShareBackendException, + self._driver.ensure_share, + self._context, self.invalid_share) + self.assertTrue(self.mock_log.debug.called) + + def test_extend_share(self): + self.mock_object(ssh.HNASSSHBackend, 'extend_share') + + self._driver.extend_share(self.share, 5) + + ssh.HNASSSHBackend.extend_share.assert_called_once_with('fake_id', 5, + 'nfs') + self.assertTrue(self.mock_log.debug.called) + self.assertTrue(self.mock_log.info.called) + + def test_extend_share_invalid_share_protocol(self): + # invalid share with proto != nfs + m_extend = self.mock_object(ssh.HNASSSHBackend, 'extend_share') + + self.assertRaises(exception.ShareBackendException, + self._driver.extend_share, + self.invalid_share, 5) + self.assertFalse(m_extend.called) + self.assertTrue(self.mock_log.debug.called) + + # TODO(alyson): Implement network tests in DHSS = true mode + def test_get_network_allocations_number(self): + self.assertEqual(0, self._driver.get_network_allocations_number()) + + def test_create_snapshot(self): + # tests when hnas.create_snapshot returns successfully + self.mock_object(ssh.HNASSSHBackend, 'create_snapshot') + + self._driver.create_snapshot(self._context, self.snapshot) + + ssh.HNASSSHBackend.create_snapshot.assert_called_once_with('fake_name', + 'snap_name') + self.assertTrue(self.mock_log.debug.called) + self.assertTrue(self.mock_log.info.called) + + def test_delete_snapshot(self): + # tests when hnas.delete_snapshot returns True + self.mock_object(ssh.HNASSSHBackend, 'delete_snapshot') + + self._driver.delete_snapshot(self._context, self.snapshot) + + ssh.HNASSSHBackend.delete_snapshot.assert_called_once_with('fake_name', + 'snap_name') + self.assertTrue(self.mock_log.debug.called) + self.assertTrue(self.mock_log.info.called) + + def test_create_share_from_snapshot(self): + # share server none + path = '/' + self.share['id'] + + self.mock_object(ssh.HNASSSHBackend, 'create_share_from_snapshot', + mock.Mock(return_value=path)) + + result = self._driver.create_share_from_snapshot(self._context, + self.share, + self.snapshot) + + (ssh.HNASSSHBackend.create_share_from_snapshot. + assert_called_with(self.share, self.snapshot)) + self.assertEqual('172.24.44.10:/fake_id', result) + self.assertTrue(self.mock_log.debug.called) + + def test_manage_existing(self): + driver_op = 'fake' + local_id = 'volume-00002' + manage_return = { + 'size': 1, + 'export_locations': '172.24.44.10:/mnt/nfs/volume-00002', + } + + CONF.set_default('share_backend_name', 'HDS1') + self.mock_object(share_types, 'get_share_type_extra_specs', + mock.Mock(return_value='False')) + self.mock_object(ssh.HNASSSHBackend, 'manage_existing', + mock.Mock(return_value=manage_return)) + + output = self._driver.manage_existing(self.share, driver_op) + + self.assertEqual(manage_return, output) + ssh.HNASSSHBackend.manage_existing.assert_called_once_with(self.share, + local_id) + self.assertTrue(self.mock_log.info.called) + + CONF._unset_defaults_and_overrides() + + def test_manage_share_type_dhss_true(self): + driver_op = 'fake' + + self.mock_object(share_types, 'get_share_type_extra_specs', + mock.Mock(return_value='True')) + + self.assertRaises(exception.ManageExistingShareTypeMismatch, + self._driver.manage_existing, + self.share, driver_op) + share_types.get_share_type_extra_specs.assert_called_once_with( + self.share['share_type_id'], self.const_dhss) + + def test_manage_conf_dhss_true(self): + driver_op = 'fake' + + CONF.set_default('driver_handles_share_servers', True) + self.mock_object(share_types, 'get_share_type_extra_specs', + mock.Mock(return_value='True')) + + self.assertRaises(exception.InvalidDriverMode, + self._driver.manage_existing, + self.share, driver_op) + + def test_manage_invalid_host(self): + driver_op = 'fake' + self.share_invalid_host = { + 'id': 'fake_id', + 'size': 1, + 'share_type_id': '7450f16e-4c7f-42ab-90f1-c1cfb2a6bc70', + 'share_proto': 'nfs', + 'share_network_id': 'fake_network_id', + 'share_server_id': 'fake_server_id', + 'host': 'fake@INVALID#fake_pool', + 'export_locations': [{'path': '172.24.44.10:/nfs/volume-00002'}], + } + + self.mock_object(share_types, 'get_share_type_extra_specs', + mock.Mock(return_value='False')) + + self.assertRaises(exception.ShareBackendException, + self._driver.manage_existing, + self.share_invalid_host, driver_op) + share_types.get_share_type_extra_specs.assert_called_once_with( + self.share_invalid_host['share_type_id'], self.const_dhss) + + def test_manage_invalid_path(self): + driver_op = 'fake' + self.share_invalid_path = { + 'id': 'fake_id', + 'size': 1, + 'share_type_id': '7450f16e-4c7f-42ab-90f1-c1cfb2a6bc70', + 'share_proto': 'nfs', + 'share_network_id': 'fake_network_id', + 'share_server_id': 'fake_server_id', + 'host': 'fake@INVALID#fake_pool', + 'export_locations': [{'path': '172.24.44.10:/volume-00002'}], + } + + self.mock_object(share_types, 'get_share_type_extra_specs', + mock.Mock(return_value='False')) + + self.assertRaises(exception.ShareBackendException, + self._driver.manage_existing, + self.share_invalid_path, driver_op) + share_types.get_share_type_extra_specs.assert_called_once_with( + self.share_invalid_path['share_type_id'], self.const_dhss) + + def test_manage_invalid_evs_ip(self): + driver_op = 'fake' + self.share_invalid_ip = { + 'id': 'fake_id', + 'size': 1, + 'share_type_id': '7450f16e-4c7f-42ab-90f1-c1cfb2a6bc70', + 'share_proto': 'nfs', + 'share_network_id': 'fake_network_id', + 'share_server_id': 'fake_server_id', + 'host': 'fake@HDS1#fake_pool', + 'export_locations': [{'path': '9.9.9.9:/nfs/volume-00002'}], + } + + self.mock_object(share_types, 'get_share_type_extra_specs', + mock.Mock(return_value='False')) + + self.assertRaises(exception.ShareBackendException, + self._driver.manage_existing, + self.share_invalid_ip, driver_op) + share_types.get_share_type_extra_specs.assert_called_once_with( + self.share_invalid_ip['share_type_id'], self.const_dhss) + + def test_unmanage(self): + self._driver.unmanage(self.share) + + self.assertTrue(self.mock_log.info.called) + self.fake_private_storage.delete.assert_called_once_with( + self.share['id']) + + def test_update_share_stats(self): + self.mock_object(ssh.HNASSSHBackend, 'get_stats', + mock.Mock(return_value=[100, 30])) + + self._driver._update_share_stats() + self.assertEqual(False, + self._driver._stats['driver_handles_share_servers']) + self.assertEqual(100, self._driver._stats['total_capacity_gb']) + self.assertEqual(30, self._driver._stats['free_capacity_gb']) + self.assertEqual(0, self._driver._stats['reserved_percentage']) + self.assertEqual(True, self._driver._stats['snapshot_support']) + ssh.HNASSSHBackend.get_stats.assert_called_once_with() + self.assertTrue(self.mock_log.info.called) diff --git a/manila/tests/share/drivers/hitachi/test_ssh.py b/manila/tests/share/drivers/hitachi/test_ssh.py new file mode 100644 index 0000000000..8f45faa859 --- /dev/null +++ b/manila/tests/share/drivers/hitachi/test_ssh.py @@ -0,0 +1,1068 @@ +# Copyright (c) 2015 Hitachi Data 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 mock +from oslo_concurrency import processutils as putils +from oslo_config import cfg +import paramiko + +from manila import exception +from manila.share.drivers.hitachi import ssh +from manila import test + +CONF = cfg.CONF + +HNAS_RESULT_empty = "" + +HNAS_RESULT_limits = """ +Filesystem Ensure on span fake_fs: + +Current capacity 50GiB + +Thin provision: disabled + +Filesystem is confined to: 100GiB + (Run 'filesystem-confine') +Free space on span allows expansion to: 143GiB + (Run 'span-expand') +Chunk size allows growth to: 1069GiB + (This is a conservative estimate) +Largest filesystem that can be checked: 262144GiB + (This is a hard limit) +This server model allows growth to: 262144GiB + (Upgrade the server) """ + +HNAS_RESULT_expdel = """Deleting the export '/dir1' on fs 'fake_fs'... +NFS Export Delete: Export successfully deleted""" + +HNAS_RESULT_vvoldel = """ +Warning: Clearing dangling space trackers from empty vivol""" + +HNAS_RESULT_selectfs = "Current selected file system: fake_fs, number(1)" + +HNAS_RESULT_fs = """ \ +Instance name Dev On span State EVS Cap/GiB Confined Flag +----------------- ---- ------- ------ --- ------- -------- ---- +Filesystem 8e6e2c85-fake-long-filesystem-b9b4-e4b09993841e: +8e6e2c8..9993841e 1057 fake_span Mount 2 4 3 +file_system 1055 fake_span Mount 2 4 5 1 +fake_fs 1051 fake_span Mount 2 100 1024 """ + +HNAS_RESULT_u_fs = """ \ +Instance name Dev On span State EVS Cap/GiB Confined Flag +----------------- ---- ------- ------ --- ------- -------- ---- +file_system 1055 fake_span Umount 2 4 5 +file_system2 1050 fake_span2 NoEVS - 10 0 1 +fake_fs 1051 fake_span Umount 2 100 1024 """ + + +HNAS_RESULT_one_fs = """ \ +Instance name Dev On span State EVS Cap/GiB Confined Flag +----------------- ---- ------- ------ --- ------- -------- ---- +fake_fs 1051 fake_span Mount 2 100 1024 1""" + +HNAS_RESULT_expadd = "NFS Export Add: Export added successfully" + +HNAS_RESULT_vvol = """vvol_test + email : + root : /vvol_test + tag : 39 + usage bytes : 0 B files: 1 + last modified: 2015-06-23 22:36:12.830698800+00:00""" + +HNAS_RESULT_vvol_error = "The virtual volume does not exist." + +HNAS_RESULT_mount = """ \ +Request to mount file system fake_fs submitted successfully. +File system fake_fs successfully mounted.""" + +HNAS_RESULT_quota = """Type : Explicit +Target : ViVol: vvol_test +Usage : 0 B + Limit : 5 GB (Hard) + Warning : Unset + Critical : Unset + Reset : 5% (51.2 MB) +File Count : 1 + Limit : Unset + Warning : Unset + Critical : Unset + Reset : 5% (0) +Generate Events : Disabled +Global id : 28a3c9f8-ae05-11d0-9025-836896aada5d +Last modified : 2015-06-23 22:37:17.363660800+00:00 """ + +HNAS_RESULT_quota_tb = """Type : Explicit +Target : ViVol: vvol_test +Usage : 0 B + Limit : 1 TB (Hard) + Warning : Unset + Critical : Unset + Reset : 5% (51.2 MB) +File Count : 1 + Limit : Unset + Warning : Unset + Critical : Unset + Reset : 5% (0) +Generate Events : Disabled +Global id : 28a3c9f8-ae05-11d0-9025-836896aada5d +Last modified : 2015-06-23 22:37:17.363660800+00:00 """ + +HNAS_RESULT_quota_unset = """Type : Explicit +Target : ViVol: vvol_test +Usage : 0 B + Limit : Unset + Warning : Unset + Critical : Unset + Reset : 5% (51.2 MB) +File Count : 1 + Limit : Unset + Warning : Unset + Critical : Unset + Reset : 5% (0) +Generate Events : Disabled +Global id : 28a3c9f8-ae05-11d0-9025-836896aada5d +Last modified : 2015-06-23 22:37:17.363660800+00:00 """ + +HNAS_RESULT_quota_err = """No quotas matching specified filter criteria. +""" + +HNAS_RESULT_export = """ +Export name: vvol_test + Export path: /vvol_test + File system label: file_system + File system size: 3.969 GB + File system free space: 1.848 GB + File system state: + formatted = Yes + mounted = Yes + failed = No + thin provisioned = No + Access snapshots: No + Display snapshots: No + Read Caching: Disabled +Disaster recovery setting: + Recovered = No + Transfer setting = Use file system default \n + Export configuration:\n +127.0.0.2 +""" + +HNAS_RESULT_wrong_export = """ +Export name: wrong_name + Export path: /vvol_test + File system label: file_system + File system size: 3.969 GB + File system free space: 1.848 GB + File system state: + formatted = Yes + mounted = Yes + failed = No + thin provisioned = No + Access snapshots: No + Display snapshots: No + Read Caching: Disabled +Disaster recovery setting: + Recovered = No + Transfer setting = Use file system default + Export configuration: +127.0.0.1""" + +HNAS_RESULT_exp_no_fs = """ + Export name: no_fs + Export path: /export_without_fs + File system info: *** not available *** + Access snapshots: Yes + Display snapshots: Yes + Read Caching: Disabled +Disaster recovery setting: + Recovered = No + Transfer setting = Use file system default + Export configuration: + """ + +HNAS_RESULT_export_ip = """ + Export name: vvol_test + Export path: /vvol_test + File system label: fake_fs + File system size: 3.969 GB + File system free space: 1.848 GB + File system state: + formatted = Yes + mounted = Yes + failed = No + thin provisioned = No + Access snapshots: No + Display snapshots: No + Read Caching: Disabled +Disaster recovery setting: + Recovered = No + Transfer setting = Use file system default + Export configuration: +127.0.0.1(rw) +""" + +HNAS_RESULT_export_ip2 = """ + Export name: vvol_test + Export path: /vvol_test + File system label: fake_fs + File system size: 3.969 GB + File system free space: 1.848 GB + File system state: + formatted = Yes + mounted = Yes + failed = No + thin provisioned = No + Access snapshots: No + Display snapshots: No + Read Caching: Disabled +Disaster recovery setting: + Recovered = No + Transfer setting = Use file system default + Export configuration: +127.0.0.1(ro) +""" + +HNAS_RESULT_expmod = """Modifying the export '/fake_export' on fs 'fake_fs'... +NFS Export Modify: changing configuration options to: 127.0.0.2 NFS +Export Modify: Export modified successfully""" + +HNAS_RESULT_expnotmod = "Export not modified." + +HNAS_RESULT_fslimits = """ +Filesystem fake_fs on span fake_span: + +Current capacity 100GiB + +Thin provision: disabled + +Free space on span allows expansion to: 10GiB (Run 'span-expand') +Filesystem is confined to: 1024GiB (Run 'filesystem-confine') +Chunk size allows growth to: 1024GiB (This is a conservative \ + estimate) +Largest filesystem that can be checked: 10000GiB (This is a hard limit) +This server model allows growth to: 10000GiB (Upgrade the server) +""" + +HNAS_RESULT_fslimits_tb = """ \ +Filesystem fake_fs on span fake_span: + +Current capacity 1500GiB + +Thin provision: disabled + +Free space on span allows expansion to: 1000GiB (Run 'span-expand') +Filesystem is confined to: 10240GiB (Run 'filesystem-confine') +Chunk size allows growth to: 10240GiB (This is a conservative \ +estimate) +Largest filesystem that can be checked: 10000GiB (This is a hard limit) +This server model allows growth to: 10000GiB (Upgrade the server) +""" + +HNAS_RESULT_job = """tree-operation-job-submit: Request submitted successfully. +tree-operation-job-submit: Job id = d933100a-b5f6-11d0-91d9-836896aada5d""" + +HNAS_RESULT_vvol_list = """vol1 + email : + root : /shares/vol1 + tag : 10 + usage bytes : 0 B files: 1 + last modified: 2015-07-27 22:25:02.746426000+00:00 +vol2 + email : + root : /shares/vol2 + tag : 13 + usage bytes : 0 B files: 1 + last modified: 2015-07-28 01:30:21.125671700+00:00 +vol3 + email : + root : /shares/vol3 + tag : 14 + usage bytes : 5 GB (5368709120 B) files: 2 + last modified: 2015-07-28 20:23:05.672404600+00:00""" + + +class HNASSSHTestCase(test.TestCase): + def setUp(self): + super(HNASSSHTestCase, self).setUp() + + self.ip = '192.168.1.1' + self.port = 22 + self.user = 'hnas_user' + self.password = 'hnas_password' + self.default_commands = ['ssc', '127.0.0.1'] + self.fs_name = 'file_system' + self.evs_ip = '172.24.44.1' + self.evs_id = 2 + self.ssh_private_key = 'private_key' + self.cluster_admin_ip0 = 'fake' + + self.mock_log = self.mock_object(ssh, 'LOG') + + self._driver = ssh.HNASSSHBackend(self.ip, self.user, self.password, + self.ssh_private_key, + self.cluster_admin_ip0, self.evs_id, + self.evs_ip, self.fs_name) + + self.vvol = { + 'id': 'vvol_test', + 'share_proto': 'nfs', + 'size': 4, + 'host': '127.0.0.1', + } + + self.snapshot = { + 'id': 'snapshot_test', + 'share_proto': 'nfs', + 'size': 4, + 'share_id': 'vvol_test', + 'host': 'ubuntu@hds2#HDS2', + } + + def test_get_stats(self): + fake_list_command = ['quota', 'list', 'file_system', 'vol3'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fslimits, ""), + (HNAS_RESULT_vvol_list, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_vvol_list, "")])) + + total, free = self._driver.get_stats() + + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + self.assertTrue(self.mock_log.debug.called) + self.assertEqual(100, total) + self.assertEqual(85, free) + + def test_get_stats_terabytes(self): + fake_list_command = ['quota', 'list', 'file_system', 'vol3'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fslimits_tb, ""), + (HNAS_RESULT_vvol_list, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_quota_tb, ""), + (HNAS_RESULT_quota, "")])) + + total, free = self._driver.get_stats() + + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + self.assertTrue(self.mock_log.debug.called) + self.assertEqual(1500, total) + self.assertEqual(466, free) + + def test_allow_access(self): + fake_mod_command = ['nfs-export', 'mod', '-c', + '"127.0.0.2,127.0.0.1(rw)"', '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_expmod, "")])) + + self._driver.allow_access(self.vvol['id'], self.vvol['host'], + self.vvol['share_proto']) + + # Assert that _execute sent the right mod command + ssh.HNASSSHBackend._execute.assert_called_with(fake_mod_command) + + def test_allow_access_host_allowed(self): + fake_mod_command = ['nfs-export', 'list ', '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_export_ip, "")])) + + self._driver.allow_access(self.vvol['id'], self.vvol['host'], + self.vvol['share_proto']) + + self.assertTrue(self.mock_log.debug.called) + # Assert that _execute sent the right list command + ssh.HNASSSHBackend._execute.assert_called_with(fake_mod_command) + + def test_allow_access_host_with_other_permission(self): + fake_mod_command = ['nfs-export', 'mod', '-c', '"127.0.0.1(rw)"', + '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_export_ip2, ""), + (HNAS_RESULT_expmod, "")])) + + self._driver.allow_access(self.vvol['id'], self.vvol['host'], + self.vvol['share_proto']) + + # Assert that _execute sent the right mod command + ssh.HNASSSHBackend._execute.assert_called_with(fake_mod_command) + + def test_allow_access_wrong_permission(self): + fake_list_command = ['nfs-export', 'list ', '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_export, "")])) + + self.assertRaises(exception.HNASBackendException, + self._driver.allow_access, self.vvol['id'], + self.vvol['host'], self.vvol['share_proto'], + 'fake_permission') + + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_deny_access(self): + fake_mod_command = ['nfs-export', 'mod', '-c', '127.0.0.1', + '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_export_ip2, ""), + (HNAS_RESULT_expmod, "")])) + + self._driver.deny_access(self.vvol['id'], self.vvol['host'], + self.vvol['share_proto'], 'ro') + + # Assert that _execute sent the right mod command + ssh.HNASSSHBackend._execute.assert_called_with(fake_mod_command) + + def test_deny_access_host_not_allowed(self): + fake_list_command = ['nfs-export', 'list ', '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_expmod, "")])) + + self._driver.deny_access(self.vvol['id'], self.vvol['host'], + self.vvol['share_proto'], 'rw') + + # Assert that _execute sent the right list command + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_deny_access_export_modified(self): + fake_mod_command = ['nfs-export', 'mod', '-c', '127.0.0.1', + '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_export_ip2, ""), + (HNAS_RESULT_expnotmod, "")])) + + self._driver.deny_access(self.vvol['id'], self.vvol['host'], + self.vvol['share_proto'], 'ro') + + # Assert that _execute sent the right mod command + ssh.HNASSSHBackend._execute.assert_called_with(fake_mod_command) + + def test_deny_access_wrong_permission(self): + fake_list_command = ['nfs-export', 'list ', '/shares/vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_export, "")])) + + self.assertRaises(exception.HNASBackendException, + self._driver.deny_access, self.vvol['id'], + self.vvol['host'], self.vvol['share_proto'], + 'fake_permission') + + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_delete_share(self): + fake_delete_command = ['tree-delete-job-submit', '--confirm', '-f', + 'file_system', '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_expdel, ""), + (HNAS_RESULT_job, "")])) + + self._driver.delete_share(self.vvol['id'], self.vvol['share_proto']) + + self.assertTrue(self.mock_log.debug.called) + # Assert that _execute sent the right tree-delete command + ssh.HNASSSHBackend._execute.assert_called_with(fake_delete_command) + + def test_delete_inexistent_share(self): + fake_delete_command = ['tree-delete-job-submit', '--confirm', '-f', + 'file_system', '/shares/vvol_test'] + msg = 'Share does not exists.' + msg_err = 'Source path: Cannot access' + + self.mock_object(ssh.HNASSSHBackend, 'ensure_share', + mock.Mock(side_effect=exception.HNASBackendException + (msg))) + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[exception.HNASBackendException + (msg_err), + putils.ProcessExecutionError + (stderr=msg_err)])) + + self._driver.delete_share(self.vvol['id'], self.vvol['share_proto']) + + self.assertTrue(self.mock_log.warning.called) + self.assertTrue(self.mock_log.debug.called) + # Assert that _execute sent the right tree-delete command + ssh.HNASSSHBackend._execute.assert_called_with(fake_delete_command) + + def test_delete_share_fails(self): + msg = 'Share does not exists.' + msg_err = 'Cannot delete share' + fake_tree_command = ['tree-delete-job-submit', '--confirm', '-f', + 'file_system', '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, 'ensure_share', + mock.Mock(side_effect=exception.HNASBackendException + (msg))) + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_expdel, ""), + putils.ProcessExecutionError + (stderr=msg_err)])) + + self.assertRaises(putils.ProcessExecutionError, + self._driver.delete_share, self.vvol['id'], + self.vvol['share_proto']) + + self.assertTrue(self.mock_log.warning.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_tree_command) + + def test_ensure_share(self): + fake_list_command = ['nfs-export', 'list ', '/shares/vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, "")])) + + path = self._driver.ensure_share(self.vvol['id'], + self.vvol['share_proto']) + + self.assertEqual('/shares/' + self.vvol['id'], path) + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_ensure_share_umounted_fs(self): + fake_list_command = ['nfs-export', 'list ', '/shares/vvol_test'] + # tests when filesystem is umounted + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_u_fs, ""), + (HNAS_RESULT_mount, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, "")])) + + path = self._driver.ensure_share(self.vvol['id'], + self.vvol['share_proto']) + + self.assertTrue(self.mock_log.debug.called) + self.assertEqual('/shares/' + self.vvol['id'], path) + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_ensure_share_inexistent_vvol(self): + fake_list_command = ['virtual-volume', 'list', '--verbose', + 'file_system', 'vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + putils.ProcessExecutionError])) + + # Raise exception when vvol doesnt exist + self.assertRaises(exception.HNASBackendException, + self._driver.ensure_share, self.vvol['id'], + self.vvol['share_proto']) + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_ensure_share_quota_unset(self): + fake_quota_list_command = ['quota', 'list', '--verbose', 'file_system', + 'vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota_err, "") + ])) + + self.assertRaises(exception.HNASBackendException, + self._driver.ensure_share, self.vvol['id'], + self.vvol['share_proto']) + ssh.HNASSSHBackend._execute.assert_called_with(fake_quota_list_command) + + def test_ensure_share_wrong_export_name(self): + fake_list_command = ['nfs-export', 'list ', '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_wrong_export, "") + ])) + + # Raise exception when vvol name != export name + self.assertRaises(exception.HNASBackendException, + self._driver.ensure_share, self.vvol['id'], + self.vvol['share_proto']) + + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_ensure_share_export_with_no_fs(self): + fake_list_command = ['nfs-export', 'list ', '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_exp_no_fs, "")])) + + self.assertRaises(exception.HNASBackendException, + self._driver.ensure_share, self.vvol['id'], + self.vvol['share_proto']) + + # Assert that _execute sent the right list command + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_create_share(self): + fake_list_command = ['nfs-export', 'add', '-S', 'disable', '-c', + '127.0.0.1', '/shares/vvol_test', 'file_system', + '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_expadd, "")])) + + path = self._driver.create_share(self.vvol['id'], + self.vvol['size'], + self.vvol['share_proto']) + + self.assertEqual('/shares/' + self.vvol['id'], path) + # Assert that _execute sent the right export add command + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + self.assertTrue(self.mock_log.debug.called) + + def test_create_share_without_fs(self): + fake_list_command = ['filesystem-list'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_one_fs, "")])) + + self.assertRaises(exception.HNASBackendException, + self._driver.create_share, self.vvol['id'], + self.vvol['size'], self.vvol['share_proto']) + + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_create_share_fails(self): + fake_tree_command = ['tree-delete-job-submit', '--confirm', '-f', + 'file_system', '/shares/vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_empty, ""), + putils.ProcessExecutionError, + (HNAS_RESULT_empty, "")])) + + self.assertRaises(putils.ProcessExecutionError, + self._driver.create_share, self.vvol['id'], + self.vvol['size'], + self.vvol['share_proto']) + + self.assertTrue(self.mock_log.debug.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_tree_command) + + def test_create_share_without_size(self): + fake_add_command = ['nfs-export', 'add', '-S', 'disable', '-c', + '127.0.0.1', '/shares/vvol_test', 'file_system', + '/shares/vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_expadd, "")])) + + path = self._driver.create_share(self.vvol['id'], + 0, self.vvol['share_proto']) + + self.assertEqual('/shares/' + self.vvol['id'], path) + self.assertTrue(self.mock_log.debug.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_add_command) + + def test_extend_share(self): + fake_quota_mod_command = ['quota', 'mod', '--usage-limit', '4G', + 'file_system', 'vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_fslimits, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_empty, "")])) + + self._driver.extend_share(self.vvol['id'], + self.vvol['size'], + self.vvol['share_proto']) + + self.assertTrue(self.mock_log.debug.called) + # Assert that _execute sent the right quota modify command + ssh.HNASSSHBackend._execute.assert_called_with(fake_quota_mod_command) + + def test_extend_share_no_space(self): + fake_list_command = ['quota', 'list', 'file_system', 'vol3'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_fslimits, ""), + (HNAS_RESULT_vvol_list, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_quota, "")])) + + # Tests when try to create a share bigger than available free space + self.assertRaises(exception.HNASBackendException, + self._driver.extend_share, self.vvol['id'], + 100, self.vvol['share_proto']) + + self.assertTrue(self.mock_log.debug.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_manage_existing(self): + fake_list_command = ['quota', 'list', 'file_system', 'vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_quota, "")])) + + output = self._driver.manage_existing(self.vvol, self.vvol['id']) + + self.assertEqual({'export_locations': + ['172.24.44.1:/shares/vvol_test'], + 'size': 5.0}, output) + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_manage_existing_share_without_size(self): + fake_list_command = ['quota', 'list', 'file_system', 'vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_fs, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_vvol, ""), + (HNAS_RESULT_quota, ""), + (HNAS_RESULT_export, ""), + (HNAS_RESULT_quota_err, "")])) + + self.assertRaises(exception.HNASBackendException, + self._driver.manage_existing, + self.vvol, self.vvol['id']) + ssh.HNASSSHBackend._execute.assert_called_with(fake_list_command) + + def test_create_snapshot(self): + fake_create_command = ['tree-clone-job-submit', '-e', + '-f', 'file_system', '/shares/vvol_test', + '/snapshots/vvol_test/snapshot_test'] + + # Tests when a tree job is successfully submitted + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(return_value=(HNAS_RESULT_job, ""))) + + self._driver.create_snapshot(self.vvol['id'], + self.snapshot['id']) + + self.assertTrue(self.mock_log.debug.called) + # Assert that _execute sent the right tree-clone command + ssh.HNASSSHBackend._execute.assert_called_with(fake_create_command) + + def test_create_empty_snapshot(self): + fake_create_command = ['selectfs', 'file_system', '\n', 'ssc', + '127.0.0.1', 'console-context', + '--evs', '2', 'mkdir', '-p', + '/snapshots/vvol_test/snapshot_test'] + # Tests when submit a tree job of an empty directory + msg = 'Cannot find any clonable files in the source directory' + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(putils.ProcessExecutionError + (stderr=msg)), + (HNAS_RESULT_empty, "")])) + + self._driver.create_snapshot(self.vvol['id'], self.snapshot['id']) + + self.assertTrue(self.mock_log.warning.called) + # Assert that _execute sent the right command to select fs and create + # a directory. + ssh.HNASSSHBackend._execute.assert_called_with(fake_create_command) + + def test_create_snapshot_submit_fails(self): + fake_create_command = ['tree-clone-job-submit', '-e', '-f', + 'file_system', '/shares/vvol_test', + '/snapshots/vvol_test/snapshot_test'] + # Tests when submit a tree job fails + msg = 'Cannot create copy from this directory' + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=putils.ProcessExecutionError + (stderr=msg))) + + self.assertRaises(exception.HNASBackendException, + self._driver.create_snapshot, self.vvol['id'], + self.snapshot['id']) + + self.assertTrue(self.mock_log.exception.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_create_command) + + def test_delete_snapshot(self): + fake_delete_command = ['selectfs', 'file_system', '\n', 'ssc', + '127.0.0.1', 'console-context', '--evs', '2', + 'rmdir', '/snapshots/vvol_test'] + + # Tests when successfully delete the snapshot + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_job, ""), + putils.ProcessExecutionError])) + + self._driver.delete_snapshot(self.vvol['id'], + self.snapshot['id']) + + self.assertTrue(self.mock_log.debug.called) + # Assert that _execute sent the right command to select fs and + # delete the directory. + ssh.HNASSSHBackend._execute.assert_called_with(fake_delete_command) + + def test_delete_snapshot_last_snapshot(self): + fake_delete_command = ['selectfs', 'file_system', '\n', 'ssc', + '127.0.0.1', 'console-context', '--evs', '2', + 'rmdir', '/snapshots/vvol_test'] + + # Tests when successfully delete the last snapshot (it requires delete + # the parent directory). + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(return_value=(HNAS_RESULT_job, ""))) + + self._driver.delete_snapshot(self.vvol['id'], + self.snapshot['id']) + + # Assert that _execute sent the right command to select fs and + # delete the directory. + ssh.HNASSSHBackend._execute.assert_called_with(fake_delete_command) + + def test_delete_snapshot_submit_fails(self): + msg = 'Cannot delete snapshot.' + fake_tree_del_command = ['tree-delete-job-submit', '--confirm', + '-f', 'file_system', + '/snapshots/vvol_test/snapshot_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=putils.ProcessExecutionError + (stderr=msg))) + + self.assertRaises(putils.ProcessExecutionError, + self._driver.delete_snapshot, + self.vvol['id'], self.snapshot['id']) + + self.assertTrue(self.mock_log.exception.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_tree_del_command) + + def test_create_share_from_snapshot(self): + fake_export_command = ['nfs-export', 'add', '-S', 'disable', '-c', + '127.0.0.1', '/shares/vvol_test', 'file_system', + '/shares/vvol_test'] + # Tests when successfully creates a share from snapshot + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_quota, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_job, ""), + (HNAS_RESULT_export, "")])) + + output = self._driver.create_share_from_snapshot(self.vvol, + self.snapshot) + + self.assertEqual('/shares/' + self.vvol['id'], output) + self.assertTrue(self.mock_log.debug.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_export_command) + + def test_create_share_from_snapshot_quota_unset(self): + # Tests when quota is unset + fake_quota_command = ['quota', 'list', 'file_system', 'vvol_test'] + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(return_value=(HNAS_RESULT_quota_err, ""))) + + self.assertRaises(exception.HNASBackendException, + self._driver.create_share_from_snapshot, + self.vvol, self.snapshot) + ssh.HNASSSHBackend._execute.assert_called_with(fake_quota_command) + + def test_create_share_from_empty_snapshot(self): + msg = 'Cannot find any clonable files in the source directory' + fake_export_command = ['nfs-export', 'add', '-S', 'disable', '-c', + '127.0.0.1', '/shares/vvol_test', + 'file_system', '/shares/vvol_test'] + + # Tests when successfully creates a share from snapshot + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_quota, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_empty, ""), + (putils.ProcessExecutionError + (stderr=msg)), + (HNAS_RESULT_export, "")])) + + output = self._driver.create_share_from_snapshot(self.vvol, + self.snapshot) + + self.assertEqual('/shares/' + self.vvol['id'], output) + self.assertTrue(self.mock_log.debug.called) + self.assertTrue(self.mock_log.warning.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_export_command) + + def test_create_share_from_snapshot_fails(self): + msg = 'Cannot copy from source directory' + fake_submit_command = ['tree-clone-job-submit', '-f', 'file_system', + '/snapshots/vvol_test/snapshot_test', + '/shares/vvol_test'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[(HNAS_RESULT_quota, ""), + (HNAS_RESULT_fs, ""), + (HNAS_RESULT_empty, ""), + (HNAS_RESULT_empty, ""), + (putils.ProcessExecutionError + (stderr=msg)), + (HNAS_RESULT_export, "")])) + + self.assertRaises(exception.HNASBackendException, + self._driver.create_share_from_snapshot, + self.vvol, self.snapshot) + + self.assertTrue(self.mock_log.debug.called) + ssh.HNASSSHBackend._execute.assert_called_with(fake_submit_command) + + def test__execute(self): + key = self.ssh_private_key + commands = ['tree-clone-job-submit', '-e', '/src', '/dst'] + concat_command = ('ssc --smuauth fake console-context --evs 2 ' + 'tree-clone-job-submit -e /src /dst') + self.mock_object(paramiko.SSHClient, 'connect') + self.mock_object(putils, 'ssh_execute', + mock.Mock(return_value=[HNAS_RESULT_job, ''])) + + output, err = self._driver._execute(commands) + + putils.ssh_execute.assert_called_once_with(mock.ANY, concat_command, + check_exit_code=True) + paramiko.SSHClient.connect.assert_called_with(self.ip, + username=self.user, + key_filename=key, + look_for_keys=False, + timeout=None, + password=self.password, + port=self.port) + self.assertIn('Request submitted successfully.', output) + + def test__execute_ssh_exception(self): + key = self.ssh_private_key + commands = ['tree-clone-job-submit', '-e', '/src', '/dst'] + concat_command = ('ssc --smuauth fake console-context --evs 2 ' + 'tree-clone-job-submit -e /src /dst') + self.mock_object(paramiko.SSHClient, 'connect') + self.mock_object(putils, 'ssh_execute', + mock.Mock(side_effect=putils.ProcessExecutionError)) + + self.assertRaises(putils.ProcessExecutionError, + self._driver._execute, commands) + + putils.ssh_execute.assert_called_once_with(mock.ANY, concat_command, + check_exit_code=True) + paramiko.SSHClient.connect.assert_called_with(self.ip, + username=self.user, + key_filename=key, + look_for_keys=False, + timeout=None, + password=self.password, + port=self.port) + self.assertTrue(self.mock_log.debug.called) + self.assertTrue(self.mock_log.error.called) + + def test_mount_fs_already_mounted(self): + msg = 'file system is already mounted' + fake_mount_command = ['mount', 'file_system'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[putils.ProcessExecutionError + (stderr=msg)])) + + output = self._driver._mount(self.fs_name) + + self.assertTrue(output) + ssh.HNASSSHBackend._execute.assert_called_with(fake_mount_command) + + def test_error_mount_fs(self): + msg = 'File system not found.' + fake_mount_command = ['mount', 'file_system'] + + self.mock_object(ssh.HNASSSHBackend, '_execute', + mock.Mock(side_effect=[putils.ProcessExecutionError + (stderr=msg)])) + + self.assertRaises(putils.ProcessExecutionError, + self._driver._mount, self.fs_name) + ssh.HNASSSHBackend._execute.assert_called_with(fake_mount_command) diff --git a/manila/tests/test_utils.py b/manila/tests/test_utils.py index f2c81d7f42..5756d8accd 100644 --- a/manila/tests/test_utils.py +++ b/manila/tests/test_utils.py @@ -193,6 +193,7 @@ class GetFromPathTestCase(test.TestCase): self.assertEqual(['b_1'], f(input, "a/b")) +@ddt.ddt class GenericUtilsTestCase(test.TestCase): def test_read_cached_file(self): cache_data = {"data": 1123, "mtime": 1} @@ -332,6 +333,26 @@ class GenericUtilsTestCase(test.TestCase): self.assertFalse(utils.is_eventlet_bug105()) fake_dns.getaddrinfo.assert_called_once_with('::1', 80) + @ddt.data(['ssh', '-D', 'my_name@name_of_remote_computer'], + ['echo', '"quoted arg with space"'], + ['echo', "'quoted arg with space'"]) + def test_check_ssh_injection(self, cmd): + cmd_list = cmd + self.assertIsNone(utils.check_ssh_injection(cmd_list)) + + @ddt.data(['ssh', 'my_name@ name_of_remote_computer'], + ['||', 'my_name@name_of_remote_computer'], + ['cmd', 'virus;ls'], + ['cmd', '"arg\"withunescaped"'], + ['cmd', 'virus;"quoted argument"'], + ['echo', '"quoted argument";rm -rf'], + ['echo', "'quoted argument `rm -rf`'"], + ['echo', '"quoted";virus;"quoted"'], + ['echo', '"quoted";virus;\'quoted\'']) + def test_check_ssh_injection_on_error0(self, cmd): + self.assertRaises(exception.SSHInjectionThreat, + utils.check_ssh_injection, cmd) + class MonkeyPatchTestCase(test.TestCase): """Unit test for utils.monkey_patch().""" diff --git a/manila/utils.py b/manila/utils.py index 8819a3cdf4..e736573700 100644 --- a/manila/utils.py +++ b/manila/utils.py @@ -22,6 +22,7 @@ import errno import inspect import os import pyclbr +import re import shutil import socket import sys @@ -147,6 +148,41 @@ class SSHPool(pools.Pool): self.current_size -= 1 +def check_ssh_injection(cmd_list): + ssh_injection_pattern = ['`', '$', '|', '||', ';', '&', '&&', '>', '>>', + '<'] + + # Check whether injection attacks exist + for arg in cmd_list: + arg = arg.strip() + + # Check for matching quotes on the ends + is_quoted = re.match('^(?P[\'"])(?P.*)(?P=quote)$', arg) + if is_quoted: + # Check for unescaped quotes within the quoted argument + quoted = is_quoted.group('quoted') + if quoted: + if (re.match('[\'"]', quoted) or + re.search('[^\\\\][\'"]', quoted)): + raise exception.SSHInjectionThreat(command=cmd_list) + else: + # We only allow spaces within quoted arguments, and that + # is the only special character allowed within quotes + if len(arg.split()) > 1: + raise exception.SSHInjectionThreat(command=cmd_list) + + # Second, check whether danger character in command. So the shell + # special operator must be a single argument. + for c in ssh_injection_pattern: + if c not in arg: + continue + + result = arg.find(c) + if not result == -1: + if result == 0 or not arg[result - 1] == '\\': + raise exception.SSHInjectionThreat(command=cmd_list) + + class LazyPluggable(object): """A pluggable backend loaded lazily based on some value."""