murano/murano/api/v1/catalog.py

469 lines
18 KiB
Python

# 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.
import cgi
import os
import tempfile
import jsonschema
from keystoneclient import exceptions as keystone_ex
from keystoneclient import service_catalog
from oslo_config import cfg
from oslo_db import exception as db_exc
from oslo_log import log as logging
from webob import exc
import murano.api.v1
from murano.api.v1 import validation_schemas
from murano.common import exceptions
from murano.common.i18n import _
from murano.common import policy
import murano.common.utils as murano_utils
from murano.common import wsgi
from murano.db.catalog import api as db_api
from murano.packages import exceptions as pkg_exc
from murano.packages import load_utils
from muranoclient.glance import client as glare_client
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
SUPPORTED_PARAMS = murano.api.v1.SUPPORTED_PARAMS
LIST_PARAMS = murano.api.v1.LIST_PARAMS
ORDER_VALUES = murano.api.v1.ORDER_VALUES
PKG_PARAMS_MAP = murano.api.v1.PKG_PARAMS_MAP
OPERATOR_VALUES = murano.api.v1.OPERATOR_VALUES
def _check_content_type(req, content_type):
try:
req.get_content_type((content_type,))
except exceptions.UnsupportedContentType:
msg = _("Content-Type must be '{type}'").format(type=content_type)
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
def _get_filters(query_params):
filters = {}
for param_pair in query_params:
k, v = param_pair
if k not in SUPPORTED_PARAMS:
LOG.warning("Search by parameter '{name}' "
"is not supported. Skipping it.".format(name=k))
continue
if k in LIST_PARAMS:
if v.startswith('in:') and k in OPERATOR_VALUES:
in_value = v[len('in:'):]
try:
filters[k] = murano_utils.split_for_quotes(in_value)
except ValueError as err:
LOG.warning("Search by parameter '{name}' "
"caused an {message} error."
"Skipping it.".format(name=k,
message=err))
else:
filters.setdefault(k, []).append(v)
else:
filters[k] = v
order_by = filters.get('order_by', [])
for i in order_by[:]:
if ORDER_VALUES and i not in ORDER_VALUES:
filters['order_by'].remove(i)
LOG.warning("Value of 'order_by' parameter is not valid. "
"Allowed values are: {values}. Skipping it."
.format(values=", ".join(ORDER_VALUES)))
return filters
def _validate_body(body):
"""Check multipart/form-data has two parts
Check multipart/form-data has two parts: text (which is json string and
should parsed into dictionary in serializer) and file, which stores as
cgi.FieldStorage instance. Also validate file size doesn't exceed
the limit: seek to the end of the file, get the position of EOF and
reset the file position to the beginning
"""
def check_file_size(f):
mb_limit = CONF.murano.package_size_limit
pkg_size_limit = mb_limit * 1024 * 1024
f.seek(0, 2)
size = f.tell()
f.seek(0)
if size > pkg_size_limit:
raise exc.HTTPBadRequest(explanation=_(
'Uploading file is too large. '
'The limit is {0} Mb').format(mb_limit))
if len(body) > 2:
msg = _("'multipart/form-data' request body should contain 1 or 2 "
"parts: json string and zip archive. Current body consists "
"of {amount} part(s)").format(amount=len(body.keys()))
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
file_obj = None
package_meta = None
for part in body.values():
if isinstance(part, cgi.FieldStorage):
file_obj = part
check_file_size(file_obj.file)
if isinstance(part, dict):
package_meta = part
if file_obj is None:
msg = _('There is no file package with application description')
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
return file_obj, package_meta
class Controller(object):
"""WSGI controller for application catalog resource in Murano v1 API."""
def _validate_limit(self, value):
if value is None:
return
try:
value = int(value)
except ValueError:
msg = _("Limit param must be an integer")
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
if value <= 0:
msg = _("Limit param must be positive")
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
return value
def update(self, req, body, package_id):
"""List of allowed changes
List of allowed changes:
{ "op": "add", "path": "/tags", "value": [ "foo", "bar" ] }
{ "op": "add", "path": "/categories", "value": [ "foo", "bar" ] }
{ "op": "remove", "path": "/tags" }
{ "op": "remove", "path": "/categories" }
{ "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" }
"""
policy.check("modify_package", req.context, {'package_id': package_id})
pkg_to_update = db_api.package_get(package_id, req.context)
if pkg_to_update.is_public:
policy.check("manage_public_package", req.context)
_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)
for change in body:
if 'is_public' in change['path']:
if change['value'] is True and not pkg_to_update.is_public:
policy.check('publicize_package', req.context)
if 'name' in change['path']:
if len(change['value']) > 80:
msg = _('Package name should be 80 characters maximum')
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):
policy.check("get_package", req.context, {'package_id': package_id})
package = db_api.package_get(package_id, req.context)
return package.to_dict()
def search(self, req):
policy.check("get_package", req.context)
manage_public = True
try:
policy.check("manage_public_package", req.context)
except exc.HTTPForbidden:
manage_public = False
filters = _get_filters(req.GET.items())
limit = self._validate_limit(filters.get('limit'))
if limit is None:
limit = CONF.murano.limit_param_default
limit = min(CONF.murano.api_limit_max, limit)
result = {}
catalog = req.GET.pop('catalog', '').lower() == 'true'
packages = db_api.package_search(
filters, req.context, manage_public, limit, catalog=catalog)
if len(packages) == limit:
result['next_marker'] = packages[-1].id
result['packages'] = [package.to_dict() for package in packages]
return result
def upload(self, req, body=None):
"""Upload new file archive
Upload new file archive for the new package
together with package metadata.
"""
policy.check("upload_package", req.context)
_check_content_type(req, 'multipart/form-data')
file_obj, package_meta = _validate_body(body)
if package_meta:
try:
jsonschema.validate(package_meta,
validation_schemas.PKG_UPLOAD_SCHEMA)
except jsonschema.ValidationError as e:
msg = _("Package schema is not valid: {reason}").format(
reason=e)
LOG.exception(msg)
raise exc.HTTPBadRequest(explanation=msg)
else:
package_meta = {}
if package_meta.get('is_public'):
policy.check('publicize_package', req.context)
with tempfile.NamedTemporaryFile(delete=False) as tempf:
LOG.debug("Storing package archive in a temporary file")
content = file_obj.file.read()
if not content:
msg = _("Uploading file can't be empty")
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
tempf.write(content)
package_meta['archive'] = content
try:
with load_utils.load_from_file(
tempf.name, target_dir=None,
drop_dir=True) as pkg_to_upload:
# extend dictionary for update db
for k, v in PKG_PARAMS_MAP.items():
if hasattr(pkg_to_upload, k):
if k == "tags" and package_meta.get(k):
package_meta[v] = list(set(
package_meta[v] + getattr(pkg_to_upload, k)))
else:
package_meta[v] = getattr(pkg_to_upload, k)
if len(package_meta['name']) > 80:
msg = _('Package name should be 80 characters maximum')
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
try:
package = db_api.package_upload(
package_meta, req.context.tenant)
except db_exc.DBDuplicateEntry:
msg = _('Package with specified full '
'name is already registered')
LOG.exception(msg)
raise exc.HTTPConflict(msg)
return package.to_dict()
except pkg_exc.PackageLoadError as e:
msg = _("Couldn't load package from file: {reason}").format(
reason=e)
LOG.exception(msg)
raise exc.HTTPBadRequest(explanation=msg)
finally:
LOG.debug("Deleting package archive temporary file")
os.remove(tempf.name)
def get_ui(self, req, package_id):
if CONF.engine.packages_service == 'murano':
target = {'package_id': package_id}
policy.check("get_package", req.context, target)
package = db_api.package_get(package_id, req.context)
return package.ui_definition
else:
g_client = self._get_glare_client(req)
blob_data = g_client.artifacts.download_blob(package_id, 'archive')
with tempfile.NamedTemporaryFile() as tempf:
for chunk in blob_data:
tempf.write(chunk)
tempf.file.flush()
os.fsync(tempf.file.fileno())
with load_utils.load_from_file(tempf.name, target_dir=None,
drop_dir=True) as pkg:
return pkg.ui
def get_logo(self, req, package_id):
target = {'package_id': package_id}
policy.check("get_package", req.context, target)
package = db_api.package_get(package_id, req.context)
return package.logo
def get_supplier_logo(self, req, package_id):
package = db_api.package_get(package_id, req.context)
return package.supplier_logo
def download(self, req, package_id):
target = {'package_id': package_id}
policy.check("download_package", req.context, target)
package = db_api.package_get(package_id, req.context)
return package.archive
def delete(self, req, package_id):
target = {'package_id': package_id}
policy.check("delete_package", req.context, target)
package = db_api.package_get(package_id, req.context)
if package.is_public:
policy.check("manage_public_package", req.context, target)
db_api.package_delete(package_id, req.context)
def get_category(self, req, category_id):
policy.check("get_category", req.context)
category = db_api.category_get(category_id, packages=True)
return category.to_dict()
def list_categories(self, req):
"""List all categories
List all categories with pagination and sorting
Acceptable filter params:
:param sort_keys: an array of fields used to sort the list
:param sort_dir: the direction of the sort ('asc' or 'desc')
:param limit: the number of categories to list
:param marker: the ID of the last item in the previous page
"""
def _get_category_filters(req):
query_params = {}
valid_query_params = ['sort_keys', 'sort_dir', 'limit', 'marker']
for key, value in req.GET.items():
if key not in valid_query_params:
raise exc.HTTPBadRequest(
_('Bad value passed to filter. '
'Got {key}, expected:{valid}').format(
key=key, valid=', '.join(valid_query_params)))
if key == 'sort_keys':
available_sort_keys = ['name', 'created',
'updated', 'package_count', 'id']
value = [v.strip() for v in value.split(',')]
for sort_key in value:
if sort_key not in available_sort_keys:
raise exc.HTTPBadRequest(
explanation=_('Invalid sort key: {sort_key}. '
'Must be one of the following: '
'{available}').format(
sort_key=sort_key,
available=', '.join(available_sort_keys)))
if key == 'sort_dir':
if value not in ['asc', 'desc']:
msg = _('Invalid sort direction: {0}').format(value)
raise exc.HTTPBadRequest(explanation=msg)
query_params[key] = value
return query_params
policy.check("get_category", req.context)
filters = _get_category_filters(req)
marker = filters.get('marker')
limit = self._validate_limit(filters.get('limit'))
result = {}
categories = db_api.categories_list(filters,
limit=limit,
marker=marker)
if len(categories) == limit:
result['next_marker'] = categories[-1].id
result['categories'] = [category.to_dict() for category in categories]
return result
def add_category(self, req, body=None):
policy.check("add_category", req.context)
category_name = body.get('name')
if not category_name:
raise exc.HTTPBadRequest(
explanation='Please, specify a name of the category to create')
if len(category_name) > 80:
msg = _('Category name should be 80 characters maximum')
LOG.error(msg)
raise exc.HTTPBadRequest(explanation=msg)
try:
category = db_api.category_add(category_name)
except db_exc.DBDuplicateEntry:
msg = _('Category with specified name is already exist')
LOG.error(msg)
raise exc.HTTPConflict(explanation=msg)
return category.to_dict()
def delete_category(self, req, category_id):
target = {'category_id': category_id}
policy.check("delete_category", req.context, target)
category = db_api.category_get(category_id, packages=True)
if category.packages:
msg = _("It's impossible to delete categories assigned "
"to the package, uploaded to the catalog")
raise exc.HTTPForbidden(explanation=msg)
db_api.category_delete(category_id)
def _get_glare_client(self, request):
glare_settings = CONF.glare
token = request.context.auth_token
url = glare_settings.url
if not url:
url = self._get_glare_url(request)
# TODO(gyurco): use auth_utils.get_session_client_parameters
client = glare_client.Client(
endpoint=url, token=token, insecure=glare_settings.insecure,
key_file=glare_settings.keyfile or None,
ca_file=glare_settings.cafile or None,
cert_file=glare_settings.certfile or None,
type_name='murano',
type_version=1)
return client
def _get_glare_url(self, request):
sc = request.context.service_catalog
token = request.context.auth_token
try:
return service_catalog.ServiceCatalogV2(
{'serviceCatalog': sc}).url_for(
service_type='artifact',
endpoint_type=CONF.glare.endpoint_type,
region_name=CONF.home_region)
except keystone_ex.EndpointNotFound:
return service_catalog.ServiceCatalogV3(
token,
{'catalog': sc}).url_for(
service_type='artifact',
endpoint_type=CONF.glare.endpoint_type,
region_name=CONF.home_region)
def create_resource():
specific_content_types = {
'get_ui': ['text/plain'],
'download': ['application/octet-stream'],
'get_logo': ['application/octet-stream'],
'get_supplier_logo': ['application/octet-stream']}
deserializer = wsgi.RequestDeserializer(
specific_content_types=specific_content_types)
return wsgi.Resource(Controller(), deserializer=deserializer)