add support for webhooks

Implements: blueprint configurable-notifications
Change-Id: I0c808c5e44f9d6092d113bb277c8ab8cf0d69716
This commit is contained in:
Niv Oppenhaim 2017-12-31 14:11:28 +00:00
parent 4ec7fa8ede
commit 274c5b71bf
29 changed files with 1584 additions and 39 deletions

View File

@ -66,7 +66,7 @@ topics = notifications, vitrage_notifications
[[post-config|\$VITRAGE_CONF]]
[DEFAULT]
notifiers = mistral,nova
notifiers = mistral,nova,webhook
[static_physical]
changes_interval = 5

View File

@ -17,7 +17,8 @@ DEVSTACK_PATH="$BASE/new"
#Argument is received from Zuul
if [ "$1" = "api" ]; then
TESTS="topology|test_rca|test_alarms|test_resources|test_template"
TESTS="topology|test_rca|test_alarms|test_resources|test_template
|test_webhook"
elif [ "$1" = "datasources" ]; then
TESTS="datasources|test_events|notifiers|e2e|database"
else

View File

@ -37,6 +37,7 @@ Notifiers
nova-notifier
notifier-snmp-plugin
mistral-config
notifier-webhook-plugin
Machine_Learning

View File

@ -0,0 +1,116 @@
=====================
Webhook Configuration
=====================
Vitrage can be configured to support webhooks for the sending of
notifications regarding raised or cleared alarms to any registered target.
Enable Webhook Notifier
-----------------------
To enable the webhook plugin, add it to the list of notifiers in
**/etc/vitrage/vitrage.conf** file:
.. code::
[DEFAULT]
notifiers = webhook
Webhook API
===========
Webhooks can be added, listed and deleted from the database using the
following commands:
Add
---
To add a new webhook to the database, use the command 'vitrage webhook add'.
The fields are:
+------------------+-----------------------------------------------------------------+--------------+
| Name | Description | Required |
+==================+=================================================================+==============+
| url | The webhook URL to which notifications will be sent | Yes |
+------------------+-----------------------------------------------------------------+--------------+
| regex_filter | A JSON string to filter for specific events | No |
+------------------+-----------------------------------------------------------------+--------------+
| headers | A JSON string specifying additional headers to the notification | No |
+------------------+-----------------------------------------------------------------+--------------+
Usage example::
vitrage webhook add --url https://www.myserver.com --headers
"{'content-type': 'application/json'}" --regex_filter "{'vitrage_type':
'.*'}"
- If no regex filter is supplied, all notifications will be sent.
- The defaults headers are : '{'content-type': 'application/json'}'
Data is sent by the webhook notifier in the following format.
* notification: ``vitrage.alarm.activate`` or ``vitrage.alarm.deactivate``
* payload: The alarm data
::
{
"notification": "vitrage.alarm.activate",
"payload": {
"vitrage_id": "2def31e9-6d9f-4c16-b007-893caa806cd4",
"resource": {
"vitrage_id": "437f1f4c-ccce-40a4-ac62-1c2f1fd9f6ac",
"name": "app-1-server-1-jz6qvznkmnif",
"update_timestamp": "2018-01-22 10:00:34.327142+00:00",
"vitrage_category": "RESOURCE",
"vitrage_operational_state": "OK",
"state": "active",
"vitrage_type": "nova.instance",
"vitrage_sample_timestamp": "2018-01-22 10:00:34.327142+00:00",
"vitrage_aggregated_state": "ACTIVE",
"host_id": "iafek-devstack-pre-queens",
"project_id": "8f007e5ba0944e84baa6f2a4f2b5d03a",
"id": "9b7d93b9-94ec-41e1-9cec-f28d4f8d702c"
},
"severity": "warning",
"update_timestamp": "2018-01-22T10:00:34Z",
"resource_id": "437f1f4c-ccce-40a4-ac62-1c2f1fd9f6ac",
"vitrage_category": "ALARM",
"state": "Active",
"vitrage_type": "vitrage",
"vitrage_sample_timestamp": "2018-01-22 10:00:34.366364+00:00",
"vitrage_operational_severity": "WARNING",
"vitrage_aggregated_severity": "WARNING",
"vitrage_resource_id": "437f1f4c-ccce-40a4-ac62-1c2f1fd9f6ac",
"vitrage_resource_type": "nova.instance",
"name": "Instance memory performance degraded"
}
}
Each of the fields listed can be used to filter the data when specifying a
regex filter for the webhook.
List
----
List all webhooks currently in the DB::
vitrage webhook list
Show
----
Show a webhook with specified id::
vitrage webhook show <id>
ID of webhooks is decided by Vitrage and can be found using the 'list' command
Delete
------
Delete a webhook with specified id::
vitrage webhook delete <id>
ID of webhooks is decided by Vitrage and can be found using the 'list' command

View File

