diff --git a/etc/policy.json b/etc/policy.json index bf414b21..83d8a2ac 100644 --- a/etc/policy.json +++ b/etc/policy.json @@ -24,5 +24,11 @@ "provider:checkpoint_get": "rule:admin_or_owner", "provider:checkpoint_get_all": "rule:admin_or_owner", "provider:checkpoint_create": "rule:admin_or_owner", - "provider:checkpoint_delete": "rule:admin_or_owner" + "provider:checkpoint_delete": "rule:admin_or_owner", + + "trigger:create": "", + "trigger:delete": "rule:admin_or_owner", + "trigger:update": "rule:admin_or_owner", + "trigger:get": "rule:admin_or_owner", + "trigger:list": "" } diff --git a/smaug/api/common.py b/smaug/api/common.py index db8c68d1..755f279f 100644 --- a/smaug/api/common.py +++ b/smaug/api/common.py @@ -74,7 +74,7 @@ def _get_limit_param(params, max_limit=None): except ValueError: msg = _('limit param must be an integer') raise webob.exc.HTTPBadRequest(explanation=msg) - if limit < 0: + if limit <= 0: msg = _('limit param must be positive') raise webob.exc.HTTPBadRequest(explanation=msg) limit = min(limit, max_limit) diff --git a/smaug/api/v1/router.py b/smaug/api/v1/router.py index 73053d44..d166190a 100644 --- a/smaug/api/v1/router.py +++ b/smaug/api/v1/router.py @@ -16,6 +16,7 @@ from smaug.api.v1 import protectables from smaug.api.v1 import providers from smaug.api.v1 import restores from smaug.api.v1 import scheduled_operations +from smaug.api.v1 import triggers from smaug.wsgi import common as wsgi_common @@ -29,7 +30,9 @@ class APIRouter(wsgi_common.Router): restores_resources = restores.create_resource() protectables_resources = protectables.create_resource() providers_resources = providers.create_resource() + trigger_resources = triggers.create_resource() scheduled_operation_resources = scheduled_operations.create_resource() + mapper.resource("plan", "plans", controller=plans_resources, collection={}, @@ -74,6 +77,10 @@ class APIRouter(wsgi_common.Router): controller=providers_resources, action='checkpoints_delete', conditions={"method": ['DELETE']}) + mapper.resource("trigger", "triggers", + controller=trigger_resources, + collection={}, + member={'action': 'POST'}) mapper.resource("scheduled_operation", "scheduled_operations", controller=scheduled_operation_resources, collection={'detail': 'GET'}, diff --git a/smaug/api/v1/triggers.py b/smaug/api/v1/triggers.py new file mode 100644 index 00000000..6578ad94 --- /dev/null +++ b/smaug/api/v1/triggers.py @@ -0,0 +1,242 @@ +# 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. + +"""The triggers api.""" + + +from oslo_log import log as logging +from oslo_utils import uuidutils +import uuid +from webob import exc + +from smaug.api import common +from smaug.api.openstack import wsgi +from smaug import exception +from smaug.i18n import _ +from smaug import objects +from smaug.operationengine import api as operationengine_api +from smaug import policy +from smaug import utils + +LOG = logging.getLogger(__name__) + + +def check_policy(context, action, target_obj=None): + _action = 'trigger:%s' % action + policy.enforce(context, _action, target_obj) + + +class TriggerViewBuilder(common.ViewBuilder): + """Model a trigger API response as a python dictionary.""" + + _collection_name = "triggers" + + def detail(self, request, trigger): + """Detailed view of a single trigger.""" + + trigger_ref = { + 'trigger_info': { + 'id': trigger.get('id'), + 'name': trigger.get('name'), + 'type': trigger.get('type'), + 'properties': trigger.get('properties'), + } + } + return trigger_ref + + def detail_list(self, request, triggers): + """Detailed view of a list of triggers.""" + return self._list_view(self.detail, request, triggers) + + def _list_view(self, func, request, triggers): + triggers_list = [func(request, item)['trigger_info'] + for item in triggers] + + triggers_links = self._get_collection_links(request, + triggers, + self._collection_name, + ) + ret = {'triggers': triggers_list} + if triggers_links: + ret['triggers_links'] = triggers_links + + return ret + + +class TriggersController(wsgi.Controller): + """The Triggers API controller for the OpenStack API.""" + + _view_builder_class = TriggerViewBuilder + + def __init__(self): + self.operationengine_api = operationengine_api.API() + super(TriggersController, self).__init__() + + def create(self, req, body): + """Creates a new trigger.""" + + LOG.debug('Create trigger start') + + if not self.is_valid_body(body, 'trigger_info'): + raise exc.HTTPUnprocessableEntity() + LOG.debug('Create a trigger, request body: %s', body) + + context = req.environ['smaug.context'] + check_policy(context, 'create') + trigger_info = body['trigger_info'] + + trigger_name = trigger_info.get("name", None) + trigger_type = trigger_info.get("type", None) + trigger_property = trigger_info.get("properties", None) + if not trigger_name or not trigger_type or not trigger_property: + msg = _("Trigger name or type or property is not provided.") + raise exc.HTTPBadRequest(explanation=msg) + + self.validate_name_and_description(trigger_info) + + trigger_definition = { + 'id': str(uuid.uuid4()), + 'name': trigger_name, + 'project_id': context.project_id, + 'type': trigger_type, + 'properties': trigger_property, + } + try: + trigger = objects.Trigger(context=context, **trigger_definition) + self.operationengine_api.create_trigger(context, trigger) + trigger.create() + except exception.Invalid as ex: + raise exc.HTTPBadRequest(explanation=ex.msg) + except Exception as ex: + self._raise_unknown_exception(ex) + + return self._view_builder.detail(req, trigger) + + def delete(self, req, id): + """Delete a trigger.""" + + LOG.debug('Delete trigger(%s) start', id) + + context = req.environ['smaug.context'] + trigger = self._get_trigger_by_id(context, id) + + check_policy(context, 'delete', trigger) + + try: + self.operationengine_api.delete_trigger(context, id) + except exception.TriggerNotFound as ex: + pass + except exception.DeleteTriggerNotAllowed as ex: + raise exc.HTTPBadRequest(explanation=ex.msg) + except Exception as ex: + self._raise_unknown_exception(ex) + + trigger.destroy() + + def update(self, req, id, body): + """Update a trigger""" + + LOG.debug('Update trigger(%s) start', id) + + context = req.environ['smaug.context'] + trigger = self._get_trigger_by_id(context, id) + + check_policy(context, 'update', trigger) + + trigger_info = body['trigger_info'] + trigger_name = trigger_info.get("name", None) + trigger_property = trigger_info.get("properties", None) + + if trigger_name: + self.validate_name_and_description(trigger_info) + trigger.name = trigger_name + + if trigger_property: + try: + trigger.properties = trigger_property + self.operationengine_api.update_trigger(context, trigger) + except exception.InvalidInput as ex: + raise exc.HTTPBadRequest(explanation=ex.msg) + except (exception.TriggerNotFound, Exception) as ex: + self._raise_unknown_exception(ex) + try: + trigger.save() + except Exception as ex: + self._raise_unknown_exception(ex) + + return self._view_builder.detail(req, trigger) + + def show(self, req, id): + """Return data about the given trigger.""" + + LOG.debug('Get trigger(%s) start', id) + + context = req.environ['smaug.context'] + trigger = self._get_trigger_by_id(context, id) + + check_policy(context, 'get', trigger) + return self._view_builder.detail(req, trigger) + + def index(self, req): + """Returns a list of triggers, transformed through view builder.""" + + context = req.environ['smaug.context'] + check_policy(context, 'list') + + params = req.params.copy() + LOG.debug('List triggers start, params=%s', params) + marker, limit, offset = common.get_pagination_params(params) + sort_keys, sort_dirs = common.get_sort_params(params) + filters = params + + valid_filters = ["all_tenants", "name", "type", "properties"] + utils.remove_invalid_filter_options(context, filters, valid_filters) + utils.check_filters(filters) + + all_tenants = utils.get_bool_param("all_tenants", filters) + if not (context.is_admin and all_tenants): + filters["project_id"] = context.project_id + + try: + triggers = objects.TriggerList.get_by_filters( + context, filters, limit, marker, sort_keys, sort_dirs) + except Exception as ex: + self._raise_unknown_exception(ex) + + return self._view_builder.detail_list(req, triggers) + + def _get_trigger_by_id(self, context, id): + if not uuidutils.is_uuid_like(id): + msg = _("Invalid trigger id provided.") + raise exc.HTTPBadRequest(explanation=msg) + + try: + trigger = objects.Trigger.get_by_id(context, id) + except exception.TriggerNotFound as error: + raise exc.HTTPNotFound(explanation=error.msg) + except Exception as ex: + self._raise_unknown_exception(ex) + + return trigger + + def _raise_unknown_exception(self, exception_instance): + value = exception_instance.msg if isinstance( + exception_instance, exception.SmaugException) else type( + exception_instance) + msg = (_('Unexpected API Error. Please report this at ' + 'http://bugs.launchpad.net/smaug/ and attach the ' + 'Smaug API log if possible.\n%s') % value) + raise exc.HTTPInternalServerError(explanation=msg) + + +def create_resource(): + return wsgi.Resource(TriggersController()) diff --git a/smaug/db/api.py b/smaug/db/api.py index c671e435..97b87fe3 100644 --- a/smaug/db/api.py +++ b/smaug/db/api.py @@ -178,6 +178,18 @@ def trigger_delete(context, id): return IMPL.trigger_delete(context, id) +def trigger_get_all_by_filters_sort(context, filters, limit=None, + marker=None, sort_keys=None, + sort_dirs=None): + """Get all triggers that match all filters sorted by multiple keys. + + sort_keys and sort_dirs must be a list of strings. + """ + return IMPL.trigger_get_all_by_filters_sort( + context, filters, limit=limit, marker=marker, + sort_keys=sort_keys, sort_dirs=sort_dirs) + + ################### diff --git a/smaug/db/sqlalchemy/api.py b/smaug/db/sqlalchemy/api.py index 4ede9708..62c4cd07 100644 --- a/smaug/db/sqlalchemy/api.py +++ b/smaug/db/sqlalchemy/api.py @@ -14,6 +14,7 @@ import functools import re +import six import sys import threading import time @@ -395,6 +396,30 @@ def trigger_delete(context, id): trigger_ref.delete(session=session) +def _trigger_list_process_filters(query, filters): + exact_match_filter_names = ['project_id', 'type'] + query = _list_common_process_exact_filter(models.Trigger, query, filters, + exact_match_filter_names) + + regex_match_filter_names = ['name', 'properties'] + query = _list_common_process_regex_filter(models.Trigger, query, filters, + regex_match_filter_names) + + return query + + +def trigger_get_all_by_filters_sort(context, filters, limit=None, marker=None, + sort_keys=None, sort_dirs=None): + session = get_session() + with session.begin(): + query = _generate_paginate_query(context, session, marker, limit, + sort_keys, sort_dirs, filters, + paginate_type=models.Trigger, + use_model=True) + + return query.all() if query else [] + + ################### @@ -990,21 +1015,105 @@ def _process_restore_filters(query, filters): return None query = query.filter_by(**filters) return query + + ############################### +@require_context +def _list_common_get_query(context, model, session=None): + return model_query(context, model, session=session) + + +def _list_common_process_exact_filter(model, query, filters, legal_keys): + """Applies exact match filtering to a query. + + :param model: model to apply filters to + :param query: query to apply filters to + :param filters: dictionary of filters; values that are lists, + tuples, sets, or frozensets cause an 'IN' test to + be performed, while exact matching ('==' operator) + is used for other values + :param legal_keys: list of keys to apply exact filtering to + :returns: the updated query. + """ + + filter_dict = {} + for key in legal_keys: + if key not in filters: + continue + + value = filters.get(key) + if isinstance(value, (list, tuple, set, frozenset)): + if not value: + return None # empty IN-predicate; short circuit + # Looking for values in a list; apply to query directly + column_attr = getattr(model, key) + query = query.filter(column_attr.in_(value)) + else: + # OK, simple exact match; save for later + filter_dict[key] = value + + # Apply simple exact matches + if filter_dict: + query = query.filter_by(**filter_dict) + + return query + + +def _list_common_process_regex_filter(model, query, filters, legal_keys): + """Applies regular expression filtering to a query. + + :param model: model to apply filters to + :param query: query to apply filters to + :param filters: dictionary of filters with regex values + :param legal_keys: list of keys to apply regex filtering to + :returns: the updated query. + """ + + def _get_regexp_op_for_connection(db_connection): + db_string = db_connection.split(':')[0].split('+')[0] + regexp_op_map = { + 'postgresql': '~', + 'mysql': 'REGEXP', + 'sqlite': 'REGEXP' + } + return regexp_op_map.get(db_string, 'LIKE') + + db_regexp_op = _get_regexp_op_for_connection(CONF.database.connection) + for key in legal_keys: + if key not in filters: + continue + + value = filters[key] + if not isinstance(value, six.string_types): + continue + + column_attr = getattr(model, key) + if db_regexp_op == 'LIKE': + query = query.filter(column_attr.op(db_regexp_op)( + u'%' + value + u'%')) + else: + query = query.filter(column_attr.op(db_regexp_op)( + value)) + return query + + PAGINATION_HELPERS = { models.Plan: (_plan_get_query, _process_plan_filters, _plan_get), models.Restore: (_restore_get_query, _process_restore_filters, - _restore_get) + _restore_get), + models.Trigger: (_list_common_get_query, _trigger_list_process_filters, + _trigger_get), } ############################### + def _generate_paginate_query(context, session, marker, limit, sort_keys, sort_dirs, filters, offset=None, - paginate_type=models.Plan): + paginate_type=models.Plan, use_model=False): """Generate the query to include the filters and the paginate options. Returns a query with sorting / pagination criteria added or None @@ -1032,7 +1141,10 @@ def _generate_paginate_query(context, session, marker, limit, sort_keys, sort_keys, sort_dirs = process_sort_params(sort_keys, sort_dirs, default_dir='desc') - query = get_query(context, session=session) + if use_model: + query = get_query(context, session=session, model=paginate_type) + else: + query = get_query(context, session=session) if filters: query = process_filters(query, filters) diff --git a/smaug/objects/trigger.py b/smaug/objects/trigger.py index b64328fa..aed3bb9d 100644 --- a/smaug/objects/trigger.py +++ b/smaug/objects/trigger.py @@ -16,8 +16,6 @@ from oslo_serialization import jsonutils from oslo_versionedobjects import fields from smaug import db -from smaug import exception -from smaug.i18n import _ from smaug.objects import base CONF = cfg.CONF @@ -69,10 +67,6 @@ class Trigger(base.SmaugPersistentObject, base.SmaugObject, @base.remotable def create(self): - if self.obj_attr_is_set('id'): - raise exception.ObjectActionError(action='create', - reason=_('already created')) - updates = self.smaug_obj_get_changes() self._convert_properties_to_db_format(updates) db_trigger = db.trigger_create(self._context, updates) diff --git a/smaug/tests/unit/api/v1/test_triggers.py b/smaug/tests/unit/api/v1/test_triggers.py new file mode 100644 index 00000000..c1dd0167 --- /dev/null +++ b/smaug/tests/unit/api/v1/test_triggers.py @@ -0,0 +1,148 @@ +# 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 smaug.api.v1 import triggers as trigger_api +from smaug import context +from smaug import exception +from smaug.i18n import _ +from smaug.tests import base +from smaug.tests.unit.api import fakes + + +class FakeRemoteOperationApi(object): + def create_trigger(self, context, trigger): + if trigger.type not in ['time']: + msg = (_("Invalid trigger type:%s") % trigger.type) + raise exception.InvalidInput(msg) + + if trigger.properties['format'] not in ['crontab']: + msg = (_("Invalid trigger time format type")) + raise exception.InvalidInput(msg) + + def delete_trigger(self, context, trigger_id): + pass + + def update_trigger(self, context, trigger): + pass + + +class TriggerApiTest(base.TestCase): + def setUp(self): + super(TriggerApiTest, self).setUp() + self.controller = trigger_api.TriggersController() + self.controller.operationengine_api = FakeRemoteOperationApi() + self.ctxt = context.RequestContext('admin', 'fakeproject', + True) + self.req = fakes.HTTPRequest.blank('/v1/triggers') + self.default_create_trigger_param = { + "name": "123", + "type": "time", + "properties": { + "format": "crontab", + "pattern": "* * * * *" + }, + } + + def test_create_trigger_InvalidBody(self): + self.assertRaises(exc.HTTPUnprocessableEntity, + self.controller.create, + self.req, {}) + + def test_create_trigger_InvalidName(self): + body = self._get_create_trigger_request_body() + self.assertRaises(exc.HTTPBadRequest, + self.controller.create, + self.req, body) + + def test_create_trigger_invalid_trigger_type(self): + param = self.default_create_trigger_param.copy() + param['type'] = "123" + body = self._get_create_trigger_request_body(param) + self.assertRaises(exc.HTTPBadRequest, + self.controller.create, + self.req, body) + + def test_create_trigger_invalid_trigger_formt_type(self): + param = self.default_create_trigger_param.copy() + param['properties']['format'] = "123" + body = self._get_create_trigger_request_body(param) + self.assertRaises(exc.HTTPBadRequest, + self.controller.create, + self.req, body) + + def test_create_trigger(self): + name = 'every minutes' + param = self.default_create_trigger_param.copy() + param['name'] = name + body = self._get_create_trigger_request_body(param) + trigger = self.controller.create(self.req, body) + self.assertEqual(name, trigger['trigger_info']['name']) + + def test_delete_trigger(self): + trigger = self._create_one_trigger() + self.controller.delete(self.req, trigger['trigger_info']['id']) + self.assertRaises(exc.HTTPNotFound, + self.controller.show, + self.req, + trigger['trigger_info']['id']) + + def test_update_trigger(self): + trigger = self._create_one_trigger() + + name = 'every minutes' + param = self.default_create_trigger_param.copy() + param['name'] = name + param['properties']['window'] = 10 + body = self._get_create_trigger_request_body(param) + trigger1 = self.controller.update( + self.req, trigger['trigger_info']['id'], body) + + self.assertEqual(name, trigger1['trigger_info']['name']) + self.assertEqual(10, int( + trigger1['trigger_info']['properties']['window'])) + + def test_show_trigger_not_exist(self): + self.assertRaises(exc.HTTPNotFound, + self.controller.show, + self.req, + '2a9ce1f3-cc1a-4516-9435-0ebb13caa398') + + def test_show_trigger_invalid_id(self): + self.assertRaises(exc.HTTPBadRequest, + self.controller.show, + self.req, 1) + + def test_show_trigger(self): + trigger = self._create_one_trigger() + trigger1 = self.controller.show(self.req, + trigger['trigger_info']['id']) + self.assertEqual(trigger['trigger_info']['id'], + trigger1['trigger_info']['id']) + + def test_list_trigger(self): + trigger = self._create_one_trigger() + triggers = self.controller.index(self.req) + for item in triggers['triggers']: + if item['id'] == trigger['trigger_info']['id']: + self.assertTrue(1) + + self.assertFalse(0) + + def _create_one_trigger(self): + param = self.default_create_trigger_param.copy() + body = self._get_create_trigger_request_body(param) + return self.controller.create(self.req, body) + + def _get_create_trigger_request_body(self, param={}): + return {"trigger_info": param} diff --git a/smaug/tests/unit/policy.json b/smaug/tests/unit/policy.json index 8b61735a..e187abfc 100644 --- a/smaug/tests/unit/policy.json +++ b/smaug/tests/unit/policy.json @@ -6,5 +6,10 @@ "admin_api": "is_admin:True", "plan:create": "", - "plan:delete": "rule:admin_or_owner" + "plan:delete": "rule:admin_or_owner", + + "trigger:create": "", + "trigger:delete": "rule:admin_or_owner", + "trigger:get": "rule:admin_or_owner", + "trigger:list": "" }