diff --git a/etc/murano/murano-api.conf.sample b/etc/murano/murano-api.conf.sample index 250e35ef..6e9f183c 100644 --- a/etc/murano/murano-api.conf.sample +++ b/etc/murano/murano-api.conf.sample @@ -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 diff --git a/muranoapi/api/middleware/context.py b/muranoapi/api/middleware/context.py index 924666bc..e9d7204c 100644 --- a/muranoapi/api/middleware/context.py +++ b/muranoapi/api/middleware/context.py @@ -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) diff --git a/muranoapi/api/v1/catalog.py b/muranoapi/api/v1/catalog.py new file mode 100644 index 00000000..ca43a69a --- /dev/null +++ b/muranoapi/api/v1/catalog.py @@ -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()) diff --git a/muranoapi/api/v1/router.py b/muranoapi/api/v1/router.py index 280fb5ad..f1c99693 100644 --- a/muranoapi/api/v1/router.py +++ b/muranoapi/api/v1/router.py @@ -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) diff --git a/muranoapi/api/v1/schemas.py b/muranoapi/api/v1/schemas.py index cba82b1e..e39fc7c8 100644 --- a/muranoapi/api/v1/schemas.py +++ b/muranoapi/api/v1/schemas.py @@ -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, +} diff --git a/muranoapi/context.py b/muranoapi/context.py index 8adeb647..13e84198 100644 --- a/muranoapi/context.py +++ b/muranoapi/context.py @@ -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 { diff --git a/muranoapi/db/catalog/__init__.py b/muranoapi/db/catalog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/muranoapi/db/catalog/api.py b/muranoapi/db/catalog/api.py new file mode 100644 index 00000000..87a6ca8c --- /dev/null +++ b/muranoapi/db/catalog/api.py @@ -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 diff --git a/muranoapi/db/models.py b/muranoapi/db/models.py index f24e08bf..0dc9beb7 100644 --- a/muranoapi/db/models.py +++ b/muranoapi/db/models.py @@ -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): """ diff --git a/muranoapi/db/session.py b/muranoapi/db/session.py index 80e34f55..8541266a 100644 --- a/muranoapi/db/session.py +++ b/muranoapi/db/session.py @@ -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 diff --git a/muranoapi/openstack/common/wsgi.py b/muranoapi/openstack/common/wsgi.py index 1cf840d3..931f81a7 100644 --- a/muranoapi/openstack/common/wsgi.py +++ b/muranoapi/openstack/common/wsgi.py @@ -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 @@ -108,7 +111,7 @@ class Service(service.Service): eventlet.sleep(0.1) if not sock: raise RuntimeError(_("Could not bind to %(host)s:%(port)s " - "after trying for 30 seconds") % + "after trying for 30 seconds") % {'host': host, 'port': port}) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # sockets can hang around forever without keepalive @@ -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