@ -1498,7 +1498,7 @@ Resource show
Show the details of specified resource.
GET /v1/resources/[vitrage_id]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Headers
=======
@ -1562,3 +1562,270 @@ Response Examples
"id": "dc35fa2f-4515-1653-ef6b-03b471bb395b",
"vitrage_id": "RESOURCE:nova.instance:dc35fa2f-4515-1653-ef6b-03b471bb395b"
}
Webhook List
^^^^^^^^^^^^
List all webhooks.
GET /v1/webhook/
~~~~~~~~~~~~~~~~
Headers
=======
- X-Auth-Token (string, required) - Keystone auth token
- Accept (string) - application/json
- User-Agent (String)
- Content-Type (String): application/json
Path Parameters
===============
None.
Query Parameters
================
None.
Request Body
============
None.
Request Examples
================
::
GET /v1/webhook
Host: 135.248.18.122:8999
User-Agent: keystoneauth1/2.3.0 python-requests/2.9.1 CPython/2.7.6
Content-Type: application/json
Accept: application/json
X-Auth-Token: 2b8882ba2ec44295bf300aecb2caa4f7
Response Status code
====================
- 200 - OK
- 404 - Bad request
Response Body
=============
Returns a list with all webhooks.
Response Examples
=================
::
[
{
"url":"https://requestb.in/tq3fkvtq",
"headers":"{'content-type': 'application/json'}",
"regex_filter":"{'name':'e2e.*'}",
"created_at":"2018-01-04T12:27:47.000000",
"id":"c35caf11-f34d-440e-a804-0c1a4fdfb95b"
}
]
Webhook Show
^^^^^^^^^^^^
Show the details of specified webhook.
GET /v1/webhook/[id]
~~~~~~~~~~~~~~~~~~~~
Headers
=======
- X-Auth-Token (string, required) - Keystone auth token
- Accept (string) - application/json
- User-Agent (String)
- Content-Type (String): application/json
Path Parameters
===============
- id.
Query Parameters
================
None.
Request Body
============
None.
Request Examples
================
::
GET /v1/resources/`<id>`
Host: 127.0.0.1:8999
User-Agent: keystoneauth1/2.3.0 python-requests/2.9.1 CPython/2.7.6
Accept: application/json
X-Auth-Token: 2b8882ba2ec44295bf300aecb2caa4f7
Response Status code
====================
- 200 - OK
- 404 - Bad request
Response Body
=============
Returns details of the requested webhook.
Response Examples
=================
::
{
"url":"https://requestb.in/tq3fkvtq",
"created_at":"2018-01-04T12:27:47.000000",
"updated_at":null,
"id":"c35caf11-f34d-440e-a804-0c1a4fdfb95b",
"headers":"{'content-type': 'application/json'}",
"regex_filter":"{'name':'e2e.*'}"
}
Webhook Add
^^^^^^^^^^^
Add a webhook to the database, to be used by the notifier.
POST /v1/webhook/
~~~~~~~~~~~~~~~~~
Headers
=======
- X-Auth-Token (string, required) - Keystone auth token
- Accept (string) - application/json
- User-Agent (String)
- Content-Type (String): application/json
Path Parameters
===============
None.
Query Parameters
================
None.
Request Body
============
A webhook to be added. Will contain the following fields:
+------------------+-----------------------------------------------------------------+--------------+
| Name | Description | Required |
+==================+=================================================================+==============+
| url | The webhook URL to which notifications will be sent | Yes |
+------------------+-----------------------------------------------------------------+--------------+
| regex_filter | A JSON string to filter for specific events | No |
+------------------+-----------------------------------------------------------------+--------------+
| headers | A JSON string specifying additional headers to the notification | No |
+------------------+-----------------------------------------------------------------+--------------+
- If no regex filter is supplied, all notifications will be sent.
- The defaults headers are : '{'content-type': 'application/json'}'
Request Examples
================
::
POST /v1/webhook/
Host: 135.248.18.122:8999
User-Agent: keystoneauth1/2.3.0 python-requests/2.9.1 CPython/2.7.6
Content-Type: application/json
Accept: application/json
X-Auth-Token: 2b8882ba2ec44295bf300aecb2caa4f7
::
{
"webhook":{
"url":"https://requestb.in/tqfkvtqa",
"headers":null,
"regex_filter":"{'name':'e2e.*'}"
}
}
Response Status code
====================
- 200 - OK
- 400 - Bad request
Response Body
=============
Returns webhook details if request was OK,
otherwise returns a detailed error message (e.g. 'headers in bad format').
Webhook Delete
^^^^^^^^^^^^^^
Delete a specified webhook.
DELETE /v1/webhook/[id]
~~~~~~~~~~~~~~~~~~~~~~~
Headers
=======
- X-Auth-Token (string, required) - Keystone auth token
- Accept (string) - application/json
- User-Agent (String)
- Content-Type (String): application/json
Path Parameters
===============
- id.
Query Parameters
================
None.
Request Body
============
None.
Request Examples
================
::
DELETE /v1/resources/`<id>`
Host: 127.0.0.1:8999
User-Agent: keystoneauth1/2.3.0 python-requests/2.9.1 CPython/2.7.6
Accept: application/json
X-Auth-Token: 2b8882ba2ec44295bf300aecb2caa4f7
Response Status code
====================
- 200 - OK
- 404 - Bad request
Response Body
=============
Returns a success message if the webhook is deleted, otherwise an error
message is returned.

View File

