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'])