Implement get, add and delete for cinder store

This implements get, add, delete methods for cinder storage
driver to enable users to upload/download the images to/from
the volumes. This also will be useful for users who want to
utilize storage features such as copy-on-write cloning
for creating a new volume from an image volume efficiently.

Change-Id: Ie228d1d95365e81881691af4cb8b170335a73868
Implements: blueprint cinder-store-upload-download
This commit is contained in:
Tomoki Sekiyama 2015-03-20 18:35:17 -04:00
parent d4eb2c9ed2
commit 410747822c
7 changed files with 755 additions and 94 deletions

27
etc/rootwrap.conf Normal file
View File

@ -0,0 +1,27 @@
# Configuration for glance-rootwrap
# This file should be owned by (and only-writeable by) the root user
[DEFAULT]
# List of directories to load filter definitions from (separated by ',').
# These directories MUST all be only writeable by root !
filters_path=/etc/glance/rootwrap.d,/usr/share/glance/rootwrap
# List of directories to search executables in, in case filters do not
# explicitely specify a full path (separated by ',')
# If not specified, defaults to system PATH environment variable.
# These directories MUST all be only writeable by root !
exec_dirs=/sbin,/usr/sbin,/bin,/usr/bin,/usr/local/bin,/usr/local/sbin
# Enable logging to syslog
# Default value is False
use_syslog=False
# Which syslog facility to use.
# Valid values include auth, authpriv, syslog, local0, local1...
# Default value is 'syslog'
syslog_log_facility=syslog
# Which messages to log.
# INFO means log all usage
# ERROR means only log unsuccessful attempts
syslog_log_level=ERROR

View File

@ -0,0 +1,29 @@
# glance-rootwrap command filters for glance cinder store
# This file should be owned by (and only-writeable by) the root user
[Filters]
# cinder store driver
disk_chown: RegExpFilter, chown, root, chown, \d+, /dev/(?!.*/\.\.).*
# os-brick
mount: CommandFilter, mount, root
blockdev: RegExpFilter, blockdev, root, blockdev, (--getsize64|--flushbufs), /dev/.*
tee: CommandFilter, tee, root
mkdir: CommandFilter, mkdir, root
chown: RegExpFilter, chown, root, chown root:root /etc/pstorage/clusters/(?!.*/\.\.).*
ip: CommandFilter, ip, root
dd: CommandFilter, dd, root
iscsiadm: CommandFilter, iscsiadm, root
aoe-revalidate: CommandFilter, aoe-revalidate, root
aoe-discover: CommandFilter, aoe-discover, root
aoe-flush: CommandFilter, aoe-flush, root
read_initiator: ReadFileFilter, /etc/iscsi/initiatorname.iscsi
multipath: CommandFilter, multipath, root
multipathd: CommandFilter, multipathd, root
systool: CommandFilter, systool, root
sg_scan: CommandFilter, sg_scan, root
cp: CommandFilter, cp, root
drv_cfg: CommandFilter, /opt/emc/scaleio/sdc/bin/drv_cfg, root, /opt/emc/scaleio/sdc/bin/drv_cfg, --query_guid
sds_cli: CommandFilter, /usr/local/bin/sds/sds_cli, root
vgc-cluster: CommandFilter, vgc-cluster, root
scsi_id: CommandFilter, /lib/udev/scsi_id, root

View File