@ -12,9 +12,11 @@
# License for the specific language governing permissions and limitations
# under the License.
import json
import pecan
from oslo_log import log
from oslo_utils import encodeutils
from oslo_utils.strutils import bool_from_string
@ -24,6 +26,8 @@ from pecan.core import abort
from vitrage.api.controllers.rest import RootRestController
from vitrage.api.controllers.v1 import count
from vitrage.api.policy import enforce
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as Vprops
LOG = log.getLogger(__name__)
@ -36,8 +40,8 @@ class AlarmsController(RootRestController):
@pecan.expose('json')
def get_all(self, **kwargs):
vitrage_id = kwargs.get('vitrage_id')
all_tenants = kwargs.get('all_tenants', False)
vitrage_id = kwargs.get(Vprops.VITRAGE_ID)
all_tenants = kwargs.get(TenantProps.ALL_TENANTS, False)
all_tenants = bool_from_string(all_tenants)
if all_tenants:
enforce("list alarms:all_tenants", pecan.request.headers,

View File

@ -16,6 +16,7 @@ from vitrage.api.controllers.v1 import rca
from vitrage.api.controllers.v1 import resource
from vitrage.api.controllers.v1 import template
from vitrage.api.controllers.v1 import topology
from vitrage.api.controllers.v1 import webhook
class V1Controller(object):
@ -23,5 +24,6 @@ class V1Controller(object):
resources = resource.ResourcesController()
alarm = alarm.AlarmsController()
rca = rca.RCAController()
webhook = webhook.WebhookController()
template = template.TemplateController()
event = event.EventController()

View File

@ -0,0 +1,141 @@
# Copyright 2018 - Nokia Corporation
#
# 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 pecan
from oslo_log import log
from oslo_utils.strutils import bool_from_string
from osprofiler import profiler
from pecan.core import abort
from vitrage.api.controllers.rest import RootRestController
from vitrage.api.policy import enforce
from vitrage.common.constants import TenantProps
LOG = log.getLogger(__name__)
@profiler.trace_cls("webhook controller",
info={}, hide_args=False, trace_private=False)
class WebhookController(RootRestController):
@pecan.expose('json')
def get_all(self, **kwargs):
LOG.info('list all webhooks with args: %s', kwargs)
all_tenants = kwargs.get(TenantProps.ALL_TENANTS, False)
all_tenants = bool_from_string(all_tenants)
if all_tenants:
enforce('webhook list:all_tenants', pecan.request.headers,
pecan.request.enforcer, {})
else:
enforce('webhook list', pecan.request.headers,
pecan.request.enforcer, {})
try:
return self._get_all(all_tenants)
except Exception as e:
LOG.exception('failed to list webhooks %s', e)
abort(404, str(e))
@staticmethod
def _get_all(all_tenants):
webhooks = \
pecan.request.client.call(pecan.request.context,
'get_all_webhooks',
all_tenants=all_tenants)
LOG.info(webhooks)
return webhooks
@pecan.expose('json')
def get(self, id):
LOG.info('Show webhook with id: %s', id)
enforce('webhook show', pecan.request.headers,
pecan.request.enforcer, {})
try:
return self._get(id)
except Exception as e:
LOG.exception('Failed to get webhooks %s', e)
abort(404, str(e))
@staticmethod
def _get(id):
webhook = \
pecan.request.client.call(pecan.request.context,
'get_webhook',
id=id)
LOG.info(webhook)
if not webhook:
abort(404, "Failed to find webhook with ID: %s" % id)
return webhook
@pecan.expose('json')
def post(self, **kwargs):
LOG.info("Add webhook with following props: %s" % str(
kwargs))
enforce('webhook add', pecan.request.headers,
pecan.request.enforcer, {})
try:
return self._post(**kwargs)
except Exception as e:
LOG.exception('Failed to add webhooks %s', e)
abort(400, str(e))
@staticmethod
def _post(**kwargs):
url = kwargs.get('url')
if not url:
abort(400, 'Missing mandatory field: URL')
regex_filter = kwargs.get('regex_filter', None)
headers = kwargs.get('headers', None)
webhook = \
pecan.request.client.call(pecan.request.context,
'add_webhook',
url=url,
regex_filter=regex_filter,
headers=headers)
LOG.info(webhook)
if webhook.get("ERROR"):
abort(400, "Failed to add webhook: %s" % webhook.get("ERROR"))
return webhook
@pecan.expose('json')
def delete(self, id):
LOG.info('delete webhook with id: %s', id)
enforce("webhook delete",
pecan.request.headers,
pecan.request.enforcer,
{})
try:
return self._delete_registration(id)
except Exception as e:
LOG.exception('Failed to delete webhook %s: '
'%s' % (id, str(e)))
abort(404, str(e))
@staticmethod
def _delete_registration(id):
resource = pecan.request.client.call(
pecan.request.context,
'delete_webhook',
id=id)
if not resource:
abort(404, "Failed to find resource with ID: %s" % id)
LOG.info("Request returned with: %s" % resource)
return resource

View File

@ -20,6 +20,7 @@ from vitrage.api_handler.apis.base import ALARM_QUERY
from vitrage.api_handler.apis.base import ALARMS_ALL_QUERY
from vitrage.api_handler.apis.base import EntityGraphApisBase
from vitrage.common.constants import EntityCategory as ECategory
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as VProps
from vitrage.entity_graph.mappings.operational_alarm_severity import \
OperationalAlarmSeverity
@ -40,8 +41,8 @@ class AlarmApis(EntityGraphApisBase):
LOG.debug("AlarmApis get_alarms - vitrage_id: %s, all_tenants=%s",
str(vitrage_id), all_tenants)
project_id = ctx.get(self.TENANT_PROPERTY, None)
is_admin_project = ctx.get(self.IS_ADMIN_PROJECT_PROPERTY, False)
project_id = ctx.get(TenantProps.TENANT, None)
is_admin_project = ctx.get(TenantProps.IS_ADMIN, False)
if not vitrage_id or vitrage_id == 'all':
if all_tenants:
@ -68,8 +69,8 @@ class AlarmApis(EntityGraphApisBase):
LOG.warning('Alarm show - Not found (%s)', vitrage_id)
return None
is_admin = ctx.get(self.IS_ADMIN_PROJECT_PROPERTY, False)
curr_project = ctx.get(self.TENANT_PROPERTY, None)
is_admin = ctx.get(TenantProps.IS_ADMIN, False)
curr_project = ctx.get(TenantProps.TENANT, None)
alarm_project = alarm.get(VProps.PROJECT_ID)
if not is_admin and curr_project != alarm_project:
LOG.warning('Alarm show - Authorization failed (%s)', vitrage_id)
@ -80,8 +81,8 @@ class AlarmApis(EntityGraphApisBase):
def get_alarm_counts(self, ctx, all_tenants):
LOG.debug("AlarmApis get_alarm_counts - all_tenants=%s", all_tenants)
project_id = ctx.get(self.TENANT_PROPERTY, None)
is_admin_project = ctx.get(self.IS_ADMIN_PROJECT_PROPERTY, False)
project_id = ctx.get(TenantProps.TENANT, None)
is_admin_project = ctx.get(TenantProps.IS_ADMIN, False)
if all_tenants:
alarms = self.entity_graph.get_vertices(

View File

@ -89,8 +89,6 @@ RESOURCES_ALL_QUERY = {
class EntityGraphApisBase(object):
TENANT_PROPERTY = 'tenant'
IS_ADMIN_PROJECT_PROPERTY = 'is_admin'
@staticmethod
def _get_query_with_project(vitrage_category, project_id, is_admin):

View File

@ -15,10 +15,12 @@
from oslo_log import log
from osprofiler import profiler
from vitrage.api_handler.apis.base import ALARMS_ALL_QUERY
from vitrage.api_handler.apis.base import EDGE_QUERY
from vitrage.api_handler.apis.base import EntityGraphApisBase
from vitrage.api_handler.apis.base import RCA_QUERY
from vitrage.common.constants import TenantProps
from vitrage.graph import Direction
@ -37,8 +39,8 @@ class RcaApis(EntityGraphApisBase):
LOG.debug("RcaApis get_rca - root: %s, all_tenants=%s",
str(root), all_tenants)
project_id = ctx.get(self.TENANT_PROPERTY, None)
is_admin_project = ctx.get(self.IS_ADMIN_PROJECT_PROPERTY, False)
project_id = ctx.get(TenantProps.TENANT, None)
is_admin_project = ctx.get(TenantProps.IS_ADMIN, False)
ga = self.entity_graph.algo
found_graph_out = ga.graph_query_vertices(root,

View File

@ -19,6 +19,7 @@ from osprofiler import profiler
from vitrage.api_handler.apis.base import EntityGraphApisBase
from vitrage.api_handler.apis.base import RESOURCES_ALL_QUERY
from vitrage.common.constants import EntityCategory
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as VProps
@ -37,8 +38,8 @@ class ResourceApis(EntityGraphApisBase):
LOG.debug('ResourceApis get_resources - resource_type: %s,'
'all_tenants: %s', str(resource_type), all_tenants)
project_id = ctx.get(self.TENANT_PROPERTY, None)
is_admin_project = ctx.get(self.IS_ADMIN_PROJECT_PROPERTY, False)
project_id = ctx.get(TenantProps.TENANT, None)
is_admin_project = ctx.get(TenantProps.IS_ADMIN, False)
if all_tenants:
resource_query = RESOURCES_ALL_QUERY
@ -66,8 +67,8 @@ class ResourceApis(EntityGraphApisBase):
LOG.warning('Resource show - Not found (%s)', vitrage_id)
return None
is_admin = ctx.get(self.IS_ADMIN_PROJECT_PROPERTY, False)
curr_project = ctx.get(self.TENANT_PROPERTY, None)
is_admin = ctx.get(TenantProps.IS_ADMIN, False)
curr_project = ctx.get(TenantProps.TENANT, None)
resource_project = resource.get(VProps.PROJECT_ID)
if not is_admin and curr_project != resource_project:
LOG.warning('Resource show - Authorization failed (%s)',

View File

@ -21,6 +21,7 @@ from vitrage.api_handler.apis.base import EntityGraphApisBase
from vitrage.api_handler.apis.base import TOPOLOGY_AND_ALARMS_QUERY
from vitrage.common.constants import EdgeProperties as EProps
from vitrage.common.constants import EntityCategory
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as VProps
from vitrage.common.exception import VitrageError
from vitrage.datasources.nova.instance import NOVA_INSTANCE_DATASOURCE
@ -41,8 +42,8 @@ class TopologyApis(EntityGraphApisBase):
LOG.debug("TopologyApis get_topology - root: %s, all_tenants=%s",
str(root), all_tenants)
project_id = ctx.get(self.TENANT_PROPERTY, None)
is_admin_project = ctx.get(self.IS_ADMIN_PROJECT_PROPERTY, False)
project_id = ctx.get(TenantProps.TENANT, None)
is_admin_project = ctx.get(TenantProps.IS_ADMIN, False)
ga = self.entity_graph.algo
LOG.debug('project_id = %s, is_admin_project %s',

View File

@ -0,0 +1,153 @@
# Copyright 2018 - Nokia
#
# 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 ast
from collections import namedtuple
import datetime
from oslo_log import log
from oslo_utils import uuidutils
from osprofiler import profiler
import re
from six.moves.urllib.parse import urlparse
from vitrage.common.constants import TenantProps
from vitrage.common.constants import VertexProperties as Vprops
from vitrage.notifier.plugins.webhook.utils import db_row_to_dict
from vitrage import storage
from vitrage.storage.sqlalchemy.models import Webhooks
LOG = log.getLogger(__name__)
Result = namedtuple("Result", ["is_valid", "message"])
@profiler.trace_cls("webhook apis",
info={}, hide_args=False, trace_private=False)
class WebhookApis(object):
DELETED_ROWS_SUCCESS = 1
def __init__(self, conf):
self.conf = conf
self.db_conn = storage.get_connection_from_config(conf)
def delete_webhook(self, ctx, id):
LOG.info("Delete webhook with id: %s",
str(id))
deleted_rows_count = self.db_conn.webhooks.delete(id)
if deleted_rows_count == self.DELETED_ROWS_SUCCESS:
return {'SUCCESS': 'Webhook %s deleted' % id}
else:
return None
def get_all_webhooks(self, ctx, all_tenants):
LOG.info("List all webhooks")
if all_tenants and ctx.get(TenantProps.IS_ADMIN, False):
res = self.db_conn.webhooks.query()
else:
res = self.db_conn.webhooks.query(project_id=ctx.get(
TenantProps.TENANT, ""))
LOG.info(res)
webhooks = [db_row_to_dict(webhook) for webhook in res]
return webhooks
def add_webhook(self, ctx, url, headers=None, regex_filter=None):
res = self._check_valid_webhook(url, headers, regex_filter)
if not res.is_valid:
LOG.exception("Failed to create webhook: %s" % res.message)
return res.message
try:
db_row = self._webhook_to_db_row(url, headers, regex_filter, ctx)
self.db_conn.webhooks.create(db_row)
return db_row_to_dict(db_row)
except Exception as e:
LOG.exception("Failed to add webhook to DB: %s", str(e))
return {"ERROR": str(e)}
def get_webhook(self, ctx, id):
try:
webhooks = self.db_conn.webhooks.query(id=id)
# Check that webhook belongs to current tenant or current tenant
# is admin
if len(webhooks) == 0:
LOG.warning("Webhook not found - %s" % id)
return None
if ctx.get(TenantProps.TENANT, "") == \
webhooks[0][Vprops.PROJECT_ID] or ctx.get(
TenantProps.IS_ADMIN, False):
return (webhooks[0])
else:
LOG.warning('Webhook show - Authorization failed (%s)',
id)
return None
except Exception as e:
LOG.exception("Failed to get webhook: %s", str(e))
return {"ERROR": str(e)}
def _webhook_to_db_row(self, url, headers, regex_filter, ctx):
if not regex_filter:
regex_filter = ""
if not headers:
headers = ""
uuid = uuidutils.generate_uuid()
project_id = ctx.get(TenantProps.TENANT, "")
is_admin = ctx.get(TenantProps.IS_ADMIN, False)
created_at = str(datetime.datetime.now())
db_row = Webhooks(id=uuid,
project_id=project_id,
is_admin_webhook=is_admin,
created_at=created_at,
url=url,
headers=headers,
regex_filter=regex_filter)
return db_row
def _check_valid_webhook(self, url, headers, regex_filter):
if not self._validate_url(url):
return Result(False, {"ERROR": "Invalid URL"})
elif not self._validate_headers(headers):
return Result(False, {"ERROR": "Headers in invalid format"})
elif not self._validate_regex(regex_filter):
return Result(False, {"ERROR": "Invalid RegEx"})
return Result(True, "")
def _validate_url(self, url):
try:
result = urlparse(url)
if not result.scheme or not result.netloc:
return False
except Exception:
return False
return True
def _validate_regex(self, regex_filter):
if regex_filter:
try:
filter_dict = ast.literal_eval(regex_filter)
if not isinstance(filter_dict, dict):
return False
for filter in filter_dict.values():
re.compile(filter)
except Exception:
return False
return True
def _validate_headers(self, headers):
if headers:
try:
return isinstance(ast.literal_eval(headers), dict)
except Exception:
return False
return True

View File

@ -25,6 +25,7 @@ from vitrage.api_handler.apis.rca import RcaApis
from vitrage.api_handler.apis.resource import ResourceApis
from vitrage.api_handler.apis.template import TemplateApis
from vitrage.api_handler.apis.topology import TopologyApis
from vitrage.api_handler.apis.webhook import WebhookApis
from vitrage import messaging
from vitrage import rpc as vitrage_rpc
@ -55,7 +56,8 @@ class VitrageApiHandlerService(os_service.Service):
RcaApis(self.entity_graph, self.conf),
TemplateApis(self.notifier),
EventApis(self.conf),
ResourceApis(self.entity_graph, self.conf)]
ResourceApis(self.entity_graph, self.conf),
WebhookApis(self.conf)]
server = vitrage_rpc.get_server(target, endpoints, transport)

View File

@ -169,3 +169,9 @@ class TemplateStatus(object):
DELETING = 'DELETING'
DELETED = 'DELETED'
LOADING = 'LOADING'
class TenantProps(object):
ALL_TENANTS = 'all_tenants'
TENANT = 'tenant'
IS_ADMIN = 'is_admin'

View File

@ -18,6 +18,7 @@ from vitrage.common.policies import rca
from vitrage.common.policies import resource
from vitrage.common.policies import template
from vitrage.common.policies import topology
from vitrage.common.policies import webhook
def list_rules():
@ -27,5 +28,6 @@ def list_rules():
rca.list_rules(),
template.list_rules(),
topology.list_rules(),
resource.list_rules()
resource.list_rules(),
webhook.list_rules()
)

View File

@ -0,0 +1,82 @@
# Copyright 2018 - Nokia Corporation
#
# 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_policy import policy
from vitrage.common.policies import base
webhook = 'webhook %s'
rules = [
policy.DocumentedRuleDefault(
name=webhook % 'delete',
check_str=base.UNPROTECTED,
description='Delete a webhook registration',
operations=[
{
'path': '/webhook/{webhook_id}',
'method': 'DELETE'
}
]
),
policy.DocumentedRuleDefault(
name=webhook % 'list',
check_str=base.UNPROTECTED,
description='List all webhook registrations',
operations=[
{
'path': '/webhook',
'method': 'GET'
}
]
),
policy.DocumentedRuleDefault(
name=webhook % 'list:all_tenants',
check_str=base.ROLE_ADMIN,
description='List all webhooks (if the user'
' has the permissions)',
operations=[
{
'path': '/webhook',
'method': 'GET'
}
]
),
policy.DocumentedRuleDefault(
name=webhook % 'show',
check_str=base.UNPROTECTED,
description='Show a webhook registration with a given id',
operations=[
{
'path': '/webhook/{webhook_id}',
'method': 'GET'
}
]
),
policy.DocumentedRuleDefault(
name=webhook % 'add',
check_str=base.UNPROTECTED,
description='Add a webhook registration with given info',
operations=[
{
'path': '/webhook',
'method': 'POST'
}
]
)
]
def list_rules():
return rules

View File

@ -0,0 +1,27 @@
# Copyright 2018 - Nokia Corporation
#
# 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
OPTS = [
cfg.StrOpt('notifier',
default='vitrage.notifier.plugins.webhook.'
'webhook.Webhook',
help='notifier webhook class path',
required=True),
cfg.IntOpt('max_retries',
default=2,
help='rest http post max retries',
required=False),
]

View File

@ -0,0 +1,26 @@
# Copyright 2018 - Nokia Corporation
#
# 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.
def db_row_to_dict(row):
return {
'id': row.id,
'project_id': row.project_id,
'is_admin_webhook': row.is_admin_webhook,
'created_at': row.created_at,
'url': row.url,
'regex_filter': row.regex_filter,
'headers': row.headers
}

View File

@ -0,0 +1,139 @@
# Copyright 2017 - Nokia
#
# 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 ast
import re
from oslo_log import log as logging
from oslo_serialization import jsonutils
from oslo_utils import uuidutils
import requests
from vitrage.common.constants import NotifierEventTypes
from vitrage.common.constants import VertexProperties as VProps
from vitrage.notifier.plugins.base import NotifierBase
from vitrage.notifier.plugins.webhook import utils as webhook_utils
from vitrage import storage
LOG = logging.getLogger(__name__)
URL = 'url'
IS_ADMIN_WEBHOOK = 'is_admin_webhook'
FIELDS_TO_REMOVE = (VProps.VITRAGE_IS_PLACEHOLDER,
VProps.VITRAGE_IS_DELETED,
VProps.IS_REAL_VITRAGE_ID)
NOTIFICATION_TYPE = 'notification_type'
PAYLOAD = 'payload'
class Webhook(NotifierBase):
@staticmethod
def get_notifier_name():
return 'webhook'
def __init__(self, conf):
super(Webhook, self).__init__(conf)
self.conf = conf
self._db = storage.get_connection_from_config(self.conf)
self.max_retries = self.conf.webhook.max_retries
self.default_headers = {'content-type': 'application/json'}
def process_event(self, data, event_type):
if event_type == NotifierEventTypes.ACTIVATE_ALARM_EVENT \
or event_type == NotifierEventTypes.DEACTIVATE_ALARM_EVENT:
LOG.info('Webhook API starting to process %s', str(data))
webhooks = self._load_webhooks()
if webhooks:
for webhook in webhooks:
webhook_filters = self._get_webhook_filters(webhook)
data = self._filter_fields(data)
if self._check_against_filter(webhook_filters, data)\
and self._check_correct_tenant(webhook, data):
self._post_data(webhook, event_type, data)
LOG.info('Webhook API finished processing %s', str(data))
def _post_data(self, webhook, event_type, data):
try:
webhook_data = {'notification': event_type, 'payload': data}
webhook_headers = self._get_webhook_headers(webhook)
session = requests.Session()
session.mount(str(webhook[URL]),
requests.adapters.HTTPAdapter(
max_retries=self.max_retries))
resp = session.post(str(webhook[URL]),
data=jsonutils.dumps(webhook_data),
headers=webhook_headers)
LOG.info('posted %s to %s. Response status %s, reason %s',
str(webhook_data), str(webhook[URL]),
resp.status_code, resp.reason)
except Exception as e:
LOG.exception("Could not post to webhook %s %s" % (
str(webhook['id']), str(e)))
def _load_webhooks(self):
db_webhooks = self._db.webhooks.query()
return [webhook_utils.db_row_to_dict(webhook) for webhook in
db_webhooks]
def _get_webhook_headers(self, webhook):
headers = self.default_headers.copy()
headers['x-openstack-request-id'] = b'req-' + \
uuidutils.generate_uuid().encode(
'ascii')
if webhook.get('headers') != '':
headers.update(ast.literal_eval(webhook['headers']))
return headers
def _get_webhook_filters(self, webhook):
filters = webhook.get('regex_filter')
if filters != '':
filters = ast.literal_eval(filters)
for k, v in filters.items():
filters[k] = re.compile(v, re.IGNORECASE)
return filters
return None
def _check_against_filter(self, webhook_filters, event):
# Check if the event matches the specified filters
if webhook_filters:
for field, filter in webhook_filters.items():
value = event.get(field)
if value is None:
return False
elif filter.match(value) is None:
return False
return True
def _filter_fields(self, data):
data = {k: v for k, v in data.items() if k not in FIELDS_TO_REMOVE}
if data.get(VProps.RESOURCE):
data[VProps.RESOURCE] = \
self._filter_fields(data[VProps.RESOURCE])
return data
def _check_correct_tenant(self, webhook, data):
# Check that the resource project ID matches the project ID under
# which the webhook was added.
if webhook.get(IS_ADMIN_WEBHOOK):
return True
if data.get(VProps.RESOURCE):
if data[VProps.RESOURCE].get(VProps.PROJECT_ID):
return data[VProps.RESOURCE][VProps.PROJECT_ID] == \
webhook.get(VProps.PROJECT_ID)
return True

View File

@ -27,6 +27,7 @@ import vitrage.machine_learning
import vitrage.machine_learning.plugins.jaccard_correlation
import vitrage.notifier
import vitrage.notifier.plugins.snmp
import vitrage.notifier.plugins.webhook
import vitrage.os_clients
import vitrage.persistency
import vitrage.rpc
@ -56,6 +57,7 @@ def list_opts():
('jaccard_correlation',
vitrage.machine_learning.plugins.jaccard_correlation.OPTS),
('snmp', vitrage.notifier.plugins.snmp.OPTS),
('webhook', vitrage.notifier.plugins.webhook.OPTS),
('snmp_parsing', vitrage.snmp_parsing.OPTS),
('DEFAULT', itertools.chain(
vitrage.os_clients.OPTS,
@ -98,6 +100,8 @@ def _normalize_path_to_datasource_name(path_list, top=os.getcwd()):
def register_opts(conf, package_name, paths):
"""register opts of package package_name, with base path in paths"""
for path in paths:
LOG.info("package name: %s" % package_name)
LOG.info("path: % s" % path)
try:
opt = importutils.import_module(
"%s.%s" % (path, package_name)).OPTS
@ -107,6 +111,5 @@ def register_opts(conf, package_name, paths):
)
return
except ImportError:
pass
LOG.error("Failed to register config options for %s" % package_name)
LOG.error("Failed to register config options for %s" %
package_name)

View File

@ -39,17 +39,21 @@ class Connection(object):
def graph_snapshots(self):
return None
@property
def webhooks(self):
return None
@abc.abstractmethod
def upgrade(self, nocreate=False):
raise NotImplementedError('upgrade not implemented')
raise NotImplementedError('upgrade is not implemented')
@abc.abstractmethod
def disconnect(self):
raise NotImplementedError('disconnect not implemented')
raise NotImplementedError('disconnect is not implemented')
@abc.abstractmethod
def clear(self):
raise NotImplementedError('clear not implemented')
raise NotImplementedError('clear is not implemented')
@six.add_metaclass(abc.ABCMeta)
@ -60,7 +64,7 @@ class ActiveActionsConnection(object):
:type active_action: vitrage.storage.sqlalchemy.models.ActiveAction
"""
raise NotImplementedError('create active action not implemented')
raise NotImplementedError('create active action is not implemented')
@abc.abstractmethod
def update(self, active_action):
@ -68,7 +72,7 @@ class ActiveActionsConnection(object):
:type active_action: vitrage.storage.sqlalchemy.models.ActiveAction
"""
raise NotImplementedError('update active action not implemented')
raise NotImplementedError('update active action is not implemented')
@abc.abstractmethod
def query(self,
@ -84,7 +88,7 @@ class ActiveActionsConnection(object):
:rtype: list of vitrage.storage.sqlalchemy.models.ActiveAction
"""
raise NotImplementedError('query active actions not implemented')
raise NotImplementedError('query active actions is not implemented')
@abc.abstractmethod
def delete(self,
@ -97,7 +101,40 @@ class ActiveActionsConnection(object):
trigger=None,
):
"""Delete all active actions that match the filters."""
raise NotImplementedError('delete active actions not implemented')
raise NotImplementedError('delete active actions is not implemented')
@six.add_metaclass(abc.ABCMeta)
class WebhooksConnection(object):
@abc.abstractmethod
def create(self, webhook):
"""Create a new webhook.
:type webhook:
vitrage.storage.sqlalchemy.models.Webhook
"""
raise NotImplementedError('create webhook is not implemented')
@abc.abstractmethod
def query(self,
id=None,
project_id=None,
is_admin_webhook=None,
url=None,
headers=None,
regex_filter=None,
):
"""Yields a lists of webhooks that match filters.
:rtype: list of vitrage.storage.sqlalchemy.models.Webhook
"""
raise NotImplementedError('query webhook is not implemented')
@abc.abstractmethod
def delete(self, id=None):
"""Delete all webhooks that match the filters."""
raise NotImplementedError('delete webhook is not implemented')
@six.add_metaclass(abc.ABCMeta)
@ -145,14 +182,14 @@ class EventsConnection(object):
:type event: vitrage.storage.sqlalchemy.models.Event
"""
raise NotImplementedError('create event not implemented')
raise NotImplementedError('create event is not implemented')
def update(self, event):
"""Update an existing event.
:type event: vitrage.storage.sqlalchemy.models.Event
"""
raise NotImplementedError('update event not implemented')
raise NotImplementedError('update event is not implemented')
def query(self,
event_id=None,
@ -164,7 +201,7 @@ class EventsConnection(object):
:rtype: list of vitrage.storage.sqlalchemy.models.Event
"""
raise NotImplementedError('query events not implemented')
raise NotImplementedError('query events is not implemented')
def delete(self,
event_id=None,
@ -172,7 +209,7 @@ class EventsConnection(object):
gt_collector_timestamp=None,
lt_collector_timestamp=None):
"""Delete all events that match the filters."""
raise NotImplementedError('delete events not implemented')
raise NotImplementedError('delete events is not implemented')
@six.add_metaclass(abc.ABCMeta)

View File

@ -44,6 +44,12 @@ class Connection(base.Connection):
self._events = EventsConnection(self._engine_facade)
self._templates = TemplatesConnection(self._engine_facade)
self._graph_snapshots = GraphSnapshotsConnection(self._engine_facade)
self._webhooks = WebhooksConnection(
self._engine_facade)
@property
def webhooks(self):
return self._webhooks
@property
def active_actions(self):
@ -191,6 +197,38 @@ class ActiveActionsConnection(base.ActiveActionsConnection, BaseTableConn):
return query.delete()
class WebhooksConnection(base.WebhooksConnection,
BaseTableConn):
def __init__(self, engine_facade):
super(WebhooksConnection, self).__init__(engine_facade)
def create(self, webhook):
session = self._engine_facade.get_session()
with session.begin():
session.add(webhook)
def query(self,
id=None,
project_id=None,
is_admin_webhook=None,
url=None,
headers=None,
regex_filter=None):
query = self.query_filter(
models.Webhooks,
id=id,
project_id=project_id,
is_admin_webhook=is_admin_webhook,
url=url,
headers=headers,
regex_filter=regex_filter)
return query.all()
def delete(self, id=None):
query = self.query_filter(models.Webhooks, id=id)
return query.delete()
class EventsConnection(base.EventsConnection, BaseTableConn):
def __init__(self, engine_facade):
super(EventsConnection, self).__init__(engine_facade)

View File

@ -16,13 +16,13 @@ import json
from oslo_db.sqlalchemy import models
from sqlalchemy import Column, DateTime, INTEGER, String, \
SmallInteger, BigInteger, Index
SmallInteger, BigInteger, Index, Boolean, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base
import sqlalchemy.types as types
class VitrageBase(models.ModelBase):
class VitrageBase(models.TimestampMixin, models.ModelBase):
"""Base class for Vitrage Models."""
__table_args__ = {'mysql_charset': "utf8",
'mysql_engine': "InnoDB"}
@ -161,3 +161,37 @@ class Template(Base, models.TimestampMixin):
self.status_details,
self.file_content,
self.template_type,)
class Webhooks(Base):
__tablename__ = 'webhooks'
id = Column(String(128), primary_key=True)
project_id = Column(String(128), nullable=False)
is_admin_webhook = Column(Boolean, nullable=False)
url = Column(String(256), nullable=False)
headers = Column(String(1024))
regex_filter = Column(String(512))
constraint = UniqueConstraint('url', 'regex_filter')
__table_args__ = (UniqueConstraint('url', 'regex_filter'),)
def __repr__(self):
return \
"<Webhook(" \
"id='%s', " \
"created_at='%s', " \
"project_id='%s', " \
"is_admin_webhook='%s', " \
"url='%s', " \
"headers='%s', " \
"regex_filter='%s')> " %\
(
self.id,
self.created_at,
self.project_id,
self.is_admin_webhook,
self.url,
self.headers,
self.regex_filter
)

View File

@ -0,0 +1,15 @@
# Copyright 2017 - Nokia
#
# 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.
__author__ = 'stack'

View File

@ -0,0 +1,149 @@
# Copyright 2017 Nokia
#
# 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_log import log as logging
from vitrage_tempest_tests.tests.base import BaseVitrageTempest
from vitrage_tempest_tests.tests.common.tempest_clients import TempestClients
from vitrageclient.exceptions import ClientException
LOG = logging.getLogger(__name__)
URL = 'url'
REGEX_FILTER = 'regex_filter'
HEADERS = 'headers'
HEADERS_PROPS = '{"content": "application/json"}'
REGEX_PROPS = '{"name": "e2e.*"}'
class TestWebhook(BaseVitrageTempest):
"""Webhook test class for Vitrage API tests."""
@classmethod
def setUpClass(cls):
super(TestWebhook, cls).setUpClass()
cls.pre_test_webhook_count = \
len(TempestClients.vitrage().webhook.list())
def test_add_webhook(self):
webhooks = TempestClients.vitrage().webhook.list()
self.assertEqual(self.pre_test_webhook_count,
len(webhooks),
'Amount of webhooks should be the same as '
'before the test')
created_webhook = TempestClients.vitrage().webhook.add(
url="https://www.test.com",
regex_filter=REGEX_PROPS,
headers=HEADERS_PROPS
)
self.assertIsNone(created_webhook.get('ERROR'), 'webhook not '
'created')
self.assertEqual(created_webhook[HEADERS],
HEADERS_PROPS,
'headers not created correctly')
self.assertEqual(created_webhook[REGEX_FILTER],
REGEX_PROPS,
'regex not created correctly')
self.assertEqual(created_webhook[URL],
"https://www.test.com",
'URL not created correctly')
webhooks = TempestClients.vitrage().webhook.list()
self.assertEqual(self.pre_test_webhook_count + 1, len(webhooks))
TempestClients.vitrage().webhook.delete(
created_webhook['id'])
def test_delete_webhook(self):
webhooks = TempestClients.vitrage().webhook.list()
self.assertEqual(self.pre_test_webhook_count,
len(webhooks),
'Amount of webhooks should be the same as '
'before the test')
created_webhook = TempestClients.vitrage().webhook.add(
url="https://www.test.com",
regex_filter=REGEX_PROPS,
headers=HEADERS_PROPS
)
created_webhook = TempestClients.vitrage().webhook.delete(
id=created_webhook['id'])
self.assertIsNotNone(created_webhook.get('SUCCESS'),
'failed to delete')
self.assertEqual(self.pre_test_webhook_count, len(webhooks),
'No webhooks should exist after deletion')
def test_delete_non_existing_webhook(self):
self.assertRaises(ClientException,
TempestClients.vitrage().webhook.delete,
('non existant'))
def test_list_webhook(self):
webhooks = TempestClients.vitrage().webhook.list()
self.assertEqual(self.pre_test_webhook_count,
len(webhooks),
'Amount of webhooks should be the same as '
'before the test')
created_webhook = TempestClients.vitrage().webhook.add(
url="https://www.test.com",
regex_filter=REGEX_PROPS,
headers=HEADERS_PROPS
)
webhooks = TempestClients.vitrage().webhook.list()
self.assertEqual(self.pre_test_webhook_count + 1, len(webhooks))
self.assertEqual(created_webhook[HEADERS], webhooks[0][HEADERS])
self.assertEqual(created_webhook['id'], webhooks[0]['id'])
self.assertEqual(created_webhook[REGEX_FILTER],
webhooks[0][REGEX_FILTER])
TempestClients.vitrage().webhook.delete(
created_webhook['id'])
def test_show_webhook(self):
webhooks = TempestClients.vitrage().webhook.list()
self.assertEqual(self.pre_test_webhook_count,
len(webhooks),
'Amount of webhooks should be the same as '
'before the test')
created_webhook = TempestClients.vitrage().webhook.add(
url="https://www.test.com",
regex_filter=REGEX_PROPS,
headers=HEADERS_PROPS
)
show_webhook = TempestClients.vitrage().webhook.show(
created_webhook['id']
)
self.assertIsNotNone(show_webhook, 'webhook not listed')
self.assertEqual(created_webhook[HEADERS],
show_webhook[HEADERS],
'headers mismatch')
self.assertEqual(created_webhook[REGEX_FILTER],
show_webhook[REGEX_FILTER],
'regex mismatch')
self.assertEqual(created_webhook[URL],
show_webhook[URL],
'URL mismatch')
TempestClients.vitrage().webhook.delete(
created_webhook['id'])

View File

@ -0,0 +1,268 @@
# Copyright 2017 - Nokia
#
# 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_log import log as logging
import requests
from six.moves import BaseHTTPServer
import socket
from threading import Thread
from vitrage.common.constants import VertexProperties as VProps
from vitrage_tempest_tests.tests.common.tempest_clients import TempestClients
from vitrage_tempest_tests.tests.e2e.test_actions_base import TestActionsBase
from vitrage_tempest_tests.tests import utils
LOG = logging.getLogger(__name__)
TRIGGER_ALARM_1 = 'e2e.test_webhook.alarm1'
TRIGGER_ALARM_2 = 'e2e.test_webhook.alarm2'
TRIGGER_ALARM_WITH_DEDUCED = 'e2e.test_webhook.alarm_with_deduced'
URL = 'url'
REGEX_FILTER = 'regex_filter'
HEADERS = 'headers'
HEADERS_PROPS = '{"content": "application/json"}'
NAME_FILTER = '{"name": "e2e.*"}'
NAME_FILTER_FOR_DEDUCED = '{"name": "e2e.test_webhook.deduced"}'
TYPE_FILTER = '{"vitrage_type": "doctor"}'
FILTER_NO_MATCH = '{"name": "NO MATCH"}'
class TestWebhook(TestActionsBase):
@classmethod
def setUpClass(cls):
super(TestWebhook, cls).setUpClass()
# Configure mock server.
cls.mock_server_port = _get_free_port()
cls.mock_server = MockHTTPServer(('localhost', cls.mock_server_port),
MockServerRequestHandler)
# Start running mock server in a separate thread.
cls.mock_server_thread = Thread(target=cls.mock_server.serve_forever)
cls.mock_server_thread.setDaemon(True)
cls.mock_server_thread.start()
cls.URL_PROPS = 'http://localhost:%s/' % cls.mock_server_port
@utils.tempest_logger
def test_webhook_basic_event(self):
try:
# Add webhook with filter matching alarm
TempestClients.vitrage().webhook.add(
url=self.URL_PROPS,
regex_filter=NAME_FILTER,
headers=HEADERS_PROPS
)
# Raise alarm
self._trigger_do_action(TRIGGER_ALARM_1)
# Check event received
self.assertEqual(1, len(self.mock_server.requests),
'Wrong number of notifications for raise alarm')
# Undo
self._trigger_undo_action(TRIGGER_ALARM_1)
# Check event undo received
self.assertEqual(2, len(self.mock_server.requests),
'Wrong number of notifications for clear alarm')
finally:
self._delete_webhooks()
self._trigger_undo_action(TRIGGER_ALARM_1)
self.mock_server.reset_requests_list()
def test_webhook_with_no_filter(self):
"""Test to see that a webhook with no filter receives all
notifications
"""
try:
# Add webhook
TempestClients.vitrage().webhook.add(
url=self.URL_PROPS,
regex_filter=NAME_FILTER,
)
# Raise alarm
self._trigger_do_action(TRIGGER_ALARM_1)
# Check event received
self.assertEqual(1, len(self.mock_server.requests),
'Wrong number of notifications for raise alarm')
# Raise another alarm
self._trigger_do_action(TRIGGER_ALARM_2)
# Check second event received
self.assertEqual(2, len(self.mock_server.requests),
'Wrong number of notifications for clear alarm')
finally:
self._delete_webhooks()
self._trigger_undo_action(TRIGGER_ALARM_1)
self._trigger_undo_action(TRIGGER_ALARM_2)
self.mock_server.reset_requests_list()
def test_webhook_with_no_match(self):
"""Test to check that filters with no match do not send event """
try:
# Add webhook
TempestClients.vitrage().webhook.add(
url=self.URL_PROPS,
regex_filter=FILTER_NO_MATCH,
)
# Raise alarm
self._trigger_do_action(TRIGGER_ALARM_1)
# Check event not received
self.assertEqual(0, len(self.mock_server.requests),
'event should not have passed filter')
# Raise another alarm
self._trigger_do_action(TRIGGER_ALARM_2)
# Check second event not received
self.assertEqual(0, len(self.mock_server.requests),
'event should not have passed filter')
finally:
self._delete_webhooks()
self._trigger_undo_action(TRIGGER_ALARM_1)
self._trigger_undo_action(TRIGGER_ALARM_2)
self.mock_server.reset_requests_list()
def test_multiple_webhooks(self):
"""Test to check filter by type and by ID (with 2 different
webhooks)
"""
host_id = self.orig_host[VProps.VITRAGE_ID]
ID_FILTER = '{"%s": "%s"}' % (VProps.VITRAGE_RESOURCE_ID, host_id)
try:
# Add webhook
TempestClients.vitrage().webhook.add(
url=self.URL_PROPS,
regex_filter=TYPE_FILTER,
)
TempestClients.vitrage().webhook.add(
url=self.URL_PROPS,
regex_filter=ID_FILTER,
)
# Raise alarm
self._trigger_do_action(TRIGGER_ALARM_1)
# Check event received
self.assertEqual(2, len(self.mock_server.requests),
'event not posted to all webhooks')
# Raise another alarm
self._trigger_do_action(TRIGGER_ALARM_2)
# Check second event received
self.assertEqual(4, len(self.mock_server.requests),
'event not posted to all webhooks')
finally:
self._delete_webhooks()
self._trigger_undo_action(TRIGGER_ALARM_1)
self._trigger_undo_action(TRIGGER_ALARM_2)
self.mock_server.reset_requests_list()
# Will be un-commented-out in the next change
#
# @utils.tempest_logger
# def test_webhook_for_deduced_alarm(self):
#
# try:
#
# # Add webhook with filter for the deduced alarm
# TempestClients.vitrage().webhook.add(
# url=self.URL_PROPS,
# regex_filter=NAME_FILTER_FOR_DEDUCED,
# headers=HEADERS_PROPS
# )
#
# # Raise the trigger alarm
# self._trigger_do_action(TRIGGER_ALARM_WITH_DEDUCED)
#
# # Check event received - expected one for the deduced alarm
# # (the trigger alarm does not pass the filter). This test verifies
# # that the webhook is called only once for the deduced alarm.
# self.assertEqual(1, len(self.mock_server.requests),
# 'Wrong number of notifications for deduced alarm')
#
# # Undo
# self._trigger_undo_action(TRIGGER_ALARM_WITH_DEDUCED)
#
# # Check event undo received
# self.assertEqual(2, len(self.mock_server.requests),
# 'Wrong number of notifications for clear deduced '
# 'alarm')
#
# finally:
# self._delete_webhooks()
# self._trigger_undo_action(TRIGGER_ALARM_WITH_DEDUCED)
# self.mock_server.reset_requests_list()
def _delete_webhooks(self):
webhooks = TempestClients.vitrage().webhook.list()
for webhook in webhooks:
TempestClients.vitrage().webhook.delete(webhook['id'])
def _get_free_port():
s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM)
s.bind(('localhost', 0))
address, port = s.getsockname()
s.close()
return port
class MockHTTPServer(BaseHTTPServer.HTTPServer):
def __init__(self, server, handler):
BaseHTTPServer.HTTPServer.__init__(self, server, handler)
self.requests = []
def process_request(self, request, client_address):
self.requests.append(request)
LOG.info('received request: %s', str(request))
BaseHTTPServer.HTTPServer.process_request(
self, client_address=client_address, request=request)
def reset_requests_list(self):
self.requests = []
class MockServerRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
def do_POST(self):
# Process a HTTP Post request and return status code 200
self.send_response(requests.codes.ok)
self.end_headers()
return

View File

@ -0,0 +1,29 @@
metadata:
name: e2e_webhook
definitions:
entities:
- entity:
category: ALARM
name: e2e.test_webhook.alarm_with_deduced
template_id: host_alarm
- entity:
category: RESOURCE
type: nova.host
template_id: host
relationships:
- relationship:
source: host_alarm
target: host
relationship_type: on
template_id : alarm_on_host
scenarios:
- scenario:
condition: alarm_on_host
actions:
- action:
action_type: raise_alarm
action_target:
target: host
properties:
alarm_name: e2e.test_webhook.deduced
severity: WARNING