REST API layer for Artifact Repository

Adds REST API layer for Artifact Repository, binds all other changes
together.

Artifact Repository is run as a part of Glance api.
Requests are routed to /v3/artifacts endpoint on the api port.
API version in version selector is marked as experimental.

Registers artifacts service and a sample Artifact Type in Glance entry
points config.

Implements-blueprint: artifact-repository

FastTrack

Co-Authored-By: Inessa Vasilevskaya <ivasilevskaya@mirantis.com>
Co-Authored-By: Mike Fedosin <mfedosin@mirantis.com>
Co-Authored-By: Alexander Tivelkov <ativelkov@mirantis.com>

Change-Id: Ib6a0d2482208a37aa343a747dfe5f63f9080dd04
This commit is contained in:
Mike Fedosin 2014-11-05 21:23:51 +03:00 committed by Inessa Vasilevskaya
parent 35e35a17bd
commit 3cdd5bba7c
16 changed files with 2037 additions and 3 deletions

View File

@ -39,6 +39,7 @@ paste.composite_factory = glance.api:root_app_factory
/: apiversions
/v1: apiv1app
/v2: apiv2app
/v3: apiv3app
[app:apiversions]
paste.app_factory = glance.api.versions:create_resource
@ -49,6 +50,9 @@ paste.app_factory = glance.api.v1.router:API.factory
[app:apiv2app]
paste.app_factory = glance.api.v2.router:API.factory
[app:apiv3app]
paste.app_factory = glance.api.v3.router:API.factory
[filter:versionnegotiation]
paste.filter_factory = glance.api.middleware.version_negotiation:VersionNegotiationFilter.factory

View File

@ -24,4 +24,6 @@ def root_app_factory(loader, global_conf, **local_conf):
del local_conf['/v1']
if not CONF.enable_v2_api:
del local_conf['/v2']
if not CONF.enable_v3_api:
del local_conf['/v3']
return paste.urlmap.urlmap_factory(loader, global_conf, **local_conf)

View File

@ -86,6 +86,8 @@ class VersionNegotiationFilter(wsgi.Middleware):
major_version = 1
elif subject in ('v2', 'v2.0', 'v2.1', 'v2.2') and CONF.enable_v2_api:
major_version = 2
elif subject in ('v3', 'v3.0') and CONF.enable_v3_api:
major_version = 3
else:
raise ValueError()

View File

701
glance/api/v3/artifacts.py Normal file
View File

@ -0,0 +1,701 @@
# 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 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") % utils.exception_to_str(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': utils.exception_to_str(e)})
LOG.exception(msg)
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
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 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)

87
glance/api/v3/router.py Normal file
View File

@ -0,0 +1,87 @@
# 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.
from glance.api.v3 import artifacts
from glance.common import wsgi
UUID_REGEX = (
R'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}')
class API(wsgi.Router):
def _get_artifacts_resource(self):
if not self.artifacts_resource:
self.artifacts_resource = artifacts.create_resource()
return self.artifacts_resource
def __init__(self, mapper):
self.artifacts_resource = None
artifacts_resource = self._get_artifacts_resource()
def _check_json_content_type(environ, result):
return "application/json" in environ["CONTENT_TYPE"]
def _check_octet_stream_content_type(environ, result):
return "application/octet-stream" in environ["CONTENT_TYPE"]
def connect_routes(m, read_only):
with m.submapper(resource_name="artifact_operations",
path_prefix="/{id}",
requirements={'id': UUID_REGEX}) as art:
art.show()
if not read_only:
art.delete()
art.action('update', method='PATCH')
art.link('publish', method='POST')
def connect_attr_action(attr):
if not read_only:
attr.action("upload", conditions={
'method': ["POST", "PUT"],
'function': _check_octet_stream_content_type})
attr.action("update_property",
conditions={
'method': ["POST", "PUT"],
'function': _check_json_content_type})
attr.link("download", method="GET")
attr_map = art.submapper(resource_name="attr_operations",
path_prefix="/{attr}", path_left=None)
attr_items = art.submapper(
resource_name="attr_item_ops",
path_prefix="/{attr}/{path_left:.*}")
connect_attr_action(attr_map)
connect_attr_action(attr_items)
m.connect("", action='list', conditions={'method': 'GET'},
state='active')
m.connect("/drafts", action='list', conditions={'method': 'GET'},
state='creating')
if not read_only:
m.connect("/drafts", action='create',
conditions={'method': 'POST'})
versioned = mapper.submapper(path_prefix='/artifacts/{type_name}/'
'v{type_version}',
controller=artifacts_resource)
non_versioned = mapper.submapper(path_prefix='/artifacts/{type_name}',
type_version=None,
controller=artifacts_resource)
connect_routes(versioned, False)
connect_routes(non_versioned, True)
super(API, self).__init__(mapper)

View File

