From 949291c3e59ea922cf6acb6a23ade5fcd65087d0 Mon Sep 17 00:00:00 2001 From: Avishay Traeger Date: Wed, 16 Jan 2013 16:19:40 +0200 Subject: [PATCH] Generic iSCSI copy volume<->image. Implements a generic version of copy_volume_to_image and copy_image_to_volume for iSCSI drivers. Change-Id: Iff097629bcce9154829a7eb5aee0ea6302338b26 Implements: blueprint generic-iscsi-copy-vol-image --- cinder/image/image_utils.py | 42 +++++++ cinder/tests/test_volume.py | 31 +++-- cinder/tests/test_volume_rpcapi.py | 5 +- cinder/volume/api.py | 20 +++- cinder/volume/driver.py | 124 +++++++++++++++++++- cinder/volume/drivers/emc/emc_smis_iscsi.py | 5 +- cinder/volume/drivers/lvm.py | 10 +- cinder/volume/drivers/netapp.py | 2 +- cinder/volume/drivers/nexenta/volume.py | 2 +- cinder/volume/drivers/san/san.py | 8 -- cinder/volume/drivers/windows.py | 2 +- cinder/volume/drivers/xenapi/sm.py | 2 +- cinder/volume/drivers/zadara.py | 2 +- cinder/volume/manager.py | 21 ++-- cinder/volume/rpcapi.py | 8 +- etc/cinder/rootwrap.d/volume.filters | 3 + 16 files changed, 240 insertions(+), 47 deletions(-) diff --git a/cinder/image/image_utils.py b/cinder/image/image_utils.py index f0d947f12..dad7d42d8 100644 --- a/cinder/image/image_utils.py +++ b/cinder/image/image_utils.py @@ -208,6 +208,10 @@ def fetch_to_raw(context, image_service, os.path.exists(FLAGS.image_conversion_dir)): os.makedirs(FLAGS.image_conversion_dir) + # NOTE(avishay): I'm not crazy about creating temp files which may be + # large and cause disk full errors which would confuse users. + # Unfortunately it seems that you can't pipe to 'qemu-img convert' because + # it seeks. Maybe we can think of something for a future version. fd, tmp = tempfile.mkstemp(dir=FLAGS.image_conversion_dir) os.close(fd) with utils.remove_path_on_error(tmp): @@ -229,6 +233,11 @@ def fetch_to_raw(context, image_service, # NOTE(jdg): I'm using qemu-img convert to write # to the volume regardless if it *needs* conversion or not + # TODO(avishay): We can speed this up by checking if the image is raw + # and if so, writing directly to the device. However, we need to keep + # check via 'qemu-img info' that what we copied was in fact a raw + # image and not a different format with a backing file, which may be + # malicious. LOG.debug("%s was %s, converting to raw" % (image_id, fmt)) convert_image(tmp, dest, 'raw') @@ -239,3 +248,36 @@ def fetch_to_raw(context, image_service, reason=_("Converted to raw, but format is now %s") % data.file_format) os.unlink(tmp) + + +def upload_volume(context, image_service, image_meta, volume_path): + image_id = image_meta['id'] + if (image_meta['disk_format'] == 'raw'): + LOG.debug("%s was raw, no need to convert to %s" % + (image_id, image_meta['disk_format'])) + with utils.temporary_chown(volume_path): + with utils.file_open(volume_path) as image_file: + image_service.update(context, image_id, {}, image_file) + return + + if (FLAGS.image_conversion_dir and not + os.path.exists(FLAGS.image_conversion_dir)): + os.makedirs(FLAGS.image_conversion_dir) + + fd, tmp = tempfile.mkstemp(dir=FLAGS.image_conversion_dir) + os.close(fd) + with utils.remove_path_on_error(tmp): + LOG.debug("%s was raw, converting to %s" % + (image_id, image_meta['disk_format'])) + convert_image(volume_path, tmp, image_meta['disk_format']) + + data = qemu_img_info(tmp) + if data.file_format != image_meta['disk_format']: + raise exception.ImageUnacceptable( + image_id=image_id, + reason=_("Converted to %(f1)s, but format is now %(f2)s") % + {'f1': image_meta['disk_format'], 'f2': data.file_format}) + + with utils.file_open(tmp) as image_file: + image_service.update(context, image_id, {}, image_file) + os.unlink(tmp) diff --git a/cinder/tests/test_volume.py b/cinder/tests/test_volume.py index 98b634db1..1f35c43f4 100644 --- a/cinder/tests/test_volume.py +++ b/cinder/tests/test_volume.py @@ -594,7 +594,11 @@ class VolumeTestCase(test.TestCase): self.stubs.Set(self.volume.driver, 'local_path', fake_local_path) - image_id = '70a599e0-31e7-49b7-b260-868f441e862b' + image_meta = { + 'id': '70a599e0-31e7-49b7-b260-868f441e862b', + 'container_format': 'bare', + 'disk_format': 'raw'} + # creating volume testdata volume_id = 1 db.volume_create(self.context, @@ -610,7 +614,7 @@ class VolumeTestCase(test.TestCase): # start test self.volume.copy_volume_to_image(self.context, volume_id, - image_id) + image_meta) volume = db.volume_get(self.context, volume_id) self.assertEqual(volume['status'], 'available') @@ -628,8 +632,10 @@ class VolumeTestCase(test.TestCase): self.stubs.Set(self.volume.driver, 'local_path', fake_local_path) - #image_id = '70a599e0-31e7-49b7-b260-868f441e862b' - image_id = 'a440c04b-79fa-479c-bed1-0b816eaec379' + image_meta = { + 'id': 'a440c04b-79fa-479c-bed1-0b816eaec379', + 'container_format': 'bare', + 'disk_format': 'raw'} # creating volume testdata volume_id = 1 db.volume_create( @@ -646,7 +652,7 @@ class VolumeTestCase(test.TestCase): # start test self.volume.copy_volume_to_image(self.context, volume_id, - image_id) + image_meta) volume = db.volume_get(self.context, volume_id) self.assertEqual(volume['status'], 'in-use') @@ -664,7 +670,10 @@ class VolumeTestCase(test.TestCase): self.stubs.Set(self.volume.driver, 'local_path', fake_local_path) - image_id = 'aaaaaaaa-0000-0000-0000-000000000000' + image_meta = { + 'id': 'aaaaaaaa-0000-0000-0000-000000000000', + 'container_format': 'bare', + 'disk_format': 'raw'} # creating volume testdata volume_id = 1 db.volume_create(self.context, @@ -681,7 +690,7 @@ class VolumeTestCase(test.TestCase): self.volume.copy_volume_to_image, self.context, volume_id, - image_id) + image_meta) volume = db.volume_get(self.context, volume_id) self.assertEqual(volume['status'], 'available') @@ -698,7 +707,9 @@ class VolumeTestCase(test.TestCase): pass def show(self, context, image_id): - return {'size': 2 * 1024 * 1024 * 1024} + return {'size': 2 * 1024 * 1024 * 1024, + 'disk_format': 'raw', + 'container_format': 'bare'} image_id = '70a599e0-31e7-49b7-b260-868f441e862b' @@ -722,7 +733,9 @@ class VolumeTestCase(test.TestCase): pass def show(self, context, image_id): - return {'size': 2 * 1024 * 1024 * 1024 + 1} + return {'size': 2 * 1024 * 1024 * 1024 + 1, + 'disk_format': 'raw', + 'container_format': 'bare'} image_id = '70a599e0-31e7-49b7-b260-868f441e862b' diff --git a/cinder/tests/test_volume_rpcapi.py b/cinder/tests/test_volume_rpcapi.py index 0b785387d..5fdc8092a 100644 --- a/cinder/tests/test_volume_rpcapi.py +++ b/cinder/tests/test_volume_rpcapi.py @@ -150,7 +150,10 @@ class VolumeRpcAPITestCase(test.TestCase): self._test_volume_api('copy_volume_to_image', rpc_method='cast', volume=self.fake_volume, - image_id='fake_image_id') + image_meta={'id': 'fake_image_id', + 'container_format': 'fake_type', + 'disk_format': 'fake_type'}, + version='1.3') def test_initialize_connection(self): self._test_volume_api('initialize_connection', diff --git a/cinder/volume/api.py b/cinder/volume/api.py index 050060ad3..0bba3a336 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -144,6 +144,14 @@ class API(base.Base): if image_size_in_gb > size: msg = _('Size of specified image is larger than volume size.') raise exception.InvalidInput(reason=msg) + #We use qemu-img to convert images to raw and so we can only + #support the intersection of what qemu-img and glance support + if (image_meta['container_format'] != 'bare' or + image_meta['disk_format'] not in ['raw', 'qcow2', + 'vmdk', 'vdi']): + msg = (_("Image format must be one of raw, qcow2, " + "vmdk, or vdi.")) + raise exception.InvalidInput(reason=msg) try: reservations = QUOTAS.reserve(context, volumes=1, gigabytes=size) @@ -592,11 +600,21 @@ class API(base.Base): """Create a new image from the specified volume.""" self._check_volume_availability(context, volume, force) + #We use qemu-img to convert raw images to the requested type + #and so we can only support the intersection of what qemu-img and + #glance support + if (metadata['container_format'] != 'bare' or + metadata['disk_format'] not in ['raw', 'qcow2', + 'vmdk', 'vdi']): + msg = (_("Image format must be one of raw, qcow2, " + "vmdk, or vdi.")) + raise exception.InvalidInput(reason=msg) + recv_metadata = self.image_service.create(context, metadata) self.update(context, volume, {'status': 'uploading'}) self.volume_rpcapi.copy_volume_to_image(context, volume, - recv_metadata['id']) + recv_metadata) response = {"id": volume['id'], "updated_at": volume['updated_at'], diff --git a/cinder/volume/driver.py b/cinder/volume/driver.py index 2e4de3648..5c5677be4 100644 --- a/cinder/volume/driver.py +++ b/cinder/volume/driver.py @@ -20,10 +20,12 @@ Drivers for volumes. """ +import os import time 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 import utils @@ -172,7 +174,7 @@ class VolumeDriver(object): """Fetch the image from image_service and write it to the volume.""" raise NotImplementedError() - def copy_volume_to_image(self, context, volume, image_service, image_id): + def copy_volume_to_image(self, context, volume, image_service, image_meta): """Copy the volume to the specified image.""" raise NotImplementedError() @@ -287,19 +289,22 @@ class ISCSIDriver(VolumeDriver): return properties - def _run_iscsiadm(self, iscsi_properties, iscsi_command): + def _run_iscsiadm(self, iscsi_properties, iscsi_command, **kwargs): + check_exit_code = kwargs.pop('check_exit_code', 0) (out, err) = self._execute('iscsiadm', '-m', 'node', '-T', iscsi_properties['target_iqn'], '-p', iscsi_properties['target_portal'], - *iscsi_command, run_as_root=True) + *iscsi_command, run_as_root=True, + check_exit_code=check_exit_code) LOG.debug("iscsiadm %s: stdout=%s stderr=%s" % (iscsi_command, out, err)) return (out, err) - def _iscsiadm_update(self, iscsi_properties, property_key, property_value): + def _iscsiadm_update(self, iscsi_properties, property_key, property_value, + **kwargs): iscsi_command = ('--op', 'update', '-n', property_key, '-v', property_value) - return self._run_iscsiadm(iscsi_properties, iscsi_command) + return self._run_iscsiadm(iscsi_properties, iscsi_command, **kwargs) def initialize_connection(self, volume, connector): """Initializes the connection and returns connection info. @@ -329,6 +334,115 @@ class ISCSIDriver(VolumeDriver): def terminate_connection(self, volume, connector, **kwargs): pass + def _get_iscsi_initiator(self): + """Get iscsi initiator name for this machine""" + # NOTE openiscsi stores initiator name in a file that + # needs root permission to read. + contents = utils.read_file_as_root('/etc/iscsi/initiatorname.iscsi') + for l in contents.split('\n'): + if l.startswith('InitiatorName='): + return l[l.index('=') + 1:].strip() + + def copy_image_to_volume(self, context, volume, image_service, image_id): + """Fetch the image from image_service and write it to the volume.""" + LOG.debug(_('copy_image_to_volume %s.') % volume['name']) + initiator = self._get_iscsi_initiator() + connector = {} + connector['initiator'] = initiator + + iscsi_properties, volume_path = self._attach_volume( + context, volume, connector) + + try: + image_utils.fetch_to_raw(context, + image_service, + image_id, + volume_path) + finally: + self.terminate_connection(volume, connector) + + def copy_volume_to_image(self, context, volume, image_service, image_meta): + """Copy the volume to the specified image.""" + LOG.debug(_('copy_volume_to_image %s.') % volume['name']) + initiator = self._get_iscsi_initiator() + connector = {} + connector['initiator'] = initiator + + iscsi_properties, volume_path = self._attach_volume( + context, volume, connector) + + try: + image_utils.upload_volume(context, + image_service, + image_meta, + volume_path) + finally: + self.terminate_connection(volume, connector) + + def _attach_volume(self, context, volume, connector): + """Attach the volume.""" + iscsi_properties = None + host_device = None + init_conn = self.initialize_connection(volume, connector) + iscsi_properties = init_conn['data'] + + # code "inspired by" nova/virt/libvirt/volume.py + try: + self._run_iscsiadm(iscsi_properties, ()) + except exception.ProcessExecutionError as exc: + # iscsiadm returns 21 for "No records found" after version 2.0-871 + if exc.exit_code in [21, 255]: + self._run_iscsiadm(iscsi_properties, ('--op', 'new')) + else: + raise + + if iscsi_properties.get('auth_method'): + self._iscsiadm_update(iscsi_properties, + "node.session.auth.authmethod", + iscsi_properties['auth_method']) + self._iscsiadm_update(iscsi_properties, + "node.session.auth.username", + iscsi_properties['auth_username']) + self._iscsiadm_update(iscsi_properties, + "node.session.auth.password", + iscsi_properties['auth_password']) + + # NOTE(vish): If we have another lun on the same target, we may + # have a duplicate login + self._run_iscsiadm(iscsi_properties, ("--login",), + check_exit_code=[0, 255]) + + self._iscsiadm_update(iscsi_properties, "node.startup", "automatic") + + host_device = ("/dev/disk/by-path/ip-%s-iscsi-%s-lun-%s" % + (iscsi_properties['target_portal'], + iscsi_properties['target_iqn'], + iscsi_properties.get('target_lun', 0))) + + tries = 0 + while not os.path.exists(host_device): + if tries >= FLAGS.num_iscsi_scan_tries: + raise exception.CinderException( + _("iSCSI device not found at %s") % (host_device)) + + LOG.warn(_("ISCSI volume not yet found at: %(host_device)s. " + "Will rescan & retry. Try number: %(tries)s") % + locals()) + + # The rescan isn't documented as being necessary(?), but it helps + self._run_iscsiadm(iscsi_properties, ("--rescan")) + + tries = tries + 1 + if not os.path.exists(host_device): + time.sleep(tries ** 2) + + if tries != 0: + LOG.debug(_("Found iSCSI node %(host_device)s " + "(after %(tries)s rescans)") % + locals()) + + return iscsi_properties, host_device + class FakeISCSIDriver(ISCSIDriver): """Logs calls instead of executing.""" diff --git a/cinder/volume/drivers/emc/emc_smis_iscsi.py b/cinder/volume/drivers/emc/emc_smis_iscsi.py index 0ffe0e42b..936d17e70 100644 --- a/cinder/volume/drivers/emc/emc_smis_iscsi.py +++ b/cinder/volume/drivers/emc/emc_smis_iscsi.py @@ -269,7 +269,7 @@ class EMCSMISISCSIDriver(driver.ISCSIDriver): return iscsi_properties, host_device - def copy_volume_to_image(self, context, volume, image_service, image_id): + def copy_volume_to_image(self, context, volume, image_service, image_meta): """Copy the volume to the specified image.""" LOG.debug(_('copy_volume_to_image %s.') % volume['name']) initiator = get_iscsi_initiator() @@ -281,7 +281,8 @@ class EMCSMISISCSIDriver(driver.ISCSIDriver): with utils.temporary_chown(volume_path): with utils.file_open(volume_path) as volume_file: - image_service.update(context, image_id, {}, volume_file) + image_service.update(context, image_meta['id'], {}, + volume_file) self.terminate_connection(volume, connector) diff --git a/cinder/volume/drivers/lvm.py b/cinder/volume/drivers/lvm.py index b3bde19b8..bc353cbff 100644 --- a/cinder/volume/drivers/lvm.py +++ b/cinder/volume/drivers/lvm.py @@ -237,12 +237,12 @@ class LVMVolumeDriver(driver.VolumeDriver): image_id, self.local_path(volume)) - def copy_volume_to_image(self, context, volume, image_service, image_id): + def copy_volume_to_image(self, context, volume, image_service, image_meta): """Copy the volume to the specified image.""" - volume_path = self.local_path(volume) - with utils.temporary_chown(volume_path): - with utils.file_open(volume_path) as volume_file: - image_service.update(context, image_id, {}, volume_file) + image_utils.upload_volume(context, + image_service, + image_meta, + self.local_path(volume)) def clone_image(self, volume, image_location): return False diff --git a/cinder/volume/drivers/netapp.py b/cinder/volume/drivers/netapp.py index 3ce77c5c2..eb613cb76 100644 --- a/cinder/volume/drivers/netapp.py +++ b/cinder/volume/drivers/netapp.py @@ -1307,7 +1307,7 @@ class NetAppCmodeISCSIDriver(driver.ISCSIDriver): """Fetch the image from image_service and write it to the volume.""" raise NotImplementedError() - def copy_volume_to_image(self, context, volume, image_service, image_id): + def copy_volume_to_image(self, context, volume, image_service, image_meta): """Copy the volume to the specified image.""" raise NotImplementedError() diff --git a/cinder/volume/drivers/nexenta/volume.py b/cinder/volume/drivers/nexenta/volume.py index a384f3427..5af2084e9 100644 --- a/cinder/volume/drivers/nexenta/volume.py +++ b/cinder/volume/drivers/nexenta/volume.py @@ -284,7 +284,7 @@ class NexentaDriver(driver.ISCSIDriver): # pylint: disable=R0921 """Fetch the image from image_service and write it to the volume.""" raise NotImplementedError() - def copy_volume_to_image(self, context, volume, image_service, image_id): + def copy_volume_to_image(self, context, volume, image_service, image_meta): """Copy the volume to the specified image.""" raise NotImplementedError() diff --git a/cinder/volume/drivers/san/san.py b/cinder/volume/drivers/san/san.py index ec5f65195..b6b442c16 100644 --- a/cinder/volume/drivers/san/san.py +++ b/cinder/volume/drivers/san/san.py @@ -154,14 +154,6 @@ class SanISCSIDriver(ISCSIDriver): if not FLAGS.san_ip: raise exception.InvalidInput(reason=_("san_ip must be set")) - def copy_image_to_volume(self, context, volume, image_service, image_id): - """Fetch the image from image_service and write it to the volume.""" - raise NotImplementedError() - - def copy_volume_to_image(self, context, volume, image_service, image_id): - """Copy the volume to the specified image.""" - raise NotImplementedError() - def create_cloned_volume(self, volume, src_vref): """Create a cloen of the specified volume.""" raise NotImplementedError() diff --git a/cinder/volume/drivers/windows.py b/cinder/volume/drivers/windows.py index ac6a531d6..4c3a07026 100644 --- a/cinder/volume/drivers/windows.py +++ b/cinder/volume/drivers/windows.py @@ -239,6 +239,6 @@ class WindowsDriver(driver.ISCSIDriver): """Fetch the image from image_service and write it to the volume.""" raise NotImplementedError() - def copy_volume_to_image(self, context, volume, image_service, image_id): + def copy_volume_to_image(self, context, volume, image_service, image_meta): """Copy the volume to the specified image.""" raise NotImplementedError() diff --git a/cinder/volume/drivers/xenapi/sm.py b/cinder/volume/drivers/xenapi/sm.py index 6c1709152..337a3d851 100644 --- a/cinder/volume/drivers/xenapi/sm.py +++ b/cinder/volume/drivers/xenapi/sm.py @@ -144,5 +144,5 @@ class XenAPINFSDriver(driver.VolumeDriver): def copy_image_to_volume(self, context, volume, image_service, image_id): raise NotImplementedError() - def copy_volume_to_image(self, context, volume, image_service, image_id): + def copy_volume_to_image(self, context, volume, image_service, image_meta): raise NotImplementedError() diff --git a/cinder/volume/drivers/zadara.py b/cinder/volume/drivers/zadara.py index 912b67117..6af373c15 100644 --- a/cinder/volume/drivers/zadara.py +++ b/cinder/volume/drivers/zadara.py @@ -483,7 +483,7 @@ class ZadaraVPSAISCSIDriver(driver.ISCSIDriver): """Fetch the image from image_service and write it to the volume.""" raise NotImplementedError() - def copy_volume_to_image(self, context, volume, image_service, image_id): + def copy_volume_to_image(self, context, volume, image_service, image_meta): """Copy the volume to the specified image.""" raise NotImplementedError() diff --git a/cinder/volume/manager.py b/cinder/volume/manager.py index 72e7cd396..3d9bf8d97 100644 --- a/cinder/volume/manager.py +++ b/cinder/volume/manager.py @@ -103,7 +103,7 @@ MAPPING = { class VolumeManager(manager.SchedulerDependentManager): """Manages attachable block storage devices.""" - RPC_API_VERSION = '1.2' + RPC_API_VERSION = '1.3' def __init__(self, volume_driver=None, *args, **kwargs): """Load the driver from the one specified in args, or from flags.""" @@ -234,7 +234,7 @@ class VolumeManager(manager.SchedulerDependentManager): volume_ref['id'], key, value) - #copy the image onto the volume. + # Copy the image onto the volume. self._copy_image_to_volume(context, volume_ref, image_id) self._notify_about_volume_usage(context, volume_ref, "create.end") return volume_ref['id'] @@ -421,16 +421,21 @@ class VolumeManager(manager.SchedulerDependentManager): payload['message'] = unicode(error) self.db.volume_update(context, volume_id, {'status': 'error'}) - def copy_volume_to_image(self, context, volume_id, image_id): - """Uploads the specified volume to Glance.""" - payload = {'volume_id': volume_id, 'image_id': image_id} + def copy_volume_to_image(self, context, volume_id, image_meta): + """Uploads the specified volume to Glance. + + image_meta is a dictionary containing the following keys: + 'id', 'container_format', 'disk_format' + + """ + payload = {'volume_id': volume_id, 'image_id': image_meta['id']} try: volume = self.db.volume_get(context, volume_id) self.driver.ensure_export(context.elevated(), volume) - image_service, image_id = glance.get_remote_image_service(context, - image_id) + image_service, image_id = \ + glance.get_remote_image_service(context, image_meta['id']) self.driver.copy_volume_to_image(context, volume, image_service, - image_id) + image_meta) LOG.debug(_("Uploaded volume %(volume_id)s to " "image (%(image_id)s) successfully") % locals()) except Exception, error: diff --git a/cinder/volume/rpcapi.py b/cinder/volume/rpcapi.py index 0f7621016..d9f72f0a0 100644 --- a/cinder/volume/rpcapi.py +++ b/cinder/volume/rpcapi.py @@ -35,6 +35,7 @@ class VolumeAPI(cinder.openstack.common.rpc.proxy.RpcProxy): 1.0 - Initial version. 1.1 - Adds clone volume option to create_volume. 1.2 - Add publish_service_capabilities() method. + 1.3 - Pass all image metadata (not just ID) in copy_volume_to_image ''' BASE_RPC_API_VERSION = '1.0' @@ -91,13 +92,14 @@ class VolumeAPI(cinder.openstack.common.rpc.proxy.RpcProxy): self.topic, volume['host'])) - def copy_volume_to_image(self, ctxt, volume, image_id): + def copy_volume_to_image(self, ctxt, volume, image_meta): self.cast(ctxt, self.make_msg('copy_volume_to_image', volume_id=volume['id'], - image_id=image_id), + image_meta=image_meta), topic=rpc.queue_get_for(ctxt, self.topic, - volume['host'])) + volume['host']), + version='1.3') def initialize_connection(self, ctxt, volume, connector): return self.call(ctxt, self.make_msg('initialize_connection', diff --git a/etc/cinder/rootwrap.d/volume.filters b/etc/cinder/rootwrap.d/volume.filters index 68a57b1df..51e972def 100644 --- a/etc/cinder/rootwrap.d/volume.filters +++ b/etc/cinder/rootwrap.d/volume.filters @@ -42,6 +42,9 @@ ln: CommandFilter, /bin/ln, root qemu-img: CommandFilter, /usr/bin/qemu-img, root env: CommandFilter, /usr/bin/env, root +# cinder/volume/driver.py: utils.read_file_as_root() +cat: CommandFilter, /bin/cat, root + # cinder/volume/nfs.py stat: CommandFilter, /usr/bin/stat, root mount: CommandFilter, /bin/mount, root -- 2.45.2