diff options
-rw-r--r-- | cinder/image/image_utils.py | 239 | ||||
-rw-r--r-- | cinder/tests/unit/test_image_utils.py | 468 | ||||
-rw-r--r-- | cinder/tests/unit/volume/drivers/test_nfs.py | 223 | ||||
-rw-r--r-- | cinder/tests/unit/volume/drivers/test_quobyte.py | 127 | ||||
-rw-r--r-- | cinder/volume/api.py | 4 | ||||
-rw-r--r-- | cinder/volume/flows/manager/create_volume.py | 4 | ||||
-rw-r--r-- | releasenotes/notes/bug-1996188-vmdk-subformat-allow-list-93e6943d9a486d11.yaml | 33 | ||||
-rw-r--r-- | releasenotes/notes/fix-reserved-image-properties-9519ddc080e7ed1a.yaml | 28 |
8 files changed, 908 insertions, 218 deletions
diff --git a/cinder/image/image_utils.py b/cinder/image/image_utils.py index 198499d8f..0c3c3ac95 100644 --- a/cinder/image/image_utils.py +++ b/cinder/image/image_utils.py @@ -80,6 +80,33 @@ image_opts = [ 'conversion consumes a large amount of system resources and ' 'can cause performance problems on the cinder-volume node. ' 'When set True, this option disables image conversion.'), + cfg.ListOpt('vmdk_allowed_types', + default=['streamOptimized', 'monolithicSparse'], + help='A list of strings describing the VMDK createType ' + 'subformats that are allowed. We recommend that you only ' + 'include single-file-with-sparse-header variants to avoid ' + 'potential host file exposure when processing named extents ' + 'when an image is converted to raw format as it is written ' + 'to a volume. If this list is empty, no VMDK images are ' + 'allowed.'), + cfg.ListOpt('reserved_image_namespaces', + help='List of reserved image namespaces that should be ' + 'filtered out when uploading a volume as an image back ' + 'to Glance. When a volume is created from an image, ' + 'Cinder stores the image properties as volume ' + 'image metadata, and if the volume is later uploaded as ' + 'an image, Cinder will add these properties when it ' + 'creates the image in Glance. This can cause problems ' + 'for image metadata that are in namespaces that glance ' + 'reserves for itself, or when properties (such as an ' + 'image signature) cannot apply to the new image, or when ' + 'an operator has configured glance property protections ' + 'to make some image properties read-only. Cinder will ' + '*always* filter out image metadata in the namespaces ' + '`os_glance` and `img_signature`; this configuration ' + 'option allows operators to specify *additional* ' + 'namespaces to be excluded.', + default=[]), ] CONF = cfg.CONF @@ -104,6 +131,8 @@ QEMU_IMG_MIN_CONVERT_LUKS_VERSION = '2.10' COMPRESSIBLE_IMAGE_FORMATS = ('qcow2',) +GLANCE_RESERVED_NAMESPACES = ["os_glance", "img_signature"] + def validate_stores_id(context: context.RequestContext, image_service_store_id: str) -> None: @@ -135,7 +164,7 @@ def qemu_img_info(path: str, run_as_root: bool = True, force_share: bool = False) -> imageutils.QemuImgInfo: """Return an object containing the parsed output from qemu-img info.""" - cmd = ['env', 'LC_ALL=C', 'qemu-img', 'info'] + cmd = ['env', 'LC_ALL=C', 'qemu-img', 'info', '--output=json'] if force_share: if qemu_img_supports_force_share(): cmd.append('--force-share') @@ -149,7 +178,7 @@ def qemu_img_info(path: str, cmd = cmd[2:] out, _err = utils.execute(*cmd, run_as_root=run_as_root, prlimit=QEMU_IMG_LIMITS) - info = imageutils.QemuImgInfo(out) + info = imageutils.QemuImgInfo(out, format='json') # From Cinder's point of view, any 'luks' formatted images # should be treated as 'raw'. @@ -435,7 +464,35 @@ def convert_image(source: str, cipher_spec: Optional[dict] = None, passphrase_file: Optional[str] = None, compress: bool = False, - src_passphrase_file: Optional[str] = None) -> None: + src_passphrase_file: Optional[str] = None, + image_id: Optional[str] = None, + data: Optional[imageutils.QemuImgInfo] = None) -> None: + """Convert image to other format. + + NOTE: If the qemu-img convert command fails and this function raises an + exception, a non-empty dest file may be left in the filesystem. + It is the responsibility of the caller to decide what to do with this file. + + :param source: source filename + :param dest: destination filename + :param out_format: output image format of qemu-img + :param out_subformat: output image subformat + :param src_format: source image format (use image_utils.fixup_disk_format() + to translate from a Glance format to one recognizable by qemu_img) + :param run_as_root: run qemu-img as root + :param throttle: a cinder.throttling.Throttle object, or None + :param cipher_spec: encryption details + :param passphrase_file: filename containing luks passphrase + :param compress: compress w/ qemu-img when possible (best effort) + :param src_passphrase_file: filename containing source volume's + luks passphrase + :param image_id: the image ID if this is a Glance image, or None + :param data: a imageutils.QemuImgInfo object from this image, or None + :raises ImageUnacceptable: when the image fails some format checks + :raises ProcessExecutionError: when something goes wrong during conversion + """ + check_image_format(source, src_format, image_id, data, run_as_root) + if not throttle: throttle = throttling.Throttle.get_default() with throttle.subcommand(source, dest) as throttle_cmd: @@ -630,6 +687,98 @@ def get_qemu_data(image_id: str, return data +def check_vmdk_image(image_id: str, data: imageutils.QemuImgInfo) -> None: + """Check some rules about VMDK images. + + Make sure the VMDK subformat (the "createType" in vmware docs) + is one that we allow as determined by the 'vmdk_allowed_types' + configuration option. The default set includes only types that + do not reference files outside the VMDK file, which can otherwise + be used in exploits to expose host information. + + :param image_id: the image id + :param data: an imageutils.QemuImgInfo object + :raises ImageUnacceptable: when the VMDK createType is not in the + allowed list + """ + allowed_types = CONF.vmdk_allowed_types + + if not len(allowed_types): + msg = _('Image is a VMDK, but no VMDK createType is allowed') + raise exception.ImageUnacceptable(image_id=image_id, reason=msg) + + try: + create_type = data.format_specific['data']['create-type'] + except KeyError: + msg = _('Unable to determine VMDK createType') + raise exception.ImageUnacceptable(image_id=image_id, reason=msg) + except TypeError: + msg = _('Unable to determine VMDK createType as no format-specific ' + 'information is available') + raise exception.ImageUnacceptable(image_id=image_id, reason=msg) + + if create_type not in allowed_types: + LOG.warning('Refusing to process VMDK file with createType of %r ' + 'which is not in allowed set of: %s', create_type, + ','.join(allowed_types)) + msg = _('Invalid VMDK create-type specified') + raise exception.ImageUnacceptable(image_id=image_id, reason=msg) + + +def check_image_format(source: str, + src_format: Optional[str] = None, + image_id: Optional[str] = None, + data: Optional[imageutils.QemuImgInfo] = None, + run_as_root: bool = True) -> None: + """Do some image format checks. + + Verifies that the src_format matches what qemu-img thinks the image + format is, and does some vmdk subformat checks. See Bug #1996188. + + - Does not check for a qcow2 backing file. + - Will make a call out to qemu_img if data is None. + + :param source: filename of the image to check + :param src_format: source image format recognized by qemu_img, or None + :param image_id: the image ID if this is a Glance image, or None + :param data: a imageutils.QemuImgInfo object from this image, or None + :param run_as_root: when 'data' is None, call 'qemu-img info' as root + :raises ImageUnacceptable: when the image fails some format checks + :raises ProcessExecutionError: if 'qemu-img info' fails + """ + if image_id is None: + image_id = 'internal image' + if data is None: + data = qemu_img_info(source, run_as_root=run_as_root) + + if data.file_format is None: + raise exception.ImageUnacceptable( + reason=_("'qemu-img info' parsing failed."), + image_id=image_id) + + if src_format is not None: + if src_format.lower() == 'ami': + # qemu-img doesn't recognize AMI format; see change Icde4c0f936ce. + # We also use lower() here (though nowhere else) to be consistent + # with that change. + pass + elif data.file_format != src_format: + LOG.debug("Rejecting image %(image_id)s due to format mismatch. " + "src_format: '%(src)s', but qemu-img info reports: " + "'%(qemu)s'", + {'image_id': image_id, + 'src': src_format, + 'qemu': data.file_format}) + msg = _("The image format was claimed to be '%(src)s' but the " + "image data appears to be in a different format.") + raise exception.ImageUnacceptable( + image_id=image_id, + reason=(msg % {'src': src_format})) + + if data.file_format == 'vmdk': + check_vmdk_image(image_id, data) + + def fetch_verify_image(context: context.RequestContext, image_service: glance.GlanceImageService, image_id: str, @@ -647,7 +796,10 @@ def fetch_verify_image(context: context.RequestContext, data = get_qemu_data(image_id, has_meta, format_raw, dest, True) # We can only really do verification of the image if we have - # qemu data to use + # qemu data to use. + # NOTE: We won't have data if qemu_img is not installed *and* the + # disk_format recorded in Glance is raw (otherwise an ImageUnacceptable + # would have been raised already). So this isn't as bad as it looks. if data is not None: fmt = data.file_format if fmt is None: @@ -662,6 +814,11 @@ def fetch_verify_image(context: context.RequestContext, reason=(_("fmt=%(fmt)s backed by: %(backing_file)s") % {'fmt': fmt, 'backing_file': backing_file})) + # a VMDK can have a backing file, but we have to check for + # it differently + if fmt == 'vmdk': + check_vmdk_image(image_id, data) + def fetch_to_vhd(context: context.RequestContext, image_service: glance.GlanceImageService, @@ -752,6 +909,10 @@ def fetch_to_volume_format(context: context.RequestContext, format_raw = True if image_meta['disk_format'] == 'raw' else False except TypeError: format_raw = False + + # Probe using the empty tmp file to see if qemu-img is available. + # If it's not, and the disk_format recorded in Glance is not 'raw', + # this will raise ImageUnacceptable data = get_qemu_data(image_id, has_meta, format_raw, tmp, run_as_root) if data is None: @@ -764,6 +925,21 @@ def fetch_to_volume_format(context: context.RequestContext, else: fetch(context, image_service, image_id, tmp, user_id, project_id) + # NOTE(ZhengMa): This is used to do image decompression on image + # downloading with 'compressed' container_format. It is a + # transparent level between original image downloaded from + # Glance and Cinder image service. So the source file path is + # the same with destination file path. + if image_meta.get('container_format') == 'compressed': + LOG.debug("Found image with compressed container format") + if not accelerator.is_gzip_compressed(tmp): + raise exception.ImageUnacceptable( + image_id=image_id, + reason=_("Unsupported compressed image format found. " + "Only gzip is supported currently")) + accel = accelerator.ImageAccel(tmp, tmp) + accel.decompress_img(run_as_root=run_as_root) + if is_xenserver_format(image_meta): replace_xenserver_image_with_coalesced_vhd(tmp) @@ -799,21 +975,6 @@ def fetch_to_volume_format(context: context.RequestContext, reason=_("fmt=%(fmt)s backed by:%(backing_file)s") % {'fmt': fmt, 'backing_file': backing_file, }) - # NOTE(ZhengMa): This is used to do image decompression on image - # downloading with 'compressed' container_format. It is a - # transparent level between original image downloaded from - # Glance and Cinder image service. So the source file path is - # the same with destination file path. - if image_meta.get('container_format') == 'compressed': - LOG.debug("Found image with compressed container format") - if not accelerator.is_gzip_compressed(tmp): - raise exception.ImageUnacceptable( - image_id=image_id, - reason=_("Unsupported compressed image format found. " - "Only gzip is supported currently")) - accel = accelerator.ImageAccel(tmp, tmp) - accel.decompress_img(run_as_root=run_as_root) - # 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 @@ -821,13 +982,21 @@ def fetch_to_volume_format(context: context.RequestContext, # 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. + # FIXME: revisit the above 2 comments. We already have an exception + # above for RAW format images when qemu-img is not available, and I'm + # pretty sure that the backing file exploit only happens when + # converting from some format that supports a backing file TO raw ... + # a bit-for-bit copy of a qcow2 with backing file will copy the backing + # file *reference* but not its *content*. disk_format = fixup_disk_format(image_meta['disk_format']) LOG.debug("%s was %s, converting to %s", image_id, fmt, volume_format) convert_image(tmp, dest, volume_format, out_subformat=volume_subformat, src_format=disk_format, - run_as_root=run_as_root) + run_as_root=run_as_root, + image_id=image_id, + data=data) def upload_volume(context: context.RequestContext, @@ -885,7 +1054,9 @@ def upload_volume(context: context.RequestContext, out_format = fixup_disk_format(image_meta['disk_format']) convert_image(volume_path, tmp, out_format, run_as_root=run_as_root, - compress=compress) + compress=compress, + image_id=image_id, + data=data) data = qemu_img_info(tmp, run_as_root=run_as_root) if data.file_format != out_format: @@ -1133,3 +1304,29 @@ class TemporaryImages(object): if not self.temporary_images.get(user): return None return self.temporary_images[user].get(image_id) + + +def filter_out_reserved_namespaces_metadata( + metadata: Optional[dict[str, str]]) -> dict[str, str]: + + reserved_name_spaces = GLANCE_RESERVED_NAMESPACES.copy() + if CONF.reserved_image_namespaces: + for image_namespace in CONF.reserved_image_namespaces: + if image_namespace not in reserved_name_spaces: + reserved_name_spaces.append(image_namespace) + + if not metadata: + LOG.debug("No metadata to be filtered.") + return {} + + new_metadata = {} + for k, v in metadata.items(): + if any(k.startswith(reserved_name_space) + for reserved_name_space in reserved_name_spaces): + continue + new_metadata[k] = v + + LOG.debug("The metadata set [%s] was filtered using the reserved name " + "spaces [%s], and the result is [%s].", metadata, + reserved_name_spaces, new_metadata) + return new_metadata diff --git a/cinder/tests/unit/test_image_utils.py b/cinder/tests/unit/test_image_utils.py index 452b644c4..d616b5b94 100644 --- a/cinder/tests/unit/test_image_utils.py +++ b/cinder/tests/unit/test_image_utils.py @@ -19,6 +19,7 @@ from unittest import mock import cryptography import ddt from oslo_concurrency import processutils +from oslo_utils import imageutils from oslo_utils import units from cinder import exception @@ -40,7 +41,8 @@ class TestQemuImgInfo(test.TestCase): output = image_utils.qemu_img_info(test_path) mock_exec.assert_called_once_with('env', 'LC_ALL=C', 'qemu-img', - 'info', test_path, run_as_root=True, + 'info', '--output=json', test_path, + run_as_root=True, prlimit=image_utils.QEMU_IMG_LIMITS) self.assertEqual(mock_info.return_value, output) @@ -57,7 +59,8 @@ class TestQemuImgInfo(test.TestCase): force_share=False, run_as_root=False) mock_exec.assert_called_once_with('env', 'LC_ALL=C', 'qemu-img', - 'info', test_path, run_as_root=False, + 'info', '--output=json', test_path, + run_as_root=False, prlimit=image_utils.QEMU_IMG_LIMITS) self.assertEqual(mock_info.return_value, output) @@ -72,8 +75,8 @@ class TestQemuImgInfo(test.TestCase): mock_os.name = 'nt' output = image_utils.qemu_img_info(test_path) - mock_exec.assert_called_once_with('qemu-img', 'info', test_path, - run_as_root=True, + mock_exec.assert_called_once_with('qemu-img', 'info', '--output=json', + test_path, run_as_root=True, prlimit=image_utils.QEMU_IMG_LIMITS) self.assertEqual(mock_info.return_value, output) @@ -171,7 +174,8 @@ class TestConvertImage(test.TestCase): source = mock.sentinel.source dest = mock.sentinel.dest out_format = mock.sentinel.out_format - mock_info.side_effect = ValueError + mock_info.return_value.file_format = 'qcow2' + mock_info.return_value.virtual_size = 1048576 throttle = throttling.Throttle(prefix=['cgcmd']) with mock.patch('cinder.volume.volume_utils.check_for_odirect_support', @@ -179,7 +183,8 @@ class TestConvertImage(test.TestCase): output = image_utils.convert_image(source, dest, out_format, throttle=throttle) - mock_info.assert_called_once_with(source, run_as_root=True) + my_call = mock.call(source, run_as_root=True) + mock_info.assert_has_calls([my_call, my_call]) self.assertIsNone(output) mock_exec.assert_called_once_with('cgcmd', 'qemu-img', 'convert', '-O', out_format, '-t', 'none', @@ -235,7 +240,6 @@ class TestConvertImage(test.TestCase): dest = mock.sentinel.dest out_format = mock.sentinel.out_format out_subformat = 'fake_subformat' - mock_info.side_effect = ValueError output = image_utils.convert_image(source, dest, out_format, out_subformat=out_subformat) @@ -334,7 +338,6 @@ class TestConvertImage(test.TestCase): self.flags(image_conversion_dir='fakedir') dest = ['fakedir'] out_format = mock.sentinel.out_format - mock_info.side_effect = ValueError mock_exec.side_effect = processutils.ProcessExecutionError( stderr='No space left on device') self.assertRaises(processutils.ProcessExecutionError, @@ -740,7 +743,9 @@ class TestUploadVolume(test.TestCase): temp_file, output_format, run_as_root=True, - compress=do_compress) + compress=do_compress, + image_id=image_meta['id'], + data=data) mock_info.assert_called_with(temp_file, run_as_root=True) self.assertEqual(2, mock_info.call_count) mock_open.assert_called_once_with(temp_file, 'rb') @@ -827,7 +832,9 @@ class TestUploadVolume(test.TestCase): temp_file, 'raw', compress=True, - run_as_root=True) + run_as_root=True, + image_id=image_meta['id'], + data=data) mock_info.assert_called_with(temp_file, run_as_root=True) self.assertEqual(2, mock_info.call_count) mock_open.assert_called_once_with(temp_file, 'rb') @@ -916,7 +923,9 @@ class TestUploadVolume(test.TestCase): temp_file, 'raw', compress=True, - run_as_root=True) + run_as_root=True, + image_id=image_meta['id'], + data=data) mock_info.assert_called_with(temp_file, run_as_root=True) self.assertEqual(2, mock_info.call_count) mock_open.assert_called_once_with(temp_file, 'rb') @@ -951,7 +960,9 @@ class TestUploadVolume(test.TestCase): temp_file, mock.sentinel.disk_format, run_as_root=True, - compress=True) + compress=True, + image_id=image_meta['id'], + data=data) mock_info.assert_called_with(temp_file, run_as_root=True) self.assertEqual(2, mock_info.call_count) self.assertFalse(image_service.update.called) @@ -1109,8 +1120,10 @@ class TestFetchToVolumeFormat(test.TestCase): out_subformat = None blocksize = mock.sentinel.blocksize + disk_format = 'raw' + data = mock_info.return_value - data.file_format = volume_format + data.file_format = disk_format data.backing_file = None data.virtual_size = 1234 tmp = mock_temp.return_value.__enter__.return_value @@ -1132,7 +1145,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_convert.assert_called_once_with(tmp, dest, volume_format, out_subformat=out_subformat, run_as_root=True, - src_format='raw') + src_format=disk_format, + image_id=image_id, + data=data) @mock.patch('cinder.image.image_utils.check_virtual_size') @mock.patch('cinder.image.image_utils.check_available_space') @@ -1149,7 +1164,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_is_xen, mock_repl_xen, mock_copy, mock_convert, mock_check_space, mock_check_size): ctxt = mock.sentinel.context - image_service = FakeImageService() + disk_format = 'ploop' + qemu_img_format = image_utils.QEMU_IMG_FORMAT_MAP[disk_format] + image_service = FakeImageService(disk_format=disk_format) image_id = mock.sentinel.image_id dest = mock.sentinel.dest volume_format = mock.sentinel.volume_format @@ -1161,7 +1178,7 @@ class TestFetchToVolumeFormat(test.TestCase): run_as_root = mock.sentinel.run_as_root data = mock_info.return_value - data.file_format = volume_format + data.file_format = qemu_img_format data.backing_file = None data.virtual_size = 1234 tmp = mock_temp.return_value.__enter__.return_value @@ -1184,7 +1201,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_convert.assert_called_once_with(tmp, dest, volume_format, out_subformat=out_subformat, run_as_root=run_as_root, - src_format='raw') + src_format=qemu_img_format, + image_id=image_id, + data=data) mock_check_size.assert_called_once_with(data.virtual_size, size, image_id) @@ -1240,12 +1259,14 @@ class TestFetchToVolumeFormat(test.TestCase): size = 4321 run_as_root = mock.sentinel.run_as_root + disk_format = 'vhd' + data = mock_info.return_value - data.file_format = volume_format + data.file_format = image_utils.QEMU_IMG_FORMAT_MAP[disk_format] data.backing_file = None data.virtual_size = 1234 tmp = mock_temp.return_value.__enter__.return_value - image_service = FakeImageService(disk_format='vhd') + image_service = FakeImageService(disk_format=disk_format) expect_format = 'vpc' output = image_utils.fetch_to_volume_format( @@ -1266,7 +1287,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_convert.assert_called_once_with(tmp, dest, volume_format, out_subformat=out_subformat, run_as_root=run_as_root, - src_format=expect_format) + src_format=expect_format, + image_id=image_id, + data=data) @mock.patch('cinder.image.image_utils.check_virtual_size') @mock.patch('cinder.image.image_utils.check_available_space') @@ -1292,12 +1315,14 @@ class TestFetchToVolumeFormat(test.TestCase): size = 4321 run_as_root = mock.sentinel.run_as_root + disk_format = 'iso' + data = mock_info.return_value - data.file_format = volume_format + data.file_format = image_utils.QEMU_IMG_FORMAT_MAP[disk_format] data.backing_file = None data.virtual_size = 1234 tmp = mock_temp.return_value.__enter__.return_value - image_service = FakeImageService(disk_format='iso') + image_service = FakeImageService(disk_format=disk_format) expect_format = 'raw' output = image_utils.fetch_to_volume_format( @@ -1317,7 +1342,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_convert.assert_called_once_with(tmp, dest, volume_format, out_subformat=out_subformat, run_as_root=run_as_root, - src_format=expect_format) + src_format=expect_format, + image_id=image_id, + data=data) @mock.patch('cinder.image.image_utils.check_available_space', new=mock.Mock()) @@ -1335,7 +1362,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_copy, mock_convert): ctxt = mock.sentinel.context ctxt.user_id = mock.sentinel.user_id - image_service = FakeImageService() + disk_format = 'ploop' + qemu_img_format = image_utils.QEMU_IMG_FORMAT_MAP[disk_format] + image_service = FakeImageService(disk_format=disk_format) image_id = mock.sentinel.image_id dest = mock.sentinel.dest volume_format = mock.sentinel.volume_format @@ -1343,7 +1372,7 @@ class TestFetchToVolumeFormat(test.TestCase): blocksize = mock.sentinel.blocksize data = mock_info.return_value - data.file_format = volume_format + data.file_format = qemu_img_format data.backing_file = None data.virtual_size = 1234 tmp = mock.sentinel.tmp @@ -1371,7 +1400,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_convert.assert_called_once_with(tmp, dest, volume_format, out_subformat=out_subformat, run_as_root=True, - src_format='raw') + src_format=qemu_img_format, + image_id=image_id, + data=data) @mock.patch('cinder.image.image_utils.convert_image') @mock.patch('cinder.image.image_utils.volume_utils.copy_volume') @@ -1633,7 +1664,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_copy, mock_convert, mock_check_space, mock_check_size): ctxt = mock.sentinel.context - image_service = FakeImageService() + disk_format = 'vhd' + qemu_img_format = image_utils.QEMU_IMG_FORMAT_MAP[disk_format] + image_service = FakeImageService(disk_format=disk_format) image_id = mock.sentinel.image_id dest = mock.sentinel.dest volume_format = mock.sentinel.volume_format @@ -1644,7 +1677,7 @@ class TestFetchToVolumeFormat(test.TestCase): run_as_root = mock.sentinel.run_as_root data = mock_info.return_value - data.file_format = volume_format + data.file_format = qemu_img_format data.backing_file = None data.virtual_size = 1234 tmp = mock_temp.return_value.__enter__.return_value @@ -1667,7 +1700,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_convert.assert_called_once_with(tmp, dest, volume_format, out_subformat=None, run_as_root=run_as_root, - src_format='raw') + src_format=qemu_img_format, + image_id=image_id, + data=data) @mock.patch('cinder.image.image_utils.fetch') @mock.patch('cinder.image.image_utils.qemu_img_info', @@ -1786,7 +1821,9 @@ class TestFetchToVolumeFormat(test.TestCase): ctxt = mock.sentinel.context ctxt.user_id = mock.sentinel.user_id - image_service = FakeImageService() + disk_format = 'ploop' + qemu_img_format = image_utils.QEMU_IMG_FORMAT_MAP[disk_format] + image_service = FakeImageService(disk_format=disk_format) image_id = mock.sentinel.image_id dest = mock.sentinel.dest volume_format = mock.sentinel.volume_format @@ -1794,7 +1831,7 @@ class TestFetchToVolumeFormat(test.TestCase): blocksize = mock.sentinel.blocksize data = mock_info.return_value - data.file_format = volume_format + data.file_format = qemu_img_format data.backing_file = None data.virtual_size = 1234 tmp = mock_temp.return_value.__enter__.return_value @@ -1819,7 +1856,9 @@ class TestFetchToVolumeFormat(test.TestCase): mock_convert.assert_called_once_with(tmp, dest, volume_format, out_subformat=out_subformat, run_as_root=True, - src_format='raw') + src_format=qemu_img_format, + image_id=image_id, + data=data) mock_engine.decompress_img.assert_called() @@ -2117,3 +2156,368 @@ class TestImageUtils(test.TestCase): image_utils.decode_cipher, 'aes', 256) + + +@ddt.ddt(testNameFormat=ddt.TestNameFormat.INDEX_ONLY) +class TestVmdkImageChecks(test.TestCase): + def setUp(self): + super(TestVmdkImageChecks, self).setUp() + # Test data from: + # $ qemu-img create -f vmdk fake.vmdk 1M -o subformat=monolithicSparse + # $ qemu-img info -f vmdk --output=json fake.vmdk + # + # What qemu-img calls the "subformat" is called the "createType" in + # vmware-speak and it's found at "/format-specific/data/create-type". + qemu_img_info = ''' + { + "virtual-size": 1048576, + "filename": "fake.vmdk", + "cluster-size": 65536, + "format": "vmdk", + "actual-size": 12288, + "format-specific": { + "type": "vmdk", + "data": { + "cid": 1200165687, + "parent-cid": 4294967295, + "create-type": "monolithicSparse", + "extents": [ + { + "virtual-size": 1048576, + "filename": "fake.vmdk", + "cluster-size": 65536, + "format": "" + } + ] + } + }, + "dirty-flag": false + }''' + self.qdata = imageutils.QemuImgInfo(qemu_img_info, format='json') + self.qdata_data = self.qdata.format_specific['data'] + # we will populate this in each test + self.qdata_data["create-type"] = None + + @ddt.data('monolithicSparse', 'streamOptimized') + def test_check_vmdk_image_default_config(self, subformat): + # none of these should raise + self.qdata_data["create-type"] = subformat + image_utils.check_vmdk_image(fake.IMAGE_ID, self.qdata) + + @ddt.data('monolithicFlat', 'twoGbMaxExtentFlat') + def test_check_vmdk_image_negative_default_config(self, subformat): + self.qdata_data["create-type"] = subformat + self.assertRaises(exception.ImageUnacceptable, + image_utils.check_vmdk_image, + fake.IMAGE_ID, + self.qdata) + + def test_check_vmdk_image_handles_missing_info(self): + expected = 'Unable to determine VMDK createType' + # remove create-type + del(self.qdata_data['create-type']) + iue = self.assertRaises(exception.ImageUnacceptable, + image_utils.check_vmdk_image, + fake.IMAGE_ID, + self.qdata) + self.assertIn(expected, str(iue)) + + # remove entire data section + del(self.qdata_data) + iue = self.assertRaises(exception.ImageUnacceptable, + image_utils.check_vmdk_image, + fake.IMAGE_ID, + self.qdata) + self.assertIn(expected, str(iue)) + + # oslo.utils.imageutils guarantees that format_specific is + # defined, so let's see what happens when it's empty + self.qdata.format_specific = None + iue = self.assertRaises(exception.ImageUnacceptable, + image_utils.check_vmdk_image, + fake.IMAGE_ID, + self.qdata) + self.assertIn('no format-specific information is available', str(iue)) + + def test_check_vmdk_image_positive(self): + allowed = 'twoGbMaxExtentFlat' + self.flags(vmdk_allowed_types=['garbage', allowed]) + self.qdata_data["create-type"] = allowed + image_utils.check_vmdk_image(fake.IMAGE_ID, self.qdata) + + @ddt.data('monolithicSparse', 'streamOptimized') + def test_check_vmdk_image_negative(self, subformat): + allow_list = ['vmfs', 'filler'] + self.assertNotIn(subformat, allow_list) + self.flags(vmdk_allowed_types=allow_list) + self.qdata_data["create-type"] = subformat + self.assertRaises(exception.ImageUnacceptable, + image_utils.check_vmdk_image, + fake.IMAGE_ID, + self.qdata) + + @ddt.data('monolithicSparse', 'streamOptimized', 'twoGbMaxExtentFlat') + def test_check_vmdk_image_negative_empty_list(self, subformat): + # anything should raise + allow_list = [] + self.flags(vmdk_allowed_types=allow_list) + self.qdata_data["create-type"] = subformat + self.assertRaises(exception.ImageUnacceptable, + image_utils.check_vmdk_image, + fake.IMAGE_ID, + self.qdata) + + # OK, now that we know the function works properly, let's make sure + # it's called in all the situations where Bug #1996188 indicates that + # we need this check + + @mock.patch('cinder.image.image_utils.check_vmdk_image') + @mock.patch('cinder.image.image_utils.qemu_img_info') + @mock.patch('cinder.image.image_utils.fileutils') + @mock.patch('cinder.image.image_utils.fetch') + def test_vmdk_subformat_checked_fetch_verify_image( + self, mock_fetch, mock_fileutils, mock_info, mock_check): + ctxt = mock.sentinel.context + image_service = mock.Mock() + image_id = mock.sentinel.image_id + dest = mock.sentinel.dest + mock_info.return_value = self.qdata + mock_check.side_effect = exception.ImageUnacceptable( + image_id=image_id, reason='mock check') + + iue = self.assertRaises(exception.ImageUnacceptable, + image_utils.fetch_verify_image, + ctxt, image_service, image_id, dest) + self.assertIn('mock check', str(iue)) + mock_check.assert_called_with(image_id, self.qdata) + + @mock.patch('cinder.image.image_utils.check_vmdk_image') + @mock.patch('cinder.image.image_utils.qemu_img_info') + @mock.patch('cinder.image.image_utils.get_qemu_data') + @mock.patch('cinder.image.image_utils.check_image_conversion_disable') + def test_vmdk_subformat_checked_fetch_to_volume_format( + self, mock_convert, mock_qdata, mock_info, mock_check): + ctxt = mock.sentinel.context + image_service = mock.Mock() + image_meta = {'disk_format': 'vmdk'} + image_service.show.return_value = image_meta + image_id = mock.sentinel.image_id + dest = mock.sentinel.dest + volume_format = mock.sentinel.volume_format + blocksize = 1024 + self.flags(allow_compression_on_image_upload=False) + mock_qdata.return_value = self.qdata + mock_info.return_value = self.qdata + mock_check.side_effect = exception.ImageUnacceptable( + image_id=image_id, reason='mock check') + + iue = self.assertRaises(exception.ImageUnacceptable, + image_utils.fetch_to_volume_format, + ctxt, + image_service, + image_id, + dest, + volume_format, + blocksize) + self.assertIn('mock check', str(iue)) + mock_check.assert_called_with(image_id, self.qdata) + + @mock.patch('cinder.image.image_utils.check_vmdk_image') + @mock.patch('cinder.image.image_utils.qemu_img_info') + @mock.patch('cinder.image.image_utils.check_image_conversion_disable') + def test_vmdk_subformat_checked_upload_volume( + self, mock_convert, mock_info, mock_check): + ctxt = mock.sentinel.context + image_service = mock.Mock() + image_meta = {'disk_format': 'vmdk'} + image_id = mock.sentinel.image_id + image_meta['id'] = image_id + self.flags(allow_compression_on_image_upload=False) + mock_info.return_value = self.qdata + mock_check.side_effect = exception.ImageUnacceptable( + image_id=image_id, reason='mock check') + + iue = self.assertRaises(exception.ImageUnacceptable, + image_utils.upload_volume, + ctxt, + image_service, + image_meta, + volume_path=mock.sentinel.volume_path, + volume_format=mock.sentinel.volume_format) + self.assertIn('mock check', str(iue)) + mock_check.assert_called_with(image_id, self.qdata) + + @mock.patch('cinder.image.image_utils.check_vmdk_image') + @mock.patch('cinder.image.image_utils.qemu_img_info') + def test_vmdk_checked_convert_image_no_src_format( + self, mock_info, mock_check): + source = mock.sentinel.source + dest = mock.sentinel.dest + out_format = mock.sentinel.out_format + mock_info.return_value = self.qdata + image_id = 'internal image' + mock_check.side_effect = exception.ImageUnacceptable( + image_id=image_id, reason='mock check') + + self.assertRaises(exception.ImageUnacceptable, + image_utils.convert_image, + source, dest, out_format) + mock_check.assert_called_with(image_id, self.qdata) + + +@ddt.ddt(testNameFormat=ddt.TestNameFormat.INDEX_ONLY) +class TestImageFormatCheck(test.TestCase): + def setUp(self): + super(TestImageFormatCheck, self).setUp() + qemu_img_info = ''' + { + "virtual-size": 1048576, + "filename": "whatever.img", + "cluster-size": 65536, + "format": "qcow2", + "actual-size": 200704, + "format-specific": { + "type": "qcow2", + "data": { + "compat": "1.1", + "compression-type": "zlib", + "lazy-refcounts": false, + "refcount-bits": 16, + "corrupt": false, + "extended-l2": false + } + }, + "dirty-flag": false + }''' + self.qdata = imageutils.QemuImgInfo(qemu_img_info, format='json') + + @mock.patch('cinder.image.image_utils.check_vmdk_image') + @mock.patch('cinder.image.image_utils.qemu_img_info') + def test_check_image_format_defaults(self, mock_info, mock_vmdk): + """Doesn't blow up when only the mandatory arg is passed.""" + src = mock.sentinel.src + mock_info.return_value = self.qdata + expected_image_id = 'internal image' + + # empty file_format should raise + self.qdata.file_format = None + iue = self.assertRaises(exception.ImageUnacceptable, + image_utils.check_image_format, + src) + self.assertIn(expected_image_id, str(iue)) + mock_info.assert_called_with(src, run_as_root=True) + + # a VMDK should trigger an additional check + mock_info.reset_mock() + self.qdata.file_format = 'vmdk' + image_utils.check_image_format(src) + mock_vmdk.assert_called_with(expected_image_id, self.qdata) + + @mock.patch('cinder.image.image_utils.qemu_img_info') + def test_check_image_format_uses_passed_data(self, mock_info): + src = mock.sentinel.src + image_utils.check_image_format(src, data=self.qdata) + mock_info.assert_not_called() + + @mock.patch('cinder.image.image_utils.qemu_img_info') + def test_check_image_format_mismatch(self, mock_info): + src = mock.sentinel.src + mock_info.return_value = self.qdata + self.qdata.file_format = 'fake_format' + + src_format = 'qcow2' + iue = self.assertRaises(exception.ImageUnacceptable, + image_utils.check_image_format, + src, + src_format=src_format) + self.assertIn(src_format, str(iue)) + self.assertIn('different format', str(iue)) + + @ddt.data('AMI', 'ami') + @mock.patch('cinder.image.image_utils.qemu_img_info') + def test_check_image_format_AMI(self, ami, mock_info): + """Mismatch OK in this case, see change Icde4c0f936ce.""" + src = mock.sentinel.src + mock_info.return_value = self.qdata + self.qdata.file_format = 'raw' + + src_format = ami + image_utils.check_image_format(src, src_format=src_format) + + @mock.patch('cinder.image.image_utils._convert_image') + @mock.patch('cinder.image.image_utils.check_image_format') + def test_check_image_format_called_by_convert_image( + self, mock_check, mock__convert): + """Make sure the function we've been testing is actually called.""" + src = mock.sentinel.src + dest = mock.sentinel.dest + out_fmt = mock.sentinel.out_fmt + + image_utils.convert_image(src, dest, out_fmt) + mock_check.assert_called_once_with(src, None, None, None, True) + + +@ddt.ddt +class TestFilterReservedNamespaces(test.TestCase): + + def setUp(self): + super(TestFilterReservedNamespaces, self).setUp() + self.mock_object(image_utils, 'LOG', side_effect=image_utils.LOG) + + def test_filter_out_reserved_namespaces_metadata_with_empty_metadata(self): + metadata_for_test = None + method_return = image_utils.filter_out_reserved_namespaces_metadata( + metadata_for_test) + + self.assertEqual({}, method_return) + + image_utils.LOG.debug.assert_has_calls( + [mock.call("No metadata to be filtered.")] + ) + + @ddt.data( # remove default keys + ({"some_key": 13, "other_key": "test", + "os_glance_key": "this should be removed", + "os_glance_key2": "this should also be removed"}, + None, + []), + # remove nothing + ({"some_key": 13, "other_key": "test"}, + None, + []), + # custom config empty + ({"some_key": 13, "other_key": "test", + "os_glance_key": "this should be removed", + "os_glance_key2": "this should also be removed"}, + [], + []), + # custom config + ({"some_key": 13, "other_key": "test", + "os_glance_key": "this should be removed", + "os_glance_key2": "this should also be removed", + "custom_key": "this should be removed", + "another_custom_key": "this should also be removed"}, + ['custom_key', 'another_custom_key'], + ['custom_key', 'another_custom_key'])) + @ddt.unpack + def test_filter_out_reserved_namespaces_metadata( + self, metadata_for_test, config, keys_to_pop): + hardcoded_keys = ['os_glance', "img_signature"] + + keys_to_pop = hardcoded_keys + keys_to_pop + + if config: + self.override_config('reserved_image_namespaces', config) + + expected_result = {"some_key": 13, "other_key": "test"} + + method_return = image_utils.filter_out_reserved_namespaces_metadata( + metadata_for_test) + + self.assertEqual(expected_result, method_return) + + image_utils.LOG.debug.assert_has_calls([ + mock.call("The metadata set [%s] was filtered using the reserved " + "name spaces [%s], and the result is [%s].", + metadata_for_test, keys_to_pop, expected_result) + ]) diff --git a/cinder/tests/unit/volume/drivers/test_nfs.py b/cinder/tests/unit/volume/drivers/test_nfs.py index 7aab7eff2..b1fa90cd1 100644 --- a/cinder/tests/unit/volume/drivers/test_nfs.py +++ b/cinder/tests/unit/volume/drivers/test_nfs.py @@ -336,102 +336,112 @@ NFS_CONFIG4 = {'max_over_subscription_ratio': 20.0, 'nas_secure_file_permissions': 'false', 'nas_secure_file_operations': 'true'} -QEMU_IMG_INFO_OUT1 = """image: %(volid)s - file format: raw - virtual size: %(size_gb)sG (%(size_b)s bytes) - disk size: 173K - """ - -QEMU_IMG_INFO_OUT2 = """image: %(volid)s -file format: qcow2 -virtual size: %(size_gb)sG (%(size_b)s bytes) -disk size: 196K -cluster_size: 65536 -Format specific information: - compat: 1.1 - lazy refcounts: false - refcount bits: 16 - corrupt: false - """ - -QEMU_IMG_INFO_OUT3 = """image: volume-%(volid)s.%(snapid)s -file format: qcow2 -virtual size: %(size_gb)sG (%(size_b)s bytes) -disk size: 196K -cluster_size: 65536 -backing file: volume-%(volid)s -backing file format: qcow2 -Format specific information: - compat: 1.1 - lazy refcounts: false - refcount bits: 16 - corrupt: false - """ - -QEMU_IMG_INFO_OUT4 = """image: volume-%(volid)s.%(snapid)s -file format: raw -virtual size: %(size_gb)sG (%(size_b)s bytes) -disk size: 196K -cluster_size: 65536 -backing file: volume-%(volid)s -backing file format: raw -Format specific information: - compat: 1.1 - lazy refcounts: false - refcount bits: 16 - corrupt: false - """ - -QEMU_IMG_INFO_OUT5 = """image: volume-%(volid)s.%(snapid)s -file format: qcow2 -virtual size: %(size_gb)sG (%(size_b)s bytes) -disk size: 196K -encrypted: yes -cluster_size: 65536 -backing file: volume-%(volid)s -backing file format: raw -Format specific information: - compat: 1.1 - lazy refcounts: false - refcount bits: 16 - encrypt: - ivgen alg: plain64 - hash alg: sha256 - cipher alg: aes-256 - uuid: 386f8626-33f0-4683-a517-78ddfe385e33 - format: luks - cipher mode: xts - slots: - [0]: - active: true - iters: 1892498 - key offset: 4096 - stripes: 4000 - [1]: - active: false - key offset: 262144 - [2]: - active: false - key offset: 520192 - [3]: - active: false - key offset: 778240 - [4]: - active: false - key offset: 1036288 - [5]: - active: false - key offset: 1294336 - [6]: - active: false - key offset: 1552384 - [7]: - active: false - key offset: 1810432 - payload offset: 2068480 - master key iters: 459347 - corrupt: false -""" +QEMU_IMG_INFO_OUT1 = """{ + "filename": "%(volid)s", + "format": "raw", + "virtual-size": %(size_b)s, + "actual-size": 173000 +}""" + +QEMU_IMG_INFO_OUT2 = """{ + "filename": "%(volid)s", + "format": "qcow2", + "virtual-size": %(size_b)s, + "actual-size": 196000, + "cluster-size": 65536, + "format-specific": { + "compat": "1.1", + "lazy-refcounts": false, + "refcount-bits": 16, + "corrupt": false + } +}""" + +QEMU_IMG_INFO_OUT3 = """{ + "filename": "volume-%(volid)s.%(snapid)s", + "format": "qcow2", + "virtual-size": %(size_b)s, + "actual-size": 196000, + "cluster-size": 65536, + "backing-filename": "volume-%(volid)s", + "backing-filename-format": "qcow2", + "format-specific": { + "compat": "1.1", + "lazy-refcounts": false, + "refcount-bits": 16, + "corrupt": false + } +}""" + +QEMU_IMG_INFO_OUT4 = """{ + "filename": "volume-%(volid)s.%(snapid)s", + "format": "raw", + "virtual-size": %(size_b)s, + "actual-size": 196000, + "cluster-size": 65536, + "backing-filename": "volume-%(volid)s", + "backing-filename-format": "raw" +}""" + +QEMU_IMG_INFO_OUT5 = """{ + "filename": "volume-%(volid)s.%(snapid)s", + "format": "qcow2", + "virtual-size": %(size_b)s, + "actual-size": 196000, + "encrypted": true, + "cluster-size": 65536, + "backing-filename": "volume-%(volid)s", + "backing-filename-format": "raw", + "format-specific": { + "type": "luks", + "data": { + "ivgen-alg": "plain64", + "hash-alg": "sha256", + "cipher-alg": "aes-256", + "uuid": "386f8626-33f0-4683-a517-78ddfe385e33", + "cipher-mode": "xts", + "slots": [ + { + "active": true, + "iters": 1892498, + "key offset": 4096, + "stripes": 4000 + }, + { + "active": false, + "key offset": 262144 + }, + { + "active": false, + "key offset": 520192 + }, + { + "active": false, + "key offset": 778240 + }, + { + "active": false, + "key offset": 1036288 + }, + { + "active": false, + "key offset": 1294336 + }, + { + "active": false, + "key offset": 1552384 + }, + { + "active": false, + "key offset": 1810432 + } + ], + "payload-offset": 2068480, + "master-key-iters": 459347 + }, + "corrupt": false + } +}""" @ddt.ddt @@ -1323,7 +1333,7 @@ class NfsDriverTestCase(test.TestCase): 'size_gb': src_volume.size, 'size_b': src_volume.size * units.Gi} - img_info = imageutils.QemuImgInfo(img_out) + img_info = imageutils.QemuImgInfo(img_out, format='json') mock_img_info = self.mock_object(image_utils, 'qemu_img_info') mock_img_info.return_value = img_info mock_convert_image = self.mock_object(image_utils, 'convert_image') @@ -1406,7 +1416,7 @@ class NfsDriverTestCase(test.TestCase): 'snapid': fake_snap.id, 'size_gb': src_volume.size, 'size_b': src_volume.size * units.Gi} - img_info = imageutils.QemuImgInfo(img_out) + img_info = imageutils.QemuImgInfo(img_out, format='json') mock_img_info = self.mock_object(image_utils, 'qemu_img_info') mock_img_info.return_value = img_info @@ -1472,7 +1482,8 @@ class NfsDriverTestCase(test.TestCase): mock_img_utils = self.mock_object(image_utils, 'qemu_img_info') img_out = qemu_img_info % {'volid': volume.id, 'size_gb': volume.size, 'size_b': volume.size * units.Gi} - mock_img_utils.return_value = imageutils.QemuImgInfo(img_out) + mock_img_utils.return_value = imageutils.QemuImgInfo(img_out, + format='json') self.mock_object(drv, '_read_info_file', return_value={'active': "volume-%s" % volume.id}) @@ -1492,12 +1503,14 @@ class NfsDriverTestCase(test.TestCase): drv = self._driver volume = self._simple_volume() - qemu_img_output = """image: %s - file format: iso - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - """ % volume['name'] - mock_img_info.return_value = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "%s", + "format": "iso", + "virtual-size": 1073741824, + "actual-size": 173000 +}""" % volume['name'] + mock_img_info.return_value = imageutils.QemuImgInfo(qemu_img_output, + format='json') self.assertRaises(exception.InvalidVolume, drv.initialize_connection, diff --git a/cinder/tests/unit/volume/drivers/test_quobyte.py b/cinder/tests/unit/volume/drivers/test_quobyte.py index 767afd97e..b4e41f04e 100644 --- a/cinder/tests/unit/volume/drivers/test_quobyte.py +++ b/cinder/tests/unit/volume/drivers/test_quobyte.py @@ -941,13 +941,14 @@ class QuobyteDriverTestCase(test.TestCase): self.TEST_QUOBYTE_VOLUME), self.VOLUME_UUID) - qemu_img_info_output = """image: volume-%s - file format: qcow2 - virtual size: 1.0G (1073741824 bytes) - disk size: 473K - """ % self.VOLUME_UUID + qemu_img_info_output = """{ + "filename": "volume-%s", + "format": "qcow2", + "virtual-size": 1073741824, + "actual-size": 473000 +}""" % self.VOLUME_UUID - img_info = imageutils.QemuImgInfo(qemu_img_info_output) + img_info = imageutils.QemuImgInfo(qemu_img_info_output, format='json') self.mock_object(image_utils, 'qemu_img_info', return_value=img_info) self.mock_object(image_utils, 'resize_image') @@ -987,13 +988,14 @@ class QuobyteDriverTestCase(test.TestCase): size = dest_volume['size'] - qemu_img_output = """image: %s - file format: raw - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - backing file: %s - """ % (snap_file, src_volume['name']) - img_info = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "%s", + "format": "raw", + "virtual-size": 1073741824, + "actual-size": 173000, + "backing-filename": "%s" +}""" % (snap_file, src_volume['name']) + img_info = imageutils.QemuImgInfo(qemu_img_output, format='json') # mocking and testing starts here mock_convert = self.mock_object(image_utils, 'convert_image') @@ -1043,13 +1045,14 @@ class QuobyteDriverTestCase(test.TestCase): size = dest_volume['size'] - qemu_img_output = """image: %s - file format: raw - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - backing file: %s - """ % (snap_file, src_volume['name']) - img_info = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "%s", + "format": "raw", + "virtual-size": 1073741824, + "actual-size": 173000, + "backing-filename": "%s" +}""" % (snap_file, src_volume['name']) + img_info = imageutils.QemuImgInfo(qemu_img_output, format='json') # mocking and testing starts here mock_convert = self.mock_object(image_utils, 'convert_image') @@ -1104,13 +1107,14 @@ class QuobyteDriverTestCase(test.TestCase): size = dest_volume['size'] - qemu_img_output = """image: %s - file format: raw - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - backing file: %s - """ % (snap_file, src_volume['name']) - img_info = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "%s", + "format": "raw", + "virtual-size": 1073741824, + "actual-size": 173000, + "backing-filename": "%s" +}""" % (snap_file, src_volume['name']) + img_info = imageutils.QemuImgInfo(qemu_img_output, format='json') # mocking and testing starts here mock_convert = self.mock_object(image_utils, 'convert_image') @@ -1168,13 +1172,14 @@ class QuobyteDriverTestCase(test.TestCase): size = dest_volume['size'] - qemu_img_output = """image: %s - file format: raw - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - backing file: %s - """ % (snap_file, src_volume['name']) - img_info = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "%s", + "format": "raw", + "virtual-size": 1073741824, + "actual-size": 173000, + "backing-filename": "%s" +}""" % (snap_file, src_volume['name']) + img_info = imageutils.QemuImgInfo(qemu_img_output, format='json') # mocking and testing starts here mock_convert = self.mock_object(image_utils, 'convert_image') @@ -1245,12 +1250,13 @@ class QuobyteDriverTestCase(test.TestCase): drv._get_hash_str(self.TEST_QUOBYTE_VOLUME)) vol_path = os.path.join(vol_dir, volume['name']) - qemu_img_output = """image: %s - file format: raw - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - """ % volume['name'] - img_info = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "%s", + "format": "raw", + "virtual-size": 1073741824, + "actual-size": 173000 +}""" % volume['name'] + img_info = imageutils.QemuImgInfo(qemu_img_output, format='json') drv.get_active_image_from_info = mock.Mock(return_value=volume['name']) self.mock_object(image_utils, 'qemu_img_info', return_value=img_info) @@ -1294,12 +1300,13 @@ class QuobyteDriverTestCase(test.TestCase): mock_create_temporary_file.return_value = self.TEST_TMP_FILE - qemu_img_output = """image: %s - file format: raw - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - """ % volume['name'] - img_info = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "%s", + "format": "raw", + "virtual-size": 1073741824, + "actual-size": 173000 +}""" % volume['name'] + img_info = imageutils.QemuImgInfo(qemu_img_output, format='json') mock_qemu_img_info.return_value = img_info upload_path = volume_path @@ -1346,12 +1353,13 @@ class QuobyteDriverTestCase(test.TestCase): mock_create_temporary_file.return_value = self.TEST_TMP_FILE - qemu_img_output = """image: %s - file format: qcow2 - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - """ % volume['name'] - img_info = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "%s", + "format": "qcow2", + "virtual-size": 1073741824, + "actual-size": 173000 +}""" % volume['name'] + img_info = imageutils.QemuImgInfo(qemu_img_output, format='json') mock_qemu_img_info.return_value = img_info upload_path = self.TEST_TMP_FILE @@ -1401,13 +1409,14 @@ class QuobyteDriverTestCase(test.TestCase): mock_create_temporary_file.return_value = self.TEST_TMP_FILE - qemu_img_output = """image: volume-%s.%s - file format: qcow2 - virtual size: 1.0G (1073741824 bytes) - disk size: 173K - backing file: %s - """ % (self.VOLUME_UUID, self.SNAP_UUID, volume_filename) - img_info = imageutils.QemuImgInfo(qemu_img_output) + qemu_img_output = """{ + "filename": "volume-%s.%s", + "format": "qcow2", + "virtual-size": 1073741824, + "actual-size": 173000, + "backing-filename": "%s" +}""" % (self.VOLUME_UUID, self.SNAP_UUID, volume_filename) + img_info = imageutils.QemuImgInfo(qemu_img_output, format='json') mock_qemu_img_info.return_value = img_info upload_path = self.TEST_TMP_FILE diff --git a/cinder/volume/api.py b/cinder/volume/api.py index 87e64d41e..39e3efa5d 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -43,6 +43,7 @@ from cinder import flow_utils from cinder.i18n import _ from cinder.image import cache as image_cache from cinder.image import glance +from cinder.image import image_utils from cinder.message import api as message_api from cinder.message import message_field from cinder import objects @@ -1483,6 +1484,9 @@ class API(base.Base): try: self._merge_volume_image_meta(context, volume, metadata) + metadata = image_utils.filter_out_reserved_namespaces_metadata( + metadata) + recv_metadata = self.image_service.create(context, metadata) # NOTE(ZhengMa): Check if allow image compression before image diff --git a/cinder/volume/flows/manager/create_volume.py b/cinder/volume/flows/manager/create_volume.py index 782b991f6..0ae3cb59d 100644 --- a/cinder/volume/flows/manager/create_volume.py +++ b/cinder/volume/flows/manager/create_volume.py @@ -25,6 +25,7 @@ from oslo_log import log as logging from oslo_utils import excutils from oslo_utils import fileutils from oslo_utils import netutils +from oslo_utils import strutils from oslo_utils import timeutils from oslo_utils import uuidutils import taskflow.engines @@ -572,7 +573,8 @@ class CreateVolumeFromSpecTask(flow_utils.CinderTask): volume, encryption) - if image_info.encrypted == 'yes': + # see Bug #1942682 and Change I949f07582a708 for why we do this + if strutils.bool_from_string(image_info.encrypted): key_str = source_pass + "\n" + new_pass + "\n" del source_pass diff --git a/releasenotes/notes/bug-1996188-vmdk-subformat-allow-list-93e6943d9a486d11.yaml b/releasenotes/notes/bug-1996188-vmdk-subformat-allow-list-93e6943d9a486d11.yaml new file mode 100644 index 000000000..9b3292189 --- /dev/null +++ b/releasenotes/notes/bug-1996188-vmdk-subformat-allow-list-93e6943d9a486d11.yaml @@ -0,0 +1,33 @@ +--- +upgrade: + - | + This release introduces a new configuration option, + ``vmdk_allowed_types``, that specifies the list of VMDK image + subformats that Cinder will allow. The default setting allows + only the 'streamOptimized' and 'monolithicSparse' subformats, + which do not use named extents. +security: + - | + This release introduces a new configuration option, + ``vmdk_allowed_types``, that specifies the list of VMDK image + subformats that Cinder will allow in order to prevent exposure + of host information by modifying the named extents in a VMDK + image. The default setting allows only the 'streamOptimized' + and 'monolithicSparse' subformats, which do not use named extents. + - | + As part of the fix for `Bug #1996188 + <https://bugs.launchpad.net/cinder/+bug/1996188>`_, cinder is now more + strict in checking that the ``disk_format`` recorded for an image (as + revealed by the Image Service API image-show response) matches what + cinder detects when it downloads the image. Thus, some requests to + create a volume from a source image that had previously succeeded may + fail with an ``ImageUnacceptable`` error. +fixes: + - | + `Bug #1996188 <https://bugs.launchpad.net/cinder/+bug/1996188>`_: + Fixed issue where a VMDK image file whose createType allowed named + extents could expose host information. This change introduces a new + configuration option, ``vmdk_allowed_types``, that specifies the list + of VMDK image subformats that Cinder will allow. The default + setting allows only the 'streamOptimized' and 'monolithicSparse' + subformats. diff --git a/releasenotes/notes/fix-reserved-image-properties-9519ddc080e7ed1a.yaml b/releasenotes/notes/fix-reserved-image-properties-9519ddc080e7ed1a.yaml new file mode 100644 index 000000000..8f3f92eee --- /dev/null +++ b/releasenotes/notes/fix-reserved-image-properties-9519ddc080e7ed1a.yaml @@ -0,0 +1,28 @@ +--- +upgrade: + - | + We introduced a new config parameter, ``reserved_image_namespaces``, + that allows operators to set the image properties to filter out from + volume image metadata by namespace when uploading a volume to Glance. + These properties, if not filtered out, cause failures when uploading + images back to Glance. The error will happen on Glance side when the + reserved namespaces are used. This option is also useful when an operator + wants to use the Glance property protections feature to make some image + properties read-only. +fixes: + - | + `Bug #1945500 <https://bugs.launchpad.net/cinder/+bug/1945500>`_: Fixed + an error when uploading to Glance a previously downloaded glance image + when glance multistore is enabled. Glance reserves image properties + in the namespace 'os_glance' for its own use and will not allow + images to be created with these properties. Additionally, there are image + properties, such as those associated with image signature verification, + that are stored in a volume's image metadata, which should not be added + to a new image when a volume is being uploaded as an image. Thus Cinder + will no longer include any volume image metadata in the namespaces + ``os_glance`` and ``img_signature`` when it creates an image in Glance. + Furthermore, because the Glance property protections feature allows an + operator to configure specific image properties as read-only, this fix + adds a configuration option, ``reserved_image_namespaces``, that allows an + operator to exclude additional image properties by namespace (the + ``os_glance`` and ``img_signature`` namespaces are *always* excluded). |