1
# Copyright 2012 OpenStack LLC.
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
8
# http://www.apache.org/licenses/LICENSE-2.0
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
24
from glance.api import policy
25
import glance.api.v2 as v2
26
from glance.common import exception
27
from glance.common import utils
28
from glance.common import wsgi
30
import glance.notifier
31
from glance.openstack.common import cfg
32
import glance.openstack.common.log as logging
33
from glance.openstack.common import timeutils
38
LOG = logging.getLogger(__name__)
43
class ImagesController(object):
44
def __init__(self, db_api=None, policy_enforcer=None, notifier=None,
46
self.db_api = db_api or glance.db.get_api()
47
self.db_api.configure_db()
48
self.policy = policy_enforcer or policy.Enforcer()
49
self.notifier = notifier or glance.notifier.Notifier()
50
self.store_api = store_api or glance.store
51
self.store_api.create_stores()
53
def _enforce(self, req, action):
54
"""Authorize an action against our policies"""
56
self.policy.enforce(req.context, action, {})
57
except exception.Forbidden:
58
raise webob.exc.HTTPForbidden()
60
def _normalize_properties(self, image):
61
"""Convert the properties from the stored format to a dict
63
The db api returns a list of dicts that look like
64
{'name': <key>, 'value': <value>}, while it expects a format
65
like {<key>: <value>} in image create and update calls. This
66
function takes the extra step that the db api should be
67
responsible for in the image get calls.
69
The db api will also return deleted image properties that must
72
properties = [(p['name'], p['value'])
73
for p in image['properties'] if not p['deleted']]
74
image['properties'] = dict(properties)
77
def _extract_tags(self, image):
79
#NOTE(bcwaldon): cast to set to make the list unique, then
80
# cast back to list since that's a more sane response type
81
return list(set(image.pop('tags')))
85
def _append_tags(self, context, image):
86
image['tags'] = self.db_api.image_tag_get_all(context, image['id'])
90
def create(self, req, image):
91
self._enforce(req, 'add_image')
92
is_public = image.get('is_public')
94
self._enforce(req, 'publicize_image')
95
image['owner'] = req.context.owner
96
image['status'] = 'queued'
98
tags = self._extract_tags(image)
100
image = dict(self.db_api.image_create(req.context, image))
103
self.db_api.image_tag_set_all(req.context, image['id'], tags)
108
v2.update_image_read_acl(req, self.store_api, self.db_api, image)
109
image = self._normalize_properties(dict(image))
110
self.notifier.info('image.update', image)
113
def index(self, req, marker=None, limit=None, sort_key='created_at',
114
sort_dir='desc', filters={}):
115
self._enforce(req, 'get_images')
116
filters['deleted'] = False
117
#NOTE(bcwaldon): is_public=True gets public images and those
118
# owned by the authenticated tenant
120
filters.setdefault('is_public', True)
122
limit = CONF.limit_param_default
123
limit = min(CONF.api_limit_max, limit)
126
images = self.db_api.image_get_all(req.context, filters=filters,
127
marker=marker, limit=limit,
130
if len(images) != 0 and len(images) == limit:
131
result['next_marker'] = images[-1]['id']
132
except exception.InvalidFilterRangeValue as e:
133
raise webob.exc.HTTPBadRequest(explanation=unicode(e))
134
except exception.InvalidSortKey as e:
135
raise webob.exc.HTTPBadRequest(explanation=unicode(e))
136
except exception.NotFound as e:
137
raise webob.exc.HTTPBadRequest(explanation=unicode(e))
138
images = [self._normalize_properties(dict(image)) for image in images]
139
result['images'] = [self._append_tags(req.context, image)
143
def _get_image(self, context, image_id):
145
return self.db_api.image_get(context, image_id)
146
except (exception.NotFound, exception.Forbidden):
147
raise webob.exc.HTTPNotFound()
149
def show(self, req, image_id):
150
self._enforce(req, 'get_image')
151
image = self._get_image(req.context, image_id)
152
image = self._normalize_properties(dict(image))
153
return self._append_tags(req.context, image)
156
def update(self, req, image_id, changes):
157
self._enforce(req, 'modify_image')
158
context = req.context
160
image = self.db_api.image_get(context, image_id)
161
except (exception.NotFound, exception.Forbidden):
162
msg = ("Failed to find image %(image_id)s to update" % locals())
164
raise webob.exc.HTTPNotFound(explanation=msg)
166
image = self._normalize_properties(dict(image))
167
updates = self._extract_updates(req, image, changes)
171
tags = self._extract_tags(updates)
172
purge_props = 'properties' in updates
174
image = self.db_api.image_update(context, image_id, updates,
176
except (exception.NotFound, exception.Forbidden):
177
raise webob.exc.HTTPNotFound()
178
image = self._normalize_properties(dict(image))
180
v2.update_image_read_acl(req, self.store_api, self.db_api, image)
183
self.db_api.image_tag_set_all(req.context, image_id, tags)
186
self._append_tags(req.context, image)
188
self.notifier.info('image.update', image)
191
def _extract_updates(self, req, image, changes):
192
""" Determine the updates to pass to the database api.
194
Given the current image, convert a list of changes to be made
195
into the corresponding update dictionary that should be passed to
198
Changes have the following parts
199
op - 'add' a new attribute, 'replace' an existing attribute, or
200
'remove' an existing attribute.
201
path - A list of path parts for determining which attribute the
202
the operation applies to.
203
value - For 'add' and 'replace', the new value the attribute should
206
For the current use case, there are two types of valid paths. For base
207
attributes (fields stored directly on the Image object) the path
208
must take the form ['<attribute name>']. These attributes are always
209
present so the only valid operation on them is 'replace'. For image
210
properties, the path takes the form ['properties', '<property name>']
211
and all operations are valid.
213
Future refactoring should simplify this code by hardening the image
214
abstraction such that database details such as how image properties
215
are stored do not have any influence here.
218
property_updates = image['properties']
219
for change in changes:
220
path = change['path']
222
assert change['op'] == 'replace'
223
key = change['path'][0]
224
if key == 'is_public' and change['value']:
225
self._enforce(req, 'publicize_image')
226
updates[key] = change['value']
228
assert len(path) == 2
229
assert path[0] == 'properties'
230
update_method_name = '_do_%s_property' % change['op']
231
assert hasattr(self, update_method_name)
232
update_method = getattr(self, update_method_name)
233
update_method(property_updates, change)
234
updates['properties'] = property_updates
237
def _do_replace_property(self, updates, change):
238
""" Replace a single image property, ensuring it's present. """
239
key = change['path'][1]
240
if key not in updates:
241
msg = _("Property %s does not exist.")
242
raise webob.exc.HTTPConflict(msg % key)
243
updates[key] = change['value']
245
def _do_add_property(self, updates, change):
246
""" Add a new image property, ensuring it does not already exist. """
247
key = change['path'][1]
249
msg = _("Property %s already present.")
250
raise webob.exc.HTTPConflict(msg % key)
251
updates[key] = change['value']
253
def _do_remove_property(self, updates, change):
254
""" Remove an image property, ensuring it's present. """
255
key = change['path'][1]
256
if key not in updates:
257
msg = _("Property %s does not exist.")
258
raise webob.exc.HTTPConflict(msg % key)
262
def delete(self, req, image_id):
263
self._enforce(req, 'delete_image')
264
image = self._get_image(req.context, image_id)
266
if image['protected']:
267
msg = _("Unable to delete as image %(image_id)s is protected"
269
raise webob.exc.HTTPForbidden(explanation=msg)
272
if image['location']:
273
if CONF.delayed_delete:
274
status = 'pending_delete'
275
self.store_api.schedule_delayed_delete_from_backend(
276
image['location'], id)
278
self.store_api.safe_delete_from_backend(image['location'],
282
self.db_api.image_update(req.context, image_id, {'status': status})
283
self.db_api.image_destroy(req.context, image_id)
284
except (exception.NotFound, exception.Forbidden):
285
msg = ("Failed to find image %(image_id)s to delete" % locals())
287
raise webob.exc.HTTPNotFound()
289
self.notifier.info('image.delete', image)
292
class RequestDeserializer(wsgi.JSONRequestDeserializer):
294
_readonly_properties = ['created_at', 'updated_at', 'status', 'checksum',
295
'size', 'direct_url', 'self', 'file', 'schema']
296
_reserved_properties = ['owner', 'is_public', 'location',
297
'deleted', 'deleted_at']
298
_base_properties = ['checksum', 'created_at', 'container_format',
299
'disk_format', 'id', 'min_disk', 'min_ram', 'name', 'size',
300
'status', 'tags', 'updated_at', 'visibility', 'protected']
302
def __init__(self, schema=None):
303
super(RequestDeserializer, self).__init__()
304
self.schema = schema or get_schema()
306
def _parse_image(self, request):
307
body = self._get_request_body(request)
309
self.schema.validate(body)
310
except exception.InvalidObject as e:
311
raise webob.exc.HTTPBadRequest(explanation=unicode(e))
313
# Ensure all specified properties are allowed
314
self._check_readonly(body)
315
self._check_reserved(body)
317
# Create a dict of base image properties, with user- and deployer-
318
# defined properties contained in a 'properties' dictionary
319
image = {'properties': body}
320
for key in self._base_properties:
322
image[key] = image['properties'].pop(key)
326
if 'visibility' in image:
327
image['is_public'] = image.pop('visibility') == 'public'
329
return {'image': image}
331
def _get_request_body(self, request):
332
output = super(RequestDeserializer, self).default(request)
333
if not 'body' in output:
334
msg = _('Body expected in request.')
335
raise webob.exc.HTTPBadRequest(explanation=msg)
336
return output['body']
339
def _check_readonly(cls, image):
340
for key in cls._readonly_properties:
342
msg = "Attribute \'%s\' is read-only." % key
343
raise webob.exc.HTTPForbidden(explanation=unicode(msg))
346
def _check_reserved(cls, image):
347
for key in cls._reserved_properties:
349
msg = "Attribute \'%s\' is reserved." % key
350
raise webob.exc.HTTPForbidden(explanation=unicode(msg))
352
def create(self, request):
353
return self._parse_image(request)
355
def _get_change_operation(self, raw_change):
357
for key in ['replace', 'add', 'remove']:
358
if key in raw_change:
360
msg = _('Operation objects must contain only one member'
361
' named "add", "remove", or "replace".')
362
raise webob.exc.HTTPBadRequest(explanation=msg)
365
msg = _('Operation objects must contain exactly one member'
366
' named "add", "remove", or "replace".')
367
raise webob.exc.HTTPBadRequest(explanation=msg)
370
def _get_change_path(self, raw_change, op):
371
key = self._decode_json_pointer(raw_change[op])
372
if key in self._readonly_properties:
373
msg = "Attribute \'%s\' is read-only." % key
374
raise webob.exc.HTTPForbidden(explanation=unicode(msg))
375
if key in self._reserved_properties:
376
msg = "Attribute \'%s\' is reserved." % key
377
raise webob.exc.HTTPForbidden(explanation=unicode(msg))
379
# For image properties, we need to put "properties" at the beginning
380
if key not in self._base_properties:
381
return ['properties', key]
384
def _decode_json_pointer(self, pointer):
385
""" Parse a json pointer.
387
Json Pointers are defined in
388
http://tools.ietf.org/html/draft-pbryan-zyp-json-pointer .
389
The pointers use '/' for separation between object attributes, such
390
that '/A/B' would evaluate to C in {"A": {"B": "C"}}. A '/' character
391
in an attribute name is encoded as "~1" and a '~' character is encoded
394
self._validate_json_pointer(pointer)
395
return pointer.lstrip('/').replace('~1', '/').replace('~0', '~')
397
def _validate_json_pointer(self, pointer):
398
""" Validate a json pointer.
400
We only accept a limited form of json pointers. Specifically, we do
401
not allow multiple levels of indirection, so there can only be one '/'
402
in the pointer, located at the start of the string.
404
if not pointer.startswith('/'):
405
msg = _('Pointer `%s` does not start with "/".' % pointer)
406
raise webob.exc.HTTPBadRequest(explanation=msg)
407
if '/' in pointer[1:]:
408
msg = _('Pointer `%s` contains more than one "/".' % pointer)
409
raise webob.exc.HTTPBadRequest(explanation=msg)
410
if re.match('~[^01]', pointer):
411
msg = _('Pointer `%s` contains "~" not part of'
412
' a recognized escape sequence.' % pointer)
413
raise webob.exc.HTTPBadRequest(explanation=msg)
415
def _get_change_value(self, raw_change, op):
416
if 'value' not in raw_change:
417
msg = _('Operation "%s" requires a member named "value".')
418
raise webob.exc.HTTPBadRequest(explanation=msg % op)
419
return raw_change['value']
421
def _validate_change(self, change):
422
if change['op'] == 'delete':
424
partial_image = {change['path'][-1]: change['value']}
426
self.schema.validate(partial_image)
427
except exception.InvalidObject as e:
428
raise webob.exc.HTTPBadRequest(explanation=unicode(e))
430
def update(self, request):
432
valid_content_types = [
433
'application/openstack-images-v2.0-json-patch'
435
if request.content_type not in valid_content_types:
436
headers = {'Accept-Patch': ','.join(valid_content_types)}
437
raise webob.exc.HTTPUnsupportedMediaType(headers=headers)
438
body = self._get_request_body(request)
439
if not isinstance(body, list):
440
msg = _('Request body must be a JSON array of operation objects.')
441
raise webob.exc.HTTPBadRequest(explanation=msg)
442
for raw_change in body:
443
if not isinstance(raw_change, dict):
444
msg = _('Operations must be JSON objects.')
445
raise webob.exc.HTTPBadRequest(explanation=msg)
446
op = self._get_change_operation(raw_change)
447
path = self._get_change_path(raw_change, op)
448
change = {'op': op, 'path': path}
449
if not op == 'remove':
450
change['value'] = self._get_change_value(raw_change, op)
451
self._validate_change(change)
452
if change['path'] == ['visibility']:
453
change['path'] = ['is_public']
454
change['value'] = change['value'] == 'public'
455
changes.append(change)
456
return {'changes': changes}
458
def _validate_limit(self, limit):
462
msg = _("limit param must be an integer")
463
raise webob.exc.HTTPBadRequest(explanation=msg)
466
msg = _("limit param must be positive")
467
raise webob.exc.HTTPBadRequest(explanation=msg)
471
def _validate_sort_dir(self, sort_dir):
472
if sort_dir not in ['asc', 'desc']:
473
msg = _('Invalid sort direction: %s' % sort_dir)
474
raise webob.exc.HTTPBadRequest(explanation=msg)
478
def _get_filters(self, filters):
479
visibility = filters.pop('visibility', None)
481
if visibility in ['public', 'private']:
482
filters['is_public'] = visibility == 'public'
484
msg = _('Invalid visibility value: %s') % visibility
485
raise webob.exc.HTTPBadRequest(explanation=msg)
489
def index(self, request):
490
params = request.params.copy()
491
limit = params.pop('limit', None)
492
marker = params.pop('marker', None)
493
sort_dir = params.pop('sort_dir', 'desc')
495
'sort_key': params.pop('sort_key', 'created_at'),
496
'sort_dir': self._validate_sort_dir(sort_dir),
497
'filters': self._get_filters(params),
500
if marker is not None:
501
query_params['marker'] = marker
503
if limit is not None:
504
query_params['limit'] = self._validate_limit(limit)
509
class ResponseSerializer(wsgi.JSONResponseSerializer):
510
def __init__(self, schema=None):
511
super(ResponseSerializer, self).__init__()
512
self.schema = schema or get_schema()
514
def _get_image_href(self, image, subcollection=''):
515
base_href = '/v2/images/%s' % image['id']
517
base_href = '%s/%s' % (base_href, subcollection)
520
def _get_image_links(self, image):
522
{'rel': 'self', 'href': self._get_image_href(image)},
523
{'rel': 'file', 'href': self._get_image_href(image, 'file')},
524
{'rel': 'describedby', 'href': '/v2/schemas/image'},
527
def _format_image(self, image):
528
#NOTE(bcwaldon): merge the contained properties dict with the
529
# top-level image object
530
image_view = image['properties']
531
attributes = ['id', 'name', 'disk_format', 'container_format',
532
'size', 'status', 'checksum', 'tags', 'protected',
533
'created_at', 'updated_at', 'min_ram', 'min_disk']
534
for key in attributes:
535
image_view[key] = image[key]
537
location = image['location']
538
if CONF.show_image_direct_url and location is not None:
539
image_view['direct_url'] = location
541
visibility = 'public' if image['is_public'] else 'private'
542
image_view['visibility'] = visibility
544
image_view['self'] = self._get_image_href(image)
545
image_view['file'] = self._get_image_href(image, 'file')
546
image_view['schema'] = '/v2/schemas/image'
548
self._serialize_datetimes(image_view)
549
image_view = self.schema.filter(image_view)
554
def _serialize_datetimes(image):
555
for (key, value) in image.iteritems():
556
if isinstance(value, datetime.datetime):
557
image[key] = timeutils.isotime(value)
559
def create(self, response, image):
560
response.status_int = 201
561
body = json.dumps(self._format_image(image), ensure_ascii=False)
562
response.unicode_body = unicode(body)
563
response.content_type = 'application/json'
564
response.location = self._get_image_href(image)
566
def show(self, response, image):
567
body = json.dumps(self._format_image(image), ensure_ascii=False)
568
response.unicode_body = unicode(body)
569
response.content_type = 'application/json'
571
def update(self, response, image):
572
body = json.dumps(self._format_image(image), ensure_ascii=False)
573
response.unicode_body = unicode(body)
574
response.content_type = 'application/json'
576
def index(self, response, result):
577
params = dict(response.request.params)
578
params.pop('marker', None)
579
query = urllib.urlencode(params)
581
'images': [self._format_image(i) for i in result['images']],
582
'first': '/v2/images',
583
'schema': '/v2/schemas/images',
586
body['first'] = '%s?%s' % (body['first'], query)
587
if 'next_marker' in result:
588
params['marker'] = result['next_marker']
589
next_query = urllib.urlencode(params)
590
body['next'] = '/v2/images?%s' % next_query
591
response.unicode_body = unicode(json.dumps(body, ensure_ascii=False))
592
response.content_type = 'application/json'
594
def delete(self, response, result):
595
response.status_int = 204
601
'description': 'An identifier for the image',
602
'pattern': ('^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}'
603
'-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$'),
607
'description': 'Descriptive name for the image',
612
'description': 'Status of the image',
613
'enum': ['queued', 'saving', 'active', 'killed',
614
'deleted', 'pending_delete'],
618
'description': 'Scope of image accessibility',
619
'enum': ['public', 'private'],
623
'description': 'If true, image will not be deletable.',
627
'description': 'md5 hash of image contents.',
633
'description': 'Size of image file in bytes',
635
'container_format': {
639
'enum': ['bare', 'ovf', 'ami', 'aki', 'ari'],
645
'enum': ['raw', 'vhd', 'vmdk', 'vdi', 'iso', 'qcow2',
646
'aki', 'ari', 'ami'],
650
'description': 'Date and time of image registration',
651
#TODO(bcwaldon): our jsonschema library doesn't seem to like the
652
# format attribute, figure out why!
653
#'format': 'date-time',
657
'description': 'Date and time of the last image modification',
658
#'format': 'date-time',
662
'description': 'List of strings related to the image',
670
'description': 'URL to access the image file kept in external store',
674
'description': 'Amount of ram (in MB) required to boot image.',
678
'description': 'Amount of disk space (in GB) required to boot image.',
680
'self': {'type': 'string'},
681
'file': {'type': 'string'},
682
'schema': {'type': 'string'},
686
{'rel': 'self', 'href': '{self}'},
687
{'rel': 'enclosure', 'href': '{file}'},
688
{'rel': 'describedby', 'href': '{schema}'},
692
def get_schema(custom_properties=None):
693
properties = copy.deepcopy(_BASE_PROPERTIES)
694
links = copy.deepcopy(_BASE_LINKS)
695
if CONF.allow_additional_image_properties:
696
schema = glance.schema.PermissiveSchema('image', properties, links)
698
schema = glance.schema.Schema('image', properties)
699
schema.merge_properties(custom_properties or {})
703
def get_collection_schema(custom_properties=None):
704
image_schema = get_schema(custom_properties)
705
return glance.schema.CollectionSchema('images', image_schema)
708
def load_custom_properties():
709
"""Find the schema properties files and load them into a dict."""
710
filename = 'schema-image.json'
711
match = CONF.find_file(filename)
713
schema_file = open(match)
714
schema_data = schema_file.read()
715
return json.loads(schema_data)
717
msg = _('Could not find schema properties file %s. Continuing '
718
'without custom properties')
719
LOG.warn(msg % filename)
723
def create_resource(custom_properties=None):
724
"""Images resource factory method"""
725
schema = get_schema(custom_properties)
726
deserializer = RequestDeserializer(schema)
727
serializer = ResponseSerializer(schema)
728
controller = ImagesController()
729
return wsgi.Resource(controller, deserializer, serializer)