@ -12,8 +12,15 @@
"""Storage backend for Cinder"""
import contextlib
import errno
import hashlib
import logging
import os
import socket
import time
from oslo_concurrency import processutils
from oslo_config import cfg
from oslo_utils import units
@ -21,86 +28,129 @@ from glance_store import capabilities
from glance_store.common import utils
import glance_store.driver
from glance_store import exceptions
from glance_store.i18n import _
from glance_store.i18n import _, _LE, _LW, _LI
import glance_store.location
from keystoneclient import exceptions as keystone_exc
from keystoneclient import service_catalog as keystone_sc
try:
from cinderclient import exceptions as cinder_exception
from cinderclient import service_catalog
from cinderclient.v2 import client as cinderclient
from os_brick.initiator import connector
except ImportError:
cinder_exception = None
service_catalog = None
cinderclient = None
connector = None
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
_CINDER_OPTS = [
cfg.StrOpt('cinder_catalog_info',
default='volume:cinder:publicURL',
help='Info to match when looking for cinder in the service '
'catalog. Format is : separated values of the form: '
'<service_type>:<service_name>:<endpoint_type>'),
default='volumev2::publicURL',
help=_('Info to match when looking for cinder in the service '
'catalog. Format is : separated values of the form: '
'<service_type>:<service_name>:<endpoint_type>')),
cfg.StrOpt('cinder_endpoint_template',
help='Override service catalog lookup with template for cinder '
'endpoint e.g. http://localhost:8776/v1/%(project_id)s'),
cfg.StrOpt('os_region_name',
help='Region name of this node'),
help=_('Override service catalog lookup with template for '
'cinder endpoint e.g. '
'http://localhost:8776/v2/%(tenant)s')),
cfg.StrOpt('cinder_os_region_name', deprecated_name='os_region_name',
help=_('Region name of this node. If specified, it will be '
'used to locate OpenStack services for stores.')),
cfg.StrOpt('cinder_ca_certificates_file',
help='Location of ca certicates file to use for cinder client '
'requests.'),
help=_('Location of ca certicates file to use for cinder '
'client requests.')),
cfg.IntOpt('cinder_http_retries',
default=3,
help='Number of cinderclient retries on failed http calls'),
help=_('Number of cinderclient retries on failed http calls')),
cfg.IntOpt('cinder_state_transition_timeout',
default=300,
help=_('Time period of time in seconds to wait for a cinder '
'volume transition to complete.')),
cfg.BoolOpt('cinder_api_insecure',
default=False,
help='Allow to perform insecure SSL requests to cinder'),
help=_('Allow to perform insecure SSL requests to cinder')),
cfg.StrOpt('cinder_store_auth_address',
default=None,
help=_('The address where the Cinder authentication service '
'is listening. If <None>, the cinder endpoint in the '
'service catalog is used.')),
cfg.StrOpt('cinder_store_user_name',
default=None,
help=_('User name to authenticate against Cinder. If <None>, '
'the user of current context is used.')),
cfg.StrOpt('cinder_store_password', secret=True,
default=None,
help=_('Password for the user authenticating against Cinder. '
'If <None>, the current context auth token is used.')),
cfg.StrOpt('cinder_store_project_name',
default=None,
help=_('Project name where the image is stored in Cinder. '
'If <None>, the project in current context is used.')),
cfg.StrOpt('rootwrap_config',
default='/etc/glance/rootwrap.conf',
help=_('Path to the rootwrap configuration file to use for '
'running commands as root.')),
]
def get_cinderclient(conf, context):
if conf.glance_store.cinder_endpoint_template:
url = conf.glance_store.cinder_endpoint_template % context.to_dict()
else:
info = conf.glance_store.cinder_catalog_info
service_type, service_name, endpoint_type = info.split(':')
def get_root_helper():
return 'sudo glance-rootwrap %s' % CONF.glance_store.rootwrap_config
# extract the region if set in configuration
if conf.glance_store.os_region_name:
attr = 'region'
filter_value = conf.glance_store.os_region_name
else:
attr = None
filter_value = None
# FIXME: the cinderclient ServiceCatalog object is mis-named.
# It actually contains the entire access blob.
# Only needed parts of the service catalog are passed in, see
# nova/context.py.
compat_catalog = {
'access': {'serviceCatalog': context.service_catalog or []}}
sc = service_catalog.ServiceCatalog(compat_catalog)
def is_user_overriden(conf):
return all([conf.glance_store.get('cinder_store_' + key)
for key in ['user_name', 'password',
'project_name', 'auth_address']])
url = sc.url_for(attr=attr,
filter_value=filter_value,
service_type=service_type,
service_name=service_name,
endpoint_type=endpoint_type)
LOG.debug(_('Cinderclient connection created using URL: %s') % url)
def get_cinderclient(conf, context=None):
glance_store = conf.glance_store
c = cinderclient.Client(context.user,
context.auth_token,
project_id=context.tenant,
user_overriden = is_user_overriden(conf)
if user_overriden:
username = glance_store.cinder_store_user_name
password = glance_store.cinder_store_password
project = glance_store.cinder_store_project_name
url = glance_store.cinder_store_auth_address
else:
username = context.user
password = context.auth_token
project = context.tenant
if glance_store.cinder_endpoint_template:
url = glance_store.cinder_endpoint_template % context.to_dict()
else:
info = glance_store.cinder_catalog_info
service_type, service_name, endpoint_type = info.split(':')
sc = {'serviceCatalog': context.service_catalog}
try:
url = keystone_sc.ServiceCatalogV2(sc).url_for(
region_name=glance_store.cinder_os_region_name,
service_type=service_type,
service_name=service_name,
endpoint_type=endpoint_type)
except keystone_exc.EndpointNotFound:
reason = _("Failed to find Cinder from a service catalog.")
raise exceptions.BadStoreConfiguration(store_name="cinder",
reason=reason)
c = cinderclient.Client(username,
password,
project,
auth_url=url,
insecure=glance_store.cinder_api_insecure,
retries=glance_store.cinder_http_retries,
cacert=glance_store.cinder_ca_certificates_file)
LOG.debug('Cinderclient connection created for user %(user)s using URL: '
'%(url)s.', {'user': username, 'url': url})
# noauth extracts user_id:project_id from auth_token
c.client.auth_token = context.auth_token or '%s:%s' % (context.user,
context.tenant)
if not user_overriden:
c.client.auth_token = context.auth_token or '%s:%s' % (username,
project)
c.client.management_url = url
return c
@ -131,34 +181,206 @@ class StoreLocation(glance_store.location.StoreLocation):
raise exceptions.BadStoreUri(message=reason)
@contextlib.contextmanager
def temporary_chown(path):
owner_uid = os.getuid()
orig_uid = os.stat(path).st_uid
if orig_uid != owner_uid:
processutils.execute('chown', owner_uid, path,
run_as_root=True,
root_helper=get_root_helper())
try:
yield
finally:
if orig_uid != owner_uid:
processutils.execute('chown', orig_uid, path,
run_as_root=True,
root_helper=get_root_helper())
class Store(glance_store.driver.Store):
"""Cinder backend store adapter."""
_CAPABILITIES = capabilities.BitMasks.DRIVER_REUSABLE
_CAPABILITIES = (capabilities.BitMasks.READ_RANDOM |
capabilities.BitMasks.WRITE_ACCESS |
capabilities.BitMasks.DRIVER_REUSABLE)
OPTIONS = _CINDER_OPTS
EXAMPLE_URL = "cinder://<VOLUME_ID>"
def __init__(self, *args, **kargs):
super(Store, self).__init__(*args, **kargs)
LOG.warning(_LW("Cinder store is considered experimental. "
"Current deployers should be aware that the use "
"of it in production right now may be risky."))
def get_schemes(self):
return ('cinder',)
def _check_context(self, context):
"""
Configure the Store to use the stored configuration options
Any store that needs special configuration should implement
this method. If the store was not able to successfully configure
itself, it should raise `exceptions.BadStoreConfiguration`
"""
def _check_context(self, context, require_tenant=False):
user_overriden = is_user_overriden(self.conf)
if user_overriden and not require_tenant:
return
if context is None:
reason = _("Cinder storage requires a context.")
raise exceptions.BadStoreConfiguration(store_name="cinder",
reason=reason)
if context.service_catalog is None:
if not user_overriden and context.service_catalog is None:
reason = _("Cinder storage requires a service catalog.")
raise exceptions.BadStoreConfiguration(store_name="cinder",
reason=reason)
def _wait_volume_status(self, volume, status_transition, status_expected):
max_recheck_wait = 15
timeout = self.conf.glance_store.cinder_state_transition_timeout
volume = volume.manager.get(volume.id)
tries = 0
elapsed = 0
while volume.status == status_transition:
if elapsed >= timeout:
msg = (_('Timeout while waiting while volume %(volume_id)s '
'status is %(status)s.')
% {'volume_id': volume.id, 'status': status_transition})
LOG.error(msg)
raise exceptions.BackendException(msg)
wait = min(0.5 * 2 ** tries, max_recheck_wait)
time.sleep(wait)
tries += 1
elapsed += wait
volume = volume.manager.get(volume.id)
if volume.status != status_expected:
msg = (_('The status of volume %(volume_id)s is unexpected: '
'status = %(status)s, expected = %(expected)s.')
% {'volume_id': volume.id, 'status': volume.status,
'expected': status_expected})
LOG.error(msg)
raise exceptions.BackendException(msg)
return volume
@contextlib.contextmanager
def _open_cinder_volume(self, client, volume, mode):
attach_mode = 'rw' if mode == 'wb' else 'ro'
device = None
root_helper = get_root_helper()
host = socket.gethostname()
properties = connector.get_connector_properties(root_helper, host,
False, False)
try:
volume.reserve(volume)
except cinder_exception.ClientException as e:
msg = (_('Failed to reserve volume %(volume_id)s: %(error)s')
% {'volume_id': volume.id, 'error': e})
LOG.error(msg)
raise exceptions.BackendException(msg)
try:
connection_info = volume.initialize_connection(volume, properties)
conn = connector.InitiatorConnector.factory(
connection_info['driver_volume_type'], root_helper)
device = conn.connect_volume(connection_info['data'])
volume.attach(None, None, attach_mode, host_name=host)
volume = self._wait_volume_status(volume, 'attaching', 'in-use')
LOG.debug('Opening host device "%s"', device['path'])
with temporary_chown(device['path']), \
open(device['path'], mode) as f:
yield f
except Exception:
LOG.exception(_LE('Exception while accessing to cinder volume '
'%(volume_id)s.'), {'volume_id': volume.id})
raise
finally:
if volume.status == 'in-use':
volume.begin_detaching(volume)
elif volume.status == 'attaching':
volume.unreserve(volume)
if device:
try:
conn.disconnect_volume(connection_info['data'], device)
except Exception:
LOG.exception(_LE('Failed to disconnect volume '
'%(volume_id)s.'),
{'volume_id': volume.id})
try:
volume.terminate_connection(volume, properties)
except Exception:
LOG.exception(_LE('Failed to terminate connection of volume '
'%(volume_id)s.'), {'volume_id': volume.id})
try:
client.volumes.detach(volume)
except Exception:
LOG.exception(_LE('Failed to detach volume %(volume_id)s.'),
{'volume_id': volume.id})
def _cinder_volume_data_iterator(self, client, volume, max_size, offset=0,
chunk_size=None, partial_length=None):
chunk_size = chunk_size if chunk_size else self.READ_CHUNKSIZE
partial = partial_length is not None
with self._open_cinder_volume(client, volume, 'rb') as fp:
if offset:
fp.seek(offset)
max_size -= offset
while True:
if partial:
size = min(chunk_size, partial_length, max_size)
else:
size = min(chunk_size, max_size)
chunk = fp.read(size)
if chunk:
yield chunk
max_size -= len(chunk)
if max_size <= 0:
break
if partial:
partial_length -= len(chunk)
if partial_length <= 0:
break
else:
break
@capabilities.check
def get(self, location, offset=0, chunk_size=None, context=None):
"""
Takes a `glance_store.location.Location` object that indicates
where to find the image file, and returns a tuple of generator
(for reading the image file) and image_size
:param location `glance_store.location.Location` object, supplied
from glance_store.location.get_location_from_uri()
:param offset: offset to start reading
:param chunk_size: size to read, or None to get all the image
:param context: Request context
:raises `glance_store.exceptions.NotFound` if image does not exist
"""
loc = location.store_location
self._check_context(context)
try:
client = get_cinderclient(self.conf, context)
volume = client.volumes.get(loc.volume_id)
size = int(volume.metadata.get('image_size',
volume.size * units.Gi))
iterator = self._cinder_volume_data_iterator(
client, volume, size, offset=offset,
chunk_size=self.READ_CHUNKSIZE, partial_length=chunk_size)
return (iterator, chunk_size or size)
except cinder_exception.NotFound:
reason = _("Failed to get image size due to "
"volume can not be found: %s") % volume.id
LOG.error(reason)
raise exceptions.NotFound(reason)
except cinder_exception.ClientException as e:
msg = (_('Failed to get image volume %(volume_id): %(error)s')
% {'volume_id': loc.volume_id, 'error': e})
LOG.error(msg)
raise exceptions.BackendException(msg)
def get_size(self, location, context=None):
"""
Takes a `glance_store.location.Location` object that indicates
@ -178,12 +400,145 @@ class Store(glance_store.driver.Store):
context).volumes.get(loc.volume_id)
# GB unit convert to byte
return volume.size * units.Gi
except cinder_exception.NotFound as e:
reason = _("Failed to get image size due to "
"volume can not be found: %s") % self.volume_id
LOG.error(reason)
raise exceptions.NotFound(reason)
except Exception as e:
LOG.exception(_("Failed to get image size due to "
"internal error: %s") % e)
except cinder_exception.NotFound:
raise exceptions.NotFound(image=loc.volume_id)
except Exception:
LOG.exception(_LE("Failed to get image size due to "
"internal error."))
return 0
@capabilities.check
def add(self, image_id, image_file, image_size, context=None,
verifier=None):
"""
Stores an image file with supplied identifier to the backend
storage system and returns a tuple containing information
about the stored image.
:param image_id: The opaque image identifier
:param image_file: The image data to write, as a file-like object
:param image_size: The size of the image data to write, in bytes
:param context: The request context
:param verifier: An object used to verify signatures for images
:retval tuple of URL in backing store, bytes written, checksum
and a dictionary with storage system specific information
:raises `glance_store.exceptions.Duplicate` if the image already
existed
"""
self._check_context(context, require_tenant=True)
client = get_cinderclient(self.conf, context)
checksum = hashlib.md5()
bytes_written = 0
size_gb = int((image_size + units.Gi - 1) / units.Gi)
if size_gb == 0:
size_gb = 1
name = "image-%s" % image_id
owner = context.tenant
metadata = {'glance_image_id': image_id,
'image_size': str(image_size),
'image_owner': owner}
LOG.debug('Creating a new volume: image_size=%d size_gb=%d',
image_size, size_gb)
if image_size == 0:
LOG.info(_LI("Since image size is zero, we will be doing "
"resize-before-write for each GB which "
"will be considerably slower than normal."))
volume = client.volumes.create(size_gb, name=name, metadata=metadata)
volume = self._wait_volume_status(volume, 'creating', 'available')
failed = True
need_extend = True
buf = None
try:
while need_extend:
with self._open_cinder_volume(client, volume, 'wb') as f:
f.seek(bytes_written)
if buf:
f.write(buf)
bytes_written += len(buf)
while True:
buf = image_file.read(self.WRITE_CHUNKSIZE)
if not buf:
need_extend = False
break
checksum.update(buf)
if verifier:
verifier.update(buf)
if (bytes_written + len(buf) > size_gb * units.Gi and
image_size == 0):
break
f.write(buf)
bytes_written += len(buf)
if need_extend:
size_gb += 1
LOG.debug("Extending volume %(volume_id)s to %(size)s GB.",
{'volume_id': volume.id, 'size': size_gb})
volume.extend(volume, size_gb)
try:
volume = self._wait_volume_status(volume,
'extending',
'available')
except exceptions.BackendException:
raise exceptions.StorageFull()
failed = False
except IOError as e:
# Convert IOError reasons to Glance Store exceptions
errors = {errno.EFBIG: exceptions.StorageFull(),
errno.ENOSPC: exceptions.StorageFull(),
errno.EACCES: exceptions.StorageWriteDenied()}
raise errors.get(e.errno, e)
finally:
if failed:
LOG.error(_LE("Failed to write to volume %(volume_id)s."),
{'volume_id': volume.id})
try:
volume.delete()
except Exception:
LOG.exception(_LE('Failed to delete of volume '
'%(volume_id)s.'),
{'volume_id': volume.id})
if image_size == 0:
metadata.update({'image_size': str(bytes_written)})
volume.update_all_metadata(metadata)
volume.update_readonly_flag(volume, True)
checksum_hex = checksum.hexdigest()
LOG.debug("Wrote %(bytes_written)d bytes to volume %(volume_id)s "
"with checksum %(checksum_hex)s.",
{'bytes_written': bytes_written,
'volume_id': volume.id,
'checksum_hex': checksum_hex})
return ('cinder://%s' % volume.id, bytes_written, checksum_hex, {})
@capabilities.check
def delete(self, location, context=None):
"""
Takes a `glance_store.location.Location` object that indicates
where to find the image file to delete
:location `glance_store.location.Location` object, supplied
from glance_store.location.get_location_from_uri()
:raises NotFound if image does not exist
:raises Forbidden if cannot delete because of permissions
"""
loc = location.store_location
self._check_context(context)
try:
volume = get_cinderclient(self.conf,
context).volumes.get(loc.volume_id)
volume.delete()
except cinder_exception.NotFound:
raise exceptions.NotFound(image=loc.volume_id)
except cinder_exception.ClientException as e:
msg = (_('Failed to delete volume %(volume_id)s: %(error)s') %
{'volume_id': loc.volume_id, 'error': e})
raise exceptions.BackendException(msg)

View File

@ -13,11 +13,21 @@
# License for the specific language governing permissions and limitations
# under the License.
import contextlib
import errno
import hashlib
import mock
from oslo_utils import units
import os
import six
import socket
import tempfile
import time
import uuid
from os_brick.initiator import connector
from oslo_concurrency import processutils
from oslo_utils import units
import glance_store
from glance_store._drivers import cinder
from glance_store import exceptions
from glance_store import location
@ -39,6 +49,140 @@ class TestCinderStore(base.StoreBaseTest,
self.store = cinder.Store(self.conf)
self.store.configure()
self.register_store_schemes(self.store, 'cinder')
self.store.READ_CHUNKSIZE = 4096
self.store.WRITE_CHUNKSIZE = 4096
fake_sc = [{u'endpoints': [{u'publicURL': u'http://foo/public_url'}],
u'endpoints_links': [],
u'name': u'cinder',
u'type': u'volumev2'}]
self.context = FakeObject(service_catalog=fake_sc,
user='fake_user',
auth_token='fake_token',
tenant='fake_tenant')
def test_get_cinderclient(self):
cc = cinder.get_cinderclient(self.conf, self.context)
self.assertEqual('fake_token', cc.client.auth_token)
self.assertEqual('http://foo/public_url', cc.client.management_url)
def test_get_cinderclient_with_user_overriden(self):
self.config(cinder_store_user_name='test_user')
self.config(cinder_store_password='test_password')
self.config(cinder_store_project_name='test_project')
self.config(cinder_store_auth_address='test_address')
cc = cinder.get_cinderclient(self.conf, self.context)
self.assertIsNone(cc.client.auth_token)
self.assertEqual('test_address', cc.client.management_url)
def test_temporary_chown(self):
class fake_stat(object):
st_uid = 1
with mock.patch.object(os, 'stat', return_value=fake_stat()), \
mock.patch.object(os, 'getuid', return_value=2), \
mock.patch.object(processutils, 'execute') as mock_execute, \
mock.patch.object(cinder, 'get_root_helper',
return_value='sudo'):
with cinder.temporary_chown('test'):
pass
expected_calls = [mock.call('chown', 2, 'test', run_as_root=True,
root_helper='sudo'),
mock.call('chown', 1, 'test', run_as_root=True,
root_helper='sudo')]
self.assertEqual(expected_calls, mock_execute.call_args_list)
@mock.patch.object(time, 'sleep')
def test_wait_volume_status(self, mock_sleep):
fake_manager = FakeObject(get=mock.Mock())
volume_available = FakeObject(manager=fake_manager,
id='fake-id',
status='available')
volume_in_use = FakeObject(manager=fake_manager,
id='fake-id',
status='in-use')
fake_manager.get.side_effect = [volume_available, volume_in_use]
self.assertEqual(volume_in_use,
self.store._wait_volume_status(
volume_available, 'available', 'in-use'))
fake_manager.get.assert_called_with('fake-id')
mock_sleep.assert_called_once_with(0.5)
@mock.patch.object(time, 'sleep')
def test_wait_volume_status_unexpected(self, mock_sleep):
fake_manager = FakeObject(get=mock.Mock())
volume_available = FakeObject(manager=fake_manager,
id='fake-id',
status='error')
fake_manager.get.return_value = volume_available
self.assertRaises(exceptions.BackendException,
self.store._wait_volume_status,
volume_available, 'available', 'in-use')
fake_manager.get.assert_called_with('fake-id')
@mock.patch.object(time, 'sleep')
def test_wait_volume_status_timeout(self, mock_sleep):
fake_manager = FakeObject(get=mock.Mock())
volume_available = FakeObject(manager=fake_manager,
id='fake-id',
status='available')
fake_manager.get.return_value = volume_available
self.assertRaises(exceptions.BackendException,
self.store._wait_volume_status,
volume_available, 'available', 'in-use')
fake_manager.get.assert_called_with('fake-id')
def _test_open_cinder_volume(self, open_mode, attach_mode, error):
fake_volume = mock.MagicMock(id=str(uuid.uuid4()), status='available')
fake_volumes = FakeObject(get=lambda id: fake_volume,
detach=mock.Mock())
fake_client = FakeObject(volumes=fake_volumes)
_, fake_dev_path = tempfile.mkstemp(dir=self.test_dir)
fake_devinfo = {'path': fake_dev_path}
fake_connector = FakeObject(
connect_volume=mock.Mock(return_value=fake_devinfo),
disconnect_volume=mock.Mock())
@contextlib.contextmanager
def fake_chown(path):
yield
def do_open():
with self.store._open_cinder_volume(
fake_client, fake_volume, open_mode):
if error:
raise error
with mock.patch.object(cinder.Store,
'_wait_volume_status',
return_value=fake_volume), \
mock.patch.object(cinder, 'temporary_chown',
side_effect=fake_chown), \
mock.patch.object(cinder, 'get_root_helper'), \
mock.patch.object(connector, 'get_connector_properties'), \
mock.patch.object(connector.InitiatorConnector, 'factory',
return_value=fake_connector):
if error:
self.assertRaises(error, do_open)
else:
do_open()
fake_connector.connect_volume.assert_called_once_with(mock.ANY)
fake_connector.disconnect_volume.assert_called_once_with(
mock.ANY, fake_devinfo)
fake_volume.attach.assert_called_once_with(
None, None, attach_mode, host_name=socket.gethostname())
fake_volumes.detach.assert_called_once_with(fake_volume)
def test_open_cinder_volume_rw(self):
self._test_open_cinder_volume('wb', 'rw', None)
def test_open_cinder_volume_ro(self):
self._test_open_cinder_volume('rb', 'ro', None)
def test_open_cinder_volume_error(self):
self._test_open_cinder_volume('wb', 'rw', IOError)
def test_cinder_configure_add(self):
self.assertRaises(exceptions.BadStoreConfiguration,
@ -50,9 +194,46 @@ class TestCinderStore(base.StoreBaseTest,
self.store._check_context(FakeObject(service_catalog='fake'))
def test_cinder_get(self):
expected_size = 5 * units.Ki
expected_file_contents = b"*" * expected_size
volume_file = six.BytesIO(expected_file_contents)
fake_client = FakeObject(auth_token=None, management_url=None)
fake_volume_uuid = str(uuid.uuid4())
fake_volume = mock.MagicMock(id=fake_volume_uuid,
metadata={'image_size': expected_size},
status='available')
fake_volume.manager.get.return_value = fake_volume
fake_volumes = FakeObject(get=lambda id: fake_volume)
@contextlib.contextmanager
def fake_open(client, volume, mode):
self.assertEqual(mode, 'rb')
yield volume_file
with mock.patch.object(cinder, 'get_cinderclient') as mock_cc, \
mock.patch.object(self.store, '_open_cinder_volume',
side_effect=fake_open):
mock_cc.return_value = FakeObject(client=fake_client,
volumes=fake_volumes)
uri = "cinder://%s" % fake_volume_uuid
loc = location.get_location_from_uri(uri, conf=self.conf)
(image_file, image_size) = self.store.get(loc,
context=self.context)
expected_num_chunks = 2
data = b""
num_chunks = 0
for chunk in image_file:
num_chunks += 1
data += chunk
self.assertEqual(expected_num_chunks, num_chunks)
self.assertEqual(expected_file_contents, data)
def test_cinder_get_size(self):
fake_client = FakeObject(auth_token=None, management_url=None)
fake_volume_uuid = '12345678-9012-3455-6789-012345678901'
fake_volume_uuid = str(uuid.uuid4())
fake_volume = FakeObject(size=5)
fake_volumes = {fake_volume_uuid: fake_volume}
@ -60,31 +241,81 @@ class TestCinderStore(base.StoreBaseTest,
mocked_cc.return_value = FakeObject(client=fake_client,
volumes=fake_volumes)
fake_sc = [{u'endpoints': [{u'publicURL': u'foo_public_url'}],
u'endpoints_links': [],
u'name': u'cinder',
u'type': u'volume'}]
fake_context = FakeObject(service_catalog=fake_sc,
user='fake_uer',
auth_tok='fake_token',
tenant='fake_tenant')
uri = 'cinder://%s' % fake_volume_uuid
loc = location.get_location_from_uri(uri, conf=self.conf)
image_size = self.store.get_size(loc, context=self.context)
self.assertEqual(image_size, fake_volume.size * units.Gi)
def _test_cinder_add(self, fake_volume, volume_file, size_kb=5,
verifier=None):
expected_image_id = str(uuid.uuid4())
expected_size = size_kb * units.Ki
expected_file_contents = b"*" * expected_size
image_file = six.BytesIO(expected_file_contents)
expected_checksum = hashlib.md5(expected_file_contents).hexdigest()
expected_location = 'cinder://%s' % fake_volume.id
fake_client = FakeObject(auth_token=None, management_url=None)
fake_volume.manager.get.return_value = fake_volume
fake_volumes = FakeObject(create=mock.Mock(return_value=fake_volume))
@contextlib.contextmanager
def fake_open(client, volume, mode):
self.assertEqual(mode, 'wb')
yield volume_file
with mock.patch.object(cinder, 'get_cinderclient') as mock_cc, \
mock.patch.object(self.store, '_open_cinder_volume',
side_effect=fake_open):
mock_cc.return_value = FakeObject(client=fake_client,
volumes=fake_volumes)
loc, size, checksum, _ = self.store.add(expected_image_id,
image_file,
expected_size,
self.context,
verifier)
self.assertEqual(expected_location, loc)
self.assertEqual(expected_size, size)
self.assertEqual(expected_checksum, checksum)
fake_volumes.create.assert_called_once_with(
1,
name='image-%s' % expected_image_id,
metadata={'image_owner': self.context.tenant,
'glance_image_id': expected_image_id,
'image_size': str(expected_size)})
def test_cinder_add(self):
fake_volume = mock.MagicMock(id=str(uuid.uuid4()), status='available')
volume_file = six.BytesIO()
self._test_cinder_add(fake_volume, volume_file)
def test_cinder_add_with_verifier(self):
fake_volume = mock.MagicMock(id=str(uuid.uuid4()), status='available')
volume_file = six.BytesIO()
verifier = mock.MagicMock()
self._test_cinder_add(fake_volume, volume_file, 1, verifier)
verifier.update.assert_called_with(b"*" * units.Ki)
def test_cinder_add_volume_full(self):
e = IOError()
volume_file = six.BytesIO()
e.errno = errno.ENOSPC
fake_volume = mock.MagicMock(id=str(uuid.uuid4()), status='available')
with mock.patch.object(volume_file, 'write', side_effect=e):
self.assertRaises(exceptions.StorageFull,
self._test_cinder_add, fake_volume, volume_file)
fake_volume.delete.assert_called_once_with()
def test_cinder_delete(self):
fake_client = FakeObject(auth_token=None, management_url=None)
fake_volume_uuid = str(uuid.uuid4())
fake_volume = FakeObject(delete=mock.Mock())
fake_volumes = {fake_volume_uuid: fake_volume}
with mock.patch.object(cinder, 'get_cinderclient') as mocked_cc:
mocked_cc.return_value = FakeObject(client=fake_client,
volumes=fake_volumes)
uri = 'cinder://%s' % fake_volume_uuid
loc = location.get_location_from_uri(uri, conf=self.conf)
image_size = self.store.get_size(loc, context=fake_context)
self.assertEqual(image_size, fake_volume.size * units.Gi)
def test_cinder_delete_raise_error(self):
uri = 'cinder://12345678-9012-3455-6789-012345678901'
loc = location.get_location_from_uri(uri, conf=self.conf)
self.assertRaises(exceptions.StoreDeleteNotSupported,
self.store.delete, loc)
self.assertRaises(exceptions.StoreDeleteNotSupported,
glance_store.delete_from_backend, uri, {})
def test_cinder_add_raise_error(self):
self.assertRaises(exceptions.StoreAddDisabled,
self.store.add, None, None, None, None)
self.assertRaises(exceptions.StoreAddDisabled,
glance_store.add_to_backend, None, None,
None, None, 'cinder')
self.store.delete(loc, context=self.context)
fake_volume.delete.assert_called_once_with()

View File

@ -64,17 +64,23 @@ class OptsTestCase(base.StoreBaseTest):
'cinder_catalog_info',
'cinder_endpoint_template',
'cinder_http_retries',
'cinder_os_region_name',
'cinder_state_transition_timeout',
'cinder_store_auth_address',
'cinder_store_user_name',
'cinder_store_password',
'cinder_store_project_name',
'default_swift_reference',
'filesystem_store_datadir',
'filesystem_store_datadirs',
'filesystem_store_file_perm',
'filesystem_store_metadata_file',
'os_region_name',
'rbd_store_ceph_conf',
'rbd_store_chunk_size',
'rbd_store_pool',
'rbd_store_user',
'rados_connect_timeout',
'rootwrap_config',
's3_store_access_key',
's3_store_bucket',
's3_store_bucket_url_format',

View File

@ -0,0 +1,8 @@
---
features:
- Implemented image uploading, downloading and deletion for cinder store.
It also supports new settings to put image volumes into a specific project
to hide them from users and to control them based on ACL of the images.
Note that cinder store is currently considered experimental, so
current deployers should be aware that the use of it in production right
now may be riscky.

View File

@ -50,6 +50,9 @@ glance_store.drivers =
oslo.config.opts =
glance.store = glance_store.backend:_list_opts
console_scripts =
glance-rootwrap = oslo_rootwrap.cmd:main
[extras]
# Dependencies for each of the optional stores
s3 =
@ -61,6 +64,8 @@ swift =
python-swiftclient>=2.2.0 # Apache-2.0
cinder =
python-cinderclient>=1.3.1 # Apache-2.0
os-brick>=1.0.0 # Apache-2.0
oslo.rootwrap>=2.0.0 # Apache-2.0
[build_sphinx]
source-dir = doc/source