Coverage for nova/virt/images.py: 81%

141 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-17 15:08 +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. 

17 

18""" 

19Handling of VM disk images. 

20""" 

21 

22import os 

23 

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 

29 

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 

36 

37LOG = logging.getLogger(__name__) 

38 

39CONF = nova.conf.CONF 

40IMAGE_API = glance.API() 

41 

42 

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) 

47 

48 info = nova.privsep.qemu.unprivileged_qemu_img_info(path, format=format) 

49 return imageutils.QemuImgInfo(info, format='json') 

50 

51 

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) 

56 

57 info = nova.privsep.qemu.privileged_qemu_img_info(path, format=format) 

58 return imageutils.QemuImgInfo(info, format='json') 

59 

60 

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) 

70 

71 

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 """ 

76 

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) 

83 

84 

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) 

99 

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) 

104 

105 

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) 

111 

112 

113def get_info(context, image_href): 

114 return IMAGE_API.get(context, image_href) 

115 

116 

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. 

122 

123 types = CONF.compute.vmdk_allowed_types 

124 

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) 

130 

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) 

136 

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) 

143 

144 

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() 

155 

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 

167 

168 

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. 

176 

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')) 

184 

185 inspector = get_image_format(path) 

186 inspector.safety_check() 

187 

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 

224 

225 

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) 

229 

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 

238 

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')) 

257 

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})) 

263 

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})) 

272 

273 if fmt == 'vmdk': 

274 check_vmdk_image(image_href, data) 

275 

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}) 

287 

288 os.unlink(path_tmp) 

289 

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) 

295 

296 os.rename(staged, path) 

297 else: 

298 os.rename(path_tmp, path)