1
# vim: tabstop=4 shiftwidth=4 softtabstop=4
3
# Copyright 2010 OpenStack LLC.
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
10
# http://www.apache.org/licenses/LICENSE-2.0
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
18
"""Implementation of an image service that uses Glance as the backend"""
20
from __future__ import absolute_import
30
from glance.common import exception as glance_exception
32
from nova import exception
33
from nova import flags
34
from nova import log as logging
35
from nova import utils
38
LOG = logging.getLogger(__name__)
44
GlanceClient = utils.import_class('glance.client.Client')
47
def _parse_image_ref(image_href):
48
"""Parse an image href into composite parts.
50
:param image_href: href of an image
51
:returns: a tuple of the form (image_id, host, port)
55
o = urlparse.urlparse(image_href)
57
host = o.netloc.split(':', 1)[0]
58
image_id = o.path.split('/')[-1]
59
return (image_id, host, port)
62
def _create_glance_client(context, host, port):
63
if FLAGS.auth_strategy == 'keystone':
64
# NOTE(dprince): Glance client just needs auth_tok right? Should we
65
# add username and tenant to the creds below?
66
creds = {'strategy': 'keystone',
67
'username': context.user_id,
68
'tenant': context.project_id}
69
glance_client = GlanceClient(host, port, auth_tok=context.auth_token,
72
glance_client = GlanceClient(host, port)
76
def pick_glance_api_server():
77
"""Return which Glance API server to use for the request
79
This method provides a very primitive form of load-balancing suitable for
80
testing and sandbox environments. In production, it would be better to use
81
one IP and route that to a real load-balancer.
85
host_port = random.choice(FLAGS.glance_api_servers)
86
host, port_str = host_port.split(':')
91
def get_glance_client(context, image_href):
92
"""Get the correct glance client and id for the given image_href.
94
The image_href param can be an href of the form
95
http://myglanceserver:9292/images/42, or just an int such as 42. If the
96
image_href is an int, then flags are used to create the default
99
:param image_href: image ref/id for an image
100
:returns: a tuple of the form (glance_client, image_id)
103
glance_host, glance_port = pick_glance_api_server()
105
# check if this is an id
106
if '/' not in str(image_href):
107
glance_client = _create_glance_client(context,
110
return (glance_client, image_href)
114
(image_id, host, port) = _parse_image_ref(image_href)
116
raise exception.InvalidImageRef(image_href=image_href)
118
glance_client = _create_glance_client(context,
121
return (glance_client, image_id)
124
class GlanceImageService(object):
125
"""Provides storage and retrieval of disk image objects within Glance."""
127
def __init__(self, client=None):
128
self._client = client
130
def _get_client(self, context):
131
# NOTE(sirp): we want to load balance each request across glance
132
# servers. Since GlanceImageService is a long-lived object, `client`
133
# is made to choose a new server each time via this property.
134
if self._client is not None:
136
glance_host, glance_port = pick_glance_api_server()
137
return _create_glance_client(context, glance_host, glance_port)
139
def _call_retry(self, context, name, *args, **kwargs):
140
"""Retry call to glance server if there is a connection error.
141
Suitable only for idempotent calls."""
142
for i in xrange(FLAGS.glance_num_retries + 1):
143
client = self._get_client(context)
145
return getattr(client, name)(*args, **kwargs)
146
except glance_exception.ClientConnectionError as e:
147
LOG.exception(_('Connection error contacting glance'
148
' server, retrying'))
152
raise exception.GlanceConnectionFailed(
153
reason=_('Maximum attempts reached'))
155
def index(self, context, **kwargs):
156
"""Calls out to Glance for a list of images available."""
157
params = self._extract_query_params(kwargs)
158
image_metas = self._get_images(context, **params)
161
for image_meta in image_metas:
162
# NOTE(sirp): We need to use `get_images_detailed` and not
163
# `get_images` here because we need `is_public` and `properties`
164
# included so we can filter by user
165
if self._is_image_available(context, image_meta):
166
meta_subset = utils.subset_dict(image_meta, ('id', 'name'))
167
images.append(meta_subset)
170
def detail(self, context, **kwargs):
171
"""Calls out to Glance for a list of detailed image information."""
172
params = self._extract_query_params(kwargs)
173
image_metas = self._get_images(context, **params)
176
for image_meta in image_metas:
177
if self._is_image_available(context, image_meta):
178
base_image_meta = self._translate_from_glance(image_meta)
179
images.append(base_image_meta)
182
def _extract_query_params(self, params):
184
accepted_params = ('filters', 'marker', 'limit',
185
'sort_key', 'sort_dir')
186
for param in accepted_params:
188
_params[param] = params.get(param)
192
def _get_images(self, context, **kwargs):
193
"""Get image entitites from images service"""
195
# ensure filters is a dict
196
kwargs['filters'] = kwargs.get('filters') or {}
197
# NOTE(vish): don't filter out private images
198
kwargs['filters'].setdefault('is_public', 'none')
200
client = self._get_client(context)
201
return self._fetch_images(client.get_images_detailed, **kwargs)
203
def _fetch_images(self, fetch_func, **kwargs):
204
"""Paginate through results from glance server"""
206
images = fetch_func(**kwargs)
208
_reraise_translated_exception()
211
# break out of recursive loop to end pagination
218
# attempt to advance the marker in order to fetch next page
219
kwargs['marker'] = images[-1]['id']
221
raise exception.ImagePaginationFailed()
224
kwargs['limit'] = kwargs['limit'] - len(images)
225
# break if we have reached a provided limit
226
if kwargs['limit'] <= 0:
229
# ignore missing limit, just proceed without it
232
for image in self._fetch_images(fetch_func, **kwargs):
235
def show(self, context, image_id):
236
"""Returns a dict with image data for the given opaque image id."""
238
image_meta = self._call_retry(context, 'get_image_meta',
241
_reraise_translated_image_exception(image_id)
243
if not self._is_image_available(context, image_meta):
244
raise exception.ImageNotFound(image_id=image_id)
246
base_image_meta = self._translate_from_glance(image_meta)
247
return base_image_meta
249
def show_by_name(self, context, name):
250
"""Returns a dict containing image data for the given name."""
251
image_metas = self.detail(context, filters={'name': name})
253
return image_metas[0]
254
except (IndexError, TypeError):
255
raise exception.ImageNotFound(image_id=name)
257
def get(self, context, image_id, data):
258
"""Calls out to Glance for metadata and data and writes data."""
260
image_meta, image_chunks = self._call_retry(context, 'get_image',
263
_reraise_translated_image_exception(image_id)
265
for chunk in image_chunks:
268
base_image_meta = self._translate_from_glance(image_meta)
269
return base_image_meta
271
def create(self, context, image_meta, data=None):
272
"""Store the image data and return the new image id.
274
:raises: AlreadyExists if the image already exist.
277
# Translate Base -> Service
278
LOG.debug(_('Creating image in Glance. Metadata passed in %s'),
280
sent_service_image_meta = self._translate_to_glance(image_meta)
281
LOG.debug(_('Metadata after formatting for Glance %s'),
282
sent_service_image_meta)
284
recv_service_image_meta = self._get_client(context).add_image(
285
sent_service_image_meta, data)
287
# Translate Service -> Base
288
base_image_meta = self._translate_from_glance(recv_service_image_meta)
289
LOG.debug(_('Metadata returned from Glance formatted for Base %s'),
291
return base_image_meta
293
def update(self, context, image_id, image_meta, data=None):
294
"""Replace the contents of the given image with the new data.
296
:raises: ImageNotFound if the image does not exist.
299
# NOTE(vish): show is to check if image is available
300
self.show(context, image_id)
301
image_meta = self._translate_to_glance(image_meta)
302
client = self._get_client(context)
304
image_meta = client.update_image(image_id, image_meta, data)
306
_reraise_translated_image_exception(image_id)
308
base_image_meta = self._translate_from_glance(image_meta)
309
return base_image_meta
311
def delete(self, context, image_id):
312
"""Delete the given image.
314
:raises: ImageNotFound if the image does not exist.
315
:raises: NotAuthorized if the user is not an owner.
318
# NOTE(vish): show is to check if image is available
319
image_meta = self.show(context, image_id)
321
if FLAGS.auth_strategy == 'deprecated':
322
# NOTE(parthi): only allow image deletions if the user
323
# is a member of the project owning the image, in case of
324
# setup without keystone
325
# TODO(parthi): Currently this access control breaks if
326
# 1. Image is not owned by a project
327
# 2. Deleting user is not bound a project
328
properties = image_meta['properties']
329
if (context.project_id and ('project_id' in properties)
330
and (context.project_id != properties['project_id'])):
331
raise exception.NotAuthorized(_("Not the image owner"))
333
if (context.project_id and ('owner_id' in properties)
334
and (context.project_id != properties['owner_id'])):
335
raise exception.NotAuthorized(_("Not the image owner"))
338
result = self._get_client(context).delete_image(image_id)
339
except glance_exception.NotFound:
340
raise exception.ImageNotFound(image_id=image_id)
343
def delete_all(self):
344
"""Clears out all images."""
348
def _translate_to_glance(cls, image_meta):
349
image_meta = _convert_to_string(image_meta)
350
image_meta = _remove_read_only(image_meta)
354
def _translate_from_glance(cls, image_meta):
355
image_meta = _limit_attributes(image_meta)
356
image_meta = _convert_timestamps_to_datetimes(image_meta)
357
image_meta = _convert_from_string(image_meta)
361
def _is_image_available(context, image_meta):
362
"""Check image availability.
364
Under Glance, images are always available if the context has
368
if hasattr(context, 'auth_token') and context.auth_token:
371
if image_meta['is_public'] or context.is_admin:
374
properties = image_meta['properties']
376
if context.project_id and ('owner_id' in properties):
377
return str(properties['owner_id']) == str(context.project_id)
379
if context.project_id and ('project_id' in properties):
380
return str(properties['project_id']) == str(context.project_id)
383
user_id = properties['user_id']
387
return str(user_id) == str(context.user_id)
391
def _convert_timestamps_to_datetimes(image_meta):
392
"""Returns image with timestamp fields converted to datetime objects."""
393
for attr in ['created_at', 'updated_at', 'deleted_at']:
394
if image_meta.get(attr):
395
image_meta[attr] = _parse_glance_iso8601_timestamp(
400
def _parse_glance_iso8601_timestamp(timestamp):
401
"""Parse a subset of iso8601 timestamps into datetime objects."""
402
iso_formats = ['%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S']
404
for iso_format in iso_formats:
406
return datetime.datetime.strptime(timestamp, iso_format)
410
raise ValueError(_('%(timestamp)s does not follow any of the '
411
'signatures: %(iso_formats)s') % locals())
414
# TODO(yamahata): use block-device-mapping extension to glance
415
def _json_loads(properties, attr):
416
prop = properties[attr]
417
if isinstance(prop, basestring):
418
properties[attr] = json.loads(prop)
421
def _json_dumps(properties, attr):
422
prop = properties[attr]
423
if not isinstance(prop, basestring):
424
properties[attr] = json.dumps(prop)
427
_CONVERT_PROPS = ('block_device_mapping', 'mappings')
430
def _convert(method, metadata):
431
metadata = copy.deepcopy(metadata) # don't touch original metadata
432
properties = metadata.get('properties')
434
for attr in _CONVERT_PROPS:
435
if attr in properties:
436
method(properties, attr)
441
def _convert_from_string(metadata):
442
return _convert(_json_loads, metadata)
445
def _convert_to_string(metadata):
446
return _convert(_json_dumps, metadata)
449
def _limit_attributes(image_meta):
450
IMAGE_ATTRIBUTES = ['size', 'disk_format',
451
'container_format', 'checksum', 'id',
452
'name', 'created_at', 'updated_at',
453
'deleted_at', 'deleted', 'status',
454
'min_disk', 'min_ram', 'is_public']
456
for attr in IMAGE_ATTRIBUTES:
457
output[attr] = image_meta.get(attr)
459
output['properties'] = image_meta.get('properties', {})
464
def _remove_read_only(image_meta):
465
IMAGE_ATTRIBUTES = ['updated_at', 'created_at', 'deleted_at']
466
output = copy.deepcopy(image_meta)
467
for attr in IMAGE_ATTRIBUTES:
473
def _reraise_translated_image_exception(image_id):
474
"""Transform the exception for the image but keep its traceback intact."""
475
exc_type, exc_value, exc_trace = sys.exc_info()
476
new_exc = _translate_image_exception(image_id, exc_type, exc_value)
477
raise new_exc, None, exc_trace
480
def _reraise_translated_exception():
481
"""Transform the exception but keep its traceback intact."""
482
exc_type, exc_value, exc_trace = sys.exc_info()
483
new_exc = _translate_plain_exception(exc_type, exc_value)
484
raise new_exc, None, exc_trace
487
def _translate_image_exception(image_id, exc_type, exc_value):
488
if exc_type in (glance_exception.Forbidden,
489
glance_exception.NotAuthenticated,
490
glance_exception.MissingCredentialError):
491
return exception.ImageNotAuthorized(image_id=image_id)
492
if exc_type is glance_exception.NotFound:
493
return exception.ImageNotFound(image_id=image_id)
494
if exc_type is glance_exception.Invalid:
495
return exception.Invalid(exc_value)
499
def _translate_plain_exception(exc_type, exc_value):
500
if exc_type in (glance_exception.Forbidden,
501
glance_exception.NotAuthenticated,
502
glance_exception.MissingCredentialError):
503
return exception.NotAuthorized(exc_value)
504
if exc_type is glance_exception.NotFound:
505
return exception.NotFound(exc_value)
506
if exc_type is glance_exception.Invalid:
507
return exception.Invalid(exc_value)