From e5b1d5111772bb25112753d619a2aa0e1a64ba14 Mon Sep 17 00:00:00 2001 From: Jean-Marc Saffroy Date: Mon, 14 Jan 2013 12:19:52 +0100 Subject: [PATCH] Add a volume driver in Cinder for Scality SOFS Scality SOFS is a network filesystem mounted with FUSE, with most options given in a configuration file. Given a mount point and a SOFS configuration file as driver options, the Scality volume driver mounts SOFS, and then creates, accesses and deletes volumes as regular (sparse) files on SOFS. Change-Id: I914714e8547a505109514e2072f9e258abca8bd4 Implements: blueprint scality-volume-driver --- cinder/tests/test_scality.py | 185 +++++++++++++++++++ cinder/volume/drivers/scality.py | 259 +++++++++++++++++++++++++++ etc/cinder/cinder.conf.sample | 17 +- etc/cinder/rootwrap.d/volume.filters | 4 + 4 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 cinder/tests/test_scality.py create mode 100644 cinder/volume/drivers/scality.py diff --git a/cinder/tests/test_scality.py b/cinder/tests/test_scality.py new file mode 100644 index 000000000..f32261cc8 --- /dev/null +++ b/cinder/tests/test_scality.py @@ -0,0 +1,185 @@ +# Copyright (c) 2013 Scality +# +# 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. + +""" +Unit tests for the Scality SOFS Volume Driver. +""" + +import errno +import os + +from cinder import exception +from cinder import test +from cinder import utils +from cinder.volume.drivers import scality + + +class ScalityDriverTestCase(test.TestCase): + """Test case for the Scality driver.""" + + TEST_MOUNT = '/tmp/fake_mount' + TEST_CONFIG = '/tmp/fake_config' + TEST_VOLDIR = 'volumes' + + TEST_VOLNAME = 'volume_name' + TEST_VOLSIZE = '0' + TEST_VOLUME = { + 'name': TEST_VOLNAME, + 'size': TEST_VOLSIZE + } + TEST_VOLPATH = os.path.join(TEST_MOUNT, + TEST_VOLDIR, + TEST_VOLNAME) + + TEST_SNAPNAME = 'snapshot_name' + TEST_SNAPSHOT = { + 'name': TEST_SNAPNAME, + 'volume_name': TEST_VOLNAME, + 'volume_size': TEST_VOLSIZE + } + TEST_SNAPPATH = os.path.join(TEST_MOUNT, + TEST_VOLDIR, + TEST_SNAPNAME) + + def _makedirs(self, path): + try: + os.makedirs(path) + except OSError as e: + if e.errno != errno.EEXIST: + raise e + + def _create_fake_config(self): + open(self.TEST_CONFIG, "w+").close() + + def _create_fake_mount(self): + self._makedirs(os.path.join(self.TEST_MOUNT, 'sys')) + self._makedirs(os.path.join(self.TEST_MOUNT, self.TEST_VOLDIR)) + + def _remove_fake_mount(self): + utils.execute('rm', '-rf', self.TEST_MOUNT) + + def _remove_fake_config(self): + try: + os.unlink(self.TEST_CONFIG) + except OSError as e: + if e.errno != errno.ENOENT: + raise e + + def _configure_driver(self): + scality.FLAGS.scality_sofs_config = self.TEST_CONFIG + scality.FLAGS.scality_sofs_mount_point = self.TEST_MOUNT + scality.FLAGS.scality_sofs_volume_dir = self.TEST_VOLDIR + + def _execute_wrapper(self, cmd, *args, **kwargs): + try: + kwargs.pop('run_as_root') + except KeyError: + pass + utils.execute(cmd, *args, **kwargs) + + def _set_access_wrapper(self, is_visible): + + def _access_wrapper(path, flags): + if path == '/sbin/mount.sofs': + return is_visible + else: + return os.access(path, flags) + + self.stubs.Set(os, 'access', _access_wrapper) + + def setUp(self): + super(ScalityDriverTestCase, self).setUp() + + self._remove_fake_mount() + self._driver = scality.ScalityDriver() + self._driver.set_execute(self._execute_wrapper) + + self._create_fake_mount() + self._create_fake_config() + self._configure_driver() + + def tearDown(self): + self._remove_fake_mount() + self._remove_fake_config() + super(ScalityDriverTestCase, self).tearDown() + + def test_setup_no_config(self): + """Missing SOFS configuration shall raise an error.""" + scality.FLAGS.scality_sofs_config = None + self.assertRaises(exception.VolumeBackendAPIException, + self._driver.do_setup, None) + + def test_setup_missing_config(self): + """Non-existent SOFS configuration file shall raise an error.""" + scality.FLAGS.scality_sofs_config = 'nonexistent.conf' + self.assertRaises(exception.VolumeBackendAPIException, + self._driver.do_setup, None) + + def test_setup_no_mount_helper(self): + """SOFS must be installed to use the driver.""" + self._set_access_wrapper(False) + self.assertRaises(exception.VolumeBackendAPIException, + self._driver.do_setup, None) + + def test_setup_make_voldir(self): + """The directory for volumes shall be created automatically.""" + self._set_access_wrapper(True) + voldir_path = os.path.join(self.TEST_MOUNT, self.TEST_VOLDIR) + os.rmdir(voldir_path) + self._driver.do_setup(None) + self.assertTrue(os.path.isdir(voldir_path)) + + def test_local_path(self): + """Expected behaviour for local_path.""" + self.assertEqual(self._driver.local_path(self.TEST_VOLUME), + self.TEST_VOLPATH) + + def test_create_volume(self): + """Expected behaviour for create_volume.""" + ret = self._driver.create_volume(self.TEST_VOLUME) + self.assertEqual(ret['provider_location'], + os.path.join(self.TEST_VOLDIR, + self.TEST_VOLNAME)) + self.assertTrue(os.path.isfile(self.TEST_VOLPATH)) + self.assertEqual(os.stat(self.TEST_VOLPATH).st_size, + 100 * 1024 * 1024) + + def test_delete_volume(self): + """Expected behaviour for delete_volume.""" + self._driver.create_volume(self.TEST_VOLUME) + self._driver.delete_volume(self.TEST_VOLUME) + self.assertFalse(os.path.isfile(self.TEST_VOLPATH)) + + def test_create_snapshot(self): + """Expected behaviour for create_snapshot.""" + self._driver.create_volume(self.TEST_VOLUME) + self._driver.create_snapshot(self.TEST_SNAPSHOT) + self.assertTrue(os.path.isfile(self.TEST_SNAPPATH)) + self.assertEqual(os.stat(self.TEST_SNAPPATH).st_size, + 100 * 1024 * 1024) + + def test_delete_snapshot(self): + """Expected behaviour for delete_snapshot.""" + self._driver.create_volume(self.TEST_VOLUME) + self._driver.create_snapshot(self.TEST_SNAPSHOT) + self._driver.delete_snapshot(self.TEST_SNAPSHOT) + self.assertFalse(os.path.isfile(self.TEST_SNAPPATH)) + + def test_initialize_connection(self): + """Expected behaviour for initialize_connection.""" + ret = self._driver.initialize_connection(self.TEST_VOLUME, None) + self.assertEqual(ret['driver_volume_type'], 'scality') + self.assertEqual(ret['data']['sofs_path'], + os.path.join(self.TEST_VOLDIR, + self.TEST_VOLNAME)) diff --git a/cinder/volume/drivers/scality.py b/cinder/volume/drivers/scality.py new file mode 100644 index 000000000..67574f83b --- /dev/null +++ b/cinder/volume/drivers/scality.py @@ -0,0 +1,259 @@ +# Copyright (c) 2013 Scality +# +# 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. + +""" +Scality SOFS Volume Driver. +""" + +import errno +import os +import urllib2 +import urlparse + +from cinder import exception +from cinder import flags +from cinder.image import image_utils +from cinder.openstack.common import cfg +from cinder.openstack.common import log as logging +from cinder.volume import driver + +LOG = logging.getLogger(__name__) + +volume_opts = [ + cfg.StrOpt('scality_sofs_config', + default=None, + help='Path or URL to Scality SOFS configuration file'), + cfg.StrOpt('scality_sofs_mount_point', + default='$state_path/scality', + help='Base dir where Scality SOFS shall be mounted'), + cfg.StrOpt('scality_sofs_volume_dir', + default='cinder/volumes', + help='Path from Scality SOFS root to volume dir'), +] + +FLAGS = flags.FLAGS +FLAGS.register_opts(volume_opts) + + +class ScalityDriver(driver.VolumeDriver): + """Scality SOFS cinder driver. + + Creates sparse files on SOFS for hypervisors to use as block + devices. + """ + + def _check_prerequisites(self): + """Sanity checks before attempting to mount SOFS.""" + + # config is mandatory + config = FLAGS.scality_sofs_config + if not config: + msg = _("Value required for 'scality_sofs_config'") + LOG.warn(msg) + raise exception.VolumeBackendAPIException(data=msg) + + # config can be a file path or a URL, check it + if urlparse.urlparse(config).scheme == '': + # turn local path into URL + config = 'file://%s' % config + try: + urllib2.urlopen(config, timeout=5).close() + except urllib2.URLError as e: + msg = _("Cannot access 'scality_sofs_config': %s") % e + LOG.warn(msg) + raise exception.VolumeBackendAPIException(data=msg) + + # mount.sofs must be installed + if not os.access('/sbin/mount.sofs', os.X_OK): + msg = _("Cannot execute /sbin/mount.sofs") + LOG.warn(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def _makedirs(self, path): + try: + os.makedirs(path) + except OSError as e: + if e.errno != errno.EEXIST: + raise e + + def _mount_sofs(self): + config = FLAGS.scality_sofs_config + mount_path = FLAGS.scality_sofs_mount_point + sysdir = os.path.join(mount_path, 'sys') + + self._makedirs(mount_path) + if not os.path.isdir(sysdir): + self._execute('mount', '-t', 'sofs', config, mount_path, + run_as_root=True) + if not os.path.isdir(sysdir): + msg = _("Cannot mount Scality SOFS, check syslog for errors") + LOG.warn(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def _size_bytes(self, size_in_g): + if int(size_in_g) == 0: + return 100 * 1024 * 1024 + return int(size_in_g) * 1024 * 1024 * 1024 + + def _create_file(self, path, size): + with open(path, "ab") as f: + f.truncate(size) + os.chmod(path, 0666) + + def _copy_file(self, src_path, dest_path): + self._execute('dd', 'if=%s' % src_path, 'of=%s' % dest_path, + 'bs=1M', 'conv=fsync,nocreat,notrunc', + run_as_root=True) + + def do_setup(self, context): + """Any initialization the volume driver does while starting.""" + self._check_prerequisites() + self._mount_sofs() + voldir = os.path.join(FLAGS.scality_sofs_mount_point, + FLAGS.scality_sofs_volume_dir) + if not os.path.isdir(voldir): + self._makedirs(voldir) + + def check_for_setup_error(self): + """Returns an error if prerequisites aren't met.""" + self._check_prerequisites() + voldir = os.path.join(FLAGS.scality_sofs_mount_point, + FLAGS.scality_sofs_volume_dir) + if not os.path.isdir(voldir): + msg = _("Cannot find volume dir for Scality SOFS at '%s'") % voldir + LOG.warn(msg) + raise exception.VolumeBackendAPIException(data=msg) + + def create_volume(self, volume): + """Creates a logical volume. + + Can optionally return a Dictionary of changes to the volume + object to be persisted. + """ + self._create_file(self.local_path(volume), + self._size_bytes(volume['size'])) + volume['provider_location'] = self._sofs_path(volume) + return {'provider_location': volume['provider_location']} + + def create_volume_from_snapshot(self, volume, snapshot): + """Creates a volume from a snapshot.""" + changes = self.create_volume(volume) + self._copy_file(self.local_path(snapshot), + self.local_path(volume)) + return changes + + def delete_volume(self, volume): + """Deletes a logical volume.""" + os.remove(self.local_path(volume)) + + def create_snapshot(self, snapshot): + """Creates a snapshot.""" + volume_path = os.path.join(FLAGS.scality_sofs_mount_point, + FLAGS.scality_sofs_volume_dir, + snapshot['volume_name']) + snapshot_path = self.local_path(snapshot) + self._create_file(snapshot_path, + self._size_bytes(snapshot['volume_size'])) + self._copy_file(volume_path, snapshot_path) + + def delete_snapshot(self, snapshot): + """Deletes a snapshot.""" + os.remove(self.local_path(snapshot)) + + def _sofs_path(self, volume): + return os.path.join(FLAGS.scality_sofs_volume_dir, + volume['name']) + + def local_path(self, volume): + return os.path.join(FLAGS.scality_sofs_mount_point, + self._sofs_path(volume)) + + def ensure_export(self, context, volume): + """Synchronously recreates an export for a logical volume.""" + pass + + def create_export(self, context, volume): + """Exports the volume. + + Can optionally return a Dictionary of changes to the volume + object to be persisted. + """ + pass + + def remove_export(self, context, volume): + """Removes an export for a logical volume.""" + pass + + def initialize_connection(self, volume, connector): + """Allow connection to connector and return connection info.""" + return { + 'driver_volume_type': 'scality', + 'data': { + 'sofs_path': self._sofs_path(volume), + } + } + + def terminate_connection(self, volume, connector, force=False, **kwargs): + """Disallow connection from connector.""" + pass + + def attach_volume(self, context, volume_id, instance_uuid, mountpoint): + """ Callback for volume attached to instance.""" + pass + + def detach_volume(self, context, volume_id): + """ Callback for volume detached.""" + pass + + def get_volume_stats(self, refresh=False): + """Return the current state of the volume service. + + If 'refresh' is True, run the update first. + """ + stats = { + 'volume_backend_name': 'Scality_SOFS', + 'vendor_name': 'Scality', + 'driver_version': '1.0', + 'storage_protocol': 'scality', + 'total_capacity_gb': 'infinite', + 'free_capacity_gb': 'infinite', + 'reserved_percentage': 0, + } + return stats + + def copy_image_to_volume(self, context, volume, image_service, image_id): + """Fetch the image from image_service and write it to the volume.""" + image_utils.fetch_to_raw(context, + image_service, + image_id, + self.local_path(volume)) + self.create_volume(volume) + + def copy_volume_to_image(self, context, volume, image_service, image_meta): + """Copy the volume to the specified image.""" + image_utils.upload_volume(context, + image_service, + image_meta, + self.local_path(volume)) + + def clone_image(self, volume, image_location): + """Create a volume efficiently from an existing image. + + image_location is a string whose format depends on the + image service backend in use. The driver should use it + to determine whether cloning is possible. + + Returns a boolean indicating whether cloning occurred + """ + return False diff --git a/etc/cinder/cinder.conf.sample b/etc/cinder/cinder.conf.sample index f5b0d0bb2..2156560ef 100644 --- a/etc/cinder/cinder.conf.sample +++ b/etc/cinder/cinder.conf.sample @@ -977,6 +977,21 @@ #san_zfs_volume_base=rpool/ +# +# Options defined in cinder.volume.drivers.scality +# + +# Path or URL to Scality SOFS configuration file (string +# value) +#scality_sofs_config= + +# Base dir where Scality SOFS shall be mounted (string value) +#scality_sofs_mount_point=$state_path/scality + +# Path from Scality SOFS root to volume dir (string value) +#scality_sofs_volume_dir=cinder/volumes + + # # Options defined in cinder.volume.drivers.solidfire # @@ -1144,4 +1159,4 @@ #volume_driver=cinder.volume.driver.FakeISCSIDriver -# Total option count: 251 +# Total option count: 254 diff --git a/etc/cinder/rootwrap.d/volume.filters b/etc/cinder/rootwrap.d/volume.filters index 3a22fa9cb..d5c954b8d 100644 --- a/etc/cinder/rootwrap.d/volume.filters +++ b/etc/cinder/rootwrap.d/volume.filters @@ -54,3 +54,7 @@ truncate: CommandFilter, /usr/bin/truncate, root chmod: CommandFilter, /bin/chmod, root rm: CommandFilter, /bin/rm, root lvs: CommandFilter, /sbin/lvs, root + +# cinder/volume/scality.py +mount: CommandFilter, /bin/mount, root +dd: CommandFilter, /bin/dd, root -- 2.45.2