From 775be27719e43493f9c02ea8b3f0ff0969ef68da Mon Sep 17 00:00:00 2001 From: licanwei Date: Fri, 13 Dec 2019 11:01:35 +0800 Subject: [PATCH] Add webhook api Add a new webhook api and its microversion is 1.4 Partially Implements: blueprint event-driven-optimization-based Change-Id: I50f7c824e52f3c5fc775d5064898ed422e375a99 --- watcher/api/config.py | 1 + watcher/api/controllers/v1/__init__.py | 13 +++++ watcher/api/controllers/v1/utils.py | 9 ++++ watcher/api/controllers/v1/versions.py | 3 +- watcher/api/controllers/v1/webhooks.py | 62 ++++++++++++++++++++++ watcher/common/exception.py | 8 +++ watcher/objects/audit.py | 1 + watcher/tests/api/test_root.py | 4 +- watcher/tests/api/v1/test_webhooks.py | 71 ++++++++++++++++++++++++++ 9 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 watcher/api/controllers/v1/webhooks.py create mode 100644 watcher/tests/api/v1/test_webhooks.py diff --git a/watcher/api/config.py b/watcher/api/config.py index 3952459ac..101d17a86 100644 --- a/watcher/api/config.py +++ b/watcher/api/config.py @@ -38,6 +38,7 @@ app = { 'enable_acl': True, 'acl_public_routes': [ '/', + '/v1/webhooks/.*', ], } diff --git a/watcher/api/controllers/v1/__init__.py b/watcher/api/controllers/v1/__init__.py index c37a9f19f..8e578691f 100644 --- a/watcher/api/controllers/v1/__init__.py +++ b/watcher/api/controllers/v1/__init__.py @@ -42,6 +42,7 @@ from watcher.api.controllers.v1 import service from watcher.api.controllers.v1 import strategy from watcher.api.controllers.v1 import utils from watcher.api.controllers.v1 import versions +from watcher.api.controllers.v1 import webhooks def min_version(): @@ -131,6 +132,9 @@ class V1(APIBase): services = [link.Link] """Links to the services resource""" + webhooks = [link.Link] + """Links to the webhooks resource""" + links = [link.Link] """Links that point to a specific URL for this version and documentation""" @@ -202,6 +206,14 @@ class V1(APIBase): 'services', '', bookmark=True) ] + if utils.allow_webhook_api(): + v1.webhooks = [link.Link.make_link( + 'self', base_url, 'webhooks', ''), + link.Link.make_link('bookmark', + base_url, + 'webhooks', '', + bookmark=True) + ] return v1 @@ -217,6 +229,7 @@ class Controller(rest.RestController): services = service.ServicesController() strategies = strategy.StrategiesController() data_model = data_model.DataModelController() + webhooks = webhooks.WebhookController() @wsme_pecan.wsexpose(V1) def get(self): diff --git a/watcher/api/controllers/v1/utils.py b/watcher/api/controllers/v1/utils.py index dee4d2bb4..25fb77e16 100644 --- a/watcher/api/controllers/v1/utils.py +++ b/watcher/api/controllers/v1/utils.py @@ -185,3 +185,12 @@ def allow_list_datamodel(): """ return pecan.request.version.minor >= ( versions.VERSIONS.MINOR_3_DATAMODEL.value) + + +def allow_webhook_api(): + """Check if we should support webhook API. + + Version 1.4 of the API added support to trigger webhook. + """ + return pecan.request.version.minor >= ( + versions.VERSIONS.MINOR_4_WEBHOOK_API.value) diff --git a/watcher/api/controllers/v1/versions.py b/watcher/api/controllers/v1/versions.py index 5ee04307a..bff91bb66 100644 --- a/watcher/api/controllers/v1/versions.py +++ b/watcher/api/controllers/v1/versions.py @@ -22,7 +22,8 @@ class VERSIONS(enum.Enum): MINOR_1_START_END_TIMING = 1 # v1.1: Add start/end timei for audit MINOR_2_FORCE = 2 # v1.2: Add force field to audit MINOR_3_DATAMODEL = 3 # v1.3: Add list datamodel API - MINOR_MAX_VERSION = 3 + MINOR_4_WEBHOOK_API = 4 # v1.4: Add webhook trigger API + MINOR_MAX_VERSION = 4 # This is the version 1 API BASE_VERSION = 1 diff --git a/watcher/api/controllers/v1/webhooks.py b/watcher/api/controllers/v1/webhooks.py new file mode 100644 index 000000000..8835acce3 --- /dev/null +++ b/watcher/api/controllers/v1/webhooks.py @@ -0,0 +1,62 @@ +# 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. + +""" +Webhook endpoint for Watcher v1 REST API. +""" + +from oslo_log import log +import pecan +from pecan import rest +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +from watcher.api.controllers.v1 import types +from watcher.api.controllers.v1 import utils +from watcher.common import exception +from watcher.decision_engine import rpcapi +from watcher import objects + +LOG = log.getLogger(__name__) + + +class WebhookController(rest.RestController): + """REST controller for webhooks resource.""" + def __init__(self): + super(WebhookController, self).__init__() + self.dc_client = rpcapi.DecisionEngineAPI() + + @wsme_pecan.wsexpose(None, wtypes.text, body=types.jsontype, + status_code=202) + def post(self, audit_ident, body): + """Trigger the given audit. + + :param audit_ident: UUID or name of an audit. + """ + + LOG.debug("Webhook trigger Audit: %s.", audit_ident) + + context = pecan.request.context + audit = utils.get_resource('Audit', audit_ident) + if audit is None: + raise exception.AuditNotFound(audit=audit_ident) + if audit.audit_type != objects.audit.AuditType.EVENT.value: + raise exception.AuditTypeNotAllowed(audit_type=audit.audit_type) + allowed_state = ( + objects.audit.State.PENDING, + objects.audit.State.SUCCEEDED, + ) + if audit.state not in allowed_state: + raise exception.AuditStateNotAllowed(state=audit.state) + + # trigger decision-engine to run the audit + self.dc_client.trigger_audit(context, audit.uuid) diff --git a/watcher/common/exception.py b/watcher/common/exception.py index 26dc5ebba..1fbc5f5e3 100644 --- a/watcher/common/exception.py +++ b/watcher/common/exception.py @@ -243,6 +243,14 @@ class AuditTypeNotFound(Invalid): msg_fmt = _("Audit type %(audit_type)s could not be found") +class AuditTypeNotAllowed(Invalid): + msg_fmt = _("Audit type %(audit_type)s is disallowed.") + + +class AuditStateNotAllowed(Invalid): + msg_fmt = _("Audit state %(state)s is disallowed.") + + class AuditParameterNotAllowed(Invalid): msg_fmt = _("Audit parameter %(parameter)s are not allowed") diff --git a/watcher/objects/audit.py b/watcher/objects/audit.py index 605d49a78..434cdf583 100644 --- a/watcher/objects/audit.py +++ b/watcher/objects/audit.py @@ -75,6 +75,7 @@ class State(object): class AuditType(enum.Enum): ONESHOT = 'ONESHOT' CONTINUOUS = 'CONTINUOUS' + EVENT = 'EVENT' @base.WatcherObjectRegistry.register diff --git a/watcher/tests/api/test_root.py b/watcher/tests/api/test_root.py index 4a246310d..4da3b92c1 100644 --- a/watcher/tests/api/test_root.py +++ b/watcher/tests/api/test_root.py @@ -29,7 +29,7 @@ class TestV1Root(base.FunctionalTest): def test_get_v1_root_all(self): data = self.get_json( - '/', headers={'OpenStack-API-Version': 'infra-optim 1.3'}) + '/', headers={'OpenStack-API-Version': 'infra-optim 1.4'}) self.assertEqual('v1', data['id']) # Check fields are not empty for f in data.keys(): @@ -39,7 +39,7 @@ class TestV1Root(base.FunctionalTest): actual_resources = tuple(set(data.keys()) - set(not_resources)) expected_resources = ('audit_templates', 'audits', 'actions', 'action_plans', 'data_model', 'scoring_engines', - 'services') + 'services', 'webhooks') self.assertEqual(sorted(expected_resources), sorted(actual_resources)) self.assertIn({'type': 'application/vnd.openstack.watcher.v1+json', diff --git a/watcher/tests/api/v1/test_webhooks.py b/watcher/tests/api/v1/test_webhooks.py new file mode 100644 index 000000000..67a4ff04a --- /dev/null +++ b/watcher/tests/api/v1/test_webhooks.py @@ -0,0 +1,71 @@ +# 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 mock + +from watcher.decision_engine import rpcapi as deapi +from watcher import objects +from watcher.tests.api import base as api_base +from watcher.tests.objects import utils as obj_utils + + +class TestPost(api_base.FunctionalTest): + + def setUp(self): + super(TestPost, self).setUp() + obj_utils.create_test_goal(self.context) + obj_utils.create_test_strategy(self.context) + obj_utils.create_test_audit_template(self.context) + + @mock.patch.object(deapi.DecisionEngineAPI, 'trigger_audit') + def test_trigger_audit(self, mock_trigger_audit): + audit = obj_utils.create_test_audit( + self.context, + audit_type=objects.audit.AuditType.EVENT.value) + response = self.post_json( + '/webhooks/%s' % audit['uuid'], {}, + headers={'OpenStack-API-Version': 'infra-optim 1.4'}) + self.assertEqual(202, response.status_int) + mock_trigger_audit.assert_called_once_with( + mock.ANY, audit['uuid']) + + def test_trigger_audit_with_no_audit(self): + response = self.post_json( + '/webhooks/no-audit', {}, + headers={'OpenStack-API-Version': 'infra-optim 1.4'}, + expect_errors=True) + self.assertEqual(404, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + def test_trigger_audit_with_not_allowed_audittype(self): + audit = obj_utils.create_test_audit(self.context) + response = self.post_json( + '/webhooks/%s' % audit['uuid'], {}, + headers={'OpenStack-API-Version': 'infra-optim 1.4'}, + expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + def test_trigger_audit_with_not_allowed_audit_state(self): + audit = obj_utils.create_test_audit( + self.context, + audit_type=objects.audit.AuditType.EVENT.value, + state=objects.audit.State.FAILED) + response = self.post_json( + '/webhooks/%s' % audit['uuid'], {}, + headers={'OpenStack-API-Version': 'infra-optim 1.4'}, + expect_errors=True) + self.assertEqual(400, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message'])