--- /dev/null
+# Copyright 2015 Odin
+#
+# 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 copy
+import errno
+import os
+
+import mock
+
+from os_brick.remotefs import remotefs
+from oslo_utils import units
+
+from cinder import exception
+from cinder.image import image_utils
+from cinder import test
+from cinder.volume.drivers import vzstorage
+
+
+class VZStorageTestCase(test.TestCase):
+
+ _FAKE_SHARE = "10.0.0.1,10.0.0.2:/cluster123:123123"
+ _FAKE_MNT_BASE = '/mnt'
+ _FAKE_MNT_POINT = os.path.join(_FAKE_MNT_BASE, 'fake_hash')
+ _FAKE_VOLUME_NAME = 'volume-4f711859-4928-4cb7-801a-a50c37ceaccc'
+ _FAKE_VOLUME_PATH = os.path.join(_FAKE_MNT_POINT, _FAKE_VOLUME_NAME)
+ _FAKE_VOLUME = {'id': '4f711859-4928-4cb7-801a-a50c37ceaccc',
+ 'size': 1,
+ 'provider_location': _FAKE_SHARE,
+ 'name': _FAKE_VOLUME_NAME,
+ 'status': 'available'}
+ _FAKE_SNAPSHOT_ID = '5g811859-4928-4cb7-801a-a50c37ceacba'
+ _FAKE_SNAPSHOT_PATH = (
+ _FAKE_VOLUME_PATH + '-snapshot' + _FAKE_SNAPSHOT_ID)
+ _FAKE_SNAPSHOT = {'id': _FAKE_SNAPSHOT_ID,
+ 'volume': _FAKE_VOLUME,
+ 'status': 'available',
+ 'volume_size': 1}
+
+ _FAKE_VZ_CONFIG = mock.MagicMock()
+ _FAKE_VZ_CONFIG.vzstorage_shares_config = '/fake/config/path'
+ _FAKE_VZ_CONFIG.vzstorage_sparsed_volumes = False
+ _FAKE_VZ_CONFIG.vzstorage_used_ratio = 0.7
+ _FAKE_VZ_CONFIG.vzstorage_mount_point_base = _FAKE_MNT_BASE
+ _FAKE_VZ_CONFIG.nas_secure_file_operations = 'auto'
+ _FAKE_VZ_CONFIG.nas_secure_file_permissions = 'auto'
+
+ def setUp(self):
+ super(VZStorageTestCase, self).setUp()
+
+ self._remotefsclient = mock.patch.object(remotefs,
+ 'RemoteFsClient').start()
+ get_mount_point = mock.Mock(return_value=self._FAKE_MNT_POINT)
+ self._remotefsclient.get_mount_point = get_mount_point
+ cfg = copy.copy(self._FAKE_VZ_CONFIG)
+ self._vz_driver = vzstorage.VZStorageDriver(configuration=cfg)
+ self._vz_driver._local_volume_dir = mock.Mock(
+ return_value=self._FAKE_MNT_POINT)
+ self._vz_driver._execute = mock.Mock()
+ self._vz_driver.base = self._FAKE_MNT_BASE
+
+ @mock.patch('os.path.exists')
+ def test_setup_ok(self, mock_exists):
+ mock_exists.return_value = True
+ self._vz_driver.do_setup(mock.sentinel.context)
+
+ @mock.patch('os.path.exists')
+ def test_setup_missing_shares_conf(self, mock_exists):
+ mock_exists.return_value = False
+ self.assertRaises(exception.VzStorageException,
+ self._vz_driver.do_setup,
+ mock.sentinel.context)
+
+ @mock.patch('os.path.exists')
+ def test_setup_invalid_usage_ratio(self, mock_exists):
+ mock_exists.return_value = True
+ self._vz_driver.configuration.vzstorage_used_ratio = 1.2
+ self.assertRaises(exception.VzStorageException,
+ self._vz_driver.do_setup,
+ mock.sentinel.context)
+
+ @mock.patch('os.path.exists')
+ def test_setup_invalid_usage_ratio2(self, mock_exists):
+ mock_exists.return_value = True
+ self._vz_driver.configuration.vzstorage_used_ratio = 0
+ self.assertRaises(exception.VzStorageException,
+ self._vz_driver.do_setup,
+ mock.sentinel.context)
+
+ @mock.patch('os.path.exists')
+ def test_setup_invalid_mount_point_base(self, mock_exists):
+ mock_exists.return_value = True
+ conf = copy.copy(self._FAKE_VZ_CONFIG)
+ conf.vzstorage_mount_point_base = './tmp'
+ vz_driver = vzstorage.VZStorageDriver(configuration=conf)
+ self.assertRaises(exception.VzStorageException,
+ vz_driver.do_setup,
+ mock.sentinel.context)
+
+ @mock.patch('os.path.exists')
+ def test_setup_no_vzstorage(self, mock_exists):
+ mock_exists.return_value = True
+ exc = OSError()
+ exc.errno = errno.ENOENT
+ self._vz_driver._execute.side_effect = exc
+ self.assertRaises(exception.VzStorageException,
+ self._vz_driver.do_setup,
+ mock.sentinel.context)
+
+ def test_initialize_connection(self):
+ drv = self._vz_driver
+ file_format = 'raw'
+ info = mock.Mock()
+ info.file_format = file_format
+ with mock.patch.object(drv, '_qemu_img_info', return_value=info):
+ ret = drv.initialize_connection(self._FAKE_VOLUME, None)
+ name = drv.get_active_image_from_info(self._FAKE_VOLUME)
+ expected = {'driver_volume_type': 'vzstorage',
+ 'data': {'export': self._FAKE_SHARE,
+ 'format': file_format,
+ 'name': name},
+ 'mount_point_base': self._FAKE_MNT_BASE}
+ self.assertEqual(expected, ret)
+
+ def test_ensure_share_mounted_invalid_share(self):
+ self.assertRaises(exception.VzStorageException,
+ self._vz_driver._ensure_share_mounted, ':')
+
+ def test_ensure_share_mounted(self):
+ drv = self._vz_driver
+ share = self._FAKE_SHARE
+ drv.shares = {'1': '["1", "2", "3"]', share: '["some", "options"]'}
+ drv._ensure_share_mounted(share)
+
+ def test_find_share(self):
+ drv = self._vz_driver
+ drv._mounted_shares = [self._FAKE_SHARE]
+ with mock.patch.object(drv, '_is_share_eligible', return_value=True):
+ ret = drv._find_share(1)
+ self.assertEqual(self._FAKE_SHARE, ret)
+
+ def test_find_share_no_shares_mounted(self):
+ drv = self._vz_driver
+ with mock.patch.object(drv, '_is_share_eligible', return_value=True):
+ self.assertRaises(exception.VzStorageNoSharesMounted,
+ drv._find_share, 1)
+
+ def test_find_share_no_shares_suitable(self):
+ drv = self._vz_driver
+ drv._mounted_shares = [self._FAKE_SHARE]
+ with mock.patch.object(drv, '_is_share_eligible', return_value=False):
+ self.assertRaises(exception.VzStorageNoSuitableShareFound,
+ drv._find_share, 1)
+
+ def test_is_share_eligible_false(self):
+ drv = self._vz_driver
+ cap_info = (100 * units.Gi, 40 * units.Gi, 60 * units.Gi)
+ with mock.patch.object(drv, '_get_capacity_info',
+ return_value = cap_info):
+ ret = drv._is_share_eligible(self._FAKE_SHARE, 50)
+ self.assertEqual(False, ret)
+
+ def test_is_share_eligible_true(self):
+ drv = self._vz_driver
+ cap_info = (100 * units.Gi, 40 * units.Gi, 60 * units.Gi)
+ with mock.patch.object(drv, '_get_capacity_info',
+ return_value = cap_info):
+ ret = drv._is_share_eligible(self._FAKE_SHARE, 30)
+ self.assertEqual(True, ret)
+
+ @mock.patch.object(image_utils, 'resize_image')
+ def test_extend_volume(self, mock_resize_image):
+ drv = self._vz_driver
+ drv._check_extend_volume_support = mock.Mock(return_value=True)
+ drv._is_file_size_equal = mock.Mock(return_value=True)
+
+ with mock.patch.object(drv, 'local_path',
+ return_value=self._FAKE_VOLUME_PATH):
+ drv.extend_volume(self._FAKE_VOLUME, 10)
+
+ mock_resize_image.assert_called_once_with(self._FAKE_VOLUME_PATH, 10)
+
+ def _test_check_extend_support(self, has_snapshots=False,
+ is_eligible=True):
+ drv = self._vz_driver
+ drv.local_path = mock.Mock(return_value=self._FAKE_VOLUME_PATH)
+ drv._is_share_eligible = mock.Mock(return_value=is_eligible)
+
+ if has_snapshots:
+ active = self._FAKE_SNAPSHOT_PATH
+ else:
+ active = self._FAKE_VOLUME_PATH
+
+ drv.get_active_image_from_info = mock.Mock(return_value=active)
+ if has_snapshots:
+ self.assertRaises(exception.InvalidVolume,
+ drv._check_extend_volume_support,
+ self._FAKE_VOLUME, 2)
+ elif not is_eligible:
+ self.assertRaises(exception.ExtendVolumeError,
+ drv._check_extend_volume_support,
+ self._FAKE_VOLUME, 2)
+ else:
+ drv._check_extend_volume_support(self._FAKE_VOLUME, 2)
+ drv._is_share_eligible.assert_called_once_with(self._FAKE_SHARE, 1)
+
+ def test_check_extend_support(self):
+ self._test_check_extend_support()
+
+ def test_check_extend_volume_with_snapshots(self):
+ self._test_check_extend_support(has_snapshots=True)
+
+ def test_check_extend_volume_uneligible_share(self):
+ self._test_check_extend_support(is_eligible=False)
+
+ @mock.patch.object(image_utils, 'convert_image')
+ def test_copy_volume_from_snapshot(self, mock_convert_image):
+ drv = self._vz_driver
+
+ fake_volume_info = {self._FAKE_SNAPSHOT_ID: 'fake_snapshot_file_name'}
+ fake_img_info = mock.MagicMock()
+ fake_img_info.backing_file = self._FAKE_VOLUME_NAME
+
+ drv.get_volume_format = mock.Mock(return_value='raw')
+ drv._local_path_volume_info = mock.Mock(
+ return_value=self._FAKE_VOLUME_PATH + '.info')
+ drv._local_volume_dir = mock.Mock(
+ return_value=self._FAKE_MNT_POINT)
+ drv._read_info_file = mock.Mock(
+ return_value=fake_volume_info)
+ drv._qemu_img_info = mock.Mock(
+ return_value=fake_img_info)
+ drv.local_path = mock.Mock(
+ return_value=self._FAKE_VOLUME_PATH[:-1])
+ drv._extend_volume = mock.Mock()
+
+ drv._copy_volume_from_snapshot(
+ self._FAKE_SNAPSHOT, self._FAKE_VOLUME,
+ self._FAKE_VOLUME['size'])
+ drv._extend_volume.assert_called_once_with(
+ self._FAKE_VOLUME, self._FAKE_VOLUME['size'])
+ mock_convert_image.assert_called_once_with(
+ self._FAKE_VOLUME_PATH, self._FAKE_VOLUME_PATH[:-1], 'raw')
+
+ def test_delete_volume(self):
+ drv = self._vz_driver
+ fake_vol_info = self._FAKE_VOLUME_PATH + '.info'
+
+ drv._ensure_share_mounted = mock.MagicMock()
+ fake_ensure_mounted = drv._ensure_share_mounted
+
+ drv._local_volume_dir = mock.Mock(
+ return_value=self._FAKE_MNT_POINT)
+ drv.get_active_image_from_info = mock.Mock(
+ return_value=self._FAKE_VOLUME_NAME)
+ drv._delete = mock.Mock()
+ drv._local_path_volume_info = mock.Mock(
+ return_value=fake_vol_info)
+
+ with mock.patch('os.path.exists', lambda x: True):
+ drv.delete_volume(self._FAKE_VOLUME)
+
+ fake_ensure_mounted.assert_called_once_with(self._FAKE_SHARE)
+ drv._delete.assert_any_call(
+ self._FAKE_VOLUME_PATH)
+ drv._delete.assert_any_call(fake_vol_info)
--- /dev/null
+# Copyright (c) 2015 Parallels IP Holdings GmbH
+# 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 errno
+import json
+import os
+import re
+
+from os_brick.remotefs import remotefs
+from oslo_concurrency import processutils as putils
+from oslo_config import cfg
+from oslo_log import log as logging
+from oslo_utils import units
+
+from cinder import exception
+from cinder.i18n import _, _LI
+from cinder.image import image_utils
+from cinder import utils
+from cinder.volume.drivers import remotefs as remotefs_drv
+
+VERSION = '1.0'
+
+LOG = logging.getLogger(__name__)
+
+vzstorage_opts = [
+ cfg.StrOpt('vzstorage_shares_config',
+ default='/etc/cinder/vzstorage_shares',
+ help='File with the list of available vzstorage shares.'),
+ cfg.BoolOpt('vzstorage_sparsed_volumes',
+ default=True,
+ help=('Create volumes as sparsed files which take no space '
+ 'rather than regular files when using raw format, '
+ 'in which case volume creation takes lot of time.')),
+ cfg.FloatOpt('vzstorage_used_ratio',
+ default=0.95,
+ help=('Percent of ACTUAL usage of the underlying volume '
+ 'before no new volumes can be allocated to the volume '
+ 'destination.')),
+ cfg.StrOpt('vzstorage_mount_point_base',
+ default='$state_path/mnt',
+ help=('Base dir containing mount points for '
+ 'vzstorage shares.')),
+ cfg.ListOpt('vzstorage_mount_options',
+ default=None,
+ help=('Mount options passed to the vzstorage client. '
+ 'See section of the pstorage-mount man page '
+ 'for details.')),
+]
+
+CONF = cfg.CONF
+CONF.register_opts(vzstorage_opts)
+
+
+class VZStorageDriver(remotefs_drv.RemoteFSSnapDriver):
+ """Cinder driver for Virtuozzo Storage.
+
+ Creates volumes as files on the mounted vzstorage cluster.
+
+ Version history:
+ 1.0 - Initial driver.
+ """
+ driver_volume_type = 'vzstorage'
+ driver_prefix = 'vzstorage'
+ volume_backend_name = 'Virtuozzo_Storage'
+ VERSION = VERSION
+ SHARE_FORMAT_REGEX = r'(?:(\S+):\/)?([a-zA-Z0-9_-]+)(?::(\S+))?'
+
+ def __init__(self, execute=putils.execute, *args, **kwargs):
+ self._remotefsclient = None
+ super(VZStorageDriver, self).__init__(*args, **kwargs)
+ self.configuration.append_config_values(vzstorage_opts)
+ self._execute_as_root = False
+ root_helper = utils.get_root_helper()
+ # base bound to instance is used in RemoteFsConnector.
+ self.base = getattr(self.configuration,
+ 'vzstorage_mount_point_base',
+ CONF.vzstorage_mount_point_base)
+ opts = getattr(self.configuration,
+ 'vzstorage_mount_options',
+ CONF.vzstorage_mount_options)
+
+ self._remotefsclient = remotefs.RemoteFsClient(
+ 'vzstorage', root_helper, execute=execute,
+ vzstorage_mount_point_base=self.base,
+ vzstorage_mount_options=opts)
+
+ def _qemu_img_info(self, path, volume_name):
+ return super(VZStorageDriver, self)._qemu_img_info_base(
+ path, volume_name, self.configuration.vzstorage_mount_point_base)
+
+ @remotefs_drv.locked_volume_id_operation
+ def initialize_connection(self, volume, connector):
+ """Allow connection to connector and return connection info.
+
+ :param volume: volume reference
+ :param connector: connector reference
+ """
+ # Find active image
+ active_file = self.get_active_image_from_info(volume)
+ active_file_path = os.path.join(self._local_volume_dir(volume),
+ active_file)
+ info = self._qemu_img_info(active_file_path, volume['name'])
+ fmt = info.file_format
+
+ data = {'export': volume['provider_location'],
+ 'format': fmt,
+ 'name': active_file,
+ }
+
+ return {
+ 'driver_volume_type': self.driver_volume_type,
+ 'data': data,
+ 'mount_point_base': self._get_mount_point_base(),
+ }
+
+ def do_setup(self, context):
+ """Any initialization the volume driver does while starting."""
+ super(VZStorageDriver, self).do_setup(context)
+
+ config = self.configuration.vzstorage_shares_config
+ if not os.path.exists(config):
+ msg = (_("VzStorage config file at %(config)s doesn't exist.") %
+ {'config': config})
+ LOG.error(msg)
+ raise exception.VzStorageException(msg)
+
+ if not os.path.isabs(self.base):
+ msg = _("Invalid mount point base: %s.") % self.base
+ LOG.error(msg)
+ raise exception.VzStorageException(msg)
+
+ used_ratio = self.configuration.vzstorage_used_ratio
+ if not ((used_ratio > 0) and (used_ratio <= 1)):
+ msg = _("VzStorage config 'vzstorage_used_ratio' invalid. "
+ "Must be > 0 and <= 1.0: %s.") % used_ratio
+ LOG.error(msg)
+ raise exception.VzStorageException(msg)
+
+ self.shares = {}
+
+ # Check if mount.fuse.pstorage is installed on this system;
+ # note that we don't need to be root to see if the package
+ # is installed.
+ package = 'mount.fuse.pstorage'
+ try:
+ self._execute(package, check_exit_code=False,
+ run_as_root=False)
+ except OSError as exc:
+ if exc.errno == errno.ENOENT:
+ msg = _('%s is not installed.') % package
+ raise exception.VzStorageException(msg)
+ else:
+ raise
+
+ self.configuration.nas_secure_file_operations = 'true'
+ self.configuration.nas_secure_file_permissions = 'true'
+
+ def _ensure_share_mounted(self, share):
+ m = re.search(self.SHARE_FORMAT_REGEX, share)
+ if not m:
+ msg = (_("Invalid Virtuozzo Storage share specification: %r. "
+ "Must be: [MDS1[,MDS2],...:/]<CLUSTER NAME>[:PASSWORD].")
+ % share)
+ raise exception.VzStorageException(msg)
+ cluster_name = m.group(2)
+
+ # set up logging to non-default path, so that it will
+ # be possible to mount the same cluster to another mount
+ # point by hand with default options.
+ mnt_flags = ['-l', '/var/log/pstorage/%s-cinder.log.gz' % cluster_name]
+ if self.shares.get(share) is not None:
+ extra_flags = json.loads(self.shares[share])
+ mnt_flags.extend(extra_flags)
+ self._remotefsclient.mount(share, mnt_flags)
+
+ def _find_share(self, volume_size_in_gib):
+ """Choose VzStorage share among available ones for given volume size.
+
+ For instances with more than one share that meets the criteria, the
+ first suitable share will be selected.
+
+ :param volume_size_in_gib: int size in GB
+ """
+
+ if not self._mounted_shares:
+ raise exception.VzStorageNoSharesMounted()
+
+ for share in self._mounted_shares:
+ if self._is_share_eligible(share, volume_size_in_gib):
+ break
+ else:
+ raise exception.VzStorageNoSuitableShareFound(
+ volume_size=volume_size_in_gib)
+
+ LOG.debug('Selected %s as target VzStorage share.', share)
+
+ return share
+
+ def _is_share_eligible(self, vz_share, volume_size_in_gib):
+ """Verifies VzStorage share is eligible to host volume with given size.
+
+ :param vz_share: vzstorage share
+ :param volume_size_in_gib: int size in GB
+ """
+
+ used_ratio = self.configuration.vzstorage_used_ratio
+ volume_size = volume_size_in_gib * units.Gi
+
+ total_size, available, allocated = self._get_capacity_info(vz_share)
+
+ if (allocated + volume_size) / total_size > used_ratio:
+ LOG.debug('_is_share_eligible: %s is above '
+ 'vzstorage_used_ratio.', vz_share)
+ return False
+
+ return True
+
+ @remotefs_drv.locked_volume_id_operation
+ def extend_volume(self, volume, size_gb):
+ LOG.info(_LI('Extending volume %s.'), volume['id'])
+ self._extend_volume(volume, size_gb)
+
+ def _extend_volume(self, volume, size_gb):
+ volume_path = self.local_path(volume)
+
+ self._check_extend_volume_support(volume, size_gb)
+ LOG.info(_LI('Resizing file to %sG...'), size_gb)
+
+ self._do_extend_volume(volume_path, size_gb)
+
+ def _do_extend_volume(self, volume_path, size_gb):
+ image_utils.resize_image(volume_path, size_gb)
+
+ if not self._is_file_size_equal(volume_path, size_gb):
+ raise exception.ExtendVolumeError(
+ reason='Resizing image file failed.')
+
+ def _check_extend_volume_support(self, volume, size_gb):
+ volume_path = self.local_path(volume)
+ active_file = self.get_active_image_from_info(volume)
+ active_file_path = os.path.join(self._local_volume_dir(volume),
+ active_file)
+
+ if active_file_path != volume_path:
+ msg = _('Extend volume is only supported for this '
+ 'driver when no snapshots exist.')
+ raise exception.InvalidVolume(msg)
+
+ extend_by = int(size_gb) - volume['size']
+ if not self._is_share_eligible(volume['provider_location'],
+ extend_by):
+ raise exception.ExtendVolumeError(reason='Insufficient space to '
+ 'extend volume %s to %sG.'
+ % (volume['id'], size_gb))
+
+ def _is_file_size_equal(self, path, size):
+ """Checks if file size at path is equal to size."""
+ data = image_utils.qemu_img_info(path)
+ virt_size = data.virtual_size / units.Gi
+ return virt_size == size
+
+ def _copy_volume_from_snapshot(self, snapshot, volume, volume_size):
+ """Copy data from snapshot to destination volume.
+
+ This is done with a qemu-img convert to raw/qcow2 from the snapshot
+ qcow2.
+ """
+
+ LOG.debug("_copy_volume_from_snapshot: snapshot: %(snap)s, "
+ "volume: %(vol)s, volume_size: %(size)s.",
+ {'snap': snapshot['id'],
+ 'vol': volume['id'],
+ 'size': volume_size,
+ })
+
+ info_path = self._local_path_volume_info(snapshot['volume'])
+ snap_info = self._read_info_file(info_path)
+ vol_dir = self._local_volume_dir(snapshot['volume'])
+ out_format = "raw"
+
+ forward_file = snap_info[snapshot['id']]
+ forward_path = os.path.join(vol_dir, forward_file)
+
+ # Find the file which backs this file, which represents the point
+ # when this snapshot was created.
+ img_info = self._qemu_img_info(forward_path,
+ snapshot['volume']['name'])
+ path_to_snap_img = os.path.join(vol_dir, img_info.backing_file)
+
+ LOG.debug("_copy_volume_from_snapshot: will copy "
+ "from snapshot at %s.", path_to_snap_img)
+
+ image_utils.convert_image(path_to_snap_img,
+ self.local_path(volume),
+ out_format)
+ self._extend_volume(volume, volume_size)
+
+ @remotefs_drv.locked_volume_id_operation
+ def delete_volume(self, volume):
+ """Deletes a logical volume."""
+ if not volume['provider_location']:
+ msg = (_('Volume %s does not have provider_location '
+ 'specified, skipping.') % volume['name'])
+ LOG.error(msg)
+ raise exception.VzStorageException(msg)
+
+ self._ensure_share_mounted(volume['provider_location'])
+ volume_dir = self._local_volume_dir(volume)
+ mounted_path = os.path.join(volume_dir,
+ self.get_active_image_from_info(volume))
+ if os.path.exists(mounted_path):
+ self._delete(mounted_path)
+ else:
+ LOG.info(_LI("Skipping deletion of volume %s "
+ "as it does not exist."), mounted_path)
+
+ info_path = self._local_path_volume_info(volume)
+ self._delete(info_path)