You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
901 lines
35 KiB
901 lines
35 KiB
# Copyright 2012 OpenStack Foundation. |
|
# All Rights Reserved. |
|
# |
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may |
|
# not use this file except in compliance with the License. You may obtain |
|
# a copy of the License at |
|
# |
|
# http://www.apache.org/licenses/LICENSE-2.0 |
|
# |
|
# Unless required by applicable law or agreed to in writing, software |
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
|
# License for the specific language governing permissions and limitations |
|
# under the License. |
|
|
|
import re |
|
|
|
import glance_store |
|
from oslo.config import cfg |
|
from oslo.serialization import jsonutils as json |
|
from oslo.utils import timeutils |
|
import six |
|
import six.moves.urllib.parse as urlparse |
|
import webob.exc |
|
|
|
from glance.api import policy |
|
from glance.common import exception |
|
from glance.common import location_strategy |
|
from glance.common import utils |
|
from glance.common import wsgi |
|
import glance.db |
|
import glance.gateway |
|
from glance import i18n |
|
import glance.notifier |
|
import glance.openstack.common.log as logging |
|
import glance.schema |
|
|
|
LOG = logging.getLogger(__name__) |
|
_ = i18n._ |
|
_LW = i18n._LW |
|
|
|
CONF = cfg.CONF |
|
CONF.import_opt('disk_formats', 'glance.common.config', group='image_format') |
|
CONF.import_opt('container_formats', 'glance.common.config', |
|
group='image_format') |
|
|
|
|
|
class ImagesController(object): |
|
def __init__(self, db_api=None, policy_enforcer=None, notifier=None, |
|
store_api=None): |
|
self.db_api = db_api or glance.db.get_api() |
|
self.policy = policy_enforcer or policy.Enforcer() |
|
self.notifier = notifier or glance.notifier.Notifier() |
|
self.store_api = store_api or glance_store |
|
self.gateway = glance.gateway.Gateway(self.db_api, self.store_api, |
|
self.notifier, self.policy) |
|
|
|
@utils.mutating |
|
def create(self, req, image, extra_properties, tags): |
|
image_factory = self.gateway.get_image_factory(req.context) |
|
image_repo = self.gateway.get_repo(req.context) |
|
try: |
|
image = image_factory.new_image(extra_properties=extra_properties, |
|
tags=tags, **image) |
|
image_repo.add(image) |
|
except exception.DuplicateLocation as dup: |
|
raise webob.exc.HTTPBadRequest(explanation=dup.msg) |
|
except exception.Invalid as e: |
|
raise webob.exc.HTTPBadRequest(explanation=e.msg) |
|
except exception.Forbidden as e: |
|
raise webob.exc.HTTPForbidden(explanation=e.msg) |
|
except exception.InvalidParameterValue as e: |
|
raise webob.exc.HTTPBadRequest(explanation=e.msg) |
|
except exception.LimitExceeded as e: |
|
LOG.warn(utils.exception_to_str(e)) |
|
raise webob.exc.HTTPRequestEntityTooLarge( |
|
explanation=e.msg, request=req, content_type='text/plain') |
|
except exception.Duplicate as dupex: |
|
raise webob.exc.HTTPConflict(explanation=dupex.msg) |
|
except exception.ReservedProperty as e: |
|
raise webob.exc.HTTPForbidden(explanation=e.msg) |
|
except exception.ReadonlyProperty as e: |
|
raise webob.exc.HTTPForbidden(explanation=e.msg) |
|
except TypeError as e: |
|
LOG.debug(utils.exception_to_str(e)) |
|
raise webob.exc.HTTPBadRequest( |
|
explanation=utils.exception_to_str(e)) |
|
|
|
return image |
|
|
|
def index(self, req, marker=None, limit=None, sort_key=['created_at'], |
|
sort_dir='desc', filters=None, member_status='accepted'): |
|
result = {} |
|
if filters is None: |
|
filters = {} |
|
filters['deleted'] = False |
|
|
|
if limit is None: |
|
limit = CONF.limit_param_default |
|
limit = min(CONF.api_limit_max, limit) |
|
|
|
image_repo = self.gateway.get_repo(req.context) |
|
try: |
|
images = image_repo.list(marker=marker, limit=limit, |
|
sort_key=sort_key, |
|
sort_dir=sort_dir, |
|
filters=filters, |
|
member_status=member_status) |
|
if len(images) != 0 and len(images) == limit: |
|
result['next_marker'] = images[-1].image_id |
|
except (exception.NotFound, exception.InvalidSortKey, |
|
exception.InvalidFilterRangeValue) as e: |
|
raise webob.exc.HTTPBadRequest(explanation=e.msg) |
|
except exception.Forbidden as e: |
|
raise webob.exc.HTTPForbidden(explanation=e.msg) |
|
result['images'] = images |
|
return result |
|
|
|
def show(self, req, image_id): |
|
image_repo = self.gateway.get_repo(req.context) |
|
try: |
|
return image_repo.get(image_id) |
|
except exception.Forbidden as e: |
|
raise webob.exc.HTTPForbidden(explanation=e.msg) |
|
except exception.NotFound as e: |
|
raise webob.exc.HTTPNotFound(explanation=e.msg) |
|
|
|
@utils.mutating |
|
def update(self, req, image_id, changes): |
|
image_repo = self.gateway.get_repo(req.context) |
|
try: |
|
image = image_repo.get(image_id) |
|
|
|
for change in changes: |
|
change_method_name = '_do_%s' % change['op'] |
|
assert hasattr(self, change_method_name) |
|
change_method = getattr(self, change_method_name) |
|
change_method(req, image, change) |
|
|
|
if changes: |
|
image_repo.save(image) |
|
except exception.NotFound as e: |
|
raise webob.exc.HTTPNotFound(explanation=e.msg) |
|
except exception.Invalid as e: |
|
raise webob.exc.HTTPBadRequest(explanation=e.msg) |
|
except exception.Forbidden as e: |
|
raise webob.exc.HTTPForbidden(explanation=e.msg) |
|
except exception.InvalidParameterValue as e: |
|
raise webob.exc.HTTPBadRequest(explanation=e.msg) |
|
except exception.StorageQuotaFull as e: |
|
msg = (_("Denying attempt to upload image because it exceeds the" |
|
" .quota: %s") % utils.exception_to_str(e)) |
|
LOG.warn(msg) |
|
raise webob.exc.HTTPRequestEntityTooLarge( |
|
explanation=msg, request=req, content_type='text/plain') |
|
except exception.LimitExceeded as e: |
|
LOG.exception(utils.exception_to_str(e)) |
|
raise webob.exc.HTTPRequestEntityTooLarge( |
|
explanation=e.msg, request=req, content_type='text/plain') |
|
|
|
return image |
|
|
|
def _do_replace(self, req, image, change): |
|
path = change['path'] |
|
path_root = path[0] |
|
value = change['value'] |
|
if path_root == 'locations': |
|
self._do_replace_locations(image, value) |
|
else: |
|
if hasattr(image, path_root): |
|
setattr(image, path_root, value) |
|
elif path_root in image.extra_properties: |
|
image.extra_properties[path_root] = value |
|
else: |
|
msg = _("Property %s does not exist.") |
|
raise webob.exc.HTTPConflict(msg % path_root) |
|
|
|
def _do_add(self, req, image, change): |
|
path = change['path'] |
|
path_root = path[0] |
|
value = change['value'] |
|
json_schema_version = change.get('json_schema_version', 10) |
|
if path_root == 'locations': |
|
self._do_add_locations(image, path[1], value) |
|
else: |
|
if ((hasattr(image, path_root) or |
|
path_root in image.extra_properties) |
|
and json_schema_version == 4): |
|
msg = _("Property %s already present.") |
|
raise webob.exc.HTTPConflict(msg % path_root) |
|
if hasattr(image, path_root): |
|
setattr(image, path_root, value) |
|
else: |
|
image.extra_properties[path_root] = value |
|
|
|
def _do_remove(self, req, image, change): |
|
path = change['path'] |
|
path_root = path[0] |
|
if path_root == 'locations': |
|
self._do_remove_locations(image, path[1]) |
|
else: |
|
if hasattr(image, path_root): |
|
msg = _("Property %s may not be removed.") |
|
raise webob.exc.HTTPForbidden(msg % path_root) |
|
elif path_root in image.extra_properties: |
|
del image.extra_properties[path_root] |
|
else: |
|
msg = _("Property %s does not exist.") |
|
raise webob.exc.HTTPConflict(msg % path_root) |
|
|
|
@utils.mutating |
|
def delete(self, req, image_id): |
|
image_repo = self.gateway.get_repo(req.context) |
|
try: |
|
image = image_repo.get(image_id) |
|
image.delete() |
|
image_repo.remove(image) |
|
except exception.Forbidden as e: |
|
raise webob.exc.HTTPForbidden(explanation=e.msg) |
|
except exception.NotFound as e: |
|
msg = (_("Failed to find image %(image_id)s to delete") % |
|
{'image_id': image_id}) |
|
LOG.warn(msg) |
|
raise webob.exc.HTTPNotFound(explanation=msg) |
|
except exception.InUseByStore as e: |
|
msg = (_("Image %(id)s could not be deleted " |
|
"because it is in use: %(exc)s") % |
|
{"id": image_id, |
|
"exc": e.msg}) |
|
LOG.warn(msg) |
|
raise webob.exc.HTTPConflict(explanation=msg) |
|
|
|
def _get_locations_op_pos(self, path_pos, max_pos, allow_max): |
|
if path_pos is None or max_pos is None: |
|
return None |
|
pos = max_pos if allow_max else max_pos - 1 |
|
if path_pos.isdigit(): |
|
pos = int(path_pos) |
|
elif path_pos != '-': |
|
return None |
|
if (not allow_max) and (pos not in range(max_pos)): |
|
return None |
|
return pos |
|
|
|
def _do_replace_locations(self, image, value): |
|
if len(image.locations) > 0 and len(value) > 0: |
|
msg = _("Cannot replace locations from a non-empty " |
|
"list to a non-empty list.") |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
if len(value) == 0: |
|
# NOTE(zhiyan): this actually deletes the location |
|
# from the backend store. |
|
del image.locations[:] |
|
if image.status == 'active': |
|
image.status = 'queued' |
|
else: # NOTE(zhiyan): len(image.locations) == 0 |
|
try: |
|
image.locations = value |
|
if image.status == 'queued': |
|
image.status = 'active' |
|
except (exception.BadStoreUri, exception.DuplicateLocation) as bse: |
|
raise webob.exc.HTTPBadRequest(explanation=bse.msg) |
|
except ValueError as ve: # update image status failed. |
|
raise webob.exc.HTTPBadRequest( |
|
explanation=utils.exception_to_str(ve)) |
|
|
|
def _do_add_locations(self, image, path_pos, value): |
|
pos = self._get_locations_op_pos(path_pos, |
|
len(image.locations), True) |
|
if pos is None: |
|
msg = _("Invalid position for adding a location.") |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
try: |
|
image.locations.insert(pos, value) |
|
if image.status == 'queued': |
|
image.status = 'active' |
|
except (exception.BadStoreUri, exception.DuplicateLocation) as bse: |
|
raise webob.exc.HTTPBadRequest(explanation=bse.msg) |
|
except ValueError as ve: # update image status failed. |
|
raise webob.exc.HTTPBadRequest( |
|
explanation=utils.exception_to_str(ve)) |
|
|
|
def _do_remove_locations(self, image, path_pos): |
|
pos = self._get_locations_op_pos(path_pos, |
|
len(image.locations), False) |
|
if pos is None: |
|
msg = _("Invalid position for removing a location.") |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
try: |
|
# NOTE(zhiyan): this actually deletes the location |
|
# from the backend store. |
|
image.locations.pop(pos) |
|
except Exception as e: |
|
raise webob.exc.HTTPInternalServerError( |
|
explanation=utils.exception_to_str(e)) |
|
if (len(image.locations) == 0) and (image.status == 'active'): |
|
image.status = 'queued' |
|
|
|
|
|
class RequestDeserializer(wsgi.JSONRequestDeserializer): |
|
|
|
_disallowed_properties = ('direct_url', 'self', 'file', 'schema') |
|
_readonly_properties = ('created_at', 'updated_at', 'status', 'checksum', |
|
'size', 'virtual_size', 'direct_url', 'self', |
|
'file', 'schema') |
|
_reserved_properties = ('owner', 'is_public', 'location', 'deleted', |
|
'deleted_at') |
|
_base_properties = ('checksum', 'created_at', 'container_format', |
|
'disk_format', 'id', 'min_disk', 'min_ram', 'name', |
|
'size', 'virtual_size', 'status', 'tags', |
|
'updated_at', 'visibility', 'protected') |
|
_available_sort_keys = ('name', 'status', 'container_format', |
|
'disk_format', 'size', 'id', 'created_at', |
|
'updated_at') |
|
|
|
_path_depth_limits = {'locations': {'add': 2, 'remove': 2, 'replace': 1}} |
|
|
|
def __init__(self, schema=None): |
|
super(RequestDeserializer, self).__init__() |
|
self.schema = schema or get_schema() |
|
|
|
def _get_request_body(self, request): |
|
output = super(RequestDeserializer, self).default(request) |
|
if 'body' not in output: |
|
msg = _('Body expected in request.') |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
return output['body'] |
|
|
|
@classmethod |
|
def _check_allowed(cls, image): |
|
for key in cls._disallowed_properties: |
|
if key in image: |
|
msg = _("Attribute '%s' is read-only.") % key |
|
raise webob.exc.HTTPForbidden( |
|
explanation=utils.exception_to_str(msg)) |
|
|
|
def create(self, request): |
|
body = self._get_request_body(request) |
|
self._check_allowed(body) |
|
try: |
|
self.schema.validate(body) |
|
except exception.InvalidObject as e: |
|
raise webob.exc.HTTPBadRequest(explanation=e.msg) |
|
image = {} |
|
properties = body |
|
tags = properties.pop('tags', []) |
|
for key in self._base_properties: |
|
try: |
|
# NOTE(flwang): Instead of changing the _check_unexpected |
|
# of ImageFactory. It would be better to do the mapping |
|
# at here. |
|
if key == 'id': |
|
image['image_id'] = properties.pop(key) |
|
else: |
|
image[key] = properties.pop(key) |
|
except KeyError: |
|
pass |
|
return dict(image=image, extra_properties=properties, tags=tags) |
|
|
|
def _get_change_operation_d10(self, raw_change): |
|
try: |
|
return raw_change['op'] |
|
except KeyError: |
|
msg = _("Unable to find '%s' in JSON Schema change") % 'op' |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
def _get_change_operation_d4(self, raw_change): |
|
op = None |
|
for key in ['replace', 'add', 'remove']: |
|
if key in raw_change: |
|
if op is not None: |
|
msg = _('Operation objects must contain only one member' |
|
' named "add", "remove", or "replace".') |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
op = key |
|
if op is None: |
|
msg = _('Operation objects must contain exactly one member' |
|
' named "add", "remove", or "replace".') |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
return op |
|
|
|
def _get_change_path_d10(self, raw_change): |
|
try: |
|
return raw_change['path'] |
|
except KeyError: |
|
msg = _("Unable to find '%s' in JSON Schema change") % 'path' |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
def _get_change_path_d4(self, raw_change, op): |
|
return raw_change[op] |
|
|
|
def _decode_json_pointer(self, pointer): |
|
"""Parse a json pointer. |
|
|
|
Json Pointers are defined in |
|
http://tools.ietf.org/html/draft-pbryan-zyp-json-pointer . |
|
The pointers use '/' for separation between object attributes, such |
|
that '/A/B' would evaluate to C in {"A": {"B": "C"}}. A '/' character |
|
in an attribute name is encoded as "~1" and a '~' character is encoded |
|
as "~0". |
|
""" |
|
self._validate_json_pointer(pointer) |
|
ret = [] |
|
for part in pointer.lstrip('/').split('/'): |
|
ret.append(part.replace('~1', '/').replace('~0', '~').strip()) |
|
return ret |
|
|
|
def _validate_json_pointer(self, pointer): |
|
"""Validate a json pointer. |
|
|
|
We only accept a limited form of json pointers. |
|
""" |
|
if not pointer.startswith('/'): |
|
msg = _('Pointer `%s` does not start with "/".') % pointer |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
if re.search('/\s*?/', pointer[1:]): |
|
msg = _('Pointer `%s` contains adjacent "/".') % pointer |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
if len(pointer) > 1 and pointer.endswith('/'): |
|
msg = _('Pointer `%s` end with "/".') % pointer |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
if pointer[1:].strip() == '/': |
|
msg = _('Pointer `%s` does not contains valid token.') % pointer |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
if re.search('~[^01]', pointer) or pointer.endswith('~'): |
|
msg = _('Pointer `%s` contains "~" not part of' |
|
' a recognized escape sequence.') % pointer |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
def _get_change_value(self, raw_change, op): |
|
if 'value' not in raw_change: |
|
msg = _('Operation "%s" requires a member named "value".') |
|
raise webob.exc.HTTPBadRequest(explanation=msg % op) |
|
return raw_change['value'] |
|
|
|
def _validate_change(self, change): |
|
path_root = change['path'][0] |
|
if path_root in self._readonly_properties: |
|
msg = _("Attribute '%s' is read-only.") % path_root |
|
raise webob.exc.HTTPForbidden(explanation=six.text_type(msg)) |
|
if path_root in self._reserved_properties: |
|
msg = _("Attribute '%s' is reserved.") % path_root |
|
raise webob.exc.HTTPForbidden(explanation=six.text_type(msg)) |
|
|
|
if change['op'] == 'delete': |
|
return |
|
|
|
partial_image = None |
|
if len(change['path']) == 1: |
|
partial_image = {path_root: change['value']} |
|
elif ((path_root in get_base_properties().keys()) and |
|
(get_base_properties()[path_root].get('type', '') == 'array')): |
|
# NOTE(zhiyan): cient can use PATCH API to adding element to |
|
# the image's existing set property directly. |
|
# Such as: 1. using '/locations/N' path to adding a location |
|
# to the image's 'locations' list at N position. |
|
# (implemented) |
|
# 2. using '/tags/-' path to appending a tag to the |
|
# image's 'tags' list at last. (Not implemented) |
|
partial_image = {path_root: [change['value']]} |
|
|
|
if partial_image: |
|
try: |
|
self.schema.validate(partial_image) |
|
except exception.InvalidObject as e: |
|
raise webob.exc.HTTPBadRequest(explanation=e.msg) |
|
|
|
def _validate_path(self, op, path): |
|
path_root = path[0] |
|
limits = self._path_depth_limits.get(path_root, {}) |
|
if len(path) != limits.get(op, 1): |
|
msg = _("Invalid JSON pointer for this resource: " |
|
"'/%s'") % '/'.join(path) |
|
raise webob.exc.HTTPBadRequest(explanation=six.text_type(msg)) |
|
|
|
def _parse_json_schema_change(self, raw_change, draft_version): |
|
if draft_version == 10: |
|
op = self._get_change_operation_d10(raw_change) |
|
path = self._get_change_path_d10(raw_change) |
|
elif draft_version == 4: |
|
op = self._get_change_operation_d4(raw_change) |
|
path = self._get_change_path_d4(raw_change, op) |
|
else: |
|
msg = _('Unrecognized JSON Schema draft version') |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
path_list = self._decode_json_pointer(path) |
|
return op, path_list |
|
|
|
def update(self, request): |
|
changes = [] |
|
content_types = { |
|
'application/openstack-images-v2.0-json-patch': 4, |
|
'application/openstack-images-v2.1-json-patch': 10, |
|
} |
|
if request.content_type not in content_types: |
|
headers = {'Accept-Patch': |
|
', '.join(sorted(content_types.keys()))} |
|
raise webob.exc.HTTPUnsupportedMediaType(headers=headers) |
|
|
|
json_schema_version = content_types[request.content_type] |
|
|
|
body = self._get_request_body(request) |
|
|
|
if not isinstance(body, list): |
|
msg = _('Request body must be a JSON array of operation objects.') |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
for raw_change in body: |
|
if not isinstance(raw_change, dict): |
|
msg = _('Operations must be JSON objects.') |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
(op, path) = self._parse_json_schema_change(raw_change, |
|
json_schema_version) |
|
|
|
# NOTE(zhiyan): the 'path' is a list. |
|
self._validate_path(op, path) |
|
change = {'op': op, 'path': path, |
|
'json_schema_version': json_schema_version} |
|
|
|
if not op == 'remove': |
|
change['value'] = self._get_change_value(raw_change, op) |
|
self._validate_change(change) |
|
|
|
changes.append(change) |
|
|
|
return {'changes': changes} |
|
|
|
def _validate_limit(self, limit): |
|
try: |
|
limit = int(limit) |
|
except ValueError: |
|
msg = _("limit param must be an integer") |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
if limit < 0: |
|
msg = _("limit param must be positive") |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
return limit |
|
|
|
def _validate_sort_key(self, sort_key): |
|
if sort_key not in self._available_sort_keys: |
|
msg = _('Invalid sort key: %(sort_key)s. ' |
|
'It must be one of the following: %(available)s.') % \ |
|
{'sort_key': sort_key, |
|
'available': ', '.join(self._available_sort_keys)} |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
return sort_key |
|
|
|
def _validate_sort_dir(self, sort_dir): |
|
if sort_dir not in ['asc', 'desc']: |
|
msg = _('Invalid sort direction: %s') % sort_dir |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
return sort_dir |
|
|
|
def _validate_member_status(self, member_status): |
|
if member_status not in ['pending', 'accepted', 'rejected', 'all']: |
|
msg = _('Invalid status: %s') % member_status |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
return member_status |
|
|
|
def _get_filters(self, filters): |
|
visibility = filters.get('visibility') |
|
if visibility: |
|
if visibility not in ['public', 'private', 'shared']: |
|
msg = _('Invalid visibility value: %s') % visibility |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
changes_since = filters.get('changes-since', None) |
|
if changes_since: |
|
msg = _('The "changes-since" filter is no longer available on v2.') |
|
raise webob.exc.HTTPBadRequest(explanation=msg) |
|
|
|
return filters |
|
|
|
def index(self, request): |
|
params = request.params.copy() |
|
limit = params.pop('limit', None) |
|
marker = params.pop('marker', None) |
|
sort_dir = params.pop('sort_dir', 'desc') |
|
member_status = params.pop('member_status', 'accepted') |
|
|
|
# NOTE (flwang) To avoid using comma or any predefined chars to split |
|
# multiple tags, now we allow user specify multiple 'tag' parameters |
|
# in URL, such as v2/images?tag=x86&tag=64bit. |
|
tags = [] |
|
while 'tag' in params: |
|
tags.append(params.pop('tag').strip()) |
|
|
|
# NOTE (mfedosin) Do the same with sorting keys |
|
# v2/images?sort_key=name&sort_key=size |
|
|
|
sort_keys = [] |
|
while 'sort_key' in params: |
|
sort_keys.append(self._validate_sort_key( |
|
params.pop('sort_key').strip())) |
|
|
|
query_params = { |
|
'sort_dir': self._validate_sort_dir(sort_dir), |
|
'filters': self._get_filters(params), |
|
'member_status': self._validate_member_status(member_status), |
|
} |
|
|
|
if marker is not None: |
|
query_params['marker'] = marker |
|
|
|
if limit is not None: |
|
query_params['limit'] = self._validate_limit(limit) |
|
|
|
if tags: |
|
query_params['filters']['tags'] = tags |
|
|
|
# NOTE(mfedosin): param is still called sort_key, instead of sort_keys |
|
# It's done because in v1 it's still a single value. |
|
if sort_keys: |
|
query_params['sort_key'] = sort_keys |
|
else: |
|
query_params['sort_key'] = ['created_at'] |
|
|
|
return query_params |
|
|
|
|
|
class ResponseSerializer(wsgi.JSONResponseSerializer): |
|
def __init__(self, schema=None): |
|
super(ResponseSerializer, self).__init__() |
|
self.schema = schema or get_schema() |
|
|
|
def _get_image_href(self, image, subcollection=''): |
|
base_href = '/v2/images/%s' % image.image_id |
|
if subcollection: |
|
base_href = '%s/%s' % (base_href, subcollection) |
|
return base_href |
|
|
|
def _format_image(self, image): |
|
image_view = dict() |
|
try: |
|
image_view = dict(image.extra_properties) |
|
attributes = ['name', 'disk_format', 'container_format', |
|
'visibility', 'size', 'virtual_size', 'status', |
|
'checksum', 'protected', 'min_ram', 'min_disk', |
|
'owner'] |
|
for key in attributes: |
|
image_view[key] = getattr(image, key) |
|
image_view['id'] = image.image_id |
|
image_view['created_at'] = timeutils.isotime(image.created_at) |
|
image_view['updated_at'] = timeutils.isotime(image.updated_at) |
|
|
|
if CONF.show_multiple_locations: |
|
locations = list(image.locations) |
|
if locations: |
|
image_view['locations'] = [] |
|
for loc in locations: |
|
tmp = dict(loc) |
|
tmp.pop('id', None) |
|
tmp.pop('status', None) |
|
image_view['locations'].append(tmp) |
|
else: |
|
# NOTE (flwang): We will still show "locations": [] if |
|
# image.locations is None to indicate it's allowed to show |
|
# locations but it's just non-existent. |
|
image_view['locations'] = [] |
|
LOG.debug("There is not available location " |
|
"for image %s" % image.image_id) |
|
|
|
if CONF.show_image_direct_url: |
|
if image.locations: |
|
# Choose best location configured strategy |
|
l = location_strategy.choose_best_location(image.locations) |
|
image_view['direct_url'] = l['url'] |
|
else: |
|
LOG.debug("There is not available location " |
|
"for image %s" % image.image_id) |
|
|
|
image_view['tags'] = list(image.tags) |
|
image_view['self'] = self._get_image_href(image) |
|
image_view['file'] = self._get_image_href(image, 'file') |
|
image_view['schema'] = '/v2/schemas/image' |
|
image_view = self.schema.filter(image_view) # domain |
|
except exception.Forbidden as e: |
|
raise webob.exc.HTTPForbidden(explanation=e.msg) |
|
return image_view |
|
|
|
def create(self, response, image): |
|
response.status_int = 201 |
|
self.show(response, image) |
|
response.location = self._get_image_href(image) |
|
|
|
def show(self, response, image): |
|
image_view = self._format_image(image) |
|
body = json.dumps(image_view, ensure_ascii=False) |
|
response.unicode_body = six.text_type(body) |
|
response.content_type = 'application/json' |
|
|
|
def update(self, response, image): |
|
image_view = self._format_image(image) |
|
body = json.dumps(image_view, ensure_ascii=False) |
|
response.unicode_body = six.text_type(body) |
|
response.content_type = 'application/json' |
|
|
|
def index(self, response, result): |
|
params = dict(response.request.params) |
|
params.pop('marker', None) |
|
query = urlparse.urlencode(params) |
|
body = { |
|
'images': [self._format_image(i) for i in result['images']], |
|
'first': '/v2/images', |
|
'schema': '/v2/schemas/images', |
|
} |
|
if query: |
|
body['first'] = '%s?%s' % (body['first'], query) |
|
if 'next_marker' in result: |
|
params['marker'] = result['next_marker'] |
|
next_query = urlparse.urlencode(params) |
|
body['next'] = '/v2/images?%s' % next_query |
|
response.unicode_body = six.text_type(json.dumps(body, |
|
ensure_ascii=False)) |
|
response.content_type = 'application/json' |
|
|
|
def delete(self, response, result): |
|
response.status_int = 204 |
|
|
|
|
|
def get_base_properties(): |
|
return { |
|
'id': { |
|
'type': 'string', |
|
'description': _('An identifier for the image'), |
|
'pattern': ('^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}' |
|
'-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$'), |
|
}, |
|
'name': { |
|
'type': ['null', 'string'], |
|
'description': _('Descriptive name for the image'), |
|
'maxLength': 255, |
|
}, |
|
'status': { |
|
'type': 'string', |
|
'description': _('Status of the image (READ-ONLY)'), |
|
'enum': ['queued', 'saving', 'active', 'killed', |
|
'deleted', 'pending_delete'], |
|
}, |
|
'visibility': { |
|
'type': 'string', |
|
'description': _('Scope of image accessibility'), |
|
'enum': ['public', 'private'], |
|
}, |
|
'protected': { |
|
'type': 'boolean', |
|
'description': _('If true, image will not be deletable.'), |
|
}, |
|
'checksum': { |
|
'type': ['null', 'string'], |
|
'description': _('md5 hash of image contents. (READ-ONLY)'), |
|
'maxLength': 32, |
|
}, |
|
'owner': { |
|
'type': ['null', 'string'], |
|
'description': _('Owner of the image'), |
|
'maxLength': 255, |
|
}, |
|
'size': { |
|
'type': ['null', 'integer'], |
|
'description': _('Size of image file in bytes (READ-ONLY)'), |
|
}, |
|
'virtual_size': { |
|
'type': ['null', 'integer'], |
|
'description': _('Virtual size of image in bytes (READ-ONLY)'), |
|
}, |
|
'container_format': { |
|
'type': ['null', 'string'], |
|
'description': _('Format of the container'), |
|
'enum': [None] + CONF.image_format.container_formats, |
|
}, |
|
'disk_format': { |
|
'type': ['null', 'string'], |
|
'description': _('Format of the disk'), |
|
'enum': [None] + CONF.image_format.disk_formats, |
|
}, |
|
'created_at': { |
|
'type': 'string', |
|
'description': _('Date and time of image registration' |
|
' (READ-ONLY)'), |
|
# TODO(bcwaldon): our jsonschema library doesn't seem to like the |
|
# format attribute, figure out why! |
|
# 'format': 'date-time', |
|
}, |
|
'updated_at': { |
|
'type': 'string', |
|
'description': _('Date and time of the last image modification' |
|
' (READ-ONLY)'), |
|
# 'format': 'date-time', |
|
}, |
|
'tags': { |
|
'type': 'array', |
|
'description': _('List of strings related to the image'), |
|
'items': { |
|
'type': 'string', |
|
'maxLength': 255, |
|
}, |
|
}, |
|
'direct_url': { |
|
'type': 'string', |
|
'description': _('URL to access the image file kept in external ' |
|
'store (READ-ONLY)'), |
|
}, |
|
'min_ram': { |
|
'type': 'integer', |
|
'description': _('Amount of ram (in MB) required to boot image.'), |
|
}, |
|
'min_disk': { |
|
'type': 'integer', |
|
'description': _('Amount of disk space (in GB) required to boot ' |
|
'image.'), |
|
}, |
|
'self': { |
|
'type': 'string', |
|
'description': '(READ-ONLY)' |
|
}, |
|
'file': { |
|
'type': 'string', |
|
'description': '(READ-ONLY)' |
|
}, |
|
'schema': { |
|
'type': 'string', |
|
'description': '(READ-ONLY)' |
|
}, |
|
'locations': { |
|
'type': 'array', |
|
'items': { |
|
'type': 'object', |
|
'properties': { |
|
'url': { |
|
'type': 'string', |
|
'maxLength': 255, |
|
}, |
|
'metadata': { |
|
'type': 'object', |
|
}, |
|
}, |
|
'required': ['url', 'metadata'], |
|
}, |
|
'description': _('A set of URLs to access the image file kept in ' |
|
'external store'), |
|
}, |
|
} |
|
|
|
|
|
def _get_base_links(): |
|
return [ |
|
{'rel': 'self', 'href': '{self}'}, |
|
{'rel': 'enclosure', 'href': '{file}'}, |
|
{'rel': 'describedby', 'href': '{schema}'}, |
|
] |
|
|
|
|
|
def get_schema(custom_properties=None): |
|
properties = get_base_properties() |
|
links = _get_base_links() |
|
if CONF.allow_additional_image_properties: |
|
schema = glance.schema.PermissiveSchema('image', properties, links) |
|
else: |
|
schema = glance.schema.Schema('image', properties) |
|
|
|
if custom_properties: |
|
for property_value in custom_properties.values(): |
|
property_value['is_base'] = False |
|
schema.merge_properties(custom_properties) |
|
return schema |
|
|
|
|
|
def get_collection_schema(custom_properties=None): |
|
image_schema = get_schema(custom_properties) |
|
return glance.schema.CollectionSchema('images', image_schema) |
|
|
|
|
|
def load_custom_properties(): |
|
"""Find the schema properties files and load them into a dict.""" |
|
filename = 'schema-image.json' |
|
match = CONF.find_file(filename) |
|
if match: |
|
with open(match, 'r') as schema_file: |
|
schema_data = schema_file.read() |
|
return json.loads(schema_data) |
|
else: |
|
msg = (_LW('Could not find schema properties file %s. Continuing ' |
|
'without custom properties') % filename) |
|
LOG.warn(msg) |
|
return {} |
|
|
|
|
|
def create_resource(custom_properties=None): |
|
"""Images resource factory method""" |
|
schema = get_schema(custom_properties) |
|
deserializer = RequestDeserializer(schema) |
|
serializer = ResponseSerializer(schema) |
|
controller = ImagesController() |
|
return wsgi.Resource(controller, deserializer, serializer)
|
|
|