glance/glance/api/v3/artifacts.py

739 lines
31 KiB
Python

# Copyright (c) 2015 Mirantis, Inc.
#
# 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 os
import sys
import glance_store
import jsonschema
from oslo_config import cfg
from oslo_serialization import jsonutils as json
from oslo_utils import encodeutils
from oslo_utils import excutils
import semantic_version
import six
import webob.exc
from glance.artifacts import gateway
from glance.artifacts import Showlevel
from glance.common.artifacts import loader
from glance.common.artifacts import serialization
from glance.common import exception
from glance.common import jsonpatchvalidator
from glance.common import utils
from glance.common import wsgi
import glance.db
from glance import i18n
from oslo_log import log as logging
LOG = logging.getLogger(__name__)
_LE = i18n._LE
_ = i18n._
possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
os.pardir,
os.pardir))
if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
sys.path.insert(0, possible_topdir)
CONF = cfg.CONF
CONF.import_group("profiler", "glance.common.wsgi")
class ArtifactsController(object):
def __init__(self, db_api=None, store_api=None, plugins=None):
self.db_api = db_api or glance.db.get_api()
self.store_api = store_api or glance_store
self.plugins = plugins or loader.ArtifactsPluginLoader(
'glance.artifacts.types')
self.gateway = gateway.Gateway(self.db_api,
self.store_api, self.plugins)
@staticmethod
def _do_update_op(artifact, change):
"""Call corresponding method of the updater proxy.
Here 'change' is a typical jsonpatch request dict:
* 'path' - a json-pointer string;
* 'op' - one of the allowed operation types;
* 'value' - value to set (omitted when op = remove)
"""
update_op = getattr(artifact, change['op'])
update_op(change['path'], change.get('value'))
return artifact
@staticmethod
def _get_artifact_with_dependencies(repo, art_id,
type_name=None, type_version=None):
"""Retrieves an artifact with dependencies from db by its id.
Show level is direct (only direct dependencies are shown).
"""
return repo.get(art_id, show_level=Showlevel.DIRECT,
type_name=type_name, type_version=type_version)
def show(self, req, type_name, type_version,
show_level=Showlevel.TRANSITIVE, **kwargs):
"""Retrieves one artifact by id with its dependencies"""
artifact_repo = self.gateway.get_artifact_repo(req.context)
try:
art_id = kwargs.get('id')
artifact = artifact_repo.get(art_id, type_name=type_name,
type_version=type_version,
show_level=show_level)
return artifact
except exception.ArtifactNotFound as e:
raise webob.exc.HTTPNotFound(explanation=e.msg)
def list(self, req, type_name, type_version, state, **kwargs):
"""Retrieves a list of artifacts that match some params"""
artifact_repo = self.gateway.get_artifact_repo(req.context)
filters = kwargs.pop('filters', {})
filters.update(type_name={'value': type_name},
state={'value': state})
if type_version is not None:
filters['type_version'] = {'value': type_version}
if 'version' in filters and filters['version']['value'] == 'latest':
if 'name' in filters:
filters['version']['value'] = self._get_latest_version(
req, filters['name']['value'], type_name, type_version)
else:
raise webob.exc.HTTPBadRequest(
'Filtering by version without specifying a name is not'
' supported.')
return artifact_repo.list(filters=filters,
show_level=Showlevel.BASIC,
**kwargs)
def _get_latest_version(self, req, name, type_name, type_version=None,
state='creating'):
artifact_repo = self.gateway.get_artifact_repo(req.context)
filters = dict(name={"value": name},
type_name={"value": type_name},
state={"value": state})
if type_version is not None:
filters["type_version"] = {"value": type_version}
result = artifact_repo.list(filters=filters,
show_level=Showlevel.NONE,
sort_keys=['version'])
if len(result):
return result[0].version
msg = "No artifacts have been found"
raise exception.ArtifactNotFound(message=msg)
@utils.mutating
def create(self, req, artifact_type, artifact_data, **kwargs):
try:
artifact_factory = self.gateway.get_artifact_type_factory(
req.context, artifact_type)
new_artifact = artifact_factory.new_artifact(**artifact_data)
artifact_repo = self.gateway.get_artifact_repo(req.context)
artifact_repo.add(new_artifact)
# retrieve artifact from db
return self._get_artifact_with_dependencies(artifact_repo,
new_artifact.id)
except TypeError as e:
raise webob.exc.HTTPBadRequest(explanation=e)
except exception.ArtifactNotFound as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
except exception.DuplicateLocation as dup:
raise webob.exc.HTTPBadRequest(explanation=dup.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:
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.Invalid as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
@utils.mutating
def update_property(self, req, id, type_name, type_version, path, data,
**kwargs):
"""Updates a single property specified by request url."""
artifact_repo = self.gateway.get_artifact_repo(req.context)
try:
artifact = self._get_artifact_with_dependencies(artifact_repo, id,
type_name,
type_version)
# use updater mixin to perform updates: generate update path
if req.method == "PUT":
# replaces existing value or creates a new one
if getattr(artifact, kwargs["attr"]):
artifact.replace(path=path, value=data)
else:
artifact.add(path=path, value=data)
else:
# append to an existing value or create a new one
artifact.add(path=path, value=data)
artifact_repo.save(artifact)
return self._get_artifact_with_dependencies(artifact_repo, id)
except (exception.InvalidArtifactPropertyValue,
exception.ArtifactInvalidProperty,
exception.InvalidJsonPatchPath) as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
except exception.NotFound as e:
raise webob.exc.HTTPNotFound(explanation=e.msg)
@utils.mutating
def update(self, req, id, type_name, type_version, changes, **kwargs):
"""Performs an update via json patch request"""
artifact_repo = self.gateway.get_artifact_repo(req.context)
try:
artifact = self._get_artifact_with_dependencies(artifact_repo, id,
type_name,
type_version)
updated = artifact
for change in changes:
updated = self._do_update_op(updated, change)
artifact_repo.save(updated)
return self._get_artifact_with_dependencies(artifact_repo, id)
except (exception.InvalidArtifactPropertyValue,
exception.InvalidJsonPatchPath,
exception.InvalidParameterValue) as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
except exception.NotFound as e:
raise webob.exc.HTTPNotFound(explanation=e.msg)
except exception.Forbidden as e:
raise webob.exc.HTTPForbidden(explanation=e.msg)
except exception.StorageQuotaFull as e:
msg = (_("Denying attempt to upload artifact because it exceeds "
"the quota: %s") % encodeutils.exception_to_unicode(e))
raise webob.exc.HTTPRequestEntityTooLarge(
explanation=msg, request=req, content_type='text/plain')
except exception.Invalid as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
except exception.LimitExceeded as e:
raise webob.exc.HTTPRequestEntityTooLarge(
explanation=e.msg, request=req, content_type='text/plain')
@utils.mutating
def delete(self, req, id, type_name, type_version, **kwargs):
artifact_repo = self.gateway.get_artifact_repo(req.context)
try:
artifact = self._get_artifact_with_dependencies(
artifact_repo, id, type_name=type_name,
type_version=type_version)
artifact_repo.remove(artifact)
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.NotFound as e:
msg = (_("Failed to find artifact %(artifact_id)s to delete") %
{'artifact_id': id})
raise webob.exc.HTTPNotFound(explanation=msg)
except exception.InUseByStore as e:
msg = (_("Artifact %s could not be deleted "
"because it is in use: %s") % (id, e.msg)) # noqa
raise webob.exc.HTTPConflict(explanation=msg)
@utils.mutating
def publish(self, req, id, type_name, type_version, **kwargs):
artifact_repo = self.gateway.get_artifact_repo(req.context)
try:
artifact = self._get_artifact_with_dependencies(
artifact_repo, id, type_name=type_name,
type_version=type_version)
return artifact_repo.publish(artifact, context=req.context)
except exception.Forbidden as e:
raise webob.exc.HTTPForbidden(explanation=e.msg)
except exception.NotFound as e:
raise webob.exc.HTTPNotFound(explanation=e.msg)
except exception.Invalid as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
def _upload_list_property(self, method, blob_list, index, data, size):
if method == 'PUT' and not index and len(blob_list) > 0:
# PUT replaces everything, so PUT to non-empty collection is
# forbidden
raise webob.exc.HTTPMethodNotAllowed(
explanation=_("Unable to PUT to non-empty collection"))
if index is not None and index > len(blob_list):
raise webob.exc.HTTPBadRequest(
explanation=_("Index is out of range"))
if index is None:
# both POST and PUT create a new blob list
blob_list.append((data, size))
elif method == 'POST':
blob_list.insert(index, (data, size))
else:
blob_list[index] = (data, size)
@utils.mutating
def upload(self, req, id, type_name, type_version, attr, size, data,
index, **kwargs):
artifact_repo = self.gateway.get_artifact_repo(req.context)
try:
artifact = self._get_artifact_with_dependencies(artifact_repo,
id,
type_name,
type_version)
blob_prop = artifact.metadata.attributes.blobs.get(attr)
if blob_prop is None:
raise webob.exc.HTTPBadRequest(
explanation=_("Not a blob property '%s'") % attr)
if isinstance(blob_prop, list):
blob_list = getattr(artifact, attr)
self._upload_list_property(req.method, blob_list,
index, data, size)
else:
if index is not None:
raise webob.exc.HTTPBadRequest(
explanation=_("Not a list property '%s'") % attr)
setattr(artifact, attr, (data, size))
artifact_repo.save(artifact)
return artifact
except exception.Forbidden as e:
raise webob.exc.HTTPForbidden(explanation=e.msg)
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 as e:
# TODO(mfedosin): add more exception handlers here
with excutils.save_and_reraise_exception():
LOG.exception(_LE("Failed to upload image data due to "
"internal error"))
self._restore(artifact_repo, artifact)
def download(self, req, id, type_name, type_version, attr, index,
**kwargs):
artifact_repo = self.gateway.get_artifact_repo(req.context)
try:
artifact = artifact_repo.get(id, type_name, type_version)
if attr in artifact.metadata.attributes.blobs:
if isinstance(artifact.metadata.attributes.blobs[attr], list):
if index is None:
raise webob.exc.HTTPBadRequest(
explanation=_("Index is required"))
blob_list = getattr(artifact, attr)
try:
return blob_list[index]
except IndexError as e:
raise webob.exc.HTTPBadRequest(explanation=e.message)
else:
if index is not None:
raise webob.exc.HTTPBadRequest(_("Not a list "
"property"))
return getattr(artifact, attr)
else:
message = _("Not a downloadable entity")
raise webob.exc.HTTPBadRequest(explanation=message)
except exception.Forbidden as e:
raise webob.exc.HTTPForbidden(explanation=e.msg)
except exception.NotFound as e:
raise webob.exc.HTTPNotFound(explanation=e.msg)
except exception.Invalid as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
def _restore(self, artifact_repo, artifact):
"""Restore the artifact to queued status.
:param artifact_repo: The instance of ArtifactRepo
:param artifact: The artifact will be restored
"""
try:
if artifact_repo and artifact:
artifact.state = 'creating'
artifact_repo.save(artifact)
except Exception as e:
msg = (_LE("Unable to restore artifact %(artifact_id)s: %(e)s") %
{'artifact_id': artifact.id,
'e': encodeutils.exception_to_unicode(e)})
LOG.exception(msg)
def list_artifact_types(self, req):
plugins = self.plugins.plugin_map
response = []
base_link = "%s/v3/artifacts" % (CONF.public_endpoint or req.host_url)
for type_name, plugin in six.iteritems(plugins.get("by_typename")):
metadata = dict(
type_name=type_name,
displayed_name=plugin[0].metadata.type_display_name,
versions=[]
)
for version in plugin:
endpoint = version.metadata.endpoint
type_version = "v" + version.metadata.type_version
version_metadata = dict(
id=type_version,
link="%s/%s/%s" % (base_link, endpoint, type_version)
)
type_description = version.metadata.type_description
if type_description is not None:
version_metadata['description'] = type_description
metadata['versions'].append(version_metadata)
response.append(metadata)
return {"artifact_types": response}
class RequestDeserializer(wsgi.JSONRequestDeserializer,
jsonpatchvalidator.JsonPatchValidatorMixin):
_available_sort_keys = ('name', 'status', 'container_format',
'disk_format', 'size', 'id', 'created_at',
'updated_at', 'version')
_default_sort_dir = 'desc'
_max_limit_number = 1000
def __init__(self, schema=None, plugins=None):
super(RequestDeserializer, self).__init__(
methods_allowed=["replace", "remove", "add"])
self.plugins = plugins or loader.ArtifactsPluginLoader(
'glance.artifacts.types')
def _validate_show_level(self, show_level):
try:
return Showlevel.from_str(show_level.strip().lower())
except exception.ArtifactUnsupportedShowLevel as e:
raise webob.exc.HTTPBadRequest(explanation=e.message)
def show(self, req):
res = self._process_type_from_request(req, True)
params = req.params.copy()
show_level = params.pop('show_level', None)
if show_level is not None:
res['show_level'] = self._validate_show_level(show_level)
return res
def _get_request_body(self, req):
output = super(RequestDeserializer, self).default(req)
if 'body' not in output:
msg = _('Body expected in request.')
raise webob.exc.HTTPBadRequest(explanation=msg)
return output['body']
def validate_body(self, request):
try:
body = self._get_request_body(request)
return super(RequestDeserializer, self).validate_body(body)
except exception.JsonPatchException as e:
raise webob.exc.HTTPBadRequest(explanation=e)
def default(self, request):
return self._process_type_from_request(request)
def _check_type_version(self, type_version):
try:
semantic_version.Version(type_version, partial=True)
except ValueError as e:
raise webob.exc.HTTPBadRequest(explanation=e)
def _process_type_from_request(self, req,
allow_implicit_version=False):
try:
type_name = req.urlvars.get('type_name')
type_version = req.urlvars.get('type_version')
if type_version is not None:
self._check_type_version(type_version)
# Even if the type_version is not specified and
# 'allow_implicit_version' is False, this call is still needed to
# ensure that at least one version of this type exists.
artifact_type = self.plugins.get_class_by_endpoint(type_name,
type_version)
res = {
'type_name': artifact_type.metadata.type_name,
'type_version':
artifact_type.metadata.type_version
if type_version is not None else None
}
if allow_implicit_version:
res['artifact_type'] = artifact_type
return res
except exception.ArtifactPluginNotFound as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)
def create(self, req):
res = self._process_type_from_request(req, True)
res["artifact_data"] = self._get_request_body(req)
return res
def update(self, req):
res = self._process_type_from_request(req)
res["changes"] = self.validate_body(req)
return res
def update_property(self, req):
"""Data is expected in form {'data': ...}"""
res = self._process_type_from_request(req)
data_schema = {
"type": "object",
"properties": {"data": {}},
"required": ["data"],
"$schema": "http://json-schema.org/draft-04/schema#"}
try:
json_body = json.loads(req.body)
jsonschema.validate(json_body, data_schema)
# TODO(ivasilevskaya):
# by now the deepest nesting level == 1 (ex. some_list/3),
# has to be fixed for dict properties
attr = req.urlvars["attr"]
path_left = req.urlvars["path_left"]
path = (attr if not path_left
else "%(attr)s/%(path_left)s" % {'attr': attr,
'path_left': path_left})
res.update(data=json_body["data"], path=path)
return res
except (ValueError, jsonschema.ValidationError) as e:
msg = _("Invalid json body: %s") % e.message
raise webob.exc.HTTPBadRequest(explanation=msg)
def upload(self, req):
res = self._process_type_from_request(req)
index = req.urlvars.get('path_left')
try:
# for blobs only one level of indexing is supported
# (ex. bloblist/0)
if index is not None:
index = int(index)
except ValueError:
msg = _("Only list indexes are allowed for blob lists")
raise webob.exc.HTTPBadRequest(explanation=msg)
artifact_size = req.content_length or None
res.update(size=artifact_size, data=req.body_file,
index=index)
return res
def download(self, req):
res = self._process_type_from_request(req)
index = req.urlvars.get('index')
if index is not None:
index = int(index)
res.update(index=index)
return res
def _validate_limit(self, limit):
if limit is None:
return self._max_limit_number
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)
if limit > self._max_limit_number:
msg = _("Limit param"
" must not be higher than %d") % self._max_limit_number
raise webob.exc.HTTPBadRequest(explanation=msg)
return limit
def _validate_sort_key(self, sort_key, artifact_type, type_version=None):
if sort_key in self._available_sort_keys:
return sort_key, None
elif type_version is None:
msg = _('Invalid sort key: %(sort_key)s. '
'If type version is not set 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)
prop_type = artifact_type.metadata.attributes.all.get(sort_key)
if prop_type is None or prop_type.DB_TYPE not in ['string',
'numeric',
'int',
'bool']:
msg = _('Invalid sort key: %(sort_key)s. '
'You cannot sort by this property') % \
{'sort_key': sort_key}
raise webob.exc.HTTPBadRequest(explanation=msg)
return sort_key, prop_type.DB_TYPE
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 _get_sorting_params(self, params, artifact_type, type_version=None):
sort_keys = []
sort_dirs = []
if 'sort' in params:
for sort_param in params.pop('sort').strip().split(','):
key, _sep, dir = sort_param.partition(':')
if not dir:
dir = self._default_sort_dir
sort_keys.append(self._validate_sort_key(key.strip(),
artifact_type,
type_version))
sort_dirs.append(self._validate_sort_dir(dir.strip()))
if not sort_keys:
sort_keys = [('created_at', None)]
if not sort_dirs:
sort_dirs = [self._default_sort_dir]
return sort_keys, sort_dirs
def _bring_to_type(self, type_name, value):
mapper = {'int': int,
'string': str,
'text': str,
'bool': bool,
'numeric': float}
return mapper[type_name](value)
def _get_filters(self, artifact_type, params):
filters = dict()
for filter, value in params.items():
value = value.strip()
prop_type = artifact_type.metadata.attributes.all.get(filter)
if prop_type.DB_TYPE is not None:
str_type = prop_type.DB_TYPE
elif isinstance(prop_type, list):
if not isinstance(prop_type.item_type, list):
str_type = prop_type.item_type.DB_TYPE
else:
raise webob.exc.HTTPBadRequest('Filtering by tuple-like'
' fields is not supported')
elif isinstance(prop_type, dict):
filters['name'] = filter + '.' + value
continue
else:
raise webob.exc.HTTPBadRequest('Filtering by this property '
'is not supported')
substr1, _sep, substr2 = value.partition(':')
if not _sep:
op = 'IN' if isinstance(prop_type, list) else 'EQ'
filters[filter] = dict(operator=op,
value=self._bring_to_type(str_type,
substr1),
type=str_type)
else:
op = substr1.strip().upper()
filters[filter] = dict(operator=op,
value=self._bring_to_type(str_type,
substr2),
type=str_type)
return filters
def list(self, req):
res = self._process_type_from_request(req, True)
params = req.params.copy()
show_level = params.pop('show_level', None)
if show_level is not None:
res['show_level'] = self._validate_show_level(show_level.strip())
limit = params.pop('limit', None)
marker = params.pop('marker', None)
tags = []
while 'tag' in params:
tags.append(params.pop('tag').strip())
query_params = dict()
query_params['sort_keys'], query_params['sort_dirs'] = \
self._get_sorting_params(params, res['artifact_type'],
res['type_version'])
if marker is not None:
query_params['marker'] = marker
query_params['limit'] = self._validate_limit(limit)
if tags:
query_params['filters']['tags'] = {'value': tags}
query_params['filters'] = self._get_filters(res['artifact_type'],
params)
query_params['type_name'] = res['artifact_type'].metadata.type_name
return query_params
def list_artifact_types(self, req):
return {}
class ResponseSerializer(wsgi.JSONResponseSerializer):
# TODO(ivasilevskaya): ideally this should be autogenerated/loaded
ARTIFACTS_ENDPOINT = '/v3/artifacts'
fields = ['id', 'name', 'version', 'type_name', 'type_version',
'visibility', 'state', 'owner', 'scope', 'created_at',
'updated_at', 'tags', 'dependencies', 'blobs', 'properties']
def __init__(self, schema=None):
super(ResponseSerializer, self).__init__()
def default(self, response, res):
artifact = serialization.serialize_for_client(
res, show_level=Showlevel.DIRECT)
body = json.dumps(artifact, ensure_ascii=False)
response.unicode_body = six.text_type(body)
response.content_type = 'application/json'
def create(self, response, artifact):
response.status_int = 201
self.default(response, artifact)
response.location = (
'%(root_url)s/%(type_name)s/v%(type_version)s/%(id)s' % dict(
root_url=ResponseSerializer.ARTIFACTS_ENDPOINT,
type_name=artifact.metadata.endpoint,
type_version=artifact.metadata.type_version,
id=artifact.id))
def list(self, response, res):
artifacts_list = [
serialization.serialize_for_client(a, show_level=Showlevel.NONE)
for a in res]
body = json.dumps(artifacts_list, ensure_ascii=False)
response.unicode_body = six.text_type(body)
response.content_type = 'application/json'
def delete(self, response, result):
response.status_int = 204
def download(self, response, blob):
response.headers['Content-Type'] = 'application/octet-stream'
response.app_iter = iter(blob.data_stream)
if blob.checksum:
response.headers['Content-MD5'] = blob.checksum
response.headers['Content-Length'] = str(blob.size)
def list_artifact_types(self, response, res):
body = json.dumps(res, ensure_ascii=False)
response.unicode_body = six.text_type(body)
response.content_type = 'application/json'
def create_resource():
"""Images resource factory method"""
plugins = loader.ArtifactsPluginLoader('glance.artifacts.types')
deserializer = RequestDeserializer(plugins=plugins)
serializer = ResponseSerializer()
controller = ArtifactsController(plugins=plugins)
return wsgi.Resource(controller, deserializer, serializer)