Coverage for nova/virt/images.py: 81%
141 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-24 11:16 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-24 11:16 +0000
1# Copyright 2010 United States Government as represented by the
2# Administrator of the National Aeronautics and Space Administration.
3# All Rights Reserved.
4# Copyright (c) 2010 Citrix Systems, Inc.
5#
6# Licensed under the Apache License, Version 2.0 (the "License"); you may
7# not use this file except in compliance with the License. You may obtain
8# a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15# License for the specific language governing permissions and limitations
16# under the License.
18"""
19Handling of VM disk images.
20"""
22import os
24from oslo_concurrency import processutils
25from oslo_log import log as logging
26from oslo_utils import fileutils
27from oslo_utils import imageutils
28from oslo_utils.imageutils import format_inspector
30from nova.compute import utils as compute_utils
31import nova.conf
32from nova import exception
33from nova.i18n import _
34from nova.image import glance
35import nova.privsep.qemu
37LOG = logging.getLogger(__name__)
39CONF = nova.conf.CONF
40IMAGE_API = glance.API()
43def qemu_img_info(path, format=None):
44 """Return an object containing the parsed output from qemu-img info."""
45 if not os.path.exists(path) and not path.startswith('rbd:'):
46 raise exception.DiskNotFound(location=path)
48 info = nova.privsep.qemu.unprivileged_qemu_img_info(path, format=format)
49 return imageutils.QemuImgInfo(info, format='json')
52def privileged_qemu_img_info(path, format=None, output_format='json'):
53 """Return an object containing the parsed output from qemu-img info."""
54 if not os.path.exists(path) and not path.startswith('rbd:'):
55 raise exception.DiskNotFound(location=path)
57 info = nova.privsep.qemu.privileged_qemu_img_info(path, format=format)
58 return imageutils.QemuImgInfo(info, format='json')
61def convert_image(source, dest, in_format, out_format, run_as_root=False,
62 compress=False, src_encryption=None, dest_encryption=None):
63 """Convert image to other format."""
64 if in_format is None: 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true
65 raise RuntimeError("convert_image without input format is a security"
66 " risk")
67 _convert_image(source, dest, in_format, out_format, run_as_root,
68 compress=compress, src_encryption=src_encryption,
69 dest_encryption=dest_encryption)
72def convert_image_unsafe(source, dest, out_format, run_as_root=False):
73 """Convert image to other format, doing unsafe automatic input format
74 detection. Do not call this function.
75 """
77 # NOTE: there is only 1 caller of this function:
78 # imagebackend.Lvm.create_image. It is not easy to fix that without a
79 # larger refactor, so for the moment it has been manually audited and
80 # allowed to continue. Remove this function when Lvm.create_image has
81 # been fixed.
82 _convert_image(source, dest, None, out_format, run_as_root)
85def _convert_image(source, dest, in_format, out_format, run_as_root,
86 compress=False, src_encryption=None, dest_encryption=None):
87 try:
88 with compute_utils.disk_ops_semaphore:
89 if not run_as_root:
90 nova.privsep.qemu.unprivileged_convert_image(
91 source, dest, in_format, out_format, CONF.instances_path,
92 compress, src_encryption=src_encryption,
93 dest_encryption=dest_encryption)
94 else:
95 nova.privsep.qemu.convert_image(
96 source, dest, in_format, out_format, CONF.instances_path,
97 compress, src_encryption=src_encryption,
98 dest_encryption=dest_encryption)
100 except processutils.ProcessExecutionError as exp:
101 msg = (_("Unable to convert image to %(format)s: %(exp)s") %
102 {'format': out_format, 'exp': exp})
103 raise exception.ImageUnacceptable(image_id=source, reason=msg)
106def fetch(context, image_href, path, trusted_certs=None):
107 with fileutils.remove_path_on_error(path):
108 with compute_utils.disk_ops_semaphore:
109 IMAGE_API.download(context, image_href, dest_path=path,
110 trusted_certs=trusted_certs)
113def get_info(context, image_href):
114 return IMAGE_API.get(context, image_href)
117def check_vmdk_image(image_id, data):
118 # Check some rules about VMDK files. Specifically we want to make
119 # sure that the "create-type" of the image is one that we allow.
120 # Some types of VMDK files can reference files outside the disk
121 # image and we do not want to allow those for obvious reasons.
123 types = CONF.compute.vmdk_allowed_types
125 if not len(types):
126 LOG.warning('Refusing to allow VMDK image as vmdk_allowed_'
127 'types is empty')
128 msg = _('Invalid VMDK create-type specified')
129 raise exception.ImageUnacceptable(image_id=image_id, reason=msg)
131 try:
132 create_type = data.format_specific['data']['create-type']
133 except KeyError:
134 msg = _('Unable to determine VMDK create-type')
135 raise exception.ImageUnacceptable(image_id=image_id, reason=msg)
137 if create_type not in CONF.compute.vmdk_allowed_types:
138 LOG.warning('Refusing to process VMDK file with create-type of %r '
139 'which is not in allowed set of: %s', create_type,
140 ','.join(CONF.compute.vmdk_allowed_types))
141 msg = _('Invalid VMDK create-type specified')
142 raise exception.ImageUnacceptable(image_id=image_id, reason=msg)
145def get_image_format(path):
146 with open(path, 'rb') as f:
147 wrapper = format_inspector.InspectWrapper(f)
148 try:
149 while f.peek(): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 wrapper.read(4096)
151 if wrapper.formats:
152 break
153 finally:
154 wrapper.close()
156 try:
157 return wrapper.format
158 except format_inspector.ImageFormatError:
159 format_names = set(str(x) for x in wrapper.formats)
160 if format_names == {'iso', 'gpt'}:
161 # If iso+gpt, we choose the iso because bootable-as-block ISOs
162 # can legitimately have a GPT bootloader in front.
163 LOG.debug('Detected %s as ISO+GPT, allowing as ISO', path)
164 return [x for x in wrapper.formats if str(x) == 'iso'][0]
165 # Any other case of multiple formats is an error
166 raise
169def do_image_deep_inspection(img, image_href, path):
170 ami_formats = ('ami', 'aki', 'ari')
171 disk_format = img['disk_format']
172 try:
173 # NOTE(danms): Use our own cautious inspector module to make sure
174 # the image file passes safety checks.
175 # See https://bugs.launchpad.net/nova/+bug/2059809 for details.
177 # Make sure we have a format inspector for the claimed format, else
178 # it is something we do not support and must reject. AMI is excluded.
179 if (disk_format not in ami_formats and
180 not format_inspector.get_inspector(disk_format)):
181 raise exception.ImageUnacceptable(
182 image_id=image_href,
183 reason=_('Image not in a supported format'))
185 inspector = get_image_format(path)
186 inspector.safety_check()
188 # Images detected as gpt but registered as raw are legacy "whole disk"
189 # formats, which we continue to allow for now.
190 # AMI formats can be other things, so don't obsess over this
191 # requirement for them. Otherwise, make sure our detection agrees
192 # with glance.
193 if disk_format == 'raw' and str(inspector) == 'gpt': 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 LOG.debug('Image %s registered as raw, but detected as gpt',
195 image_href)
196 elif disk_format not in ami_formats and str(inspector) != disk_format:
197 # If we detected the image as something other than glance claimed,
198 # we abort.
199 LOG.warning('Image %s expected to be %s but detected as %s',
200 image_href, disk_format, str(inspector))
201 raise exception.ImageUnacceptable(
202 image_id=image_href,
203 reason=_('Image content does not match disk_format'))
204 except format_inspector.SafetyCheckFailed as e:
205 LOG.error('Image %s failed safety check: %s', image_href, e)
206 raise exception.ImageUnacceptable(
207 image_id=image_href,
208 reason=(_('Image does not pass safety check')))
209 except format_inspector.ImageFormatError:
210 # If the inspector we chose based on the image's metadata does not
211 # think the image is the proper format, we refuse to use it.
212 raise exception.ImageUnacceptable(
213 image_id=image_href,
214 reason=_('Image content does not match disk_format'))
215 except Exception:
216 raise exception.ImageUnacceptable(
217 image_id=image_href,
218 reason=_('Image not in a supported format'))
219 if disk_format in ('iso',) + ami_formats:
220 # ISO or AMI image passed safety check; qemu will treat this as raw
221 # from here so return the expected formats it will find.
222 disk_format = 'raw'
223 return disk_format
226def fetch_to_raw(context, image_href, path, trusted_certs=None):
227 path_tmp = "%s.part" % path
228 fetch(context, image_href, path_tmp, trusted_certs)
230 with fileutils.remove_path_on_error(path_tmp):
231 if not CONF.workarounds.disable_deep_image_inspection:
232 # If we're doing deep inspection, we take the determined format
233 # from it.
234 img = IMAGE_API.get(context, image_href)
235 force_format = do_image_deep_inspection(img, image_href, path_tmp)
236 else:
237 force_format = None
239 # Only run qemu-img after we have done deep inspection (if enabled).
240 # If it was not enabled, we will let it detect the format.
241 data = qemu_img_info(path_tmp)
242 fmt = data.file_format
243 if fmt is None: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true
244 raise exception.ImageUnacceptable(
245 reason=_("'qemu-img info' parsing failed."),
246 image_id=image_href)
247 elif force_format is not None and fmt != force_format:
248 # Format inspector and qemu-img must agree on the format, else
249 # we reject. This will catch VMDK some variants that we don't
250 # explicitly support because qemu will identify them as such
251 # and we will not.
252 LOG.warning('Image %s detected by qemu as %s but we expected %s',
253 image_href, fmt, force_format)
254 raise exception.ImageUnacceptable(
255 image_id=image_href,
256 reason=_('Image content does not match disk_format'))
258 backing_file = data.backing_file
259 if backing_file is not None:
260 raise exception.ImageUnacceptable(image_id=image_href,
261 reason=(_("fmt=%(fmt)s backed by: %(backing_file)s") %
262 {'fmt': fmt, 'backing_file': backing_file}))
264 try:
265 data_file = data.format_specific['data']['data-file']
266 except (KeyError, TypeError, AttributeError):
267 data_file = None
268 if data_file is not None:
269 raise exception.ImageUnacceptable(image_id=image_href,
270 reason=(_("fmt=%(fmt)s has data-file: %(data_file)s") %
271 {'fmt': fmt, 'data_file': data_file}))
273 if fmt == 'vmdk':
274 check_vmdk_image(image_href, data)
276 if fmt != "raw" and CONF.force_raw_images:
277 staged = "%s.converted" % path
278 LOG.debug("%s was %s, converting to raw", image_href, fmt)
279 with fileutils.remove_path_on_error(staged):
280 try:
281 convert_image(path_tmp, staged, fmt, 'raw')
282 except exception.ImageUnacceptable as exp:
283 # re-raise to include image_href
284 raise exception.ImageUnacceptable(image_id=image_href,
285 reason=_("Unable to convert image to raw: %(exp)s")
286 % {'exp': exp})
288 os.unlink(path_tmp)
290 data = qemu_img_info(staged)
291 if data.file_format != "raw": 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true
292 raise exception.ImageUnacceptable(image_id=image_href,
293 reason=_("Converted to raw, but format is now %s") %
294 data.file_format)
296 os.rename(staged, path)
297 else:
298 os.rename(path_tmp, path)