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 facility to receive log lines
syslog_log_facility = LOG_LOCAL0 syslog_log_facility = LOG_LOCAL0
# Role used to identify an authenticated user as administrator
#admin_role = admin
# Use durable queues in amqp. (boolean value) # Use durable queues in amqp. (boolean value)
# Deprecated group/name - [DEFAULT]/rabbit_durable_queues # Deprecated group/name - [DEFAULT]/rabbit_durable_queues
#amqp_durable_queues=false #amqp_durable_queues=false

View File

@ -15,9 +15,17 @@
from oslo.config import cfg from oslo.config import cfg
import muranoapi.context import muranoapi.context
from muranoapi.openstack.common.gettextutils import _ # noqa
import muranoapi.openstack.common.log as logging import muranoapi.openstack.common.log as logging
from muranoapi.openstack.common import wsgi 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__) LOG = logging.getLogger(__name__)
CONF = cfg.CONF CONF = cfg.CONF
@ -32,11 +40,14 @@ class ContextMiddleware(wsgi.Middleware):
:param req: wsgi request object that will be given the context object :param req: wsgi request object that will be given the context object
""" """
kwargs = { kwargs = {
'user': req.headers.get('X-User-Id'), 'user': req.headers.get('X-User-Id'),
'tenant': req.headers.get('X-Tenant-Id'), 'tenant': req.headers.get('X-Tenant-Id'),
'auth_token': req.headers.get('X-Auth-Token'), '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) 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. # under the License.
import routes import routes
from muranoapi.api.v1 import catalog
from muranoapi.api.v1 import deployments from muranoapi.api.v1 import deployments
from muranoapi.api.v1 import environments from muranoapi.api.v1 import environments
from muranoapi.api.v1 import services from muranoapi.api.v1 import services
@ -119,4 +120,13 @@ class API(wsgi.Router):
action='deploy', action='deploy',
conditions={'method': ['POST']}) 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) super(API, self).__init__(mapper)

View File

@ -23,3 +23,28 @@ ENV_SCHEMA = {
}, },
"required": ["id", "name"] "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. 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.auth_token = auth_token
self.user = user self.user = user
self.tenant = tenant self.tenant = tenant
self.session = session self.session = session
self.is_admin = is_admin
def to_dict(self): def to_dict(self):
return { 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') lazy='joined')
class_definition = sa_orm.relationship("Class") 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): class Category(BASE, ModelBase):
""" """

View File

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

View File

@ -22,6 +22,8 @@ eventlet.patcher.monkey_patch(all=False, socket=True)
import datetime import datetime
import errno import errno
import re
import jsonschema
import socket import socket
import sys import sys
import time import time
@ -35,6 +37,7 @@ import webob.exc
from xml.dom import minidom from xml.dom import minidom
from xml.parsers import expat from xml.parsers import expat
from muranoapi.api.v1 import schemas
from muranoapi.openstack.common import exception from muranoapi.openstack.common import exception
from muranoapi.openstack.common.gettextutils import _ from muranoapi.openstack.common.gettextutils import _
from muranoapi.openstack.common import jsonutils from muranoapi.openstack.common import jsonutils
@ -108,7 +111,7 @@ class Service(service.Service):
eventlet.sleep(0.1) eventlet.sleep(0.1)
if not sock: if not sock:
raise RuntimeError(_("Could not bind to %(host)s:%(port)s " raise RuntimeError(_("Could not bind to %(host)s:%(port)s "
"after trying for 30 seconds") % "after trying for 30 seconds") %
{'host': host, 'port': port}) {'host': host, 'port': port})
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# sockets can hang around forever without keepalive # sockets can hang around forever without keepalive
@ -233,7 +236,6 @@ class Debug(Middleware):
class Router(object): class Router(object):
""" """
WSGI middleware that maps incoming requests to WSGI apps. WSGI middleware that maps incoming requests to WSGI apps.
""" """
@ -292,7 +294,9 @@ class Router(object):
class Request(webob.Request): class Request(webob.Request):
"""Add some Openstack API-specific logic to the base 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_types = ('application/json', 'application/xml')
default_accept_type = 'application/json' 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 may raise a webob.exc exception or return a dict, which will be
serialized by requested content type. serialized by requested content type.
""" """
def __init__(self, controller, deserializer=None, serializer=None): def __init__(self, controller, deserializer=None, serializer=None):
""" """
:param controller: object that implement methods created by routes lib :param controller: object that implement methods created by routes lib
@ -452,11 +457,11 @@ class JSONDictSerializer(DictSerializer):
_dtime = obj - datetime.timedelta(microseconds=obj.microsecond) _dtime = obj - datetime.timedelta(microseconds=obj.microsecond)
return _dtime.isoformat() return _dtime.isoformat()
return unicode(obj) return unicode(obj)
return jsonutils.dumps(data, default=sanitizer) return jsonutils.dumps(data, default=sanitizer)
class XMLDictSerializer(DictSerializer): class XMLDictSerializer(DictSerializer):
def __init__(self, metadata=None, xmlns=None): def __init__(self, metadata=None, xmlns=None):
""" """
:param metadata: information needed to deserialize xml into :param metadata: information needed to deserialize xml into
@ -627,6 +632,7 @@ class RequestDeserializer(object):
self.body_deserializers = { self.body_deserializers = {
'application/xml': XMLDeserializer(), 'application/xml': XMLDeserializer(),
'application/json': JSONDeserializer(), 'application/json': JSONDeserializer(),
'application/murano-packages-json-patch': JSONPatchDeserializer()
} }
self.body_deserializers.update(body_deserializers or {}) self.body_deserializers.update(body_deserializers or {})
@ -718,7 +724,6 @@ class TextDeserializer(ActionDispatcher):
class JSONDeserializer(TextDeserializer): class JSONDeserializer(TextDeserializer):
def _from_json(self, datastring): def _from_json(self, datastring):
try: try:
return jsonutils.loads(datastring) return jsonutils.loads(datastring)
@ -730,8 +735,142 @@ class JSONDeserializer(TextDeserializer):
return {'body': self._from_json(datastring)} 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): def __init__(self, metadata=None):
""" """
:param metadata: information needed to deserialize xml into :param metadata: information needed to deserialize xml into