Merge "rbd Windows support"

This commit is contained in:
Zuul 2021-02-04 19:12:26 +00:00 committed by Gerrit Code Review
commit 9b0a91039a
10 changed files with 447 additions and 98 deletions

View File

@ -43,6 +43,7 @@ windows_connector_list = [
'os_brick.initiator.windows.base.BaseWindowsConnector',
'os_brick.initiator.windows.iscsi.WindowsISCSIConnector',
'os_brick.initiator.windows.fibre_channel.WindowsFCConnector',
'os_brick.initiator.windows.rbd.WindowsRBDConnector',
'os_brick.initiator.windows.smbfs.WindowsSMBFSConnector'
]
@ -159,6 +160,8 @@ _connector_mapping_windows = {
'os_brick.initiator.windows.iscsi.WindowsISCSIConnector',
initiator.FIBRE_CHANNEL:
'os_brick.initiator.windows.fibre_channel.WindowsFCConnector',
initiator.RBD:
'os_brick.initiator.windows.rbd.WindowsRBDConnector',
initiator.SMBFS:
'os_brick.initiator.windows.smbfs.WindowsSMBFSConnector',
}

View File

@ -0,0 +1,53 @@
# Copyright 2020 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 netutils
LOG = logging.getLogger(__name__)
class RBDConnectorMixin(object):
"""Mixin covering cross platform RBD connector functionality"""
@staticmethod
def _sanitize_mon_hosts(hosts):
def _sanitize_host(host):
if netutils.is_valid_ipv6(host):
host = '[%s]' % host
return host
return list(map(_sanitize_host, hosts))
@classmethod
def _get_rbd_args(cls, connection_properties, conf=None):
user = connection_properties.get('auth_username')
monitor_ips = connection_properties.get('hosts')
monitor_ports = connection_properties.get('ports')
args = []
if user:
args = ['--id', user]
if monitor_ips and monitor_ports:
monitors = ["%s:%s" % (ip, port) for ip, port in
zip(
cls._sanitize_mon_hosts(monitor_ips),
monitor_ports)]
for monitor in monitors:
args += ['--mon_host', monitor]
if conf:
args += ['--conf', conf]
return args

View File

