Merge "Initial commit for repository API support"

This commit is contained in:
Jenkins 2014-03-28 10:21:20 +00:00 committed by Gerrit Code Review
commit 3621c57404
11 changed files with 490 additions and 9 deletions

View File

@ -28,6 +28,9 @@ use_syslog = False
#Syslog facility to receive log lines
syslog_log_facility = LOG_LOCAL0
# Role used to identify an authenticated user as administrator
#admin_role = admin
# Use durable queues in amqp. (boolean value)
# Deprecated group/name - [DEFAULT]/rabbit_durable_queues
#amqp_durable_queues=false

View File

@ -15,9 +15,17 @@
from oslo.config import cfg
import muranoapi.context
from muranoapi.openstack.common.gettextutils import _ # noqa
import muranoapi.openstack.common.log as logging
from muranoapi.openstack.common import wsgi
context_opts = [
cfg.StrOpt('admin_role', default='admin',
help=_('Role used to identify an authenticated user as '
'administrator.'))]
CONF = cfg.CONF
CONF.register_opts(context_opts)
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
@ -32,11 +40,14 @@ class ContextMiddleware(wsgi.Middleware):
:param req: wsgi request object that will be given the context object
"""
kwargs = {
'user': req.headers.get('X-User-Id'),
'tenant': req.headers.get('X-Tenant-Id'),
'auth_token': req.headers.get('X-Auth-Token'),
'session': req.headers.get('X-Configuration-Session')
'session': req.headers.get('X-Configuration-Session'),
'is_admin': CONF.admin_role in [
role.strip() for role in req.headers.get('X-Roles').split(',')]
}
req.context = muranoapi.context.RequestContext(**kwargs)

View File

@ -0,0 +1,71 @@
# Copyright (c) 2014 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 oslo.config import cfg
from webob import exc
from muranoapi.db.catalog import api as db_api
from muranoapi.openstack.common import exception
from muranoapi.openstack.common.gettextutils import _ # noqa
from muranoapi.openstack.common import log as logging
from muranoapi.openstack.common import wsgi
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
def _check_content_type(req, content_type):
try:
req.get_content_type((content_type,))
except exception.InvalidContentType:
msg = _("Content-Type must be '{0}'").format(content_type)
LOG.debug(msg)
raise exc.HTTPBadRequest(explanation=msg)
class Controller(object):
"""
WSGI controller for application catalog resource in Murano v1 API
"""
def update(self, req, body, package_id):
"""
List of allowed changes:
{ "op": "add", "path": "/tags", "value": [ "foo", "bar" ] }
{ "op": "add", "path": "/categories", "value": [ "foo", "bar" ] }
{ "op": "remove", "path": "/tags" }
{ "op": "replace", "path": "/tags", "value": ["foo", "bar"] }
{ "op": "replace", "path": "/is_public", "value": true }
{ "op": "replace", "path": "/description",
"value":"New description" }
{ "op": "replace", "path": "/name", "value": "New name" }
"""
_check_content_type(req, 'application/murano-packages-json-patch')
if not isinstance(body, list):
msg = _('Request body must be a JSON array of operation objects.')
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
package = db_api.package_update(package_id, body, req.context)
return package.to_dict()
def get(self, req, package_id):
package = db_api.package_get(package_id, req.context)
return package.to_dict()
def create_resource():
return wsgi.Resource(Controller())

View File

@ -13,6 +13,7 @@
# under the License.
import routes
from muranoapi.api.v1 import catalog
from muranoapi.api.v1 import deployments
from muranoapi.api.v1 import environments
from muranoapi.api.v1 import services
@ -119,4 +120,13 @@ class API(wsgi.Router):
action='deploy',
conditions={'method': ['POST']})
catalog_resource = catalog.create_resource()
mapper.connect('/catalog/packages/{package_id}',
controller=catalog_resource,
action='get',
conditions={'method': ['GET']})
mapper.connect('/catalog/packages/{package_id}',
controller=catalog_resource,
action='update',
conditions={'method': ['PATCH']})
super(API, self).__init__(mapper)

View File

@ -23,3 +23,28 @@ ENV_SCHEMA = {
},
"required": ["id", "name"]
}
PKG_UPDATE_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": True
},
"categories": {
"type": "array",
"minItems": 1,
"items": {"type": "string"},
"uniqueItems": True
},
"description": {"type": "string"},
"name": {"type": "string"},
"is_public": {"type": "boolean"},
"enabled": {"type": "boolean"}
},
"additionalProperties": False,
"minProperties": 1,
}

View File

@ -19,11 +19,13 @@ class RequestContext(object):
accesses the system, as well as additional request information.
"""
def __init__(self, auth_token=None, user=None, tenant=None, session=None):
def __init__(self, auth_token=None, user=None,
tenant=None, session=None, is_admin=None):
self.auth_token = auth_token
self.user = user
self.tenant = tenant
self.session = session
self.is_admin = is_admin
def to_dict(self):
return {

View File

206
muranoapi/db/catalog/api.py Normal file
View File

@ -0,0 +1,206 @@
# Copyright (c) 2014 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 webob import exc
from muranoapi.db import models
from muranoapi.db import session as db_session
from muranoapi.openstack.common.gettextutils import _ # noqa
from muranoapi.openstack.common import log as logging
LOG = logging.getLogger(__name__)
def get_category_by_name(name):
session = db_session.get_session()
return session.query(models.Category).filter_by(name=name).first()
def category_get_names():
session = db_session.get_session()
categories = []
for row in session.query(models.Category.name).all():
for name in row:
categories.append(name)
return categories
def get_tag_by_name(name):
session = db_session.get_session()
return session.query(models.Tag).filter_by(name=name).first()
def _package_get(package_id, session):
package = session.query(models.Package).get(package_id)
if not package:
msg = _("Package id '{0}' is not found".format(package_id))
LOG.error(msg)
raise exc.HTTPNotFound(msg)
return package
def _authorize_package(package, context, allow_public=False):
if context.is_admin:
return
if package.owner_id != context.tenant:
if not allow_public:
msg = _("Package '{0}' is not owned by "
"tenant '{1}'".format(package.id, context.tenant))
LOG.error(msg)
raise exc.HTTPForbidden(msg)
if not package.is_public:
msg = _("Package '{0}' is not public and not owned by "
"tenant '{1}' ".format(package.id, context.tenant))
LOG.error(msg)
raise exc.HTTPForbidden(msg)
def package_get(package_id, context):
"""
Return package details
:param package_id: ID of a package, string
:returns: detailed information about package, dict
"""
session = db_session.get_session()
package = _package_get(package_id, session)
_authorize_package(package, context, allow_public=True)
return package
def _get_categories(category_names):
"""
Return existing category objects or raise an exception
:param category_names: name of categories to associate with package, list
:returns: list of Category objects to associate with package, list
"""
categories = []
for ctg_name in category_names:
ctg_obj = get_category_by_name(ctg_name)
if not ctg_obj:
# it's not allowed to specify non-existent categories
raise exc.HTTPBadRequest(
"Category '{name}' doesn't exist".format(name=ctg_name))
categories.append(ctg_obj)
return categories
def _get_tags(tag_names):
"""
Return existing tags object or create new ones
:param tag_names: name of tags to associate with package, list
:returns: list of Tag objects to associate with package, list
"""
tags = []
if tag_names:
for tag_name in tag_names:
tag_obj = get_tag_by_name(tag_name)
if tag_obj:
tags.append(tag_obj)
else:
tag_record = models.Tag(name=tag_name)
tags.append(tag_record)
return tags
def _do_replace(package, change):
path = change['path'][0]
value = change['value']
calculate = {'categories': _get_categories,
'tags': _get_tags}
if path in ('categories', 'tags'):
existing_items = getattr(package, path)
duplicates = list(set(i.name for i in existing_items) & set(value))
unique_values = [x for x in value if x not in duplicates]
items_to_replace = calculate[path](unique_values)
# NOTE(efedorova): Replacing duplicate entities is not allowed,
# so need to remove anything, but duplicates
# and append everything but duplicates
for item in list(existing_items):
if item.name not in duplicates:
existing_items.remove(item)
existing_items.extend(items_to_replace)
else:
setattr(package, path, value)
return package
def _do_add(package, change):
# Only categories and tags support addition
path = change['path'][0]
value = change['value']
calculate = {'categories': _get_categories,
'tags': _get_tags}
items_to_add = calculate[path](value)
for item in items_to_add:
try:
getattr(package, path).append(item)
except AssertionError:
msg = _('One of the specified {0} is already '
'associated with a package. Doing nothing.')
LOG.warning(msg.format(path))
return package
def _do_remove(package, change):
# Only categories and tags support removal
def find(seq, predicate):
for elt in seq:
if predicate(elt):
return elt
path = change['path'][0]
values = change['value']
current_values = getattr(package, path)
for value in values:
if value not in [i.name for i in current_values]:
msg = _("Value '{0}' of property '{1}' "
"does not exist.").format(value, path)
LOG.error(msg)
raise exc.HTTPNotFound(msg)
if path == 'categories' and len(current_values) == 1:
msg = _("At least one category should be assigned to the package")
LOG.error(msg)
raise exc.HTTPBadRequest(msg)
item_to_remove = find(current_values, lambda i: i.name == value)
current_values.remove(item_to_remove)
return package
def package_update(pkg_id, changes, context):
"""
Update package information
:param changes: parameters to update
:returns: detailed information about new package, dict
"""
operation_methods = {'add': _do_add,
'replace': _do_replace,
'remove': _do_remove}
session = db_session.get_session()
pkg = _package_get(pkg_id, session)
_authorize_package(pkg, context)
for change in changes:
pkg = operation_methods[change['op']](pkg, change)
with session.begin():
session.add(pkg)
return pkg

View File

@ -257,6 +257,20 @@ class Package(BASE, ModelBase):
lazy='joined')
class_definition = sa_orm.relationship("Class")
def to_dict(self):
d = self.__dict__.copy()
not_serializable = ['_sa_instance_state',
'archive',
'logo',
'ui_definition']
nested_objects = ['categories', 'tags']
for key in not_serializable:
if key in d.keys():
del d[key]
for key in nested_objects:
d[key] = [a.name for a in d.get(key)]
return d
class Category(BASE, ModelBase):
"""

View File

@ -63,7 +63,7 @@ def _create_facade_lazily():
if _FACADE is None:
_FACADE = db_session.EngineFacade(
CONF.database.connection,
CONF.database.connection, sqlite_fk=True,
**dict(CONF.database.iteritems())
)
return _FACADE

View File

@ -22,6 +22,8 @@ eventlet.patcher.monkey_patch(all=False, socket=True)
import datetime
import errno
import re
import jsonschema
import socket
import sys
import time
@ -35,6 +37,7 @@ import webob.exc
from xml.dom import minidom
from xml.parsers import expat
from muranoapi.api.v1 import schemas
from muranoapi.openstack.common import exception
from muranoapi.openstack.common.gettextutils import _
from muranoapi.openstack.common import jsonutils
@ -233,7 +236,6 @@ class Debug(Middleware):
class Router(object):
"""
WSGI middleware that maps incoming requests to WSGI apps.
"""
@ -292,7 +294,9 @@ class Router(object):
class Request(webob.Request):
"""Add some Openstack API-specific logic to the base webob.Request."""
default_request_content_types = ('application/json', 'application/xml')
default_request_content_types = ('application/json',
'application/xml',
'application/murano-packages-json-patch')
default_accept_types = ('application/json', 'application/xml')
default_accept_type = 'application/json'
@ -350,6 +354,7 @@ class Resource(object):
may raise a webob.exc exception or return a dict, which will be
serialized by requested content type.
"""
def __init__(self, controller, deserializer=None, serializer=None):
"""
:param controller: object that implement methods created by routes lib
@ -452,11 +457,11 @@ class JSONDictSerializer(DictSerializer):
_dtime = obj - datetime.timedelta(microseconds=obj.microsecond)
return _dtime.isoformat()
return unicode(obj)
return jsonutils.dumps(data, default=sanitizer)
class XMLDictSerializer(DictSerializer):
def __init__(self, metadata=None, xmlns=None):
"""
:param metadata: information needed to deserialize xml into
@ -627,6 +632,7 @@ class RequestDeserializer(object):
self.body_deserializers = {
'application/xml': XMLDeserializer(),
'application/json': JSONDeserializer(),
'application/murano-packages-json-patch': JSONPatchDeserializer()
}
self.body_deserializers.update(body_deserializers or {})
@ -718,7 +724,6 @@ class TextDeserializer(ActionDispatcher):
class JSONDeserializer(TextDeserializer):
def _from_json(self, datastring):
try:
return jsonutils.loads(datastring)
@ -730,8 +735,142 @@ class JSONDeserializer(TextDeserializer):
return {'body': self._from_json(datastring)}
class XMLDeserializer(TextDeserializer):
class JSONPatchDeserializer(TextDeserializer):
allowed_operations = {"categories": ["add", "replace", "remove"],
"tags": ["add", "replace", "remove"],
"is_public": ["replace"],
"enabled": ["replace"],
"description": ["replace"],
"name": ["replace"]}
def _from_json_patch(self, datastring):
try:
operations = jsonutils.loads(datastring)
except ValueError:
msg = _("cannot understand JSON")
raise exception.MalformedRequestBody(reason=msg)
changes = []
for raw_change in operations:
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)
self._validate_path(path)
change = {'op': op, 'path': path}
change['value'] = self._get_change_value(raw_change, op)
self._validate_change(change)
changes.append(change)
return changes
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 _get_change_operation(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_path(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 _validate_change(self, change):
change_path = change['path'][0]
change_op = change['op']
allowed_methods = self.allowed_operations.get(change_path)
if not allowed_methods:
msg = _("Attribute '{0}' is invalid").format(change_path)
raise webob.exc.HTTPForbidden(explanation=unicode(msg))
if change_op not in allowed_methods:
msg = _("Method '{method}' is not allowed for a path with name "
"'{name}'. Allowed operations are: '{ops}'").format(
method=change_op,
name=change_path,
ops=', '.join(allowed_methods))
raise webob.exc.HTTPForbidden(explanation=unicode(msg))
property_to_update = {change_path: change['value']}
try:
jsonschema.validate(property_to_update, schemas.PKG_UPDATE_SCHEMA)
except jsonschema.ValidationError as e:
LOG.exception(e)
raise webob.exc.HTTPBadRequest(explanation=e.message)
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.
Only limited form of json pointers is accepted.
"""
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 _parse_json_schema_change(self, raw_change):
op = self._get_change_operation(raw_change)
path = self._get_change_path(raw_change)
path_list = self._decode_json_pointer(path)
return op, path_list
def _validate_path(self, path):
if len(path) > 1:
msg = _('Nested paths are not allowed')
raise webob.exc.HTTPBadRequest(explanation=msg)
def default(self, datastring):
return {'body': self._from_json_patch(datastring)}
class XMLDeserializer(TextDeserializer):
def __init__(self, metadata=None):
"""
:param metadata: information needed to deserialize xml into