From 6faa0d3d356417438f684473288dc2aae4516b12 Mon Sep 17 00:00:00 2001 From: Peter Penchev Date: Fri, 18 Dec 2015 17:13:44 +0200 Subject: [PATCH] Add the StorPool brick connector Allow clients to attach and detach Cinder volumes stored on a StorPool cluster. Change-Id: I33cfd7ab9a1d201f6c49357cacb4a809453b7201 Implements: blueprint brick-add-storpool-driver --- os_brick/initiator/__init__.py | 1 + os_brick/initiator/connector.py | 3 + os_brick/initiator/connectors/storpool.py | 222 ++++++++++++++++++ .../initiator/connectors/test_storpool.py | 152 ++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 os_brick/initiator/connectors/storpool.py create mode 100644 os_brick/tests/initiator/connectors/test_storpool.py diff --git a/os_brick/initiator/__init__.py b/os_brick/initiator/__init__.py index a3497a7fa..0f49a9747 100644 --- a/os_brick/initiator/__init__.py +++ b/os_brick/initiator/__init__.py @@ -61,4 +61,5 @@ SHEEPDOG = "SHEEPDOG" VMDK = "VMDK" GPFS = "GPFS" VERITAS_HYPERSCALE = "VERITAS_HYPERSCALE" +STORPOOL = "STORPOOL" NVME = "NVME" diff --git a/os_brick/initiator/connector.py b/os_brick/initiator/connector.py index e22d3ee0b..0aec7a3cb 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.storpool.StorPoolConnector', 'os_brick.initiator.connectors.nvme.NVMeConnector', ] @@ -111,6 +112,8 @@ _connector_mapping_linux = { 'os_brick.initiator.connectors.gpfs.GPFSConnector', initiator.VERITAS_HYPERSCALE: 'os_brick.initiator.connectors.vrtshyperscale.HyperScaleConnector', + initiator.STORPOOL: + 'os_brick.initiator.connectors.storpool.StorPoolConnector', initiator.NVME: 'os_brick.initiator.connectors.nvme.NVMeConnector', } diff --git a/os_brick/initiator/connectors/storpool.py b/os_brick/initiator/connectors/storpool.py new file mode 100644 index 000000000..132a63036 --- /dev/null +++ b/os_brick/initiator/connectors/storpool.py @@ -0,0 +1,222 @@ +# Copyright (c) 2015 - 2017 StorPool +# 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 __future__ import absolute_import + +import os +import six + +from oslo_log import log as logging +from oslo_utils import importutils + +from os_brick import exception +from os_brick.initiator.connectors import base + +LOG = logging.getLogger(__name__) + +spopenstack = importutils.try_import('storpool.spopenstack') + + +class StorPoolConnector(base.BaseLinuxConnector): + """"Connector class to attach/detach StorPool volumes.""" + + def __init__(self, root_helper, driver=None, + *args, **kwargs): + + super(StorPoolConnector, self).__init__(root_helper, driver=driver, + *args, **kwargs) + + if spopenstack is not None: + try: + self._attach = spopenstack.AttachDB(log=LOG) + except Exception as e: + raise exception.BrickException( + 'Could not initialize the StorPool API bindings: %s' % (e)) + else: + self._attach = None + + @staticmethod + def get_connector_properties(root_helper, *args, **kwargs): + """The StorPool connector properties.""" + return {} + + def connect_volume(self, connection_properties): + """Connect to a volume. + + :param connection_properties: The dictionary that describes all + of the target volume attributes; + it needs to contain the StorPool + 'client_id' and the common 'volume' and + 'access_mode' values. + :type connection_properties: dict + :returns: dict + """ + client_id = connection_properties.get('client_id', None) + if client_id is None: + raise exception.BrickException( + 'Invalid StorPool connection data, no client ID specified.') + volume_id = connection_properties.get('volume', None) + if volume_id is None: + raise exception.BrickException( + 'Invalid StorPool connection data, no volume ID specified.') + volume = self._attach.volumeName(volume_id) + mode = connection_properties.get('access_mode', None) + if mode is None or mode not in ('rw', 'ro'): + raise exception.BrickException( + 'Invalid access_mode specified in the connection data.') + req_id = 'brick-%s-%s' % (client_id, volume_id) + self._attach.add(req_id, { + 'volume': volume, + 'type': 'brick', + 'id': req_id, + 'rights': 1 if mode == 'ro' else 2, + 'volsnap': False + }) + self._attach.sync(req_id, None) + return {'type': 'block', 'path': '/dev/storpool/' + volume} + + def disconnect_volume(self, connection_properties, device_info, + force=False, ignore_errors=False): + """Disconnect a volume from the local host. + + The connection_properties are the same as from connect_volume. + The device_info is returned from connect_volume. + + :param connection_properties: The dictionary that describes all + of the target volume attributes; + it needs to contain the StorPool + 'client_id' and the common 'volume' + values. + :type connection_properties: dict + :param device_info: historical difference, but same as connection_props + :type device_info: dict + :param force: Whether to forcefully disconnect even if flush fails. + For StorPool, this parameter is ignored, the volume is + always detached. + :type force: bool + :param ignore_errors: When force is True, this will decide whether to + ignore errors or raise an exception once finished + the operation. Default is False. + For StorPool, this parameter is ignored, + no exception is raised except on + unexpected errors. + :type ignore_errors: bool + """ + client_id = connection_properties.get('client_id', None) + if client_id is None: + raise exception.BrickException( + 'Invalid StorPool connection data, no client ID specified.') + volume_id = connection_properties.get('volume', None) + if volume_id is None: + raise exception.BrickException( + 'Invalid StorPool connection data, no volume ID specified.') + volume = self._attach.volumeName(volume_id) + req_id = 'brick-%s-%s' % (client_id, volume_id) + self._attach.sync(req_id, volume) + self._attach.remove(req_id) + + def get_search_path(self): + return '/dev/storpool' + + def get_volume_paths(self, connection_properties): + """Return the list of existing paths for a volume. + + The job of this method is to find out what paths in + the system are associated with a volume as described + by the connection_properties. + + :param connection_properties: The dictionary that describes all + of the target volume attributes; + it needs to contain 'volume' and + 'device_path' values. + :type connection_properties: dict + """ + volume_id = connection_properties.get('volume', None) + if volume_id is None: + raise exception.BrickException( + 'Invalid StorPool connection data, no volume ID specified.') + volume = self._attach.volumeName(volume_id) + path = '/dev/storpool/' + volume + dpath = connection_properties.get('device_path', None) + if dpath is not None and dpath != path: + raise exception.BrickException( + 'Internal error: StorPool volume path {path} does not ' + 'match device path {dpath}', + {path: path, dpath: dpath}) + return [path] + + def get_all_available_volumes(self, connection_properties=None): + """Return all volumes that exist in the search directory. + + At connect_volume time, a Connector looks in a specific + directory to discover a volume's paths showing up. + This method's job is to return all paths in the directory + that connect_volume uses to find a volume. + + This method is used in coordination with get_volume_paths() + to verify that volumes have gone away after disconnect_volume + has been called. + + :param connection_properties: The dictionary that describes all + of the target volume attributes. + Unused for the StorPool connector. + :type connection_properties: dict + """ + names = [] + prefix = self._attach.volumeName('') + prefixlen = len(prefix) + if os.path.isdir('/dev/storpool'): + files = os.listdir('/dev/storpool') + for entry in files: + full = '/dev/storpool/' + entry + if entry.startswith(prefix) and os.path.islink(full) and \ + not os.path.isdir(full): + names.append(entry[prefixlen:]) + return names + + def _get_device_size(self, device): + """Get the size in bytes of a volume.""" + (out, _err) = self._execute('blockdev', '--getsize64', + device, run_as_root=True, + root_helper=self._root_helper) + var = six.text_type(out) + if var.isnumeric(): + return int(var) + else: + return None + + def extend_volume(self, connection_properties): + """Update the attached volume's size. + + This method will attempt to update the local hosts's + volume after the volume has been extended on the remote + system. The new volume size in bytes will be returned. + If there is a failure to update, then None will be returned. + + :param connection_properties: The volume connection properties. + :returns: new size of the volume. + """ + # The StorPool client (storpool_block service) running on this host + # should have picked up the change already, so it is enough to query + # the actual disk device to see if its size is correct. + # + # TODO(pp): query the API to see if this is really the case + volume_id = connection_properties.get('volume', None) + if volume_id is None: + raise exception.BrickException( + 'Invalid StorPool connection data, no volume ID specified.') + volume = self._attach.volumeName(volume_id) + path = '/dev/storpool/' + volume + return self._get_device_size(path) diff --git a/os_brick/tests/initiator/connectors/test_storpool.py b/os_brick/tests/initiator/connectors/test_storpool.py new file mode 100644 index 000000000..3c073b9ac --- /dev/null +++ b/os_brick/tests/initiator/connectors/test_storpool.py @@ -0,0 +1,152 @@ +# Copyright (c) 2015 - 2017 StorPool +# 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_brick import exception +from os_brick.initiator.connectors import storpool as connector +from os_brick.tests.initiator import test_connector + + +def volumeNameExt(vid): + return 'os--volume--{id}'.format(id=vid) + + +class MockStorPoolADB(object): + def __init__(self, log): + self.requests = {} + self.attached = {} + + def api(self): + pass + + def add(self, req_id, req): + if req_id in self.requests: + raise Exception('Duplicate MockStorPool request added') + self.requests[req_id] = req + + def remove(self, req_id): + req = self.requests.get(req_id, None) + if req is None: + raise Exception('Unknown MockStorPool request removed') + elif req['volume'] in self.attached: + raise Exception('Removing attached MockStorPool volume') + del self.requests[req_id] + + def sync(self, req_id, detached): + req = self.requests.get(req_id, None) + if req is None: + raise Exception('Unknown MockStorPool request synced') + volume = req.get('volume', None) + if volume is None: + raise Exception('MockStorPool request without volume') + + if detached is None: + if volume in self.attached: + raise Exception('Duplicate MockStorPool request synced') + self.attached[volume] = req + else: + if volume != detached: + raise Exception( + 'Mismatched volumes on a MockStorPool request removal') + elif detached not in self.attached: + raise Exception('MockStorPool request not attached yet') + del self.attached[detached] + + def volumeName(self, vid): + return volumeNameExt(vid) + + +spopenstack = mock.Mock() +spopenstack.AttachDB = MockStorPoolADB +connector.spopenstack = spopenstack + + +class StorPoolConnectorTestCase(test_connector.ConnectorTestCase): + def volumeName(self, vid): + return volumeNameExt(vid) + + def execute(self, *cmd, **kwargs): + if cmd[0] == 'blockdev': + self.assertEqual(len(cmd), 3) + self.assertEqual(cmd[1], '--getsize64') + self.assertEqual(cmd[2], '/dev/storpool/' + + self.volumeName(self.fakeProp['volume'])) + return (str(self.fakeSize), None) + raise Exception("Unrecognized command passed to " + + type(self).__name__ + ".execute(): " + + str.join(", ", map(lambda s: "'" + s + "'", cmd))) + + def setUp(self): + super(StorPoolConnectorTestCase, self).setUp() + + self.fakeProp = { + 'volume': 'sp-vol-1', + 'client_id': 1, + 'access_mode': 'rw', + } + self.fakeConnection = None + self.fakeSize = 1024 * 1024 * 1024 + + self.connector = connector.StorPoolConnector( + None, execute=self.execute) + self.adb = self.connector._attach + + def test_connect_volume(self): + self.assertNotIn(self.volumeName(self.fakeProp['volume']), + self.adb.attached) + conn = self.connector.connect_volume(self.fakeProp) + self.assertIn('type', conn) + self.assertIn('path', conn) + self.assertIn(self.volumeName(self.fakeProp['volume']), + self.adb.attached) + + self.assertEqual(self.connector.get_search_path(), '/dev/storpool') + paths = self.connector.get_volume_paths(self.fakeProp) + self.assertEqual(len(paths), 1) + self.assertEqual(paths[0], + "/dev/storpool/" + + self.volumeName(self.fakeProp['volume'])) + self.fakeConnection = conn + + def test_disconnect_volume(self): + if self.fakeConnection is None: + self.test_connect_volume() + self.assertIn(self.volumeName(self.fakeProp['volume']), + self.adb.attached) + self.connector.disconnect_volume(self.fakeProp, None) + self.assertNotIn(self.volumeName(self.fakeProp['volume']), + self.adb.attached) + + def test_connect_exceptions(self): + """Raise exceptions on missing connection information""" + fake = self.fakeProp + for key in fake.keys(): + c = dict(fake) + del c[key] + self.assertRaises(exception.BrickException, + self.connector.connect_volume, c) + if key != 'access_mode': + self.assertRaises(exception.BrickException, + self.connector.disconnect_volume, c, None) + + def test_extend_volume(self): + if self.fakeConnection is None: + self.test_connect_volume() + + self.fakeSize += 1024 * 1024 * 1024 + newSize = self.connector.extend_volume(self.fakeProp) + self.assertEqual(newSize, self.fakeSize)