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
This commit is contained in:
Peter Penchev 2015-12-18 17:13:44 +02:00
parent a5c60d94d8
commit 6faa0d3d35
4 changed files with 378 additions and 0 deletions

View File

@ -61,4 +61,5 @@ SHEEPDOG = "SHEEPDOG"
VMDK = "VMDK"
GPFS = "GPFS"
VERITAS_HYPERSCALE = "VERITAS_HYPERSCALE"
STORPOOL = "STORPOOL"
NVME = "NVME"

View File

@ -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',
}

View File

@ -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)

View File

@ -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)