From 4045300fa9d86efc4518c54ed7da39ec985dccb3 Mon Sep 17 00:00:00 2001 From: Lucian Petrut Date: Tue, 26 Jan 2016 14:50:18 +0200 Subject: [PATCH] Add Windows SMBFS connector This patch adds a Windows SMBFS connector. Also, a Windows RemoteFS class is added, being used by this connector, having a similar interface with the Linux RemoteFS client class. The patch using Windows os-brick connectors in the Hyper-V Nova driver: https://review.openstack.org/#/c/273504/ The Windows connector factory function has been removed as it's not needed anymore. Change-Id: I0c753a667d58391da7a903d11ab1f4729e68461a Implements: blueprint os-brick-windows-support --- os_brick/initiator/__init__.py | 1 + os_brick/initiator/connector.py | 3 + os_brick/initiator/windows/__init__.py | 43 ------ os_brick/initiator/windows/smbfs.py | 94 ++++++++++++ os_brick/remotefs/windows_remotefs.py | 122 ++++++++++++++++ .../tests/remotefs/test_windows_remotefs.py | 136 +++++++++++++++++ os_brick/tests/windows/test_factory.py | 5 +- os_brick/tests/windows/test_smbfs.py | 137 ++++++++++++++++++ .../add-windows-smbfs-d86edaa003130a31.yaml | 3 + 9 files changed, 500 insertions(+), 44 deletions(-) create mode 100644 os_brick/initiator/windows/smbfs.py create mode 100644 os_brick/remotefs/windows_remotefs.py create mode 100644 os_brick/tests/remotefs/test_windows_remotefs.py create mode 100644 os_brick/tests/windows/test_smbfs.py create mode 100644 releasenotes/notes/add-windows-smbfs-d86edaa003130a31.yaml diff --git a/os_brick/initiator/__init__.py b/os_brick/initiator/__init__.py index e25ae8d56..5d45239c3 100644 --- a/os_brick/initiator/__init__.py +++ b/os_brick/initiator/__init__.py @@ -44,6 +44,7 @@ FIBRE_CHANNEL = "FIBRE_CHANNEL" AOE = "AOE" DRBD = "DRBD" NFS = "NFS" +SMBFS = 'SMBFS' GLUSTERFS = "GLUSTERFS" LOCAL = "LOCAL" HUAWEISDSHYPERVISOR = "HUAWEISDSHYPERVISOR" diff --git a/os_brick/initiator/connector.py b/os_brick/initiator/connector.py index 823b9aac2..03a6359c4 100644 --- a/os_brick/initiator/connector.py +++ b/os_brick/initiator/connector.py @@ -92,6 +92,7 @@ connector_list = [ 'os_brick.initiator.connectors.disco.DISCOConnector', 'os_brick.initiator.windows.base.BaseWindowsConnector', 'os_brick.initiator.windows.iscsi.WindowsISCSIConnector', + 'os_brick.initiator.windows.smbfs.WindowsSMBFSConnector', ] # Mappings used to determine who to contruct in the factory @@ -146,6 +147,8 @@ _connector_mapping_linux_s390x = { _connector_mapping_windows = { initiator.ISCSI: 'os_brick.initiator.windows.iscsi.WindowsISCSIConnector', + initiator.SMBFS: + 'os_brick.initiator.windows.smbfs.WindowsSMBFSConnector', } diff --git a/os_brick/initiator/windows/__init__.py b/os_brick/initiator/windows/__init__.py index f67035fc5..e69de29bb 100644 --- a/os_brick/initiator/windows/__init__.py +++ b/os_brick/initiator/windows/__init__.py @@ -1,43 +0,0 @@ -# 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/smbfs.py b/os_brick/initiator/windows/smbfs.py new file mode 100644 index 000000000..0a8a1f016 --- /dev/null +++ b/os_brick/initiator/windows/smbfs.py @@ -0,0 +1,94 @@ +# 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 os + +from os_win import utilsfactory + +from os_brick.initiator.windows import base as win_conn_base +from os_brick.remotefs import windows_remotefs as remotefs +from os_brick import utils + + +class WindowsSMBFSConnector(win_conn_base.BaseWindowsConnector): + def __init__(self, *args, **kwargs): + super(WindowsSMBFSConnector, self).__init__(*args, **kwargs) + # If this flag is set, we use the local paths in case of local + # shares. This is in fact mandatory in some cases, for example + # for the Hyper-C scenario. + self._local_path_for_loopback = kwargs.get('local_path_for_loopback', + False) + self._remotefsclient = remotefs.WindowsRemoteFsClient( + mount_type='smbfs', + *args, **kwargs) + self._smbutils = utilsfactory.get_smbutils() + + @staticmethod + def get_connector_properties(*args, **kwargs): + # No connector properties updates in this case. + return {} + + @utils.trace + def connect_volume(self, connection_properties): + self.ensure_share_mounted(connection_properties) + disk_path = self._get_disk_path(connection_properties) + device_info = {'type': 'file', + 'path': disk_path} + return device_info + + @utils.trace + def disconnect_volume(self, connection_properties): + export_path = self._get_export_path(connection_properties) + self._remotefsclient.unmount(export_path) + + def _get_export_path(self, connection_properties): + return connection_properties['export'].replace('/', '\\') + + def _get_disk_path(self, connection_properties): + # This is expected to be the share address, as an UNC path. + export_path = self._get_export_path(connection_properties) + mount_base = self._remotefsclient.get_mount_base() + use_local_path = (self._local_path_for_loopback and + self._smbutils.is_local_share(export_path)) + + disk_dir = export_path + if mount_base: + # This will be a symlink pointing to either the share + # path directly or to the local share path, if requested + # and available. + disk_dir = self._remotefsclient.get_mount_point( + export_path) + elif use_local_path: + share_name = self._remotefsclient.get_share_name(export_path) + disk_dir = self._remotefsclient.get_local_share_path(share_name) + + disk_name = connection_properties['name'] + disk_path = os.path.join(disk_dir, disk_name) + return disk_path + + def get_search_path(self): + return self._remotefsclient.get_mount_base() + + @utils.trace + def get_volume_paths(self, connection_properties): + return [self._get_disk_path(connection_properties)] + + def ensure_share_mounted(self, connection_properties): + export_path = self._get_export_path(connection_properties) + mount_options = connection_properties.get('options') + self._remotefsclient.mount(export_path, mount_options) + + def extend_volume(self, connection_properties): + raise NotImplementedError diff --git a/os_brick/remotefs/windows_remotefs.py b/os_brick/remotefs/windows_remotefs.py new file mode 100644 index 000000000..1009666c5 --- /dev/null +++ b/os_brick/remotefs/windows_remotefs.py @@ -0,0 +1,122 @@ +# 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. + +"""Windows remote filesystem client utilities.""" + +import os +import re + +from oslo_log import log as logging + +from os_win import utilsfactory + +from os_brick import exception +from os_brick.i18n import _, _LI +from os_brick.remotefs import remotefs + +LOG = logging.getLogger(__name__) + + +class WindowsRemoteFsClient(remotefs.RemoteFsClient): + _username_regex = re.compile(r'user(?:name)?=([^, ]+)') + _password_regex = re.compile(r'pass(?:word)?=([^, ]+)') + _loopback_share_map = {} + + def __init__(self, mount_type, root_helper=None, + execute=None, *args, **kwargs): + mount_type_to_option_prefix = { + 'cifs': 'smbfs', + 'smbfs': 'smbfs', + } + + self._local_path_for_loopback = kwargs.get('local_path_for_loopback', + False) + + if mount_type not in mount_type_to_option_prefix: + raise exception.ProtocolNotSupported(protocol=mount_type) + + self._mount_type = mount_type + option_prefix = mount_type_to_option_prefix[mount_type] + + self._mount_base = kwargs.get(option_prefix + '_mount_point_base') + self._mount_options = kwargs.get(option_prefix + '_mount_options') + + self._smbutils = utilsfactory.get_smbutils() + self._pathutils = utilsfactory.get_pathutils() + + def get_local_share_path(self, share, expect_existing=True): + local_share_path = self._smbutils.get_smb_share_path(share) + if not local_share_path and expect_existing: + err_msg = _("Could not find the local " + "share path for %(share)s.") + raise exception.VolumePathsNotFound(err_msg % dict(share=share)) + + return local_share_path + + def get_share_name(self, share): + return share.replace('/', '\\').lstrip('\\').split('\\', 1)[1] + + def mount(self, share, flags=None): + share = share.replace('/', '\\') + use_local_path = (self._local_path_for_loopback and + self._smbutils.is_local_share(share)) + + if use_local_path: + LOG.info(_LI("Skipping mounting local share %(share_path)s."), + dict(share_path=share)) + else: + mount_options = " ".join( + [self._mount_options or '', flags or '']) + username, password = self._parse_credentials(mount_options) + + if not self._smbutils.check_smb_mapping( + share): + self._smbutils.mount_smb_share(share, + username=username, + password=password) + + if self._mount_base: + share_name = self.get_share_name(share) + symlink_dest = (share if not use_local_path + else self.get_local_share_path(share_name)) + self._create_mount_point(symlink_dest) + + def unmount(self, share): + self._smbutils.unmount_smb_share(share.replace('/', '\\')) + + def _create_mount_point(self, share): + mnt_point = self.get_mount_point(share) + + if not os.path.isdir(self._mount_base): + os.makedirs(self._mount_base) + + if os.path.exists(mnt_point): + if not self._pathutils.is_symlink(mnt_point): + raise exception.BrickException(_("Link path already exists " + "and it's not a symlink")) + else: + self._pathutils.create_sym_link(mnt_point, share) + + def _parse_credentials(self, opts_str): + if not opts_str: + return None, None + + match = self._username_regex.findall(opts_str) + username = match[0] if match and match[0] != 'guest' else None + + match = self._password_regex.findall(opts_str) + password = match[0] if match else None + + return username, password diff --git a/os_brick/tests/remotefs/test_windows_remotefs.py b/os_brick/tests/remotefs/test_windows_remotefs.py new file mode 100644 index 000000000..d6ea7bfc7 --- /dev/null +++ b/os_brick/tests/remotefs/test_windows_remotefs.py @@ -0,0 +1,136 @@ +# 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 import exception +from os_brick.remotefs import windows_remotefs +from os_brick.tests import base + + +@ddt.ddt +class WindowsRemotefsClientTestCase(base.TestCase): + _FAKE_SHARE_NAME = 'fake_share' + _FAKE_SHARE_SERVER = 'fake_share_server' + _FAKE_SHARE = '\\\\%s\\%s' % (_FAKE_SHARE_SERVER, + _FAKE_SHARE_NAME) + + @mock.patch.object(windows_remotefs, 'utilsfactory') + def setUp(self, mock_utilsfactory): + super(WindowsRemotefsClientTestCase, self).setUp() + + self._remotefs = windows_remotefs.WindowsRemoteFsClient( + mount_type='smbfs') + self._remotefs._mount_base = mock.sentinel.mount_base + + self._smbutils = self._remotefs._smbutils + self._pathutils = self._remotefs._pathutils + + @ddt.data({}, + {'expect_existing': False}, + {'local_path': mock.sentinel.local_path}) + @ddt.unpack + def test_get_local_share_path(self, expect_existing=True, + local_path=None): + self._smbutils.get_smb_share_path.return_value = local_path + if not local_path and expect_existing: + self.assertRaises( + exception.VolumePathsNotFound, + self._remotefs.get_local_share_path, + mock.sentinel.share_name, + expect_existing=expect_existing) + else: + share_path = self._remotefs.get_local_share_path( + mock.sentinel.share_name, + expect_existing=expect_existing) + self.assertEqual(local_path, share_path) + + def test_get_share_name(self): + resulted_name = self._remotefs.get_share_name(self._FAKE_SHARE) + self.assertEqual(self._FAKE_SHARE_NAME, resulted_name) + + @ddt.data(True, False) + @mock.patch.object(windows_remotefs.WindowsRemoteFsClient, + '_create_mount_point') + @mock.patch.object(windows_remotefs.WindowsRemoteFsClient, + 'get_local_share_path') + def test_mount(self, is_local_share, + mock_get_local_share_path, + mock_create_mount_point): + flags = '-o pass=password' + self._remotefs._mount_options = '-o user=username,randomopt' + self._remotefs._local_path_for_loopback = True + + self._smbutils.check_smb_mapping.return_value = False + self._smbutils.is_local_share.return_value = is_local_share + + self._remotefs.mount(self._FAKE_SHARE, flags) + + if is_local_share: + expected_link_dest = mock_get_local_share_path.return_value + + self.assertFalse(self._smbutils.check_smb_mapping.called) + self.assertFalse(self._smbutils.mount_smb_share.called) + mock_get_local_share_path.assert_called_once_with( + self._FAKE_SHARE_NAME) + else: + expected_link_dest = self._FAKE_SHARE + + self._smbutils.check_smb_mapping.assert_called_once_with( + self._FAKE_SHARE) + self._smbutils.mount_smb_share.assert_called_once_with( + self._FAKE_SHARE, + username='username', + password='password') + self.assertFalse(mock_get_local_share_path.called) + + mock_create_mount_point.assert_called_once_with(expected_link_dest) + + def test_unmount(self): + self._remotefs.unmount(self._FAKE_SHARE) + self._smbutils.unmount_smb_share.assert_called_once_with( + self._FAKE_SHARE) + + @ddt.data({}, + {'path_exists': True, 'is_symlink': True}, + {'path_exists': True}) + @mock.patch.object(windows_remotefs.WindowsRemoteFsClient, + 'get_mount_point') + @mock.patch.object(windows_remotefs, 'os') + @ddt.unpack + def test_create_mount_point(self, mock_os, mock_get_mount_point, + path_exists=False, is_symlink=False): + mock_os.path.exists.return_value = path_exists + mock_os.isdir.return_value = False + self._pathutils.is_symlink.return_value = is_symlink + + if path_exists and not is_symlink: + self.assertRaises(exception.BrickException, + self._remotefs._create_mount_point, + mock.sentinel.share) + else: + self._remotefs._create_mount_point(mock.sentinel.share) + + mock_get_mount_point.assert_called_once_with(mock.sentinel.share) + mock_os.path.isdir.assert_called_once_with(mock.sentinel.mount_base) + + if path_exists: + self._pathutils.is_symlink.assert_called_once_with( + mock_get_mount_point.return_value) + else: + self._pathutils.create_sym_link.assert_called_once_with( + mock_get_mount_point.return_value, + mock.sentinel.share) diff --git a/os_brick/tests/windows/test_factory.py b/os_brick/tests/windows/test_factory.py index 535166092..63f51d0d9 100644 --- a/os_brick/tests/windows/test_factory.py +++ b/os_brick/tests/windows/test_factory.py @@ -19,13 +19,16 @@ import mock from os_brick import initiator from os_brick.initiator import connector from os_brick.initiator.windows import iscsi +from os_brick.initiator.windows import smbfs from os_brick.tests.windows import test_base @ddt.ddt class WindowsConnectorFactoryTestCase(test_base.WindowsConnectorTestBase): @ddt.data({'proto': initiator.ISCSI, - 'expected_cls': iscsi.WindowsISCSIConnector}) + 'expected_cls': iscsi.WindowsISCSIConnector}, + {'proto': initiator.SMBFS, + 'expected_cls': smbfs.WindowsSMBFSConnector}) @ddt.unpack @mock.patch('sys.platform', 'win32') def test_factory(self, proto, expected_cls): diff --git a/os_brick/tests/windows/test_smbfs.py b/os_brick/tests/windows/test_smbfs.py new file mode 100644 index 000000000..0775f7b49 --- /dev/null +++ b/os_brick/tests/windows/test_smbfs.py @@ -0,0 +1,137 @@ +# 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 os + +import ddt +import mock + +from os_brick.initiator.windows import smbfs +from os_brick.remotefs import windows_remotefs +from os_brick.tests.windows import test_base + + +@ddt.ddt +class WindowsSMBFSConnectorTestCase(test_base.WindowsConnectorTestBase): + @mock.patch.object(windows_remotefs, 'WindowsRemoteFsClient') + def setUp(self, mock_remotefs_cls): + super(WindowsSMBFSConnectorTestCase, self).setUp() + + self._connector = smbfs.WindowsSMBFSConnector() + self._remotefs = mock_remotefs_cls.return_value + + @mock.patch.object(smbfs.WindowsSMBFSConnector, '_get_disk_path') + @mock.patch.object(smbfs.WindowsSMBFSConnector, 'ensure_share_mounted') + def test_connect_volume(self, mock_ensure_mounted, + mock_get_disk_path): + device_info = self._connector.connect_volume(mock.sentinel.conn_props) + expected_info = dict(type='file', + path=mock_get_disk_path.return_value) + + self.assertEqual(expected_info, device_info) + mock_ensure_mounted.assert_called_once_with(mock.sentinel.conn_props) + mock_get_disk_path.assert_called_once_with(mock.sentinel.conn_props) + + @mock.patch.object(smbfs.WindowsSMBFSConnector, '_get_export_path') + def test_disconnect_volume(self, mock_get_export_path): + self._connector.disconnect_volume(mock.sentinel.conn_props) + + self._remotefs.unmount.assert_called_once_with( + mock_get_export_path.return_value) + mock_get_export_path.assert_called_once_with(mock.sentinel.conn_props) + + def test_get_export_path(self): + fake_export = '//ip/share' + fake_conn_props = dict(export=fake_export) + + expected_export = fake_export.replace('/', '\\') + export_path = self._connector._get_export_path(fake_conn_props) + self.assertEqual(expected_export, export_path) + + @ddt.data({}, + {'mount_base': mock.sentinel.mount_base}, + {'is_local_share': True}, + {'is_local_share': True, + 'local_path_for_loopbk': True}) + @ddt.unpack + def test_get_disk_path(self, mount_base=None, + local_path_for_loopbk=False, + is_local_share=False): + fake_mount_point = r'C:\\fake_mount_point' + fake_share_name = 'fake_share' + fake_local_share_path = 'C:\\%s' % fake_share_name + fake_export_path = '\\\\host\\%s' % fake_share_name + fake_disk_name = 'fake_disk.vhdx' + fake_conn_props = dict(name=fake_disk_name, + export=fake_export_path) + + self._remotefs.get_mount_base.return_value = mount_base + self._remotefs.get_mount_point.return_value = fake_mount_point + self._remotefs.get_local_share_path.return_value = ( + fake_local_share_path) + self._remotefs.get_share_name.return_value = fake_share_name + self._connector._local_path_for_loopback = local_path_for_loopbk + self._connector._smbutils.is_local_share.return_value = is_local_share + + expecting_local = local_path_for_loopbk and is_local_share + + if mount_base: + expected_export_path = fake_mount_point + elif expecting_local: + # In this case, we expect the local share export path to be + # used directly. + expected_export_path = fake_local_share_path + else: + expected_export_path = fake_export_path + expected_disk_path = os.path.join(expected_export_path, + fake_disk_name) + + disk_path = self._connector._get_disk_path(fake_conn_props) + self.assertEqual(expected_disk_path, disk_path) + + if mount_base: + self._remotefs.get_mount_point.assert_called_once_with( + fake_export_path) + elif expecting_local: + self._connector._smbutils.is_local_share.assert_called_once_with( + fake_export_path) + self._remotefs.get_share_name.assert_called_once_with( + fake_export_path) + self._remotefs.get_local_share_path.assert_called_once_with( + fake_share_name) + + def test_get_search_path(self): + search_path = self._connector.get_search_path() + self.assertEqual(search_path, + self._remotefs.get_mount_base.return_value) + + @mock.patch.object(smbfs.WindowsSMBFSConnector, '_get_disk_path') + def test_volume_paths(self, mock_get_disk_path): + expected_paths = [mock_get_disk_path.return_value] + volume_paths = self._connector.get_volume_paths( + mock.sentinel.conn_props) + + self.assertEqual(expected_paths, volume_paths) + mock_get_disk_path.assert_called_once_with( + mock.sentinel.conn_props) + + @mock.patch.object(smbfs.WindowsSMBFSConnector, '_get_export_path') + def test_ensure_share_mounted(self, mock_get_export_path): + fake_conn_props = dict(options=mock.sentinel.mount_opts) + + self._connector.ensure_share_mounted(fake_conn_props) + self._remotefs.mount.assert_called_once_with( + mock_get_export_path.return_value, + mock.sentinel.mount_opts) diff --git a/releasenotes/notes/add-windows-smbfs-d86edaa003130a31.yaml b/releasenotes/notes/add-windows-smbfs-d86edaa003130a31.yaml new file mode 100644 index 000000000..22219a062 --- /dev/null +++ b/releasenotes/notes/add-windows-smbfs-d86edaa003130a31.yaml @@ -0,0 +1,3 @@ +--- +features: + - Add Windows SMBFS connector support.