diff --git a/os_brick/initiator/connector.py b/os_brick/initiator/connector.py index d3f506d13..9d91e86d2 100644 --- a/os_brick/initiator/connector.py +++ b/os_brick/initiator/connector.py @@ -52,6 +52,7 @@ from os_brick.initiator import linuxfc from os_brick.initiator import linuxrbd from os_brick.initiator import linuxscsi from os_brick.initiator import linuxsheepdog +from os_brick.initiator import windows as windows_connector from os_brick.remotefs import remotefs from os_brick.i18n import _, _LE, _LI, _LW @@ -68,6 +69,7 @@ PLATFORM_x86 = 'X86' PLATFORM_S390 = 'S390' OS_TYPE_ALL = 'ALL' OS_TYPE_LINUX = 'LINUX' +OS_TYPE_WINDOWS = 'WIN' S390X = "s390x" S390 = "s390" @@ -104,6 +106,8 @@ connector_list = [ 'os_brick.initiator.connector.HGSTConnector', 'os_brick.initiator.connector.ScaleIOConnector', 'os_brick.initiator.connector.DISCOConnector', + 'os_brick.initiator.windows.base.BaseWindowsConnector', + 'os_brick.initiator.windows.iscsi.WindowsISCSIConnector', ] @@ -189,6 +193,13 @@ class InitiatorConnector(executor.Executor): *args, **kwargs): """Build a Connector object based upon protocol and architecture.""" + if sys.platform == 'win32': + return windows_connector.factory( + protocol, + use_multipath=use_multipath, + device_scan_attempts=device_scan_attempts, + *args, **kwargs) + # We do this instead of assigning it in the definition # to help mocking for unit tests if arch is None: @@ -549,7 +560,35 @@ class FakeConnector(BaseLinuxConnector): '/dev/disk/by-path/fake-volume-X'] -class ISCSIConnector(BaseLinuxConnector): +class BaseISCSIConnector(InitiatorConnector): + def _iterate_all_targets(self, connection_properties): + for portal, iqn, lun in self._get_all_targets(connection_properties): + props = copy.deepcopy(connection_properties) + props['target_portal'] = portal + props['target_iqn'] = iqn + props['target_lun'] = lun + for key in ('target_portals', 'target_iqns', 'target_luns'): + props.pop(key, None) + yield props + + def _get_all_targets(self, connection_properties): + if all([key in connection_properties for key in ('target_portals', + 'target_iqns', + 'target_luns')]): + return zip(connection_properties['target_portals'], + connection_properties['target_iqns'], + connection_properties['target_luns']) + + return [(connection_properties['target_portal'], + connection_properties['target_iqn'], + connection_properties.get('target_lun', 0))] + + +class FakeBaseISCSIConnector(FakeConnector, BaseISCSIConnector): + pass + + +class ISCSIConnector(BaseLinuxConnector, BaseISCSIConnector): """Connector class to attach/detach iSCSI volumes.""" supported_transports = ['be2iscsi', 'bnx2i', 'cxgb3i', 'default', 'cxgb4i', 'qla4xxx', 'ocs', 'iser'] @@ -795,28 +834,6 @@ class ISCSIConnector(BaseLinuxConnector): def _get_transport(self): return self.transport - def _iterate_all_targets(self, connection_properties): - for portal, iqn, lun in self._get_all_targets(connection_properties): - props = copy.deepcopy(connection_properties) - props['target_portal'] = portal - props['target_iqn'] = iqn - props['target_lun'] = lun - for key in ('target_portals', 'target_iqns', 'target_luns'): - props.pop(key, None) - yield props - - def _get_all_targets(self, connection_properties): - if all([key in connection_properties for key in ('target_portals', - 'target_iqns', - 'target_luns')]): - return zip(connection_properties['target_portals'], - connection_properties['target_iqns'], - connection_properties['target_luns']) - - return [(connection_properties['target_portal'], - connection_properties['target_iqn'], - connection_properties.get('target_lun', 0))] - def _discover_iscsi_portals(self, connection_properties): if all([key in connection_properties for key in ('target_portals', 'target_iqns')]): diff --git a/os_brick/initiator/windows/__init__.py b/os_brick/initiator/windows/__init__.py new file mode 100644 index 000000000..f67035fc5 --- /dev/null +++ b/os_brick/initiator/windows/__init__.py @@ -0,0 +1,43 @@ +# Copyright 2016 Cloudbase Solutions Srl +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_log import log as logging +from oslo_utils import importutils + +from os_brick.i18n import _ + +LOG = logging.getLogger(__name__) + + +# TODO(lpetrut): once we move the protocol name constants to a +# separate module, use that instead. +_connector_dict = { + 'ISCSI': + 'os_brick.initiator.windows.iscsi.WindowsISCSIConnector', +} + + +def factory(protocol, *args, **kwargs): + LOG.debug("Retrieving connector for protocol: %s.", protocol) + + connector = _connector_dict.get(protocol.upper()) + if not connector: + msg = (_("Invalid InitiatorConnector protocol " + "specified %(protocol)s") % + dict(protocol=protocol)) + raise ValueError(msg) + + conn_cls = importutils.import_class(connector) + return conn_cls(*args, **kwargs) diff --git a/os_brick/initiator/windows/base.py b/os_brick/initiator/windows/base.py new file mode 100644 index 000000000..859fa2f64 --- /dev/null +++ b/os_brick/initiator/windows/base.py @@ -0,0 +1,108 @@ +# Copyright 2016 Cloudbase Solutions Srl +# 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 os_win import utilsfactory +from oslo_log import log as logging + +from os_brick import exception +from os_brick.i18n import _, _LE +from os_brick.initiator import connector + +LOG = logging.getLogger(__name__) + + +class BaseWindowsConnector(connector.InitiatorConnector): + platform = connector.PLATFORM_ALL + os_type = connector.OS_TYPE_WINDOWS + + def __init__(self, root_helper=None, *args, **kwargs): + super(BaseWindowsConnector, self).__init__(root_helper, + *args, **kwargs) + self._diskutils = utilsfactory.get_diskutils() + + @staticmethod + def check_multipath_support(enforce_multipath): + hostutils = utilsfactory.get_hostutils() + mpio_enabled = hostutils.check_server_feature( + hostutils.FEATURE_MPIO) + if not mpio_enabled: + err_msg = _LE( + "Using multipath connections for iSCSI and FC disks " + "requires the Multipath IO Windows feature to be " + "enabled. MPIO must be configured to claim such devices.") + LOG.error(err_msg) + if enforce_multipath: + raise exception.BrickException(err_msg) + return False + return True + + @staticmethod + def get_connector_properties(*args, **kwargs): + multipath = kwargs['multipath'] + enforce_multipath = kwargs['enforce_multipath'] + + props = {} + props['multipath'] = ( + multipath and + BaseWindowsConnector.check_multipath_support(enforce_multipath)) + return props + + def _get_scsi_wwn(self, device_number): + # NOTE(lpetrut): The Linux connectors use scsi_id to retrieve the + # disk unique id, which prepends the identifier type to the unique id + # retrieved from the page 83 SCSI inquiry data. We'll do the same + # to remain consistent. + disk_uid, uid_type = self._diskutils.get_disk_uid_and_uid_type( + device_number) + scsi_wwn = '%s%s' % (uid_type, disk_uid) + return scsi_wwn + + def check_valid_device(self, path, *args, **kwargs): + try: + with open(path, 'r') as dev: + dev.read(1) + except IOError: + LOG.exception( + _LE("Failed to access the device on the path " + "%(path)s"), {"path": path}) + return False + return True + + def get_all_available_volumes(self): + # TODO(lpetrut): query for disks based on the protocol used. + return [] + + def _check_device_paths(self, device_paths): + if len(device_paths) > 1: + err_msg = _("Multiple volume paths were found: %s. This can " + "occur if multipath is used and MPIO is not " + "properly configured, thus not claiming the device " + "paths. This issue must be addressed urgently as " + "it can lead to data corruption.") + raise exception.BrickException(err_msg % device_paths) + + def extend_volume(self, connection_properties): + volume_paths = self.get_volume_paths(connection_properties) + if not volume_paths: + err_msg = _("Could not find the disk. Extend failed.") + raise exception.NotFound(err_msg) + + device_path = volume_paths[0] + device_number = self._diskutils.get_device_number_from_device_name( + device_path) + self._diskutils.refresh_disk(device_number) + + def get_search_path(self): + return None diff --git a/os_brick/initiator/windows/iscsi.py b/os_brick/initiator/windows/iscsi.py new file mode 100644 index 000000000..56675962a --- /dev/null +++ b/os_brick/initiator/windows/iscsi.py @@ -0,0 +1,155 @@ +# Copyright 2016 Cloudbase Solutions Srl +# 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 os_win import exceptions as os_win_exc +from os_win import utilsfactory +from oslo_log import log as logging + +from os_brick import exception +from os_brick.i18n import _, _LE, _LI, _LW +from os_brick.initiator import connector +from os_brick.initiator.windows import base as win_conn_base + +LOG = logging.getLogger(__name__) + + +class WindowsISCSIConnector(win_conn_base.BaseWindowsConnector, + connector.BaseISCSIConnector): + def __init__(self, *args, **kwargs): + super(WindowsISCSIConnector, self).__init__(*args, **kwargs) + self.use_multipath = kwargs.pop('use_multipath', False) + self.initiator_list = kwargs.pop('initiator_list', []) + + self._iscsi_utils = utilsfactory.get_iscsi_initiator_utils() + + self.validate_initiators() + + def validate_initiators(self): + """Validates the list of requested initiator HBAs to be used + when estabilishing iSCSI sessions. + """ + valid_initiator_list = True + if not self.initiator_list: + LOG.info(_LI("No iSCSI initiator was explicitly requested. " + "The Microsoft iSCSI initiator will choose the " + "initiator when estabilishing sessions.")) + else: + available_initiators = self._iscsi_utils.get_iscsi_initiators() + for initiator in self.initiator_list: + if initiator not in available_initiators: + msg = _LW("The requested initiator %(req_initiator)s " + "is not in the list of available initiators: " + "%(avail_initiators)s.") + LOG.warning(msg, + dict(req_initiator=initiator, + avail_initiators=available_initiators)) + valid_initiator_list = False + return valid_initiator_list + + def get_initiator(self): + """Returns the iSCSI initiator node name.""" + return self._iscsi_utils.get_iscsi_initiator() + + @staticmethod + def get_connector_properties(*args, **kwargs): + iscsi_utils = utilsfactory.get_iscsi_initiator_utils() + initiator = iscsi_utils.get_iscsi_initiator() + return dict(initiator=initiator) + + def _get_all_paths(self, connection_properties): + initiator_list = self.initiator_list or [None] + all_targets = self._get_all_targets(connection_properties) + paths = [(initiator_name, target_portal, target_iqn, target_lun) + for target_portal, target_iqn, target_lun in all_targets + for initiator_name in initiator_list] + return paths + + def connect_volume(self, connection_properties): + volume_connected = False + for (initiator_name, + target_portal, + target_iqn, + target_lun) in self._get_all_paths(connection_properties): + try: + msg = _LI("Attempting to establish an iSCSI session to " + "target %(target_iqn)s on portal %(target_portal)s " + "acessing LUN %(target_lun)s using initiator " + "%(initiator_name)s.") + LOG.info(msg, dict(target_portal=target_portal, + target_iqn=target_iqn, + target_lun=target_lun, + initiator_name=initiator_name)) + self._iscsi_utils.login_storage_target( + target_lun=target_lun, + target_iqn=target_iqn, + target_portal=target_portal, + auth_username=connection_properties.get('auth_username'), + auth_password=connection_properties.get('auth_password'), + mpio_enabled=self.use_multipath, + initiator_name=initiator_name, + rescan_attempts=self.device_scan_attempts) + + if not volume_connected: + (device_number, + device_path) = ( + self._iscsi_utils.get_device_number_and_path( + target_iqn, target_lun)) + volume_connected = True + + if not self.use_multipath: + break + except os_win_exc.OSWinException: + LOG.exception(_LE("Could not estabilish the iSCSI session.")) + + if not volume_connected: + raise exception.BrickException( + _("Could not connect volume %s.") % connection_properties) + + scsi_wwn = self._get_scsi_wwn(device_number) + + device_info = {'type': 'block', + 'path': device_path, + 'number': device_number, + 'scsi_wwn': scsi_wwn} + return device_info + + def disconnect_volume(self, connection_properties): + # We want to refresh the cached information first. + self._diskutils.rescan_disks() + for (target_portal, + target_iqn, + target_lun) in self._get_all_targets(connection_properties): + + luns = self._iscsi_utils.get_target_luns(target_iqn) + # We disconnect the target only if it does not expose other + # luns which may be in use. + if not luns or luns == [target_lun]: + self._iscsi_utils.logout_storage_target(target_iqn) + + def get_volume_paths(self, connection_properties): + device_paths = set() + + for (target_portal, + target_iqn, + target_lun) in self._get_all_targets(connection_properties): + + (device_number, + device_path) = self._iscsi_utils.get_device_number_and_path( + target_iqn, target_lun) + if device_path: + device_paths.add(device_path) + + self._check_device_paths(device_paths) + return list(device_paths) diff --git a/os_brick/tests/initiator/test_connector.py b/os_brick/tests/initiator/test_connector.py index ae4efff92..763aad473 100644 --- a/os_brick/tests/initiator/test_connector.py +++ b/os_brick/tests/initiator/test_connector.py @@ -260,6 +260,65 @@ class ConnectorTestCase(base.TestCase): self.assertFalse(self.connector.check_valid_device('/dev')) +class BaseISCSIConnectorTestCase(base.TestCase): + + def setUp(self): + super(BaseISCSIConnectorTestCase, self).setUp() + self.connector = connector.FakeBaseISCSIConnector(None) + + @mock.patch.object(connector.BaseISCSIConnector, '_get_all_targets') + def test_iterate_all_targets(self, mock_get_all_targets): + # extra_property cannot be a sentinel, a copied sentinel will not + # identical to the original one. + connection_properties = { + 'target_portals': mock.sentinel.target_portals, + 'target_iqns': mock.sentinel.target_iqns, + 'target_luns': mock.sentinel.target_luns, + 'extra_property': 'extra_property'} + mock_get_all_targets.return_value = [( + mock.sentinel.portal, mock.sentinel.iqn, mock.sentinel.lun)] + + # method is a generator, and it yields dictionaries. list() will + # iterate over all of the method's items. + list_props = list( + self.connector._iterate_all_targets(connection_properties)) + + mock_get_all_targets.assert_called_once_with(connection_properties) + self.assertEqual(1, len(list_props)) + + expected_props = {'target_portal': mock.sentinel.portal, + 'target_iqn': mock.sentinel.iqn, + 'target_lun': mock.sentinel.lun, + 'extra_property': 'extra_property'} + self.assertDictEqual(expected_props, list_props[0]) + + def test_get_all_targets(self): + connection_properties = { + 'target_portals': [mock.sentinel.target_portals], + 'target_iqns': [mock.sentinel.target_iqns], + 'target_luns': [mock.sentinel.target_luns]} + + all_targets = self.connector._get_all_targets(connection_properties) + + expected_targets = zip([mock.sentinel.target_portals], + [mock.sentinel.target_iqns], + [mock.sentinel.target_luns]) + self.assertEqual(list(expected_targets), list(all_targets)) + + def test_get_all_targets_single_target(self): + connection_properties = { + 'target_portal': mock.sentinel.target_portal, + 'target_iqn': mock.sentinel.target_iqn, + 'target_lun': mock.sentinel.target_lun} + + all_targets = self.connector._get_all_targets(connection_properties) + + expected_target = (mock.sentinel.target_portal, + mock.sentinel.target_iqn, + mock.sentinel.target_lun) + self.assertEqual([expected_target], all_targets) + + class ISCSIConnectorTestCase(ConnectorTestCase): def setUp(self): diff --git a/os_brick/tests/windows/__init__.py b/os_brick/tests/windows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/os_brick/tests/windows/fake_win_conn.py b/os_brick/tests/windows/fake_win_conn.py new file mode 100644 index 000000000..f12887c14 --- /dev/null +++ b/os_brick/tests/windows/fake_win_conn.py @@ -0,0 +1,33 @@ +# Copyright 2016 Cloudbase Solutions Srl +# 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 os_brick.initiator.windows import base as win_conn_base + + +class FakeWindowsConnector(win_conn_base.BaseWindowsConnector): + def connect_volume(self, connection_properties): + return {} + + def disconnect_volume(self, connection_properties, device_info): + pass + + def get_volume_paths(self, connection_properties): + return [] + + def get_search_path(self): + return None + + def get_all_available_volumes(self, connection_properties=None): + return [] diff --git a/os_brick/tests/windows/test_base.py b/os_brick/tests/windows/test_base.py new file mode 100644 index 000000000..6f4c64533 --- /dev/null +++ b/os_brick/tests/windows/test_base.py @@ -0,0 +1,34 @@ +# Copyright 2016 Cloudbase Solutions Srl +# 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 os_win import utilsfactory + +from os_brick.tests import base + + +class WindowsConnectorTestBase(base.TestCase): + @mock.patch('sys.platform', 'win32') + def setUp(self): + super(WindowsConnectorTestBase, self).setUp() + + # All the Windows connectors use os_win.utilsfactory to fetch Windows + # specific utils. During init, those will run methods that will fail + # on other platforms. To make testing easier and avoid checking the + # platform in the code, we can simply mock this factory method. + utilsfactory_patcher = mock.patch.object( + utilsfactory, '_get_class') + utilsfactory_patcher.start() + self.addCleanup(utilsfactory_patcher.stop) diff --git a/os_brick/tests/windows/test_base_connector.py b/os_brick/tests/windows/test_base_connector.py new file mode 100644 index 000000000..bc1fc9867 --- /dev/null +++ b/os_brick/tests/windows/test_base_connector.py @@ -0,0 +1,134 @@ +# Copyright 2016 Cloudbase Solutions Srl +# 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 +import six + +from os_brick import exception +from os_brick.initiator.windows import base as base_win_conn +from os_brick.tests.windows import fake_win_conn +from os_brick.tests.windows import test_base + + +@ddt.ddt +class BaseWindowsConnectorTestCase(test_base.WindowsConnectorTestBase): + def setUp(self): + super(BaseWindowsConnectorTestCase, self).setUp() + + self._diskutils = mock.Mock() + + self._connector = fake_win_conn.FakeWindowsConnector() + self._connector._diskutils = self._diskutils + + @ddt.data({}, + {'feature_available': True}, + {'feature_available': False, 'enforce_multipath': True}) + @ddt.unpack + @mock.patch.object(base_win_conn.utilsfactory, 'get_hostutils') + def test_check_multipath_support(self, mock_get_hostutils, + feature_available=True, + enforce_multipath=False): + mock_hostutils = mock_get_hostutils.return_value + mock_hostutils.check_server_feature.return_value = feature_available + check_mpio = base_win_conn.BaseWindowsConnector.check_multipath_support + + if feature_available or not enforce_multipath: + multipath_support = check_mpio( + enforce_multipath=enforce_multipath) + self.assertEqual(feature_available, multipath_support) + else: + self.assertRaises(exception.BrickException, + check_mpio, + enforce_multipath=enforce_multipath) + mock_hostutils.check_server_feature.assert_called_once_with( + mock_hostutils.FEATURE_MPIO) + + @ddt.data({}, {'mpio_requested': False}, {'mpio_available': True}) + @mock.patch.object(base_win_conn.BaseWindowsConnector, + 'check_multipath_support') + @ddt.unpack + def test_get_connector_properties(self, mock_check_mpio, + mpio_requested=True, + mpio_available=True): + mock_check_mpio.return_value = mpio_available + enforce_multipath = False + + props = base_win_conn.BaseWindowsConnector.get_connector_properties( + multipath=mpio_requested, + enforce_multipath=enforce_multipath) + self.assertEqual(mpio_requested and mpio_available, + props['multipath']) + if mpio_requested: + mock_check_mpio.assert_called_once_with(enforce_multipath) + + def test_get_scsi_wwn(self): + mock_get_uid_and_type = self._diskutils.get_disk_uid_and_uid_type + mock_get_uid_and_type.return_value = (mock.sentinel.disk_uid, + mock.sentinel.uid_type) + + scsi_wwn = self._connector._get_scsi_wwn(mock.sentinel.dev_num) + expected_wwn = '%s%s' % (mock.sentinel.uid_type, + mock.sentinel.disk_uid) + self.assertEqual(expected_wwn, scsi_wwn) + mock_get_uid_and_type.assert_called_once_with(mock.sentinel.dev_num) + + @ddt.data(None, IOError) + @mock.patch.object(six.moves.builtins, 'open') + def test_check_valid_device(self, exc, mock_open): + mock_open.side_effect = exc + + valid_device = self._connector.check_valid_device( + mock.sentinel.dev_path) + self.assertEqual(not exc, valid_device) + + mock_open.assert_any_call(mock.sentinel.dev_path, 'r') + mock_read = mock_open.return_value.__enter__.return_value.read + if not exc: + mock_read.assert_called_once_with(1) + + def test_check_device_paths(self): + # We expect an exception to be raised if the same volume + # can be accessed through multiple paths. + device_paths = [mock.sentinel.dev_path_0, + mock.sentinel.dev_path_1] + self.assertRaises(exception.BrickException, + self._connector._check_device_paths, + device_paths) + + @mock.patch.object(fake_win_conn.FakeWindowsConnector, + 'get_volume_paths') + def test_extend_volume(self, mock_get_vol_paths): + mock_vol_paths = [mock.sentinel.dev_path] + mock_get_vol_paths.return_value = mock_vol_paths + + self._connector.extend_volume(mock.sentinel.conn_props) + + mock_get_vol_paths.assert_called_once_with(mock.sentinel.conn_props) + mock_get_dev_num = self._diskutils.get_device_number_from_device_name + mock_get_dev_num.assert_called_once_with(mock.sentinel.dev_path) + self._diskutils.refresh_disk.assert_called_once_with( + mock_get_dev_num.return_value) + + @mock.patch.object(fake_win_conn.FakeWindowsConnector, + 'get_volume_paths') + def test_extend_volume_missing_path(self, mock_get_vol_paths): + mock_get_vol_paths.return_value = [] + + self.assertRaises(exception.NotFound, + self._connector.extend_volume, + mock.sentinel.conn_props) + + mock_get_vol_paths.assert_called_once_with(mock.sentinel.conn_props) diff --git a/os_brick/tests/windows/test_factory.py b/os_brick/tests/windows/test_factory.py new file mode 100644 index 000000000..15cb73623 --- /dev/null +++ b/os_brick/tests/windows/test_factory.py @@ -0,0 +1,32 @@ +# Copyright 2016 Cloudbase Solutions Srl +# 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 os_brick.initiator import connector +from os_brick.initiator.windows import iscsi +from os_brick.tests.windows import test_base + + +@ddt.ddt +class WindowsConnectorFactoryTestCase(test_base.WindowsConnectorTestBase): + @ddt.data({'proto': connector.ISCSI, + 'expected_cls': iscsi.WindowsISCSIConnector}) + @ddt.unpack + @mock.patch('sys.platform', 'win32') + def test_factory(self, proto, expected_cls): + obj = connector.InitiatorConnector.factory(proto, None) + self.assertIsInstance(obj, expected_cls) diff --git a/os_brick/tests/windows/test_iscsi.py b/os_brick/tests/windows/test_iscsi.py new file mode 100644 index 000000000..03bc278e2 --- /dev/null +++ b/os_brick/tests/windows/test_iscsi.py @@ -0,0 +1,190 @@ +# Copyright 2016 Cloudbase Solutions Srl +# 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 os_win import exceptions as os_win_exc + +from os_brick import exception +from os_brick.initiator.windows import iscsi +from os_brick.tests.windows import test_base + + +@ddt.ddt +class WindowsISCSIConnectorTestCase(test_base.WindowsConnectorTestBase): + @mock.patch.object(iscsi.WindowsISCSIConnector, 'validate_initiators') + def setUp(self, mock_validate_connectors): + super(WindowsISCSIConnectorTestCase, self).setUp() + + self._diskutils = mock.Mock() + self._iscsi_utils = mock.Mock() + + self._connector = iscsi.WindowsISCSIConnector() + self._connector._diskutils = self._diskutils + self._connector._iscsi_utils = self._iscsi_utils + + @ddt.data({'requested_initiators': [mock.sentinel.initiator_0], + 'available_initiators': [mock.sentinel.initiator_0, + mock.sentinel.initiator_1]}, + {'requested_initiators': [mock.sentinel.initiator_0], + 'available_initiators': [mock.sentinel.initiator_1]}, + {'requested_initiators': [], + 'available_initiators': [mock.sentinel.software_initiator]}) + @ddt.unpack + def test_validate_initiators(self, requested_initiators, + available_initiators): + self._iscsi_utils.get_iscsi_initiators.return_value = ( + available_initiators) + self._connector.initiator_list = requested_initiators + + expected_valid_initiator = not ( + set(requested_initiators).difference(set(available_initiators))) + valid_initiator = self._connector.validate_initiators() + + self.assertEqual(expected_valid_initiator, valid_initiator) + + def test_get_initiator(self): + initiator = self._connector.get_initiator() + self.assertEqual(self._iscsi_utils.get_iscsi_initiator.return_value, + initiator) + + @mock.patch.object(iscsi, 'utilsfactory') + def test_get_connector_properties(self, mock_utilsfactory): + mock_iscsi_utils = ( + mock_utilsfactory.get_iscsi_initiator_utils.return_value) + + props = self._connector.get_connector_properties() + expected_props = dict( + initiator=mock_iscsi_utils.get_iscsi_initiator.return_value) + + self.assertEqual(expected_props, props) + + @mock.patch.object(iscsi.WindowsISCSIConnector, '_get_all_targets') + def test_get_all_paths(self, mock_get_all_targets): + initiators = [mock.sentinel.initiator_0, mock.sentinel.initiator_1] + all_targets = [(mock.sentinel.portal_0, mock.sentinel.target_0, + mock.sentinel.lun_0), + (mock.sentinel.portal_1, mock.sentinel.target_1, + mock.sentinel.lun_1)] + + self._connector.initiator_list = initiators + mock_get_all_targets.return_value = all_targets + + expected_paths = [ + (initiator_name, target_portal, target_iqn, target_lun) + for target_portal, target_iqn, target_lun in all_targets + for initiator_name in initiators] + all_paths = self._connector._get_all_paths(mock.sentinel.conn_props) + + self.assertEqual(expected_paths, all_paths) + mock_get_all_targets.assert_called_once_with(mock.sentinel.conn_props) + + @ddt.data(True, False) + @mock.patch.object(iscsi.WindowsISCSIConnector, '_get_scsi_wwn') + @mock.patch.object(iscsi.WindowsISCSIConnector, '_get_all_paths') + def test_connect_volume(self, use_multipath, + mock_get_all_paths, mock_get_scsi_wwn): + fake_paths = [(mock.sentinel.initiator_name, + mock.sentinel.target_portal, + mock.sentinel.target_iqn, + mock.sentinel.target_lun)] * 3 + fake_conn_props = dict(auth_username=mock.sentinel.auth_username, + auth_password=mock.sentinel.auth_password) + + mock_get_all_paths.return_value = fake_paths + self._iscsi_utils.login_storage_target.side_effect = [ + os_win_exc.OSWinException, None, None] + self._iscsi_utils.get_device_number_and_path.return_value = ( + mock.sentinel.device_number, mock.sentinel.device_path) + self._connector.use_multipath = use_multipath + + device_info = self._connector.connect_volume(fake_conn_props) + expected_device_info = dict(type='block', + path=mock.sentinel.device_path, + number=mock.sentinel.device_number, + scsi_wwn=mock_get_scsi_wwn.return_value) + + self.assertEqual(expected_device_info, device_info) + + mock_get_all_paths.assert_called_once_with(fake_conn_props) + expected_login_attempts = 3 if use_multipath else 2 + self._iscsi_utils.login_storage_target.assert_has_calls( + [mock.call(target_lun=mock.sentinel.target_lun, + target_iqn=mock.sentinel.target_iqn, + target_portal=mock.sentinel.target_portal, + auth_username=mock.sentinel.auth_username, + auth_password=mock.sentinel.auth_password, + mpio_enabled=use_multipath, + initiator_name=mock.sentinel.initiator_name, + rescan_attempts=( + self._connector.device_scan_attempts))] * + expected_login_attempts) + + self._iscsi_utils.get_device_number_and_path.assert_called_once_with( + mock.sentinel.target_iqn, mock.sentinel.target_lun) + mock_get_scsi_wwn.assert_called_once_with(mock.sentinel.device_number) + + @mock.patch.object(iscsi.WindowsISCSIConnector, '_get_all_paths') + def test_connect_volume_exc(self, mock_get_all_paths): + fake_paths = [(mock.sentinel.initiator_name, + mock.sentinel.target_portal, + mock.sentinel.target_iqn, + mock.sentinel.target_lun)] * 3 + + mock_get_all_paths.return_value = fake_paths + self._iscsi_utils.login_storage_target.side_effect = ( + os_win_exc.OSWinException) + self._connector.use_multipath = True + + self.assertRaises(exception.BrickException, + self._connector.connect_volume, + connection_properties={}) + + @mock.patch.object(iscsi.WindowsISCSIConnector, '_get_all_targets') + def test_disconnect_volume(self, mock_get_all_targets): + targets = [ + (mock.sentinel.portal_0, mock.sentinel.tg_0, mock.sentinel.lun_0), + (mock.sentinel.portal_1, mock.sentinel.tg_1, mock.sentinel.lun_1)] + + mock_get_all_targets.return_value = targets + self._iscsi_utils.get_target_luns.return_value = [mock.sentinel.lun_0] + + self._connector.disconnect_volume(mock.sentinel.conn_props) + + self._diskutils.rescan_disks.assert_called_once_with() + mock_get_all_targets.assert_called_once_with(mock.sentinel.conn_props) + self._iscsi_utils.logout_storage_target.assert_called_once_with( + mock.sentinel.tg_0) + self._iscsi_utils.get_target_luns.assert_has_calls( + [mock.call(mock.sentinel.tg_0), mock.call(mock.sentinel.tg_1)]) + + @mock.patch.object(iscsi.WindowsISCSIConnector, '_get_all_targets') + @mock.patch.object(iscsi.WindowsISCSIConnector, '_check_device_paths') + def test_get_volume_paths(self, mock_check_dev_paths, + mock_get_all_targets): + targets = [ + (mock.sentinel.portal_0, mock.sentinel.tg_0, mock.sentinel.lun_0), + (mock.sentinel.portal_1, mock.sentinel.tg_1, mock.sentinel.lun_1)] + + mock_get_all_targets.return_value = targets + self._iscsi_utils.get_device_number_and_path.return_value = [ + mock.sentinel.dev_num, mock.sentinel.dev_path] + + volume_paths = self._connector.get_volume_paths( + mock.sentinel.conn_props) + expected_paths = [mock.sentinel.dev_path] + + self.assertEqual(expected_paths, volume_paths) + mock_check_dev_paths.assert_called_once_with(set(expected_paths)) diff --git a/requirements.txt b/requirements.txt index 19dff7582..5995ba0a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ requests>=2.10.0 # Apache-2.0 retrying!=1.3.0,>=1.2.3 # Apache-2.0 six>=1.9.0 # MIT castellan>=0.4.0 # Apache-2.0 +os-win>=0.2.3 # Apache-2.0 diff --git a/test-requirements.txt b/test-requirements.txt index 119cc85ae..c31242485 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,6 +4,7 @@ hacking<0.11,>=0.10.0 coverage>=3.6 # Apache-2.0 +ddt>=1.0.1 # MIT python-subunit>=0.0.18 # Apache-2.0/BSD reno>=1.6.2 # Apache2 sphinx!=1.3b1,<1.3,>=1.2.1 # BSD