diff --git a/os_brick/initiator/__init__.py b/os_brick/initiator/__init__.py index e262f79d2..a3497a7fa 100644 --- a/os_brick/initiator/__init__.py +++ b/os_brick/initiator/__init__.py @@ -61,3 +61,4 @@ SHEEPDOG = "SHEEPDOG" VMDK = "VMDK" GPFS = "GPFS" VERITAS_HYPERSCALE = "VERITAS_HYPERSCALE" +NVME = "NVME" diff --git a/os_brick/initiator/connector.py b/os_brick/initiator/connector.py index bf9497191..e22d3ee0b 100644 --- a/os_brick/initiator/connector.py +++ b/os_brick/initiator/connector.py @@ -63,6 +63,7 @@ connector_list = [ 'os_brick.initiator.windows.fibre_channel.WindowsFCConnector', 'os_brick.initiator.windows.smbfs.WindowsSMBFSConnector', 'os_brick.initiator.connectors.vrtshyperscale.HyperScaleConnector', + 'os_brick.initiator.connectors.nvme.NVMeConnector', ] # Mappings used to determine who to contruct in the factory @@ -110,6 +111,8 @@ _connector_mapping_linux = { 'os_brick.initiator.connectors.gpfs.GPFSConnector', initiator.VERITAS_HYPERSCALE: 'os_brick.initiator.connectors.vrtshyperscale.HyperScaleConnector', + initiator.NVME: + 'os_brick.initiator.connectors.nvme.NVMeConnector', } # Mapping for the S390X platform diff --git a/os_brick/initiator/connectors/nvme.py b/os_brick/initiator/connectors/nvme.py new file mode 100644 index 000000000..277ac2716 --- /dev/null +++ b/os_brick/initiator/connectors/nvme.py @@ -0,0 +1,190 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re +import time + +from oslo_concurrency import lockutils +from oslo_concurrency import processutils as putils +from oslo_log import log as logging + +from os_brick import exception +from os_brick import initiator +from os_brick.initiator.connectors import base +from os_brick import utils + + +DEVICE_SCAN_ATTEMPTS_DEFAULT = 3 + +LOG = logging.getLogger(__name__) + +synchronized = lockutils.synchronized_with_prefix('os-brick-') + + +class NVMeConnector(base.BaseLinuxConnector): + + """Connector class to attach/detach NVMe over fabric volumes.""" + + def __init__(self, root_helper, driver=None, + device_scan_attempts=initiator.DEVICE_SCAN_ATTEMPTS_DEFAULT, + *args, **kwargs): + super(NVMeConnector, self).__init__( + root_helper, + driver=driver, + device_scan_attempts=device_scan_attempts, + *args, **kwargs) + + @staticmethod + def get_connector_properties(root_helper, *args, **kwargs): + """The NVMe connector properties.""" + return {} + + def get_search_path(self): + return '/dev' + + def get_volume_paths(self, connection_properties): + path = connection_properties['device_path'] + LOG.debug("Path of volume to be extended is %(path)s", {'path': path}) + return [path] + + def _get_nvme_devices(self): + nvme_devices = [] + pattern = r'/dev/nvme[0-9]n[0-9]' + cmd = ['nvme', 'list'] + try: + (out, err) = self._execute(*cmd, root_helper=self._root_helper, + run_as_root=True) + for line in out.split('\n'): + result = re.match(pattern, line) + if result: + nvme_devices.append(result.group(0)) + LOG.debug("_get_nvme_devices returned %(nvme_devices)s", + {'nvme_devices': nvme_devices}) + return nvme_devices + + except putils.ProcessExecutionError: + LOG.error( + "Failed to list available NVMe connected controllers.") + raise + + @utils.trace + @synchronized('connect_volume') + def connect_volume(self, connection_properties): + """Discover and attach the volume. + + :param connection_properties: The dictionary that describes all + of the target volume attributes. + connection_properties must include: + nqn - NVMe subsystem name to the volume to be connected + target_port - NVMe target port that hosts the nqn sybsystem + target_portal - NVMe target ip that hosts the nqn sybsystem + :type connection_properties: dict + :returns: dict + """ + + current_nvme_devices = self._get_nvme_devices() + + device_info = {'type': 'block'} + conn_nqn = connection_properties['nqn'].split('.', 1)[1] + target_portal = connection_properties['target_portal'] + port = connection_properties['target_port'] + nvme_transport_type = connection_properties['transport_type'] + cmd = [ + 'nvme', 'connect', + '-t', nvme_transport_type, + '-n', conn_nqn, + '-a', target_portal, + '-s', port] + try: + self._execute(*cmd, root_helper=self._root_helper, + run_as_root=True) + except putils.ProcessExecutionError: + LOG.error( + "Failed to connect to NVMe nqn " + "%(conn_nqn)s", {'conn_nqn': conn_nqn}) + raise + + for retry in range(1, self.device_scan_attempts + 1): + all_nvme_devices = self._get_nvme_devices() + path = set(all_nvme_devices) - set(current_nvme_devices) + if path: + break + time.sleep(retry ** 2) + else: + LOG.error("Failed to retrieve available connected NVMe " + "controllers when running nvme list") + raise exception.TargetPortalNotFound(target_portal) + + path = list(path) + LOG.debug("all_nvme_devices are %(all_nvme_devices)s", + {'all_nvme_devices': all_nvme_devices}) + device_info['path'] = path[0] + LOG.debug("NVMe device to be connected to is %(path)s", + {'path': path[0]}) + return device_info + + @utils.trace + @synchronized('disconnect_volume') + def disconnect_volume(self, connection_properties, device_info): + """Detach and flush the volume. + + :param connection_properties: The dictionary that describes all + of the target volume attributes. + connection_properties must include: + device_path - path to the volume to be connected + :type connection_properties: dict + + :param device_info: historical difference, but same as connection_props + :type device_info: dict + + """ + + conn_nqn = connection_properties['nqn'] + device_path = connection_properties['device_path'] + LOG.debug( + "Trying to disconnect from NVMe nqn " + "%(conn_nqn)s with device_path %(device_path)s", + {'conn_nqn': conn_nqn, 'device_path': device_path}) + cmd = [ + 'nvme', + 'disconnect', + '-d', + device_path] + try: + self._execute( + *cmd, + root_helper=self._root_helper, + run_as_root=True) + + except putils.ProcessExecutionError: + LOG.error( + "Failed to disconnect from NVMe nqn " + "%(conn_nqn)s with device_path %(device_path)s", + {'conn_nqn': conn_nqn, 'device_path': device_path}) + raise + + @utils.trace + @synchronized('extend_volume') + def extend_volume(self, connection_properties): + """Update the local kernel's size information. + + Try and update the local kernel's size information + for an LVM volume. + """ + volume_paths = self.get_volume_paths(connection_properties) + if volume_paths: + return self._linuxscsi.extend_volume(volume_paths) + else: + LOG.warning("Couldn't find any volume paths on the host to " + "extend volume for %(props)s", + {'props': connection_properties}) + raise exception.VolumePathsNotFound() diff --git a/os_brick/tests/initiator/connectors/test_nvme.py b/os_brick/tests/initiator/connectors/test_nvme.py new file mode 100644 index 000000000..69cfcbb10 --- /dev/null +++ b/os_brick/tests/initiator/connectors/test_nvme.py @@ -0,0 +1,168 @@ +# 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 os_brick import exception +from os_brick.initiator.connectors import nvme +from os_brick.initiator import linuxscsi +from os_brick.tests.initiator import test_connector + +FAKE_NVME_LIST_OUTPUT = """ +Node SN Model \ + Namespace Usage Format FW Rev\n +---------------- -------------------- ---------------------------------------\ +- --------- -------------------------- ---------------- --------\n +/dev/nvme0n1 67ff9467da6e5567 Linux \ + 10 1.07 GB / 1.07 GB 512 B + 0 B 4.8.0-58\n +""" + + +class NVMeConnectorTestCase(test_connector.ConnectorTestCase): + + """Test cases for NVMe initiator class.""" + + def setUp(self): + super(NVMeConnectorTestCase, self).setUp() + self.connector = nvme.NVMeConnector(None, + execute=self.fake_execute) + + def _nvme_list_cmd(self, *args, **kwargs): + return FAKE_NVME_LIST_OUTPUT, None + + def test__get_nvme_devices(self): + expected = ['/dev/nvme0n1'] + self.connector._execute = self._nvme_list_cmd + actual = self.connector._get_nvme_devices() + self.assertEqual(expected, actual) + + @mock.patch.object(nvme.NVMeConnector, '_execute') + def test_get_nvme_devices_raise(self, mock_execute): + mock_execute.side_effect = putils.ProcessExecutionError + self.assertRaises(putils.ProcessExecutionError, + self.connector._get_nvme_devices) + + @mock.patch.object(nvme.NVMeConnector, '_get_nvme_devices') + @mock.patch.object(nvme.NVMeConnector, '_execute') + @mock.patch('time.sleep') + def test_connect_volume(self, mock_sleep, mock_execute, mock_devices): + connection_properties = {'target_portal': 'portal', + 'target_port': 1, + 'nqn': 'nqn.volume_123', + 'device_path': '', + 'transport_type': 'rdma'} + + mock_devices.side_effect = [ + ['/dev/nvme0n1'], ['/dev/nvme0n2']] + + device_info = self.connector.connect_volume( + connection_properties) + self.assertEqual('/dev/nvme0n2', device_info['path']) + self.assertEqual('block', device_info['type']) + + self.assertEqual(2, mock_devices.call_count) + + mock_execute.assert_called_once_with( + 'nvme', 'connect', '-t', + connection_properties['transport_type'], '-n', + 'volume_123', + '-a', connection_properties['target_portal'], + '-s', connection_properties['target_port'], + root_helper=None, + run_as_root=True) + + @mock.patch.object(nvme.NVMeConnector, '_execute') + def test_connect_volume_raise(self, mock_execute): + connection_properties = {'target_portal': 'portal', + 'target_port': 1, + 'nqn': 'nqn.volume_123', + 'device_path': '', + 'transport_type': 'rdma'} + mock_execute.side_effect = putils.ProcessExecutionError + self.assertRaises(putils.ProcessExecutionError, + self.connector.connect_volume, + connection_properties) + + @mock.patch.object(nvme.NVMeConnector, '_get_nvme_devices') + @mock.patch.object(nvme.NVMeConnector, '_execute') + @mock.patch('time.sleep') + def test_connect_volume_max_retry( + self, mock_sleep, mock_execute, mock_devices): + connection_properties = {'target_portal': 'portal', + 'target_port': 1, + 'nqn': 'nqn.volume_123', + 'device_path': '', + 'transport_type': 'rdma'} + + mock_devices.return_value = '/dev/nvme0n1' + + self.assertRaises(exception.TargetPortalNotFound, + self.connector.connect_volume, + connection_properties) + + @mock.patch.object(nvme.NVMeConnector, '_execute') + def test_disconnect_volume(self, mock_execute): + connection_properties = {'target_portal': 'portal', + 'target_port': 1, + 'nqn': 'nqn.volume_123', + 'device_path': '', + 'transport_type': 'rdma'} + self.connector.disconnect_volume(connection_properties, None) + + mock_execute.asert_called_once_with( + 'nvme', 'disconnect', '-n', + 'volume_123', + root_helper=None, + run_as_root=True) + + @mock.patch.object(nvme.NVMeConnector, '_execute') + def test_disconnect_volume_raise(self, mock_execute): + mock_execute.side_effect = putils.ProcessExecutionError + connection_properties = {'target_portal': 'portal', + 'target_port': 1, + 'nqn': 'nqn.volume_123', + 'device_path': '', + 'transport_type': 'rdma'} + + self.assertRaises(putils.ProcessExecutionError, + self.connector.disconnect_volume, + connection_properties, + None) + + @mock.patch.object(nvme.NVMeConnector, 'get_volume_paths') + def test_extend_volume_no_path(self, mock_volume_paths): + mock_volume_paths.return_value = [] + connection_properties = {'target_portal': 'portal', + 'target_port': 1, + 'nqn': 'nqn.volume_123', + 'device_path': '', + 'transport_type': 'rdma'} + + self.assertRaises(exception.VolumePathsNotFound, + self.connector.extend_volume, + connection_properties) + + @mock.patch.object(linuxscsi.LinuxSCSI, 'extend_volume') + @mock.patch.object(nvme.NVMeConnector, 'get_volume_paths') + def test_extend_volume(self, mock_volume_paths, mock_scsi_extend): + fake_new_size = 1024 + mock_volume_paths.return_value = ['/dev/vdx'] + mock_scsi_extend.return_value = fake_new_size + connection_properties = {'target_portal': 'portal', + 'target_port': 1, + 'nqn': 'nqn.volume_123', + 'device_path': '', + 'transport_type': 'rdma'} + new_size = self.connector.extend_volume(connection_properties) + self.assertEqual(fake_new_size, new_size)