@ -21,12 +21,12 @@ from oslo_log import log as logging
from oslo_serialization import jsonutils
from oslo_utils import excutils
from oslo_utils import fileutils
from oslo_utils import netutils
from os_brick import exception
from os_brick.i18n import _
from os_brick import initiator
from os_brick.initiator.connectors import base
from os_brick.initiator.connectors import base_rbd
from os_brick.initiator import linuxrbd
from os_brick.privileged import rbd as rbd_privsep
from os_brick import utils
@ -34,7 +34,7 @@ from os_brick import utils
LOG = logging.getLogger(__name__)
class RBDConnector(base.BaseLinuxConnector):
class RBDConnector(base_rbd.RBDConnectorMixin, base.BaseLinuxConnector):
""""Connector class to attach/detach RBD volumes."""
def __init__(self, root_helper, driver=None, use_multipath=False,
@ -65,14 +65,6 @@ class RBDConnector(base.BaseLinuxConnector):
# TODO(e0ne): Implement this for local volume.
return []
@staticmethod
def _sanitize_mon_hosts(hosts):
def _sanitize_host(host):
if netutils.is_valid_ipv6(host):
host = '[%s]' % host
return host
return list(map(_sanitize_host, hosts))
@staticmethod
def _check_or_get_keyring_contents(keyring, cluster_name, user):
try:
@ -143,30 +135,6 @@ class RBDConnector(base.BaseLinuxConnector):
return rbd_handle
@classmethod
def _get_rbd_args(cls, connection_properties, conf=None):
try:
user = connection_properties['auth_username']
monitor_ips = connection_properties.get('hosts')
monitor_ports = connection_properties.get('ports')
except KeyError:
msg = _("Connect volume failed, malformed connection properties")
raise exception.BrickException(msg=msg)
args = ['--id', user]
if monitor_ips and monitor_ports:
monitors = ["%s:%s" % (ip, port) for ip, port in
zip(
cls._sanitize_mon_hosts(monitor_ips),
monitor_ports)]
for monitor in monitors:
args += ['--mon_host', monitor]
if conf:
args += ['--conf', conf]
return args
@staticmethod
def get_rbd_device_name(pool, volume):
"""Return device name which will be generated by RBD kernel module.

View File

@ -14,6 +14,7 @@
# under the License.
from os_win import utilsfactory
from oslo_concurrency import processutils as putils
from oslo_log import log as logging
from os_brick import exception
@ -32,6 +33,7 @@ class BaseWindowsConnector(initiator_connector.InitiatorConnector):
DEFAULT_DEVICE_SCAN_INTERVAL = 2
def __init__(self, root_helper=None, *args, **kwargs):
kwargs['executor'] = kwargs.get('executor') or putils.execute
super(BaseWindowsConnector, self).__init__(root_helper,
*args, **kwargs)
self.device_scan_interval = kwargs.pop(

View File

@ -0,0 +1,164 @@
# Copyright 2020 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 ctypes
import errno
import json
from oslo_concurrency import processutils
from oslo_log import log as logging
from oslo_service import loopingcall
from os_brick import exception
from os_brick.i18n import _
from os_brick.initiator.connectors import base_rbd
from os_brick.initiator.windows import base as win_conn_base
from os_brick import utils
LOG = logging.getLogger(__name__)
class WindowsRBDConnector(base_rbd.RBDConnectorMixin,
win_conn_base.BaseWindowsConnector):
"""Connector class to attach/detach RBD volumes.
The Windows RBD connector is very similar to the Linux one.
There are a few main differences though:
* the Ceph python bindings are not available on Windows yet, so we'll
always do a local mount. Besides, Hyper-V cannot use librbd, so
we'll need to do a local mount anyway.
* The device names aren't handled in the same way. On Windows,
disk names such as "\\\\.\\PhysicalDrive1" are provided by the OS and
cannot be explicitly requsted.
"""
def __init__(self, *args, **kwargs):
super(WindowsRBDConnector, self).__init__(*args, **kwargs)
self._ensure_rbd_available()
def _check_rbd(self):
cmd = ['where.exe', 'rbd']
try:
self._execute(*cmd)
return True
except processutils.ProcessExecutionError:
LOG.warning("rbd.exe is not available.")
return False
def _ensure_rbd_available(self):
if not self._check_rbd():
msg = _("rbd.exe is not available.")
LOG.error(msg)
raise exception.BrickException(msg)
def get_volume_paths(self, connection_properties):
return [self.get_device_name(connection_properties)]
def _show_rbd_mapping(self, connection_properties):
# TODO(lpetrut): consider using "rbd device show" if/when
# it becomes available.
cmd = ['rbd-wnbd', 'show', connection_properties['name'],
'--format', 'json']
try:
out, err = self._execute(*cmd)
return json.loads(out)
except processutils.ProcessExecutionError as ex:
if abs(ctypes.c_int32(ex.exit_code).value) == errno.ENOENT:
LOG.debug("Couldn't find RBD mapping: %s",
connection_properties['name'])
return
raise
except json.decoder.JSONDecodeError:
msg = _("Could not get rbd mappping.")
LOG.exception(msg)
raise exception.BrickException(msg)
def get_device_name(self, connection_properties, expect=True):
mapping = self._show_rbd_mapping(connection_properties)
if mapping:
dev_num = mapping['disk_number']
return self._diskutils.get_device_name_by_device_number(dev_num)
elif expect:
msg = _("The specified RBD image is not mounted: %s")
raise exception.VolumeDeviceNotFound(
msg % connection_properties['name'])
def _wait_for_volume(self, connection_properties):
"""Wait for the specified volume to become accessible."""
attempt = 0
dev_path = None
def _check_rbd_device():
rbd_dev_path = self.get_device_name(
connection_properties, expect=False)
if rbd_dev_path:
try:
# Under high load, it can take a second before the disk
# becomes accessible.
with open(rbd_dev_path, 'rb'):
pass
nonlocal dev_path
dev_path = rbd_dev_path
raise loopingcall.LoopingCallDone()
except FileNotFoundError:
LOG.debug("The RBD image %(image)s mapped to local device "
"%(dev)s isn't available yet.",
{'image': connection_properties['name'],
'dev': rbd_dev_path})
nonlocal attempt
attempt += 1
if attempt >= self.device_scan_attempts:
msg = _("The mounted RBD image isn't available: %s")
raise exception.VolumeDeviceNotFound(
msg % connection_properties['name'])
timer = loopingcall.FixedIntervalLoopingCall(_check_rbd_device)
timer.start(interval=self.device_scan_interval).wait()
return dev_path
@utils.trace
def connect_volume(self, connection_properties):
rbd_dev_path = self.get_device_name(connection_properties,
expect=False)
if not rbd_dev_path:
cmd = ['rbd', 'device', 'map', connection_properties['name']]
cmd += self._get_rbd_args(connection_properties)
self._execute(*cmd)
rbd_dev_path = self._wait_for_volume(connection_properties)
else:
LOG.debug('The RBD image %(image)s is already mapped to local '
'device %(dev)s',
{'image': connection_properties['name'],
'dev': rbd_dev_path})
dev_num = self._diskutils.get_device_number_from_device_name(
rbd_dev_path)
# TODO(lpetrut): remove this once wnbd honors the SAN policy setting.
self._diskutils.set_disk_offline(dev_num)
return {'path': rbd_dev_path,
'type': 'block'}
@utils.trace
def disconnect_volume(self, connection_properties, device_info=None,
force=False, ignore_errors=False):
cmd = ['rbd', 'device', 'unmap', connection_properties['name']]
cmd += self._get_rbd_args(connection_properties)
if force:
cmd += ["-o", "hard-disconnect"]
self._execute(*cmd)

View File

@ -0,0 +1,84 @@
# Copyright 2020 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 unittest import mock
import ddt
from os_brick.initiator.connectors import base_rbd
from os_brick.tests import base
# Both Linux and Windows tests are using those mocks.
class RBDConnectorTestMixin(object):
def setUp(self):
super(RBDConnectorTestMixin, self).setUp()
self.user = 'fake_user'
self.pool = 'fake_pool'
self.volume = 'fake_volume'
self.clustername = 'fake_ceph'
self.hosts = ['192.168.10.2']
self.ports = ['6789']
self.keyring = "[client.cinder]\n key = test\n"
self.image_name = '%s/%s' % (self.pool, self.volume)
self.connection_properties = {
'auth_username': self.user,
'name': self.image_name,
'cluster_name': self.clustername,
'hosts': self.hosts,
'ports': self.ports,
'keyring': self.keyring,
}
@ddt.ddt
class TestRBDConnectorMixin(RBDConnectorTestMixin, base.TestCase):
def setUp(self):
super(TestRBDConnectorMixin, self).setUp()
self._conn = base_rbd.RBDConnectorMixin()
@ddt.data((['192.168.1.1', '192.168.1.2'],
['192.168.1.1', '192.168.1.2']),
(['3ffe:1900:4545:3:200:f8ff:fe21:67cf',
'fe80:0:0:0:200:f8ff:fe21:67cf'],
['[3ffe:1900:4545:3:200:f8ff:fe21:67cf]',
'[fe80:0:0:0:200:f8ff:fe21:67cf]']),
(['foobar', 'fizzbuzz'], ['foobar', 'fizzbuzz']),
(['192.168.1.1',
'3ffe:1900:4545:3:200:f8ff:fe21:67cf',
'hello, world!'],
['192.168.1.1',
'[3ffe:1900:4545:3:200:f8ff:fe21:67cf]',
'hello, world!']))
@ddt.unpack
def test_sanitize_mon_host(self, hosts_in, hosts_out):
self.assertEqual(hosts_out, self._conn._sanitize_mon_hosts(hosts_in))
def test_get_rbd_args(self):
res = self._conn._get_rbd_args(self.connection_properties, None)
expected = ['--id', self.user,
'--mon_host', self.hosts[0] + ':' + self.ports[0]]
self.assertEqual(expected, res)
def test_get_rbd_args_with_conf(self):
res = self._conn._get_rbd_args(self.connection_properties,
mock.sentinel.conf_path)
expected = ['--id', self.user,
'--mon_host', self.hosts[0] + ':' + self.ports[0],
'--conf', mock.sentinel.conf_path]
self.assertEqual(expected, res)

View File

@ -19,32 +19,14 @@ from os_brick import exception
from os_brick.initiator.connectors import rbd
from os_brick.initiator import linuxrbd
from os_brick.privileged import rootwrap as priv_rootwrap
from os_brick.tests.initiator.connectors import test_base_rbd
from os_brick.tests.initiator import test_connector
from os_brick import utils
@ddt.ddt
class RBDConnectorTestCase(test_connector.ConnectorTestCase):
def setUp(self):
super(RBDConnectorTestCase, self).setUp()
self.user = 'fake_user'
self.pool = 'fake_pool'
self.volume = 'fake_volume'
self.clustername = 'fake_ceph'
self.hosts = ['192.168.10.2']
self.ports = ['6789']
self.keyring = "[client.cinder]\n key = test\n"
self.connection_properties = {
'auth_username': self.user,
'name': '%s/%s' % (self.pool, self.volume),
'cluster_name': self.clustername,
'hosts': self.hosts,
'ports': self.ports,
'keyring': self.keyring,
}
class RBDConnectorTestCase(test_base_rbd.RBDConnectorTestMixin,
test_connector.ConnectorTestCase):
def test_get_search_path(self):
rbd_connector = rbd.RBDConnector(None)
@ -140,24 +122,6 @@ class RBDConnectorTestCase(test_connector.ConnectorTestCase):
conn._check_or_get_keyring_contents, keyring,
'cluster', 'user')
@ddt.data((['192.168.1.1', '192.168.1.2'],
['192.168.1.1', '192.168.1.2']),
(['3ffe:1900:4545:3:200:f8ff:fe21:67cf',
'fe80:0:0:0:200:f8ff:fe21:67cf'],
['[3ffe:1900:4545:3:200:f8ff:fe21:67cf]',
'[fe80:0:0:0:200:f8ff:fe21:67cf]']),
(['foobar', 'fizzbuzz'], ['foobar', 'fizzbuzz']),
(['192.168.1.1',
'3ffe:1900:4545:3:200:f8ff:fe21:67cf',
'hello, world!'],
['192.168.1.1',
'[3ffe:1900:4545:3:200:f8ff:fe21:67cf]',
'hello, world!']))
@ddt.unpack
def test_sanitize_mon_host(self, hosts_in, hosts_out):
conn = rbd.RBDConnector(None)
self.assertEqual(hosts_out, conn._sanitize_mon_hosts(hosts_in))
@mock.patch('os_brick.initiator.connectors.rbd.tempfile.mkstemp')
def test_create_ceph_conf(self, mock_mkstemp):
mockopen = mock.mock_open()
@ -251,16 +215,6 @@ class RBDConnectorTestCase(test_connector.ConnectorTestCase):
'type': 'block'}
self.assertEqual(expected_info, device_info)
@mock.patch.object(priv_rootwrap, 'execute', return_value=None)
def test_connect_local_volume_without_auth(self, mock_execute):
rbd_connector = rbd.RBDConnector(None, do_local_attach=True)
conn = {'name': 'pool/image',
'hosts': ['192.168.10.2'],
'ports': ['6789']}
self.assertRaises(exception.BrickException,
rbd_connector.connect_volume,
conn)
@mock.patch('os_brick.initiator.connectors.rbd.'
'RBDConnector._local_attach_volume')
def test_connect_volume_local(self, mock_local_attach):
@ -489,20 +443,6 @@ class RBDConnectorTestCase(test_connector.ConnectorTestCase):
mock_delete.assert_called_once_with(mock_config.return_value)
mock_open.assert_not_called()
def test__get_rbd_args(self):
res = rbd.RBDConnector._get_rbd_args(self.connection_properties, None)
expected = ['--id', self.user,
'--mon_host', self.hosts[0] + ':' + self.ports[0]]
self.assertEqual(expected, res)
def test__get_rbd_args_with_conf(self):
res = rbd.RBDConnector._get_rbd_args(self.connection_properties,
mock.sentinel.conf_path)
expected = ['--id', self.user,
'--mon_host', self.hosts[0] + ':' + self.ports[0],
'--conf', mock.sentinel.conf_path]
self.assertEqual(expected, res)
@mock.patch.object(rbd.RBDConnector, '_get_rbd_args')
@mock.patch.object(rbd.RBDConnector, '_execute')
def test_find_root_device(self, mock_execute, mock_args):

View File

@ -195,7 +195,7 @@ class ConnectorTestCase(test_base.TestCase):
def test_get_connector_mapping_win32(self):
mapping_win32 = connector.get_connector_mapping()
self.assertTrue('ISCSI' in mapping_win32)
self.assertFalse('RBD' in mapping_win32)
self.assertTrue('RBD' in mapping_win32)
self.assertFalse('STORPOOL' in mapping_win32)
@mock.patch('os_brick.initiator.connector.platform.machine')

View File

@ -0,0 +1,130 @@
# Copyright 2020 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 unittest import mock
import ddt
from oslo_concurrency import processutils
from os_brick import exception
from os_brick.initiator.windows import rbd
from os_brick.tests.initiator.connectors import test_base_rbd
from os_brick.tests.windows import test_base
@ddt.ddt
class WindowsRBDConnectorTestCase(test_base_rbd.RBDConnectorTestMixin,
test_base.WindowsConnectorTestBase):
def setUp(self):
super(WindowsRBDConnectorTestCase, self).setUp()
self._diskutils = mock.Mock()
self._execute = mock.Mock(return_value=['fake_stdout', 'fake_stderr'])
self._conn = rbd.WindowsRBDConnector(execute=self._execute)
self._conn._diskutils = self._diskutils
self.dev_name = '\\\\.\\PhysicalDrive5'
@ddt.data(True, False)
def test_check_rbd(self, rbd_available):
self._execute.side_effect = (
None if rbd_available
else processutils.ProcessExecutionError)
self.assertEqual(rbd_available, self._conn._check_rbd())
if rbd_available:
self._conn._ensure_rbd_available()
else:
self.assertRaises(exception.BrickException,
self._conn._ensure_rbd_available)
expected_cmd = ['where.exe', 'rbd']
self._execute.assert_any_call(*expected_cmd)
@mock.patch.object(rbd.WindowsRBDConnector, 'get_device_name')
def test_get_volume_paths(self, mock_get_dev_name):
vol_paths = self._conn.get_volume_paths(mock.sentinel.conn_props)
self.assertEqual([mock_get_dev_name.return_value], vol_paths)
mock_get_dev_name.assert_called_once_with(mock.sentinel.conn_props)
@ddt.data(True, False)
@mock.patch.object(rbd.WindowsRBDConnector, 'get_device_name')
@mock.patch('oslo_utils.eventletutils.EventletEvent.wait')
def test_wait_for_volume(self, device_found, mock_wait, mock_get_dev_name):
mock_open = mock.mock_open()
if device_found:
mock_get_dev_name.return_value = mock.sentinel.dev_name
else:
# First call fails to locate the device, the following ones can't
# open it.
mock_get_dev_name.side_effect = (
[None] +
[mock.sentinel.dev_name] * self._conn.device_scan_attempts)
mock_open.side_effect = FileNotFoundError
with mock.patch.object(rbd, 'open', mock_open,
create=True):
if device_found:
dev_name = self._conn._wait_for_volume(
self.connection_properties)
self.assertEqual(mock.sentinel.dev_name, dev_name)
else:
self.assertRaises(exception.VolumeDeviceNotFound,
self._conn._wait_for_volume,
self.connection_properties)
mock_open.assert_any_call(mock.sentinel.dev_name, 'rb')
mock_get_dev_name.assert_any_call(self.connection_properties,
expect=False)
@mock.patch.object(rbd.WindowsRBDConnector, '_wait_for_volume')
@mock.patch.object(rbd.WindowsRBDConnector, 'get_device_name')
def test_connect_volume(self, mock_get_dev_name, mock_wait_vol):
mock_get_dev_name.return_value = None
mock_wait_vol.return_value = self.dev_name
ret_val = self._conn.connect_volume(self.connection_properties)
exp_ret_val = {
'path': self.dev_name,
'type': 'block'
}
self.assertEqual(exp_ret_val, ret_val)
exp_exec_args = ['rbd', 'device', 'map', self.image_name]
exp_exec_args += self._conn._get_rbd_args(self.connection_properties)
self._execute.assert_any_call(*exp_exec_args)
mock_wait_vol.assert_called_once_with(self.connection_properties)
mock_get_dev_num = self._diskutils.get_device_number_from_device_name
mock_get_dev_num.assert_called_once_with(self.dev_name)
self._diskutils.set_disk_offline.assert_called_once_with(
mock_get_dev_num.return_value)
@ddt.data(True, False)
@mock.patch.object(rbd.WindowsRBDConnector, 'get_device_name')
def test_disconnect_volume(self, force, mock_get_dev_name):
mock_get_dev_name.return_value = self.dev_name
self._conn.disconnect_volume(self.connection_properties, force=force)
exp_exec_args = ['rbd', 'device', 'unmap', self.image_name]
exp_exec_args += self._conn._get_rbd_args(self.connection_properties)
if force:
exp_exec_args += ["-o", "hard-disconnect"]
self._execute.assert_any_call(*exp_exec_args)

View File

@ -0,0 +1,5 @@
---
features:
- |
RBD volumes can now be attached to Windows hosts and Hyper-V VMs.
The minimum requirements are Ceph 16 (Pacific) and Windows Server 2016.