Coverage for nova/image/glance.py: 93%
582 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
1# Copyright 2010 OpenStack Foundation
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
16"""Implementation of an image service that uses Glance as the backend."""
18import copy
19import inspect
20import itertools
21import os
22import random
23import re
24import stat
25import sys
26import time
27import urllib.parse as urlparse
29import cryptography
30from cursive import certificate_utils
31from cursive import exception as cursive_exception
32from cursive import signature_utils
33import glanceclient
34from glanceclient.common import utils as glance_utils
35import glanceclient.exc
36from glanceclient.v2 import schemas
37from keystoneauth1 import loading as ks_loading
38from oslo_log import log as logging
39from oslo_serialization import jsonutils
40from oslo_utils import excutils
41from oslo_utils import timeutils
43import nova.conf
44from nova import exception
45from nova import objects
46from nova.objects import fields
47from nova import profiler
48from nova import service_auth
49from nova import utils
52LOG = logging.getLogger(__name__)
53CONF = nova.conf.CONF
55_SESSION = None
58def _session_and_auth(context):
59 # Session is cached, but auth needs to be pulled from context each time.
60 global _SESSION
62 if not _SESSION:
63 _SESSION = ks_loading.load_session_from_conf_options(
64 CONF, nova.conf.glance.glance_group.name)
66 auth = service_auth.get_auth_plugin(context)
68 return _SESSION, auth
71def _glanceclient_from_endpoint(context, endpoint, version):
72 sess, auth = _session_and_auth(context)
74 return glanceclient.Client(version, session=sess, auth=auth,
75 endpoint_override=endpoint,
76 global_request_id=context.global_id)
79def generate_glance_url(context):
80 """Return a random glance url from the api servers we know about."""
81 return next(get_api_servers(context))
84def _endpoint_from_image_ref(image_href):
85 """Return the image_ref and guessed endpoint from an image url.
87 :param image_href: href of an image
88 :returns: a tuple of the form (image_id, endpoint_url)
89 """
90 parts = image_href.split('/')
91 image_id = parts[-1]
92 # the endpoint is everything in the url except the last 3 bits
93 # which are version, 'images', and image_id
94 endpoint = '/'.join(parts[:-3])
95 return (image_id, endpoint)
98def generate_identity_headers(context, status='Confirmed'):
99 return {
100 'X-Auth-Token': getattr(context, 'auth_token', None),
101 'X-User-Id': getattr(context, 'user_id', None),
102 'X-Tenant-Id': getattr(context, 'project_id', None),
103 'X-Roles': ','.join(getattr(context, 'roles', [])),
104 'X-Identity-Status': status,
105 }
108def get_api_servers(context):
109 """Shuffle a list of service endpoints and return an iterator that will
110 cycle through the list, looping around to the beginning if necessary.
111 """
112 # NOTE(efried): utils.get_ksa_adapter().get_endpoint() is the preferred
113 # mechanism for endpoint discovery. Only use `api_servers` if you really
114 # need to shuffle multiple endpoints.
115 if CONF.glance.api_servers:
116 api_servers = CONF.glance.api_servers
117 random.shuffle(api_servers)
118 else:
119 sess, auth = _session_and_auth(context)
120 ksa_adap = utils.get_ksa_adapter(
121 nova.conf.glance.DEFAULT_SERVICE_TYPE,
122 ksa_auth=auth, ksa_session=sess,
123 min_version='2.0', max_version='2.latest')
124 endpoint = utils.get_endpoint(ksa_adap)
125 if endpoint: 125 ↛ 131line 125 didn't jump to line 131 because the condition on line 125 was always true
126 # NOTE(mriedem): Due to python-glanceclient bug 1707995 we have
127 # to massage the endpoint URL otherwise it won't work properly.
128 # We can't use glanceclient.common.utils.strip_version because
129 # of bug 1748009.
130 endpoint = re.sub(r'/v\d+(\.\d+)?/?$', '/', endpoint)
131 api_servers = [endpoint]
133 return itertools.cycle(api_servers)
136class GlanceClientWrapper(object):
137 """Glance client wrapper class that implements retries."""
139 def __init__(self, context=None, endpoint=None):
140 version = 2
141 if endpoint is not None:
142 self.client = self._create_static_client(context,
143 endpoint,
144 version)
145 else:
146 self.client = None
147 self.api_servers = None
149 def _create_static_client(self, context, endpoint, version):
150 """Create a client that we'll use for every call."""
151 self.api_server = str(endpoint)
152 return _glanceclient_from_endpoint(context, endpoint, version)
154 def _create_onetime_client(self, context, version):
155 """Create a client that will be used for one call."""
156 if self.api_servers is None:
157 self.api_servers = get_api_servers(context)
158 self.api_server = next(self.api_servers)
159 return _glanceclient_from_endpoint(context, self.api_server, version)
161 def call(self, context, version, method, controller=None, args=None,
162 kwargs=None):
163 """Call a glance client method. If we get a connection error,
164 retry the request according to CONF.glance.num_retries.
166 :param context: RequestContext to use
167 :param version: Numeric version of the *Glance API* to use
168 :param method: string method name to execute on the glanceclient
169 :param controller: optional string name of the client controller to
170 use. Default (None) is to use the 'images'
171 controller
172 :param args: optional iterable of arguments to pass to the
173 glanceclient method, splatted as positional args
174 :param kwargs: optional dict of arguments to pass to the glanceclient,
175 splatted into named arguments
176 """
177 args = args or []
178 kwargs = kwargs or {}
179 retry_excs = (glanceclient.exc.ServiceUnavailable,
180 glanceclient.exc.InvalidEndpoint,
181 glanceclient.exc.CommunicationError,
182 IOError)
183 num_attempts = 1 + CONF.glance.num_retries
184 controller_name = controller or 'images'
186 for attempt in range(1, num_attempts + 1): 186 ↛ exitline 186 didn't return from function 'call' because the loop on line 186 didn't complete
187 client = self.client or self._create_onetime_client(context,
188 version)
189 try:
190 controller = getattr(client, controller_name)
191 result = getattr(controller, method)(*args, **kwargs)
192 if inspect.isgenerator(result):
193 # Convert generator results to a list, so that we can
194 # catch any potential exceptions now and retry the call.
195 return list(result)
196 return result
197 except retry_excs as e:
198 if attempt < num_attempts:
199 extra = "retrying"
200 else:
201 extra = 'done trying'
203 LOG.exception("Error contacting glance server "
204 "'%(server)s' for '%(method)s', "
205 "%(extra)s.",
206 {'server': self.api_server,
207 'method': method, 'extra': extra})
208 if attempt == num_attempts:
209 raise exception.GlanceConnectionFailed(
210 server=str(self.api_server), reason=str(e))
211 time.sleep(1)
214class GlanceImageServiceV2(object):
215 """Provides storage and retrieval of disk image objects within Glance."""
217 def __init__(self, client=None):
218 self._client = client or GlanceClientWrapper()
219 # NOTE(danms): This used to be built from a list of external modules
220 # that were loaded at runtime. Preserve this list for implementations
221 # to be added here.
222 self._download_handlers = {}
224 if CONF.glance.enable_rbd_download:
225 self._download_handlers['rbd'] = self.rbd_download
227 def rbd_download(self, context, url_parts, dst_path, metadata=None):
228 """Use an explicit rbd call to download an image.
230 :param context: The `nova.context.RequestContext` object for the
231 request
232 :param url_parts: Parts of URL pointing to the image location
233 :param dst_path: Filepath to transfer the image file to.
234 :param metadata: Image location metadata (currently unused)
235 """
237 # avoid circular import
238 from nova.storage import rbd_utils
239 try:
240 # Parse the RBD URL from url_parts, it should consist of 4
241 # sections and be in the format of:
242 # <cluster_uuid>/<pool_name>/<image_uuid>/<snapshot_name>
243 url_path = str(urlparse.unquote(url_parts.path))
244 cluster_uuid, pool_name, image_uuid, snapshot_name = (
245 url_path.split('/'))
246 except ValueError as e:
247 msg = f"Invalid RBD URL format: {e}"
248 LOG.error(msg)
249 raise nova.exception.InvalidParameterValue(msg)
251 rbd_driver = rbd_utils.RBDDriver(
252 user=CONF.glance.rbd_user,
253 pool=CONF.glance.rbd_pool,
254 ceph_conf=CONF.glance.rbd_ceph_conf,
255 connect_timeout=CONF.glance.rbd_connect_timeout)
257 try:
258 LOG.debug("Attempting to export RBD image: "
259 "[pool_name: %s] [image_uuid: %s] "
260 "[snapshot_name: %s] [dst_path: %s]",
261 pool_name, image_uuid, snapshot_name, dst_path)
263 rbd_driver.export_image(dst_path, image_uuid,
264 snapshot_name, pool_name)
265 except Exception as e:
266 LOG.error("Error during RBD image export: %s", e)
267 raise nova.exception.CouldNotFetchImage(image_id=image_uuid)
269 def show(self, context, image_id, include_locations=False,
270 show_deleted=True):
271 """Returns a dict with image data for the given opaque image id.
273 :param context: The context object to pass to image client
274 :param image_id: The UUID of the image
275 :param include_locations: (Optional) include locations in the returned
276 dict of information if the image service API
277 supports it. If the image service API does
278 not support the locations attribute, it will
279 still be included in the returned dict, as an
280 empty list.
281 :param show_deleted: (Optional) show the image even the status of
282 image is deleted.
283 """
284 try:
285 image = self._client.call(context, 2, 'get', args=(image_id,))
286 except Exception:
287 _reraise_translated_image_exception(image_id)
289 if not show_deleted and getattr(image, 'deleted', False):
290 raise exception.ImageNotFound(image_id=image_id)
292 if not _is_image_available(context, image):
293 raise exception.ImageNotFound(image_id=image_id)
295 image = _translate_from_glance(image,
296 include_locations=include_locations)
297 if include_locations:
298 locations = image.get('locations', None) or []
299 du = image.get('direct_url', None)
300 if du: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true
301 locations.append({'url': du, 'metadata': {}})
302 image['locations'] = locations
304 return image
306 def _get_transfer_method(self, scheme):
307 """Returns a transfer method for scheme, or None."""
308 try:
309 return self._download_handlers[scheme]
310 except KeyError:
311 return None
313 def detail(self, context, **kwargs):
314 """Calls out to Glance for a list of detailed image information."""
315 params = _extract_query_params_v2(kwargs)
316 try:
317 images = self._client.call(context, 2, 'list', kwargs=params)
318 except Exception:
319 _reraise_translated_exception()
321 _images = []
322 for image in images:
323 if _is_image_available(context, image):
324 _images.append(_translate_from_glance(image))
326 return _images
328 @staticmethod
329 def _safe_fsync(fh):
330 """Performs os.fsync on a filehandle only if it is supported.
332 fsync on a pipe, FIFO, or socket raises OSError with EINVAL. This
333 method discovers whether the target filehandle is one of these types
334 and only performs fsync if it isn't.
336 :param fh: Open filehandle (not a path or fileno) to maybe fsync.
337 """
338 fileno = fh.fileno()
339 mode = os.fstat(fileno).st_mode
340 # A pipe answers True to S_ISFIFO
341 if not any(check(mode) for check in (stat.S_ISFIFO, stat.S_ISSOCK)):
342 os.fsync(fileno)
344 def _try_special_handlers(self, context, image_id, dst_path, verifier):
345 image = self.show(context, image_id, include_locations=True)
346 for entry in image.get('locations', []):
347 loc_url = entry['url']
348 loc_meta = entry['metadata']
349 o = urlparse.urlparse(loc_url)
350 xfer_method = self._get_transfer_method(o.scheme)
351 if not xfer_method:
352 continue
354 try:
355 xfer_method(context, o, dst_path, loc_meta)
356 LOG.info("Successfully transferred using %s", o.scheme)
358 if not verifier:
359 return True
361 # Load chunks from the downloaded image file
362 # for verification
363 with open(dst_path, 'rb') as fh:
364 downloaded_length = os.path.getsize(dst_path)
365 image_chunks = glance_utils.IterableWithLength(fh,
366 downloaded_length)
367 self._verify_and_write(context, image_id, verifier,
368 image_chunks, None, None)
369 return True
370 except Exception:
371 LOG.exception("Download image error")
373 return False
375 def download(self, context, image_id, data=None, dst_path=None,
376 trusted_certs=None):
377 """Calls out to Glance for data and writes data."""
378 # First, try to get the verifier, so we do not even start to download
379 # the image and then fail on the metadata
380 verifier = self._get_verifier(context, image_id, trusted_certs)
382 # Second, try to delegate image download to a special handler
383 if (self._download_handlers and dst_path is not None):
384 if self._try_special_handlers(context, image_id, dst_path,
385 verifier):
386 return
388 # By default (or if direct download has failed), use glance client call
389 # to fetch the image and fill image_chunks
390 try:
391 image_chunks = self._client.call(
392 context, 2, 'data', args=(image_id,))
393 except Exception:
394 _reraise_translated_image_exception(image_id)
396 if image_chunks.wrapped is None:
397 # None is a valid return value, but there's nothing we can do with
398 # a image with no associated data
399 raise exception.ImageUnacceptable(image_id=image_id,
400 reason='Image has no associated data')
402 return self._verify_and_write(context, image_id, verifier,
403 image_chunks, data, dst_path)
405 def _verify_and_write(self, context, image_id, verifier,
406 image_chunks, data, dst_path):
407 """Perform image signature verification and save the image file if
408 needed.
410 This function writes the content of the image_chunks iterator either to
411 a file object provided by the data parameter or to a filepath provided
412 by dst_path parameter. If none of them are provided then no data will
413 be written out but instead image_chunks iterator is returned.
415 :param image_id: The UUID of the image
416 :param verifier: An instance of a 'cursive.verifier'
417 :param image_chunks An iterator pointing to the image data
418 :param data: File object to use when writing the image.
419 If passed as None and dst_path is provided, new file is opened.
420 :param dst_path: Filepath to transfer the image file to.
421 :returns an iterable with image data, or nothing. Iterable is returned
422 only when data param is None and dst_path is not provided (assuming
423 the caller wants to process the data by itself).
425 """
427 close_file = False
428 if data is None and dst_path:
429 data = open(dst_path, 'wb')
430 close_file = True
432 write_image = True
433 if data is None:
434 write_image = False
436 try:
437 # Exit early if we do not need write nor verify
438 if verifier is None and not write_image:
439 return image_chunks
441 for chunk in image_chunks:
442 if verifier:
443 verifier.update(chunk)
444 if write_image:
445 data.write(chunk)
446 if verifier:
447 verifier.verify()
448 LOG.info('Image signature verification succeeded '
449 'for image %s', image_id)
450 except cryptography.exceptions.InvalidSignature:
451 if write_image:
452 data.truncate(0)
453 with excutils.save_and_reraise_exception():
454 LOG.error('Image signature verification failed '
455 'for image %s', image_id)
456 except Exception as ex:
457 if write_image: 457 ↛ 462line 457 didn't jump to line 462 because the condition on line 457 was always true
458 with excutils.save_and_reraise_exception():
459 LOG.error("Error writing to %(path)s: %(exception)s",
460 {'path': dst_path, 'exception': ex})
461 else:
462 with excutils.save_and_reraise_exception():
463 LOG.error("Error during image verification: %s", ex)
465 finally:
466 if close_file:
467 # Ensure that the data is pushed all the way down to
468 # persistent storage. This ensures that in the event of a
469 # subsequent host crash we don't have running instances
470 # using a corrupt backing file.
471 data.flush()
472 self._safe_fsync(data)
473 data.close()
475 if data is None:
476 return image_chunks
478 def _get_verifier(self, context, image_id, trusted_certs):
479 verifier = None
481 # Use the default certs if the user didn't provide any (and there are
482 # default certs configured).
483 if (not trusted_certs and CONF.glance.enable_certificate_validation and
484 CONF.glance.default_trusted_certificate_ids):
485 trusted_certs = objects.TrustedCerts(
486 ids=CONF.glance.default_trusted_certificate_ids)
488 # Verify image signature if feature is enabled or trusted
489 # certificates were provided
490 if trusted_certs or CONF.glance.verify_glance_signatures:
491 image_meta_dict = self.show(context, image_id,
492 include_locations=False)
493 image_meta = objects.ImageMeta.from_dict(image_meta_dict)
494 img_signature = image_meta.properties.get('img_signature')
495 img_sig_hash_method = image_meta.properties.get(
496 'img_signature_hash_method'
497 )
498 img_sig_cert_uuid = image_meta.properties.get(
499 'img_signature_certificate_uuid'
500 )
501 img_sig_key_type = image_meta.properties.get(
502 'img_signature_key_type'
503 )
504 try:
505 verifier = signature_utils.get_verifier(
506 context=context,
507 img_signature_certificate_uuid=img_sig_cert_uuid,
508 img_signature_hash_method=img_sig_hash_method,
509 img_signature=img_signature,
510 img_signature_key_type=img_sig_key_type,
511 )
512 except cursive_exception.SignatureVerificationError:
513 with excutils.save_and_reraise_exception():
514 LOG.error('Image signature verification failed '
515 'for image: %s', image_id)
516 # Validate image signature certificate if trusted certificates
517 # were provided
518 # NOTE(jackie-truong): Certificate validation will occur if
519 # trusted_certs are provided, even if the certificate validation
520 # feature is disabled. This is to provide safety for the user.
521 # We may want to consider making this a "soft" check in the future.
522 if trusted_certs:
523 _verify_certs(context, img_sig_cert_uuid, trusted_certs)
524 elif CONF.glance.enable_certificate_validation:
525 msg = ('Image signature certificate validation enabled, '
526 'but no trusted certificate IDs were provided. '
527 'Unable to validate the certificate used to '
528 'verify the image signature.')
529 LOG.warning(msg)
530 raise exception.CertificateValidationFailed(msg)
531 else:
532 LOG.debug('Certificate validation was not performed. A list '
533 'of trusted image certificate IDs must be provided '
534 'in order to validate an image certificate.')
536 return verifier
538 def create(self, context, image_meta, data=None):
539 """Store the image data and return the new image object."""
540 # Here we workaround the situation when user wants to activate an
541 # empty image right after the creation. In Glance v1 api (and
542 # therefore in Nova) it is enough to set 'size = 0'. v2 api
543 # doesn't allow this hack - we have to send an upload request with
544 # empty data.
545 force_activate = data is None and image_meta.get('size') == 0
547 # The "instance_owner" property is set in the API if a user, who is
548 # not the owner of an instance, is creating the image, e.g. admin
549 # snapshots or shelves another user's instance. This is used to add
550 # member access to the image for the instance owner.
551 sharing_member_id = image_meta.get('properties', {}).pop(
552 'instance_owner', None)
553 sent_service_image_meta = _translate_to_glance(image_meta)
555 try:
556 image = self._create_v2(context, sent_service_image_meta,
557 data, force_activate,
558 sharing_member_id=sharing_member_id)
559 except glanceclient.exc.HTTPException:
560 _reraise_translated_exception()
562 return _translate_from_glance(image)
564 def _add_location(self, context, image_id, location):
565 # 'show_multiple_locations' must be enabled in glance api conf file.
566 try:
567 return self._client.call(
568 context, 2, 'add_location', args=(image_id, location, {}))
569 except glanceclient.exc.HTTPBadRequest:
570 _reraise_translated_exception()
572 def _add_image_member(self, context, image_id, member_id):
573 """Grant access to another project that does not own the image
575 :param context: nova auth RequestContext where context.project_id is
576 the owner of the image
577 :param image_id: ID of the image on which to grant access
578 :param member_id: ID of the member project to grant access to the
579 image; this should not be the owner of the image
580 :returns: A Member schema object of the created image member
581 """
582 try:
583 return self._client.call(
584 context, 2, 'create', controller='image_members',
585 args=(image_id, member_id))
586 except glanceclient.exc.HTTPBadRequest:
587 _reraise_translated_exception()
589 def _upload_data(self, context, image_id, data):
590 # NOTE(aarents) offload upload in a native thread as it can block
591 # coroutine in busy environment.
592 utils.tpool_execute(self._client.call,
593 context, 2, 'upload',
594 args=(image_id, data))
596 return self._client.call(context, 2, 'get', args=(image_id,))
598 def _get_image_create_disk_format_default(self, context):
599 """Gets an acceptable default image disk_format based on the schema.
600 """
601 # These preferred disk formats are in order:
602 # 1. we want qcow2 if possible (at least for backward compat)
603 # 2. vhd for hyperv
604 # 3. vmdk for vmware
605 # 4. raw should be universally accepted
606 preferred_disk_formats = (
607 fields.DiskFormat.QCOW2,
608 fields.DiskFormat.VHD,
609 fields.DiskFormat.VMDK,
610 fields.DiskFormat.RAW,
611 )
613 # Get the image schema - note we don't cache this value since it could
614 # change under us. This looks a bit funky, but what it's basically
615 # doing is calling glanceclient.v2.Client.schemas.get('image').
616 image_schema = self._client.call(
617 context, 2, 'get', args=('image',), controller='schemas')
618 # get the disk_format schema property from the raw schema
619 disk_format_schema = (
620 image_schema.raw()['properties'].get('disk_format') if image_schema
621 else {}
622 )
623 if disk_format_schema and 'enum' in disk_format_schema:
624 supported_disk_formats = disk_format_schema['enum']
625 # try a priority ordered list
626 for preferred_format in preferred_disk_formats:
627 if preferred_format in supported_disk_formats:
628 return preferred_format
629 # alright, let's just return whatever is available
630 LOG.debug('Unable to find a preferred disk_format for image '
631 'creation with the Image Service v2 API. Using: %s',
632 supported_disk_formats[0])
633 return supported_disk_formats[0]
635 LOG.warning('Unable to determine disk_format schema from the '
636 'Image Service v2 API. Defaulting to '
637 '%(preferred_disk_format)s.',
638 {'preferred_disk_format': preferred_disk_formats[0]})
639 return preferred_disk_formats[0]
641 def _create_v2(self, context, sent_service_image_meta, data=None,
642 force_activate=False, sharing_member_id=None):
643 # Glance v1 allows image activation without setting disk and
644 # container formats, v2 doesn't. It leads to the dirtiest workaround
645 # where we have to hardcode this parameters.
646 if force_activate:
647 data = ''
648 # NOTE(danms): If we are using this terrible hack to upload
649 # zero-length data to activate the image, we cannot claim it
650 # is some format other than 'raw'. If the caller asked for
651 # something specific, that's a bug. Otherwise, we must force
652 # disk_format=raw.
653 if 'disk_format' not in sent_service_image_meta:
654 sent_service_image_meta['disk_format'] = 'raw'
655 elif sent_service_image_meta['disk_format'] != 'raw': 655 ↛ 659line 655 didn't jump to line 659 because the condition on line 655 was always true
656 raise exception.ImageBadRequest(
657 'Unable to force activate with disk_format=%s' % (
658 sent_service_image_meta['disk_format']))
659 if 'container_format' not in sent_service_image_meta: 659 ↛ 662line 659 didn't jump to line 662 because the condition on line 659 was always true
660 sent_service_image_meta['container_format'] = 'bare'
662 location = sent_service_image_meta.pop('location', None)
663 image = self._client.call(
664 context, 2, 'create', kwargs=sent_service_image_meta)
665 image_id = image['id']
667 # Sending image location in a separate request.
668 if location:
669 image = self._add_location(context, image_id, location)
671 # Add image membership in a separate request.
672 if sharing_member_id:
673 LOG.debug('Adding access for member %s to image %s',
674 sharing_member_id, image_id)
675 self._add_image_member(context, image_id, sharing_member_id)
677 # If we have some data we have to send it in separate request and
678 # update the image then.
679 if data is not None:
680 image = self._upload_data(context, image_id, data)
682 return image
684 def update(self, context, image_id, image_meta, data=None,
685 purge_props=True):
686 """Modify the given image with the new data."""
687 sent_service_image_meta = _translate_to_glance(image_meta)
688 # NOTE(bcwaldon): id is not an editable field, but it is likely to be
689 # passed in by calling code. Let's be nice and ignore it.
690 sent_service_image_meta.pop('id', None)
691 sent_service_image_meta['image_id'] = image_id
693 try:
694 if purge_props:
695 # In Glance v2 we have to explicitly set prop names
696 # we want to remove.
697 all_props = set(self.show(
698 context, image_id)['properties'].keys())
699 props_to_update = set(
700 image_meta.get('properties', {}).keys())
701 remove_props = list(all_props - props_to_update)
702 sent_service_image_meta['remove_props'] = remove_props
704 image = self._update_v2(context, sent_service_image_meta, data)
705 except Exception:
706 _reraise_translated_image_exception(image_id)
708 return _translate_from_glance(image)
710 def _update_v2(self, context, sent_service_image_meta, data=None):
711 location = sent_service_image_meta.pop('location', None)
712 image_id = sent_service_image_meta['image_id']
713 image = self._client.call(
714 context, 2, 'update', kwargs=sent_service_image_meta)
716 # Sending image location in a separate request.
717 if location:
718 image = self._add_location(context, image_id, location)
720 # If we have some data we have to send it in separate request and
721 # update the image then.
722 if data is not None:
723 image = self._upload_data(context, image_id, data)
725 return image
727 def delete(self, context, image_id):
728 """Delete the given image.
730 :raises: ImageNotFound if the image does not exist.
731 :raises: NotAuthorized if the user is not an owner.
732 :raises: ImageNotAuthorized if the user is not authorized.
733 :raises: ImageDeleteConflict if the image is conflicted to delete.
735 """
736 try:
737 self._client.call(context, 2, 'delete', args=(image_id,))
738 except glanceclient.exc.NotFound:
739 raise exception.ImageNotFound(image_id=image_id)
740 except glanceclient.exc.HTTPForbidden:
741 raise exception.ImageNotAuthorized(image_id=image_id)
742 except glanceclient.exc.HTTPConflict as exc:
743 raise exception.ImageDeleteConflict(reason=str(exc))
744 return True
746 def image_import_copy(self, context, image_id, stores):
747 """Copy an image to another store using image_import.
749 This triggers the Glance image_import API with an opinionated
750 method of 'copy-image' to a list of stores. This will initiate
751 a copy of the image from one of the existing stores to the
752 stores provided.
754 :param context: The RequestContext
755 :param image_id: The image to copy
756 :param stores: A list of stores to copy the image to
758 :raises: ImageNotFound if the image does not exist.
759 :raises: ImageNotAuthorized if the user is not permitted to
760 import/copy this image
761 :raises: ImageImportImpossible if the image cannot be imported
762 for workflow reasons (not active, etc)
763 :raises: ImageBadRequest if the image is already in the requested
764 store (which may be a race)
765 """
766 try:
767 self._client.call(context, 2, 'image_import', args=(image_id,),
768 kwargs={'method': 'copy-image',
769 'stores': stores})
770 except glanceclient.exc.NotFound:
771 raise exception.ImageNotFound(image_id=image_id)
772 except glanceclient.exc.HTTPForbidden:
773 raise exception.ImageNotAuthorized(image_id=image_id)
774 except glanceclient.exc.HTTPConflict as exc:
775 raise exception.ImageImportImpossible(image_id=image_id,
776 reason=str(exc))
777 except glanceclient.exc.HTTPBadRequest as exc:
778 raise exception.ImageBadRequest(image_id=image_id,
779 response=str(exc))
782def _extract_query_params_v2(params):
783 _params = {}
784 accepted_params = ('filters', 'marker', 'limit',
785 'page_size', 'sort_key', 'sort_dir')
786 for param in accepted_params:
787 if params.get(param):
788 _params[param] = params.get(param)
790 # ensure filters is a dict
791 _params.setdefault('filters', {})
792 # NOTE(vish): don't filter out private images
793 _params['filters'].setdefault('is_public', 'none')
795 # adopt filters to be accepted by glance v2 api
796 filters = _params['filters']
797 new_filters = {}
799 for filter_ in filters:
800 # remove 'property-' prefix from filters by custom properties
801 if filter_.startswith('property-'):
802 new_filters[filter_.lstrip('property-')] = filters[filter_]
803 elif filter_ == 'changes-since':
804 # convert old 'changes-since' into new 'updated_at' filter
805 updated_at = 'gte:' + filters['changes-since']
806 new_filters['updated_at'] = updated_at
807 elif filter_ == 'is_public':
808 # convert old 'is_public' flag into 'visibility' filter
809 # omit the filter if is_public is None
810 is_public = filters['is_public']
811 if is_public.lower() in ('true', '1'):
812 new_filters['visibility'] = 'public'
813 elif is_public.lower() in ('false', '0'): 813 ↛ 814line 813 didn't jump to line 814 because the condition on line 813 was never true
814 new_filters['visibility'] = 'private'
815 else:
816 new_filters[filter_] = filters[filter_]
818 _params['filters'] = new_filters
820 return _params
823def _is_image_available(context, image):
824 """Check image availability.
826 This check is needed in case Nova and Glance are deployed
827 without authentication turned on.
828 """
829 # The presence of an auth token implies this is an authenticated
830 # request and we need not handle the noauth use-case.
831 if hasattr(context, 'auth_token') and context.auth_token:
832 return True
834 def _is_image_public(image):
835 # NOTE(jaypipes) V2 Glance API replaced the is_public attribute
836 # with a visibility attribute. We do this here to prevent the
837 # glanceclient for a V2 image model from throwing an
838 # exception from warlock when trying to access an is_public
839 # attribute.
840 if hasattr(image, 'visibility'): 840 ↛ 843line 840 didn't jump to line 843 because the condition on line 840 was always true
841 return str(image.visibility).lower() == 'public'
842 else:
843 return image.is_public
845 if context.is_admin or _is_image_public(image):
846 return True
848 properties = image.properties
850 if context.project_id and ('owner_id' in properties):
851 return str(properties['owner_id']) == str(context.project_id)
853 if context.project_id and ('project_id' in properties):
854 return str(properties['project_id']) == str(context.project_id)
856 try:
857 user_id = properties['user_id']
858 except KeyError:
859 return False
861 return str(user_id) == str(context.user_id)
864def _translate_to_glance(image_meta):
865 image_meta = _convert_to_string(image_meta)
866 image_meta = _remove_read_only(image_meta)
867 image_meta = _convert_to_v2(image_meta)
868 return image_meta
871def _convert_to_v2(image_meta):
872 output = {}
873 for name, value in image_meta.items():
874 if name == 'properties':
875 for prop_name, prop_value in value.items():
876 # if allow_additional_image_properties is disabled we can't
877 # define kernel_id and ramdisk_id as None, so we have to omit
878 # these properties if they are not set.
879 if prop_name in ('kernel_id', 'ramdisk_id') and \
880 prop_value is not None and \
881 prop_value.strip().lower() in ('none', ''):
882 continue
883 # in glance only string and None property values are allowed,
884 # v1 client accepts any values and converts them to string,
885 # v2 doesn't - so we have to take care of it.
886 elif prop_value is None or isinstance(prop_value, str):
887 output[prop_name] = prop_value
888 else:
889 output[prop_name] = str(prop_value)
891 elif name in ('min_ram', 'min_disk'):
892 output[name] = int(value)
893 elif name == 'is_public':
894 output['visibility'] = 'public' if value else 'private'
895 elif name in ('size', 'deleted'):
896 continue
897 else:
898 output[name] = value
899 return output
902def _translate_from_glance(image, include_locations=False):
903 image_meta = _extract_attributes_v2(
904 image, include_locations=include_locations)
906 image_meta = _convert_timestamps_to_datetimes(image_meta)
907 image_meta = _convert_from_string(image_meta)
908 return image_meta
911def _convert_timestamps_to_datetimes(image_meta):
912 """Returns image with timestamp fields converted to datetime objects."""
913 for attr in ['created_at', 'updated_at', 'deleted_at']:
914 if image_meta.get(attr):
915 image_meta[attr] = timeutils.parse_isotime(image_meta[attr])
916 return image_meta
919# NOTE(bcwaldon): used to store non-string data in glance metadata
920def _json_loads(properties, attr):
921 prop = properties[attr]
922 if isinstance(prop, str): 922 ↛ exitline 922 didn't return from function '_json_loads' because the condition on line 922 was always true
923 properties[attr] = jsonutils.loads(prop)
926def _json_dumps(properties, attr):
927 prop = properties[attr]
928 if not isinstance(prop, str): 928 ↛ exitline 928 didn't return from function '_json_dumps' because the condition on line 928 was always true
929 properties[attr] = jsonutils.dumps(prop)
932_CONVERT_PROPS = ('block_device_mapping', 'mappings')
935def _convert(method, metadata):
936 metadata = copy.deepcopy(metadata)
937 properties = metadata.get('properties')
938 if properties:
939 for attr in _CONVERT_PROPS:
940 if attr in properties:
941 method(properties, attr)
943 return metadata
946def _convert_from_string(metadata):
947 return _convert(_json_loads, metadata)
950def _convert_to_string(metadata):
951 return _convert(_json_dumps, metadata)
954def _extract_attributes(image, include_locations=False):
955 # TODO(mfedosin): Remove this function once we move to glance V2
956 # completely.
957 # NOTE(hdd): If a key is not found, base.Resource.__getattr__() may perform
958 # a get(), resulting in a useless request back to glance. This list is
959 # therefore sorted, with dependent attributes as the end
960 # 'deleted_at' depends on 'deleted'
961 # 'checksum' depends on 'status' == 'active'
962 IMAGE_ATTRIBUTES = ['size', 'disk_format', 'owner',
963 'container_format', 'status', 'id',
964 'name', 'created_at', 'updated_at',
965 'deleted', 'deleted_at', 'checksum',
966 'min_disk', 'min_ram', 'is_public',
967 'direct_url', 'locations']
969 queued = getattr(image, 'status') == 'queued'
970 queued_exclude_attrs = ['disk_format', 'container_format']
971 include_locations_attrs = ['direct_url', 'locations']
972 output = {}
974 for attr in IMAGE_ATTRIBUTES:
975 if attr == 'deleted_at' and not output['deleted']:
976 output[attr] = None
977 elif attr == 'checksum' and output['status'] != 'active':
978 output[attr] = None
979 # image may not have 'name' attr
980 elif attr == 'name':
981 output[attr] = getattr(image, attr, None)
982 # NOTE(liusheng): queued image may not have these attributes and 'name'
983 elif queued and attr in queued_exclude_attrs: 983 ↛ 984line 983 didn't jump to line 984 because the condition on line 983 was never true
984 output[attr] = getattr(image, attr, None)
985 # NOTE(mriedem): Only get location attrs if including locations.
986 elif attr in include_locations_attrs:
987 if include_locations:
988 output[attr] = getattr(image, attr, None)
989 # NOTE(mdorman): 'size' attribute must not be 'None', so use 0 instead
990 elif attr == 'size':
991 # NOTE(mriedem): A snapshot image may not have the size attribute
992 # set so default to 0.
993 output[attr] = getattr(image, attr, 0) or 0
994 else:
995 # NOTE(xarses): Anything that is caught with the default value
996 # will result in an additional lookup to glance for said attr.
997 # Notable attributes that could have this issue:
998 # disk_format, container_format, name, deleted, checksum
999 output[attr] = getattr(image, attr, None)
1001 output['properties'] = getattr(image, 'properties', {})
1003 return output
1006def _extract_attributes_v2(image, include_locations=False):
1007 include_locations_attrs = ['direct_url', 'locations']
1008 omit_attrs = ['self', 'schema', 'protected', 'virtual_size', 'file',
1009 'tags']
1010 raw_schema = image.schema
1011 schema = schemas.Schema(raw_schema)
1012 output = {'properties': {}, 'deleted': False, 'deleted_at': None,
1013 'disk_format': None, 'container_format': None, 'name': None,
1014 'checksum': None}
1015 for name, value in image.items():
1016 if (name in omit_attrs or
1017 name in include_locations_attrs and not include_locations):
1018 continue
1019 elif name == 'visibility':
1020 output['is_public'] = value == 'public'
1021 elif name == 'size' and value is None: 1021 ↛ 1022line 1021 didn't jump to line 1022 because the condition on line 1021 was never true
1022 output['size'] = 0
1023 elif schema.is_base_property(name):
1024 output[name] = value
1025 else:
1026 output['properties'][name] = value
1028 return output
1031def _remove_read_only(image_meta):
1032 IMAGE_ATTRIBUTES = ['status', 'updated_at', 'created_at', 'deleted_at']
1033 output = copy.deepcopy(image_meta)
1034 for attr in IMAGE_ATTRIBUTES:
1035 if attr in output:
1036 del output[attr]
1037 return output
1040def _reraise_translated_image_exception(image_id):
1041 """Transform the exception for the image but keep its traceback intact."""
1042 exc_type, exc_value, exc_trace = sys.exc_info()
1043 new_exc = _translate_image_exception(image_id, exc_value)
1044 raise new_exc.with_traceback(exc_trace)
1047def _reraise_translated_exception():
1048 """Transform the exception but keep its traceback intact."""
1049 exc_type, exc_value, exc_trace = sys.exc_info()
1050 new_exc = _translate_plain_exception(exc_value)
1051 raise new_exc.with_traceback(exc_trace)
1054def _translate_image_exception(image_id, exc_value):
1055 if isinstance(exc_value, (glanceclient.exc.Forbidden,
1056 glanceclient.exc.Unauthorized)):
1057 return exception.ImageNotAuthorized(image_id=image_id)
1058 if isinstance(exc_value, glanceclient.exc.NotFound):
1059 return exception.ImageNotFound(image_id=image_id)
1060 if isinstance(exc_value, glanceclient.exc.BadRequest): 1060 ↛ 1061line 1060 didn't jump to line 1061 because the condition on line 1060 was never true
1061 return exception.ImageBadRequest(image_id=image_id,
1062 response=str(exc_value))
1063 if isinstance(exc_value, glanceclient.exc.HTTPOverLimit): 1063 ↛ 1065line 1063 didn't jump to line 1065 because the condition on line 1063 was always true
1064 return exception.ImageQuotaExceeded(image_id=image_id)
1065 return exc_value
1068def _translate_plain_exception(exc_value):
1069 if isinstance(exc_value, (glanceclient.exc.Forbidden,
1070 glanceclient.exc.Unauthorized)):
1071 return exception.Forbidden(str(exc_value))
1072 if isinstance(exc_value, glanceclient.exc.NotFound):
1073 return exception.NotFound(str(exc_value))
1074 if isinstance(exc_value, glanceclient.exc.BadRequest):
1075 return exception.Invalid(str(exc_value))
1076 return exc_value
1079def _verify_certs(context, img_sig_cert_uuid, trusted_certs):
1080 try:
1081 certificate_utils.verify_certificate(
1082 context=context,
1083 certificate_uuid=img_sig_cert_uuid,
1084 trusted_certificate_uuids=trusted_certs.ids)
1085 LOG.debug('Image signature certificate validation '
1086 'succeeded for certificate: %s',
1087 img_sig_cert_uuid)
1088 except cursive_exception.SignatureVerificationError as e:
1089 LOG.warning('Image signature certificate validation '
1090 'failed for certificate: %s',
1091 img_sig_cert_uuid)
1092 raise exception.CertificateValidationFailed(
1093 cert_uuid=img_sig_cert_uuid, reason=str(e))
1096def get_remote_image_service(context, image_href):
1097 """Create an image_service and parse the id from the given image_href.
1099 The image_href param can be an href of the form
1100 'http://example.com:9292/v1/images/b8b2c6f7-7345-4e2f-afa2-eedaba9cbbe3',
1101 or just an id such as 'b8b2c6f7-7345-4e2f-afa2-eedaba9cbbe3'. If the
1102 image_href is a standalone id, then the default image service is returned.
1104 :param image_href: href that describes the location of an image
1105 :returns: a tuple of the form (image_service, image_id)
1107 """
1108 # NOTE(bcwaldon): If image_href doesn't look like a URI, assume its a
1109 # standalone image ID
1110 if '/' not in str(image_href):
1111 image_service = get_default_image_service()
1112 return image_service, image_href
1114 try:
1115 (image_id, endpoint) = _endpoint_from_image_ref(image_href)
1116 glance_client = GlanceClientWrapper(context=context,
1117 endpoint=endpoint)
1118 except ValueError:
1119 raise exception.InvalidImageRef(image_href=image_href)
1121 image_service = GlanceImageServiceV2(client=glance_client)
1122 return image_service, image_id
1125def get_default_image_service():
1126 return GlanceImageServiceV2()
1129class UpdateGlanceImage(object):
1130 def __init__(self, context, image_id, metadata, stream):
1131 self.context = context
1132 self.image_id = image_id
1133 self.metadata = metadata
1134 self.image_stream = stream
1136 def start(self):
1137 image_service, image_id = get_remote_image_service(
1138 self.context, self.image_id)
1139 image_service.update(self.context, image_id, self.metadata,
1140 self.image_stream, purge_props=False)
1143@profiler.trace_cls("nova_image")
1144class API(object):
1145 """API for interacting with the image service."""
1147 def _get_session_and_image_id(self, context, id_or_uri):
1148 """Returns a tuple of (session, image_id). If the supplied `id_or_uri`
1149 is an image ID, then the default client session will be returned
1150 for the context's user, along with the image ID. If the supplied
1151 `id_or_uri` parameter is a URI, then a client session connecting to
1152 the URI's image service endpoint will be returned along with a
1153 parsed image ID from that URI.
1155 :param context: The `nova.context.Context` object for the request
1156 :param id_or_uri: A UUID identifier or an image URI to look up image
1157 information for.
1158 """
1159 return get_remote_image_service(context, id_or_uri)
1161 def _get_session(self, _context):
1162 """Returns a client session that can be used to query for image
1163 information.
1165 :param _context: The `nova.context.Context` object for the request
1166 """
1167 # TODO(jaypipes): Refactor get_remote_image_service and
1168 # get_default_image_service into a single
1169 # method that takes a context and actually respects
1170 # it, returning a real session object that keeps
1171 # the context alive...
1172 return get_default_image_service()
1174 @staticmethod
1175 def generate_image_url(image_ref, context):
1176 """Generate an image URL from an image_ref.
1178 :param image_ref: The image ref to generate URL
1179 :param context: The `nova.context.Context` object for the request
1180 """
1181 return "%s/images/%s" % (next(get_api_servers(context)), image_ref)
1183 def get_all(self, context, **kwargs):
1184 """Retrieves all information records about all disk images available
1185 to show to the requesting user. If the requesting user is an admin,
1186 all images in an ACTIVE status are returned. If the requesting user
1187 is not an admin, the all public images and all private images that
1188 are owned by the requesting user in the ACTIVE status are returned.
1190 :param context: The `nova.context.Context` object for the request
1191 :param kwargs: A dictionary of filter and pagination values that
1192 may be passed to the underlying image info driver.
1193 """
1194 session = self._get_session(context)
1195 return session.detail(context, **kwargs)
1197 def get(self, context, id_or_uri, include_locations=False,
1198 show_deleted=True):
1199 """Retrieves the information record for a single disk image. If the
1200 supplied identifier parameter is a UUID, the default driver will
1201 be used to return information about the image. If the supplied
1202 identifier is a URI, then the driver that matches that URI endpoint
1203 will be used to query for image information.
1205 :param context: The `nova.context.Context` object for the request
1206 :param id_or_uri: A UUID identifier or an image URI to look up image
1207 information for.
1208 :param include_locations: (Optional) include locations in the returned
1209 dict of information if the image service API
1210 supports it. If the image service API does
1211 not support the locations attribute, it will
1212 still be included in the returned dict, as an
1213 empty list.
1214 :param show_deleted: (Optional) show the image even the status of
1215 image is deleted.
1216 """
1217 session, image_id = self._get_session_and_image_id(context, id_or_uri)
1218 return session.show(context, image_id,
1219 include_locations=include_locations,
1220 show_deleted=show_deleted)
1222 def create(self, context, image_info, data=None):
1223 """Creates a new image record, optionally passing the image bits to
1224 backend storage.
1226 :param context: The `nova.context.Context` object for the request
1227 :param image_info: A dict of information about the image that is
1228 passed to the image registry.
1229 :param data: Optional file handle or bytestream iterator that is
1230 passed to backend storage.
1231 """
1232 session = self._get_session(context)
1233 return session.create(context, image_info, data=data)
1235 def update(self, context, id_or_uri, image_info,
1236 data=None, purge_props=False):
1237 """Update the information about an image, optionally along with a file
1238 handle or bytestream iterator for image bits. If the optional file
1239 handle for updated image bits is supplied, the image may not have
1240 already uploaded bits for the image.
1242 :param context: The `nova.context.Context` object for the request
1243 :param id_or_uri: A UUID identifier or an image URI to look up image
1244 information for.
1245 :param image_info: A dict of information about the image that is
1246 passed to the image registry.
1247 :param data: Optional file handle or bytestream iterator that is
1248 passed to backend storage.
1249 :param purge_props: Optional, defaults to False. If set, the backend
1250 image registry will clear all image properties
1251 and replace them the image properties supplied
1252 in the image_info dictionary's 'properties'
1253 collection.
1254 """
1255 session, image_id = self._get_session_and_image_id(context, id_or_uri)
1256 return session.update(context, image_id, image_info, data=data,
1257 purge_props=purge_props)
1259 def delete(self, context, id_or_uri):
1260 """Delete the information about an image and mark the image bits for
1261 deletion.
1263 :param context: The `nova.context.Context` object for the request
1264 :param id_or_uri: A UUID identifier or an image URI to look up image
1265 information for.
1266 """
1267 session, image_id = self._get_session_and_image_id(context, id_or_uri)
1268 return session.delete(context, image_id)
1270 def download(self, context, id_or_uri, data=None, dest_path=None,
1271 trusted_certs=None):
1272 """Transfer image bits from Glance or a known source location to the
1273 supplied destination filepath.
1275 :param context: The `nova.context.RequestContext` object for the
1276 request
1277 :param id_or_uri: A UUID identifier or an image URI to look up image
1278 information for.
1279 :param data: A file object to use in downloading image data.
1280 :param dest_path: Filepath to transfer image bits to.
1281 :param trusted_certs: A 'nova.objects.trusted_certs.TrustedCerts'
1282 object with a list of trusted image certificate
1283 IDs.
1285 Note that because of the poor design of the
1286 `glance.ImageService.download` method, the function returns different
1287 things depending on what arguments are passed to it. If a data argument
1288 is supplied but no dest_path is specified (not currently done by any
1289 caller) then None is returned from the method. If the data argument is
1290 not specified but a destination path *is* specified, then a writeable
1291 file handle to the destination path is constructed in the method and
1292 the image bits written to that file, and again, None is returned from
1293 the method. If no data argument is supplied and no dest_path argument
1294 is supplied (VMWare virt driver), then the method returns an iterator
1295 to the image bits that the caller uses to write to wherever location it
1296 wants. Finally, if the allow_direct_url_schemes CONF option is set to
1297 something, then the nova.image.download modules are used to attempt to
1298 do an SCP copy of the image bits from a file location to the dest_path
1299 and None is returned after retrying one or more download locations
1300 (libvirt and Hyper-V virt drivers through nova.virt.images.fetch).
1302 I think the above points to just how hacky/wacky all of this code is,
1303 and the reason it needs to be cleaned up and standardized across the
1304 virt driver callers.
1305 """
1306 # TODO(jaypipes): Deprecate and remove this method entirely when we
1307 # move to a system that simply returns a file handle
1308 # to a bytestream iterator and allows the caller to
1309 # handle streaming/copying/zero-copy as they see fit.
1310 session, image_id = self._get_session_and_image_id(context, id_or_uri)
1311 return session.download(context, image_id, data=data,
1312 dst_path=dest_path,
1313 trusted_certs=trusted_certs)
1315 def copy_image_to_store(self, context, image_id, store):
1316 """Initiate a store-to-store copy in glance.
1318 :param context: The RequestContext.
1319 :param image_id: The image to copy.
1320 :param store: The glance store to target the copy.
1321 """
1322 session, image_id = self._get_session_and_image_id(context, image_id)
1323 return session.image_import_copy(context, image_id, [store])