@ -56,6 +56,9 @@ class Controller(object):
}
version_objs = []
if CONF.enable_v3_api:
version_objs.append(
build_version_object(3.0, 'v3', 'EXPERIMENTAL'))
if CONF.enable_v2_api:
version_objs.extend([
build_version_object(2.3, 'v2', 'CURRENT'),

View File

@ -12,8 +12,11 @@
# License for the specific language governing permissions and limitations
# under the License.
import collections
import six
from glance import artifacts as ga
from glance.common.artifacts import declarative
from glance.common.artifacts import definitions
from glance.common import exception
@ -262,3 +265,66 @@ def deserialize_from_db(db_dict, plugins):
artifact_properties, plugins)
return artifact_type(**artifact_properties)
def _process_blobs_for_client(artifact, result):
"""Processes artifact's blobs: adds download links and pretty-printed data.
The result is stored in 'result' dict.
"""
def build_uri(blob_attr, position=None):
"""A helper func to build download uri"""
template = "/artifacts/%(type)s/v%(version)s/%(id)s/%(prop)s/download"
format_dict = {
"type": artifact.metadata.endpoint,
"version": artifact.type_version,
"id": artifact.id,
"prop": blob_attr.name
}
if position is not None:
template = "/artifacts/%(type)s/v%(version)s/" \
"%(id)s/%(prop)s/%(position)s/download"
format_dict["position"] = position
return template % format_dict
for blob_attr in artifact.metadata.attributes.blobs.values():
value = blob_attr.get_value(artifact)
if value is None:
result[blob_attr.name] = None
elif isinstance(value, collections.Iterable):
res_list = []
for pos, blob in enumerate(value):
blob_dict = blob.to_dict()
blob_dict["download_link"] = build_uri(blob_attr, pos)
res_list.append(blob_dict)
result[blob_attr.name] = res_list
else:
result[blob_attr.name] = value.to_dict()
result[blob_attr.name]["download_link"] = build_uri(blob_attr)
def serialize_for_client(artifact, show_level=ga.Showlevel.NONE):
# use serialize_for_db and modify some fields
# (like properties, show only value, not type)
result = {}
for prop in artifact.metadata.attributes.properties.values():
result[prop.name] = prop.get_value(artifact)
if show_level > ga.Showlevel.NONE:
for dep in artifact.metadata.attributes.dependencies.values():
inner_show_level = (ga.Showlevel.DIRECT
if show_level == ga.Showlevel.DIRECT
else ga.Showlevel.NONE)
value = dep.get_value(artifact)
if value is None:
result[dep.name] = None
elif isinstance(value, list):
result[dep.name] = [serialize_for_client(v, inner_show_level)
for v in value]
else:
result[dep.name] = serialize_for_client(value,
inner_show_level)
_process_blobs_for_client(artifact, result)
return result

View File

@ -150,6 +150,8 @@ common_opts = [
help=_("Deploy the v1 OpenStack Images API.")),
cfg.BoolOpt('enable_v2_api', default=True,
help=_("Deploy the v2 OpenStack Images API.")),
cfg.BoolOpt('enable_v3_api', default=True,
help=_("Deploy the v3 OpenStack Objects API.")),
cfg.BoolOpt('enable_v1_registry', default=True,
help=_("Deploy the v1 OpenStack Registry API.")),
cfg.BoolOpt('enable_v2_registry', default=True,

View File

@ -75,6 +75,7 @@ class Server(object):
self.property_protection_file = ''
self.enable_v1_api = True
self.enable_v2_api = True
self.enable_v3_api = True
self.enable_v1_registry = True
self.enable_v2_registry = True
self.needs_database = False
@ -340,6 +341,7 @@ show_multiple_locations = %(show_multiple_locations)s
user_storage_quota = %(user_storage_quota)s
enable_v1_api = %(enable_v1_api)s
enable_v2_api = %(enable_v2_api)s
enable_v3_api = %(enable_v3_api)s
lock_path = %(lock_path)s
property_protection_file = %(property_protection_file)s
property_protection_rule_format = %(property_protection_rule_format)s
@ -386,6 +388,7 @@ paste.composite_factory = glance.api:root_app_factory
/: apiversions
/v1: apiv1app
/v2: apiv2app
/v3: apiv3app
[app:apiversions]
paste.app_factory = glance.api.versions:create_resource
@ -396,6 +399,9 @@ paste.app_factory = glance.api.v1.router:API.factory
[app:apiv2app]
paste.app_factory = glance.api.v2.router:API.factory
[app:apiv3app]
paste.app_factory = glance.api.v3.router:API.factory
[filter:versionnegotiation]
paste.filter_factory =
glance.api.middleware.version_negotiation:VersionNegotiationFilter.factory

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,11 @@ class TestApiVersions(functional.FunctionalTest):
url = 'http://127.0.0.1:%d/v%%s/' % self.api_port
versions = {'versions': [
{
'status': 'EXPERIMENTAL',
'id': 'v3.0',
'links': [{'href': url % '3', "rel": "self"}],
},
{
'id': 'v2.3',
'status': 'CURRENT',
@ -78,6 +83,11 @@ class TestApiVersions(functional.FunctionalTest):
url = 'http://127.0.0.1:%d/v%%s/' % self.api_port
versions = {'versions': [
{
'status': 'EXPERIMENTAL',
'id': 'v3.0',
'links': [{'href': url % '3', "rel": "self"}],
},
{
'id': 'v2.3',
'status': 'CURRENT',
@ -111,6 +121,7 @@ class TestApiVersions(functional.FunctionalTest):
def test_v1_api_configuration(self):
self.api_server.enable_v1_api = True
self.api_server.enable_v2_api = False
self.api_server.enable_v3_api = False
self.start_servers(**self.__dict__.copy())
url = 'http://127.0.0.1:%d/v%%s/' % self.api_port
@ -143,6 +154,11 @@ class TestApiPaths(functional.FunctionalTest):
url = 'http://127.0.0.1:%d/v%%s/' % self.api_port
versions = {'versions': [
{
'status': 'EXPERIMENTAL',
'id': 'v3.0',
'links': [{'href': url % '3', "rel": "self"}],
},
{
'id': 'v2.3',
'status': 'CURRENT',

View File

@ -78,6 +78,7 @@ class OptsTestCase(utils.BaseTestCase):
'user_storage_quota',
'enable_v1_api',
'enable_v2_api',
'enable_v3_api',
'enable_v1_registry',
'enable_v2_registry',
'pydev_worker_debug_host',
@ -166,6 +167,7 @@ class OptsTestCase(utils.BaseTestCase):
'user_storage_quota',
'enable_v1_api',
'enable_v2_api',
'enable_v3_api',
'enable_v1_registry',
'enable_v2_registry',
'pydev_worker_debug_host',
@ -211,6 +213,7 @@ class OptsTestCase(utils.BaseTestCase):
'user_storage_quota',
'enable_v1_api',
'enable_v2_api',
'enable_v3_api',
'enable_v1_registry',
'enable_v2_registry',
'pydev_worker_debug_host',
@ -260,6 +263,7 @@ class OptsTestCase(utils.BaseTestCase):
'user_storage_quota',
'enable_v1_api',
'enable_v2_api',
'enable_v3_api',
'enable_v1_registry',
'enable_v2_registry',
'pydev_worker_debug_host',

View File

@ -34,6 +34,12 @@ class VersionsTest(base.IsolatedUnitTest):
self.assertEqual('application/json', res.content_type)
results = jsonutils.loads(res.body)['versions']
expected = [
{
'status': 'EXPERIMENTAL',
'id': 'v3.0',
'links': [{'href': 'http://127.0.0.1:9292/v3/',
'rel': 'self'}],
},
{
'id': 'v2.3',
'status': 'CURRENT',
@ -83,6 +89,12 @@ class VersionsTest(base.IsolatedUnitTest):
self.assertEqual('application/json', res.content_type)
results = jsonutils.loads(res.body)['versions']
expected = [
{
'status': 'EXPERIMENTAL',
'id': 'v3.0',
'links': [{'href': 'https://example.com:9292/v3/',
'rel': 'self'}],
},
{
'id': 'v2.3',
'status': 'CURRENT',
@ -170,12 +182,22 @@ class VersionNegotiationTest(base.IsolatedUnitTest):
self.middleware.process_request(request)
self.assertEqual('/v2/images', request.path_info)
def test_request_url_v3(self):
request = webob.Request.blank('/v3/artifacts')
self.middleware.process_request(request)
self.assertEqual('/v3/artifacts', request.path_info)
def test_request_url_v3_0(self):
request = webob.Request.blank('/v3.0/artifacts')
self.middleware.process_request(request)
self.assertEqual('/v3/artifacts', request.path_info)
def test_request_url_v2_3_unsupported(self):
request = webob.Request.blank('/v2.3/images')
resp = self.middleware.process_request(request)
self.assertIsInstance(resp, versions.Controller)
def test_request_url_v3_unsupported(self):
request = webob.Request.blank('/v3/images')
def test_request_url_v4_unsupported(self):
request = webob.Request.blank('/v4/images')
resp = self.middleware.process_request(request)
self.assertIsInstance(resp, versions.Controller)

View File

@ -20,6 +20,7 @@ classifier =
[entry_points]
console_scripts =
glance-api = glance.cmd.api:main
glance-artifacts = glance.cmd.artifacts:main
glance-cache-prefetcher = glance.cmd.cache_prefetcher:main
glance-cache-pruner = glance.cmd.cache_pruner:main
glance-cache-manage = glance.cmd.cache_manage:main
@ -47,6 +48,8 @@ glance.database.metadata_backend =
glance.search.index_backend =
image = glance.search.plugins.images:ImageIndex
metadef = glance.search.plugins.metadefs:MetadefIndex
glance.artifacts.types =
MyArtifact = glance.contrib.plugins.artifacts_sample:MY_ARTIFACT
glance.flows =
import = glance.async.flows.base_import:get_flow